mirror of
https://github.com/stack-auth/stack.git
synced 2026-06-13 21:01:21 +08:00
proxy logging implemented
This commit is contained in:
parent
b0a329f396
commit
c8195370d0
@ -1,47 +1,20 @@
|
||||
import { logAiQuery } from "@/lib/ai/ai-query-logger";
|
||||
import { logMcpCall } from "@/lib/ai/mcp-logger";
|
||||
import {
|
||||
assertProjectAccess,
|
||||
handleGenerateMode,
|
||||
handleStreamMode,
|
||||
type CommonLogFields,
|
||||
type ModeContext,
|
||||
} from "@/lib/ai/ai-query-handlers";
|
||||
import { selectModel } from "@/lib/ai/models";
|
||||
import { getFullSystemPrompt } from "@/lib/ai/prompts";
|
||||
import { reviewMcpCall } from "@/lib/ai/qa-reviewer";
|
||||
import { requestBodySchema } from "@/lib/ai/schema";
|
||||
import { getTools, validateToolNames } from "@/lib/ai/tools";
|
||||
import { getVerifiedQaContext } from "@/lib/ai/verified-qa";
|
||||
import { listManagedProjectIds } from "@/lib/projects";
|
||||
import { SmartResponse } from "@/route-handlers/smart-response";
|
||||
import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler";
|
||||
import { runAsynchronouslyAndWaitUntil } from "@/utils/background-tasks";
|
||||
import { yupMixed, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields";
|
||||
import { captureError, StatusError } from "@stackframe/stack-shared/dist/utils/errors";
|
||||
import { Json } from "@stackframe/stack-shared/dist/utils/json";
|
||||
import type { OpenRouterUsageAccounting } from "@openrouter/ai-sdk-provider";
|
||||
import { generateText, ModelMessage, stepCountIs, streamText, type StepResult, type ToolSet } from "ai";
|
||||
|
||||
type ProviderMetadata = { openrouter?: { usage?: OpenRouterUsageAccounting } };
|
||||
|
||||
function extractOpenRouterCost(meta: unknown): number | undefined {
|
||||
return (meta as ProviderMetadata | null | undefined)?.openrouter?.usage?.cost;
|
||||
}
|
||||
|
||||
function extractCachedTokens(meta: unknown): number | undefined {
|
||||
return (meta as ProviderMetadata | null | undefined)?.openrouter?.usage?.promptTokensDetails?.cachedTokens;
|
||||
}
|
||||
|
||||
function buildStepsJson(steps: ReadonlyArray<StepResult<ToolSet>>): string {
|
||||
return JSON.stringify(steps.map((step, i) => ({
|
||||
step: i,
|
||||
text: step.text || undefined,
|
||||
toolCalls: step.toolCalls.map(tc => ({
|
||||
toolName: tc.toolName,
|
||||
toolCallId: tc.toolCallId,
|
||||
args: tc.input,
|
||||
})),
|
||||
toolResults: step.toolResults.map(tr => ({
|
||||
toolName: tr.toolName,
|
||||
toolCallId: tr.toolCallId,
|
||||
result: tr.output,
|
||||
})),
|
||||
})));
|
||||
}
|
||||
import { StatusError } from "@stackframe/stack-shared/dist/utils/errors";
|
||||
import { ModelMessage } from "ai";
|
||||
|
||||
export const POST = createSmartRouteHandler({
|
||||
metadata: {
|
||||
@ -65,17 +38,7 @@ export const POST = createSmartRouteHandler({
|
||||
const { quality, speed, systemPrompt: systemPromptId, tools: toolNames, messages, projectId } = body;
|
||||
|
||||
if (projectId != null) {
|
||||
if (fullReq.auth?.project.id !== "internal") {
|
||||
throw new StatusError(StatusError.Forbidden, "You do not have access to this project");
|
||||
}
|
||||
const user = fullReq.auth.user;
|
||||
if (user == null) {
|
||||
throw new StatusError(StatusError.Forbidden, "You do not have access to this project");
|
||||
}
|
||||
const managedProjectIds = await listManagedProjectIds(user);
|
||||
if (!managedProjectIds.includes(projectId)) {
|
||||
throw new StatusError(StatusError.Forbidden, "You do not have access to this project");
|
||||
}
|
||||
await assertProjectAccess(projectId, fullReq.auth);
|
||||
}
|
||||
|
||||
const model = selectModel(quality, speed, isAuthenticated);
|
||||
@ -92,7 +55,7 @@ export const POST = createSmartRouteHandler({
|
||||
const conversationIdForLog = body.mcpCallMetadata
|
||||
? body.mcpCallMetadata.conversationId ?? crypto.randomUUID()
|
||||
: undefined;
|
||||
const commonLogFields = {
|
||||
const common: CommonLogFields = {
|
||||
correlationId,
|
||||
mode,
|
||||
systemPromptId,
|
||||
@ -107,27 +70,8 @@ export const POST = createSmartRouteHandler({
|
||||
mcpCorrelationId: body.mcpCallMetadata ? correlationId : undefined,
|
||||
conversationId: conversationIdForLog,
|
||||
};
|
||||
|
||||
const startedAt = Date.now();
|
||||
|
||||
const USER_FACING_ERROR_MESSAGE = "The AI service is temporarily unavailable. Please try again later.";
|
||||
|
||||
function logError(err: unknown) {
|
||||
captureError("ai-query-upstream", err);
|
||||
runAsynchronouslyAndWaitUntil(logAiQuery({
|
||||
...commonLogFields,
|
||||
stepsJson: "[]",
|
||||
finalText: "",
|
||||
inputTokens: undefined,
|
||||
outputTokens: undefined,
|
||||
cachedInputTokens: undefined,
|
||||
costUsd: undefined,
|
||||
stepCount: 0,
|
||||
durationMs: BigInt(Date.now() - startedAt),
|
||||
errorMessage: err instanceof Error ? err.message : String(err),
|
||||
}));
|
||||
}
|
||||
|
||||
const isAnthropic = model.modelId.startsWith("anthropic/");
|
||||
const systemMessage: ModelMessage = {
|
||||
role: "system",
|
||||
@ -137,160 +81,18 @@ export const POST = createSmartRouteHandler({
|
||||
}),
|
||||
};
|
||||
const cachedMessages: ModelMessage[] = [systemMessage, ...(messages as ModelMessage[])];
|
||||
const openrouterProviderOptions = {
|
||||
usage: { include: true },
|
||||
extraBody: {
|
||||
stream_options: { include_usage: true },
|
||||
},
|
||||
} as const;
|
||||
|
||||
const ctx: ModeContext = { model, cachedMessages, toolsArg, stepLimit, common, startedAt };
|
||||
|
||||
if (mode === "stream") {
|
||||
const result = streamText({
|
||||
model,
|
||||
messages: cachedMessages,
|
||||
tools: toolsArg,
|
||||
stopWhen: stepCountIs(stepLimit),
|
||||
providerOptions: {
|
||||
openrouter: openrouterProviderOptions,
|
||||
},
|
||||
onFinish: ({ text, steps, usage, providerMetadata }) => {
|
||||
runAsynchronouslyAndWaitUntil(logAiQuery({
|
||||
...commonLogFields,
|
||||
stepsJson: buildStepsJson(steps),
|
||||
finalText: text,
|
||||
inputTokens: usage.inputTokens ?? undefined,
|
||||
outputTokens: usage.outputTokens ?? undefined,
|
||||
cachedInputTokens: extractCachedTokens(providerMetadata),
|
||||
costUsd: extractOpenRouterCost(providerMetadata),
|
||||
stepCount: steps.length,
|
||||
durationMs: BigInt(Date.now() - startedAt),
|
||||
errorMessage: undefined,
|
||||
}));
|
||||
},
|
||||
onError: ({ error }) => logError(error),
|
||||
});
|
||||
return {
|
||||
statusCode: 200,
|
||||
bodyType: "response" as const,
|
||||
body: result.toUIMessageStreamResponse({
|
||||
onError: () => USER_FACING_ERROR_MESSAGE,
|
||||
}),
|
||||
};
|
||||
} else {
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), 120_000);
|
||||
let result: Awaited<ReturnType<typeof generateText>>;
|
||||
try {
|
||||
result = await generateText({
|
||||
model,
|
||||
messages: cachedMessages,
|
||||
tools: toolsArg,
|
||||
abortSignal: controller.signal,
|
||||
stopWhen: stepCountIs(stepLimit),
|
||||
providerOptions: {
|
||||
openrouter: openrouterProviderOptions,
|
||||
},
|
||||
}).finally(() => clearTimeout(timeoutId));
|
||||
} catch (err) {
|
||||
logError(err);
|
||||
throw new StatusError(StatusError.BadGateway, USER_FACING_ERROR_MESSAGE);
|
||||
}
|
||||
|
||||
const contentBlocks: Array<
|
||||
| { type: "text", text: string }
|
||||
| {
|
||||
type: "tool-call",
|
||||
toolName: string,
|
||||
toolCallId: string,
|
||||
args: Json,
|
||||
argsText: string,
|
||||
result: Json,
|
||||
}
|
||||
> = [];
|
||||
|
||||
result.steps.forEach((step) => {
|
||||
if (step.text) {
|
||||
contentBlocks.push({
|
||||
type: "text",
|
||||
text: step.text,
|
||||
});
|
||||
}
|
||||
|
||||
const toolResultsByCallId = new Map(
|
||||
step.toolResults.map((r) => [r.toolCallId, r])
|
||||
);
|
||||
|
||||
step.toolCalls.forEach((toolCall) => {
|
||||
const toolResult = toolResultsByCallId.get(toolCall.toolCallId);
|
||||
contentBlocks.push({
|
||||
type: "tool-call",
|
||||
toolName: toolCall.toolName,
|
||||
toolCallId: toolCall.toolCallId,
|
||||
args: toolCall.input,
|
||||
argsText: JSON.stringify(toolCall.input),
|
||||
result: (toolResult?.output ?? null) as Json,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
runAsynchronouslyAndWaitUntil(logAiQuery({
|
||||
...commonLogFields,
|
||||
stepsJson: buildStepsJson(result.steps),
|
||||
finalText: result.text,
|
||||
inputTokens: result.usage.inputTokens ?? undefined,
|
||||
outputTokens: result.usage.outputTokens ?? undefined,
|
||||
cachedInputTokens: extractCachedTokens(result.providerMetadata),
|
||||
costUsd: extractOpenRouterCost(result.providerMetadata),
|
||||
stepCount: result.steps.length,
|
||||
durationMs: BigInt(Date.now() - startedAt),
|
||||
errorMessage: undefined,
|
||||
}));
|
||||
|
||||
let responseConversationId: string | undefined;
|
||||
if (body.mcpCallMetadata != null && conversationIdForLog != null) {
|
||||
const conversationId = conversationIdForLog;
|
||||
responseConversationId = conversationId;
|
||||
const firstUserMessage = messages.find(m => m.role === "user");
|
||||
const question = typeof firstUserMessage?.content === "string"
|
||||
? firstUserMessage.content
|
||||
: JSON.stringify(firstUserMessage?.content ?? "");
|
||||
|
||||
const innerToolCallsJson = JSON.stringify(contentBlocks.filter(b => b.type === "tool-call"));
|
||||
|
||||
const logPromise = logMcpCall({
|
||||
correlationId,
|
||||
toolName: body.mcpCallMetadata.toolName,
|
||||
reason: body.mcpCallMetadata.reason,
|
||||
userPrompt: body.mcpCallMetadata.userPrompt,
|
||||
conversationId,
|
||||
question,
|
||||
response: result.text,
|
||||
stepCount: result.steps.length,
|
||||
innerToolCallsJson,
|
||||
durationMs: BigInt(Date.now() - startedAt),
|
||||
modelId: String(model.modelId),
|
||||
errorMessage: undefined,
|
||||
});
|
||||
runAsynchronouslyAndWaitUntil(logPromise);
|
||||
|
||||
runAsynchronouslyAndWaitUntil(reviewMcpCall({
|
||||
logPromise,
|
||||
correlationId,
|
||||
question,
|
||||
reason: body.mcpCallMetadata.reason,
|
||||
response: result.text,
|
||||
}));
|
||||
}
|
||||
|
||||
return {
|
||||
statusCode: 200,
|
||||
bodyType: "json" as const,
|
||||
body: {
|
||||
content: contentBlocks,
|
||||
finalText: result.text,
|
||||
conversationId: responseConversationId ?? null,
|
||||
},
|
||||
};
|
||||
return handleStreamMode(ctx);
|
||||
}
|
||||
return await handleGenerateMode({
|
||||
...ctx,
|
||||
messages,
|
||||
mcpCallMetadata: body.mcpCallMetadata ?? undefined,
|
||||
correlationId,
|
||||
conversationIdForLog,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
@ -1,37 +1,11 @@
|
||||
import { ALLOWED_MODEL_IDS } from "@/lib/ai/models";
|
||||
import { observeAndLog, sanitizeBody } from "@/lib/ai/ai-proxy-handlers";
|
||||
import { handleApiRequest } from "@/route-handlers/smart-route-handler";
|
||||
import { getEnvVariable } from "@stackframe/stack-shared/dist/utils/env";
|
||||
import { StatusError } from "@stackframe/stack-shared/dist/utils/errors";
|
||||
import { captureError } from "@stackframe/stack-shared/dist/utils/errors";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
const OPENROUTER_BASE_URL = "https://openrouter.ai/api";
|
||||
const PRODUCTION_PROXY_BASE_URL = "https://api.stack-auth.com/api/latest/integrations/ai-proxy";
|
||||
const OPENROUTER_DEFAULT_MODEL = "anthropic/claude-sonnet-4.6";
|
||||
|
||||
function sanitizeBody(raw: ArrayBuffer): Uint8Array {
|
||||
const text = new TextDecoder().decode(raw);
|
||||
let parsed;
|
||||
try {
|
||||
parsed = JSON.parse(text);
|
||||
} catch {
|
||||
throw new StatusError(400, "Request body must be valid JSON");
|
||||
}
|
||||
|
||||
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
|
||||
throw new StatusError(400, "Request body must be a JSON object");
|
||||
}
|
||||
|
||||
if (!parsed.model || !ALLOWED_MODEL_IDS.has(parsed.model)) {
|
||||
parsed.model = OPENROUTER_DEFAULT_MODEL;
|
||||
}
|
||||
|
||||
// OpenRouter limits metadata.user_id to 128 characters
|
||||
if (parsed.metadata?.user_id && parsed.metadata.user_id.length > 128) {
|
||||
parsed.metadata.user_id = parsed.metadata.user_id.slice(0, 128);
|
||||
}
|
||||
|
||||
return new TextEncoder().encode(JSON.stringify(parsed));
|
||||
}
|
||||
|
||||
async function proxyToOpenRouter(req: NextRequest, options: { params: Promise<{ path?: string[] }> }) {
|
||||
const apiKey = getEnvVariable("STACK_OPENROUTER_API_KEY");
|
||||
@ -39,54 +13,49 @@ async function proxyToOpenRouter(req: NextRequest, options: { params: Promise<{
|
||||
const subpath = params.path?.join("/") ?? "";
|
||||
|
||||
const contentType = req.headers.get("Content-Type");
|
||||
const body = req.method !== "GET" && req.method !== "HEAD"
|
||||
? Buffer.from(sanitizeBody(await req.arrayBuffer()))
|
||||
const sanitized = req.method !== "GET" && req.method !== "HEAD"
|
||||
? sanitizeBody(await req.arrayBuffer())
|
||||
: undefined;
|
||||
const body = sanitized ? Buffer.from(sanitized.bytes) : undefined;
|
||||
const callerApiKey = req.headers.get("x-api-key");
|
||||
const shouldLog = sanitized != null && callerApiKey != null && callerApiKey.startsWith("stack-auth-");
|
||||
const correlationId = crypto.randomUUID();
|
||||
const startedAt = Date.now();
|
||||
|
||||
if (apiKey === "FORWARD_TO_PRODUCTION") {
|
||||
const targetUrl = `${PRODUCTION_PROXY_BASE_URL}/${subpath}${req.nextUrl.search}`;
|
||||
const headers: Record<string, string> = {};
|
||||
if (contentType) {
|
||||
headers["Content-Type"] = contentType;
|
||||
}
|
||||
const targetUrl = apiKey === "FORWARD_TO_PRODUCTION"
|
||||
? `${PRODUCTION_PROXY_BASE_URL}/${subpath}${req.nextUrl.search}`
|
||||
: `${OPENROUTER_BASE_URL}/${subpath}${req.nextUrl.search}`;
|
||||
const forwardHeaders: Record<string, string> = apiKey === "FORWARD_TO_PRODUCTION"
|
||||
? {}
|
||||
: {
|
||||
"Authorization": `Bearer ${apiKey}`,
|
||||
"anthropic-version": "2023-06-01",
|
||||
};
|
||||
if (contentType) forwardHeaders["Content-Type"] = contentType;
|
||||
|
||||
const response = await fetch(targetUrl, {
|
||||
method: req.method,
|
||||
headers,
|
||||
body,
|
||||
});
|
||||
const response = await fetch(targetUrl, { method: req.method, headers: forwardHeaders, body });
|
||||
|
||||
return new Response(response.body, {
|
||||
status: response.status,
|
||||
headers: {
|
||||
"Content-Type": response.headers.get("Content-Type") ?? "application/json",
|
||||
"Cache-Control": "no-cache",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const targetUrl = `${OPENROUTER_BASE_URL}/${subpath}${req.nextUrl.search}`;
|
||||
const headers: Record<string, string> = {
|
||||
"Authorization": `Bearer ${apiKey}`,
|
||||
"anthropic-version": "2023-06-01",
|
||||
const responseHeaders = {
|
||||
"Content-Type": response.headers.get("Content-Type") ?? "application/json",
|
||||
"Cache-Control": "no-cache",
|
||||
};
|
||||
if (contentType) {
|
||||
headers["Content-Type"] = contentType;
|
||||
|
||||
const passthrough = () => new Response(response.body, { status: response.status, headers: responseHeaders });
|
||||
|
||||
if (!shouldLog) return passthrough();
|
||||
try {
|
||||
return await observeAndLog({
|
||||
response,
|
||||
sanitizedBody: sanitized!,
|
||||
callerApiKey,
|
||||
correlationId,
|
||||
startedAt,
|
||||
responseHeaders,
|
||||
});
|
||||
} catch (e) {
|
||||
captureError("ai-proxy-log-pipeline", e);
|
||||
return passthrough();
|
||||
}
|
||||
|
||||
const response = await fetch(targetUrl, {
|
||||
method: req.method,
|
||||
headers,
|
||||
body,
|
||||
});
|
||||
|
||||
return new Response(response.body, {
|
||||
status: response.status,
|
||||
headers: {
|
||||
"Content-Type": response.headers.get("Content-Type") ?? "application/json",
|
||||
"Cache-Control": "no-cache",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export const GET = handleApiRequest(proxyToOpenRouter);
|
||||
|
||||
140
apps/backend/src/lib/ai/ai-proxy-handlers.ts
Normal file
140
apps/backend/src/lib/ai/ai-proxy-handlers.ts
Normal file
@ -0,0 +1,140 @@
|
||||
import { logAiQuery } from "@/lib/ai/ai-query-logger";
|
||||
import { ALLOWED_MODEL_IDS } from "@/lib/ai/models";
|
||||
import { extractOpenRouterUsage, scanSseForUsage, type UsageFields } from "@/lib/ai/openrouter-usage";
|
||||
import { runAsynchronouslyAndWaitUntil } from "@/utils/background-tasks";
|
||||
import { captureError, StatusError } from "@stackframe/stack-shared/dist/utils/errors";
|
||||
|
||||
export const OPENROUTER_DEFAULT_MODEL = "anthropic/claude-sonnet-4.6";
|
||||
|
||||
export type SanitizedBody = {
|
||||
parsed: Record<string, unknown>,
|
||||
bytes: Uint8Array,
|
||||
};
|
||||
|
||||
export function sanitizeBody(raw: ArrayBuffer): SanitizedBody {
|
||||
const text = new TextDecoder().decode(raw);
|
||||
let parsed;
|
||||
try {
|
||||
parsed = JSON.parse(text);
|
||||
} catch {
|
||||
throw new StatusError(400, "Request body must be valid JSON");
|
||||
}
|
||||
|
||||
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
|
||||
throw new StatusError(400, "Request body must be a JSON object");
|
||||
}
|
||||
|
||||
if (!parsed.model || !ALLOWED_MODEL_IDS.has(parsed.model)) {
|
||||
parsed.model = OPENROUTER_DEFAULT_MODEL;
|
||||
}
|
||||
|
||||
if (parsed.metadata?.user_id && parsed.metadata.user_id.length > 128) {
|
||||
parsed.metadata.user_id = parsed.metadata.user_id.slice(0, 128);
|
||||
}
|
||||
|
||||
return { parsed: parsed as Record<string, unknown>, bytes: new TextEncoder().encode(JSON.stringify(parsed)) };
|
||||
}
|
||||
|
||||
function buildMessagesWithSystem(parsed: Record<string, unknown>): unknown[] {
|
||||
const messages = Array.isArray(parsed.messages) ? parsed.messages : [];
|
||||
const system = parsed.system;
|
||||
if (typeof system === "string" && system.length > 0) {
|
||||
return [{ role: "system", content: system }, ...messages];
|
||||
}
|
||||
return messages;
|
||||
}
|
||||
|
||||
type ProxyLogFields = {
|
||||
correlationId: string,
|
||||
parsed: Record<string, unknown>,
|
||||
apiKey: string,
|
||||
durationMs: bigint,
|
||||
responseStatus: number,
|
||||
usage?: UsageFields,
|
||||
};
|
||||
|
||||
function buildProxyLogRow(fields: ProxyLogFields) {
|
||||
const { parsed, apiKey, durationMs, responseStatus, usage, correlationId } = fields;
|
||||
const tools = Array.isArray(parsed.tools) ? parsed.tools : [];
|
||||
const toolNames = tools
|
||||
.map(t => (t && typeof t === "object" && "name" in t) ? (t as { name: unknown }).name : null)
|
||||
.filter((n): n is string => typeof n === "string");
|
||||
return {
|
||||
correlationId,
|
||||
mode: parsed.stream === true ? "stream" : "generate",
|
||||
systemPromptId: apiKey === "stack-auth-proxy" ? "stack-cli" : apiKey,
|
||||
quality: "unknown",
|
||||
speed: "unknown",
|
||||
modelId: String(parsed.model ?? OPENROUTER_DEFAULT_MODEL),
|
||||
isAuthenticated: false,
|
||||
projectId: undefined,
|
||||
userId: undefined,
|
||||
requestedToolsJson: JSON.stringify(toolNames),
|
||||
messagesJson: JSON.stringify(buildMessagesWithSystem(parsed)),
|
||||
stepsJson: "[]",
|
||||
finalText: "",
|
||||
inputTokens: usage?.inputTokens,
|
||||
outputTokens: usage?.outputTokens,
|
||||
cachedInputTokens: usage?.cachedInputTokens,
|
||||
costUsd: usage?.costUsd,
|
||||
stepCount: 0,
|
||||
durationMs,
|
||||
errorMessage: responseStatus >= 400 ? `upstream ${responseStatus}` : undefined,
|
||||
mcpCorrelationId: undefined,
|
||||
conversationId: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function scheduleLog(row: ReturnType<typeof buildProxyLogRow>) {
|
||||
try {
|
||||
const safe = logAiQuery(row).catch(e => captureError("ai-proxy-log-async", e));
|
||||
runAsynchronouslyAndWaitUntil(safe);
|
||||
} catch (e) {
|
||||
captureError("ai-proxy-log-sync", e);
|
||||
}
|
||||
}
|
||||
|
||||
export async function observeAndLog(args: {
|
||||
response: Response,
|
||||
sanitizedBody: SanitizedBody,
|
||||
callerApiKey: string,
|
||||
correlationId: string,
|
||||
startedAt: number,
|
||||
responseHeaders: Record<string, string>,
|
||||
}): Promise<Response> {
|
||||
const { response, sanitizedBody, callerApiKey, correlationId, startedAt, responseHeaders } = args;
|
||||
const isStreaming = sanitizedBody.parsed.stream === true;
|
||||
|
||||
if (isStreaming && response.body) {
|
||||
const [clientStream, observerStream] = response.body.tee();
|
||||
runAsynchronouslyAndWaitUntil((async () => {
|
||||
const usage = await scanSseForUsage(observerStream).catch(() => undefined);
|
||||
scheduleLog(buildProxyLogRow({
|
||||
correlationId,
|
||||
parsed: sanitizedBody.parsed,
|
||||
apiKey: callerApiKey,
|
||||
durationMs: BigInt(Date.now() - startedAt),
|
||||
responseStatus: response.status,
|
||||
usage,
|
||||
}));
|
||||
})());
|
||||
return new Response(clientStream, { status: response.status, headers: responseHeaders });
|
||||
}
|
||||
|
||||
const bodyBytes = await response.arrayBuffer();
|
||||
let parsedBody: unknown;
|
||||
try {
|
||||
parsedBody = JSON.parse(new TextDecoder().decode(bodyBytes));
|
||||
} catch {
|
||||
parsedBody = undefined;
|
||||
}
|
||||
scheduleLog(buildProxyLogRow({
|
||||
correlationId,
|
||||
parsed: sanitizedBody.parsed,
|
||||
apiKey: callerApiKey,
|
||||
durationMs: BigInt(Date.now() - startedAt),
|
||||
responseStatus: response.status,
|
||||
usage: extractOpenRouterUsage(parsedBody),
|
||||
}));
|
||||
return new Response(bodyBytes, { status: response.status, headers: responseHeaders });
|
||||
}
|
||||
282
apps/backend/src/lib/ai/ai-query-handlers.ts
Normal file
282
apps/backend/src/lib/ai/ai-query-handlers.ts
Normal file
@ -0,0 +1,282 @@
|
||||
import { logAiQuery } from "@/lib/ai/ai-query-logger";
|
||||
import { logMcpCall } from "@/lib/ai/mcp-logger";
|
||||
import { selectModel } from "@/lib/ai/models";
|
||||
import { extractCachedTokens, extractOpenRouterCost } from "@/lib/ai/openrouter-usage";
|
||||
import { reviewMcpCall } from "@/lib/ai/qa-reviewer";
|
||||
import { listManagedProjectIds } from "@/lib/projects";
|
||||
import type { SmartRequestAuth } from "@/route-handlers/smart-request";
|
||||
import { runAsynchronouslyAndWaitUntil } from "@/utils/background-tasks";
|
||||
import { captureError, StatusError } from "@stackframe/stack-shared/dist/utils/errors";
|
||||
import { Json } from "@stackframe/stack-shared/dist/utils/json";
|
||||
import { generateText, ModelMessage, stepCountIs, streamText, type LanguageModelUsage, type StepResult, type ToolSet } from "ai";
|
||||
|
||||
export const USER_FACING_ERROR_MESSAGE = "The AI service is temporarily unavailable. Please try again later.";
|
||||
|
||||
export const OPENROUTER_PROVIDER_OPTIONS = {
|
||||
usage: { include: true },
|
||||
extraBody: { stream_options: { include_usage: true } },
|
||||
} as const;
|
||||
|
||||
export type ContentBlock =
|
||||
| { type: "text", text: string }
|
||||
| {
|
||||
type: "tool-call",
|
||||
toolName: string,
|
||||
toolCallId: string,
|
||||
args: Json,
|
||||
argsText: string,
|
||||
result: Json,
|
||||
};
|
||||
|
||||
export type McpCallMetadata = {
|
||||
toolName: string,
|
||||
reason: string,
|
||||
userPrompt: string,
|
||||
conversationId?: string | null,
|
||||
};
|
||||
|
||||
type MessageLike = { role: string, content: unknown };
|
||||
|
||||
export type CommonLogFields = {
|
||||
correlationId: string,
|
||||
mode: "stream" | "generate",
|
||||
systemPromptId: string,
|
||||
quality: string,
|
||||
speed: string,
|
||||
modelId: string,
|
||||
isAuthenticated: boolean,
|
||||
projectId: string | undefined,
|
||||
userId: string | undefined,
|
||||
requestedToolsJson: string,
|
||||
messagesJson: string,
|
||||
mcpCorrelationId: string | undefined,
|
||||
conversationId: string | undefined,
|
||||
};
|
||||
|
||||
export type ModeContext = {
|
||||
model: ReturnType<typeof selectModel>,
|
||||
cachedMessages: ModelMessage[],
|
||||
toolsArg: ToolSet | undefined,
|
||||
stepLimit: number,
|
||||
common: CommonLogFields,
|
||||
startedAt: number,
|
||||
};
|
||||
|
||||
function buildStepsJson(steps: ReadonlyArray<StepResult<ToolSet>>): string {
|
||||
return JSON.stringify(steps.map((step, i) => ({
|
||||
step: i,
|
||||
text: step.text || undefined,
|
||||
toolCalls: step.toolCalls.map(tc => ({
|
||||
toolName: tc.toolName,
|
||||
toolCallId: tc.toolCallId,
|
||||
args: tc.input,
|
||||
})),
|
||||
toolResults: step.toolResults.map(tr => ({
|
||||
toolName: tr.toolName,
|
||||
toolCallId: tr.toolCallId,
|
||||
result: tr.output,
|
||||
})),
|
||||
})));
|
||||
}
|
||||
|
||||
function buildContentBlocks(steps: ReadonlyArray<StepResult<ToolSet>>): ContentBlock[] {
|
||||
const blocks: ContentBlock[] = [];
|
||||
for (const step of steps) {
|
||||
if (step.text) {
|
||||
blocks.push({ type: "text", text: step.text });
|
||||
}
|
||||
const resultsByCallId = new Map(step.toolResults.map(r => [r.toolCallId, r]));
|
||||
for (const toolCall of step.toolCalls) {
|
||||
const toolResult = resultsByCallId.get(toolCall.toolCallId);
|
||||
blocks.push({
|
||||
type: "tool-call",
|
||||
toolName: toolCall.toolName,
|
||||
toolCallId: toolCall.toolCallId,
|
||||
args: toolCall.input,
|
||||
argsText: JSON.stringify(toolCall.input),
|
||||
result: (toolResult?.output ?? null) as Json,
|
||||
});
|
||||
}
|
||||
}
|
||||
return blocks;
|
||||
}
|
||||
|
||||
function logSuccess(args: {
|
||||
common: CommonLogFields,
|
||||
startedAt: number,
|
||||
steps: ReadonlyArray<StepResult<ToolSet>>,
|
||||
text: string,
|
||||
usage: LanguageModelUsage,
|
||||
providerMetadata: unknown,
|
||||
}): void {
|
||||
const { common, startedAt, steps, text, usage, providerMetadata } = args;
|
||||
runAsynchronouslyAndWaitUntil(logAiQuery({
|
||||
...common,
|
||||
stepsJson: buildStepsJson(steps),
|
||||
finalText: text,
|
||||
inputTokens: usage.inputTokens ?? undefined,
|
||||
outputTokens: usage.outputTokens ?? undefined,
|
||||
cachedInputTokens: extractCachedTokens(providerMetadata),
|
||||
costUsd: extractOpenRouterCost(providerMetadata),
|
||||
stepCount: steps.length,
|
||||
durationMs: BigInt(Date.now() - startedAt),
|
||||
errorMessage: undefined,
|
||||
}));
|
||||
}
|
||||
|
||||
function logFailure(args: {
|
||||
common: CommonLogFields,
|
||||
startedAt: number,
|
||||
err: unknown,
|
||||
}): void {
|
||||
const { common, startedAt, err } = args;
|
||||
captureError("ai-query-upstream", err);
|
||||
runAsynchronouslyAndWaitUntil(logAiQuery({
|
||||
...common,
|
||||
stepsJson: "[]",
|
||||
finalText: "",
|
||||
inputTokens: undefined,
|
||||
outputTokens: undefined,
|
||||
cachedInputTokens: undefined,
|
||||
costUsd: undefined,
|
||||
stepCount: 0,
|
||||
durationMs: BigInt(Date.now() - startedAt),
|
||||
errorMessage: err instanceof Error ? err.message : String(err),
|
||||
}));
|
||||
}
|
||||
|
||||
export async function assertProjectAccess(projectId: string, auth: SmartRequestAuth | null): Promise<void> {
|
||||
if (auth?.project.id !== "internal" || auth.user == null) {
|
||||
throw new StatusError(StatusError.Forbidden, "You do not have access to this project");
|
||||
}
|
||||
const managedProjectIds = await listManagedProjectIds(auth.user);
|
||||
if (!managedProjectIds.includes(projectId)) {
|
||||
throw new StatusError(StatusError.Forbidden, "You do not have access to this project");
|
||||
}
|
||||
}
|
||||
|
||||
function logMcpCallAndReview(args: {
|
||||
mcpCallMetadata: McpCallMetadata,
|
||||
conversationId: string,
|
||||
correlationId: string,
|
||||
messages: ReadonlyArray<MessageLike>,
|
||||
contentBlocks: ContentBlock[],
|
||||
finalText: string,
|
||||
stepCount: number,
|
||||
startedAt: number,
|
||||
modelId: string,
|
||||
}): void {
|
||||
const { mcpCallMetadata, conversationId, correlationId, messages, contentBlocks, finalText, stepCount, startedAt, modelId } = args;
|
||||
const firstUserMessage = messages.find(m => m.role === "user");
|
||||
const question = typeof firstUserMessage?.content === "string"
|
||||
? firstUserMessage.content
|
||||
: JSON.stringify(firstUserMessage?.content ?? "");
|
||||
const innerToolCallsJson = JSON.stringify(contentBlocks.filter(b => b.type === "tool-call"));
|
||||
|
||||
const logPromise = logMcpCall({
|
||||
correlationId,
|
||||
toolName: mcpCallMetadata.toolName,
|
||||
reason: mcpCallMetadata.reason,
|
||||
userPrompt: mcpCallMetadata.userPrompt,
|
||||
conversationId,
|
||||
question,
|
||||
response: finalText,
|
||||
stepCount,
|
||||
innerToolCallsJson,
|
||||
durationMs: BigInt(Date.now() - startedAt),
|
||||
modelId,
|
||||
errorMessage: undefined,
|
||||
});
|
||||
runAsynchronouslyAndWaitUntil(logPromise);
|
||||
|
||||
runAsynchronouslyAndWaitUntil(reviewMcpCall({
|
||||
logPromise,
|
||||
correlationId,
|
||||
question,
|
||||
reason: mcpCallMetadata.reason,
|
||||
response: finalText,
|
||||
}));
|
||||
}
|
||||
|
||||
export function handleStreamMode(ctx: ModeContext) {
|
||||
const { model, cachedMessages, toolsArg, stepLimit, common, startedAt } = ctx;
|
||||
const result = streamText({
|
||||
model,
|
||||
messages: cachedMessages,
|
||||
tools: toolsArg,
|
||||
stopWhen: stepCountIs(stepLimit),
|
||||
providerOptions: { openrouter: OPENROUTER_PROVIDER_OPTIONS },
|
||||
onFinish: ({ text, steps, usage, providerMetadata }) => {
|
||||
logSuccess({ common, startedAt, steps, text, usage, providerMetadata });
|
||||
},
|
||||
onError: ({ error }) => logFailure({ common, startedAt, err: error }),
|
||||
});
|
||||
return {
|
||||
statusCode: 200,
|
||||
bodyType: "response" as const,
|
||||
body: result.toUIMessageStreamResponse({
|
||||
onError: () => USER_FACING_ERROR_MESSAGE,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
export async function handleGenerateMode(ctx: ModeContext & {
|
||||
messages: ReadonlyArray<MessageLike>,
|
||||
mcpCallMetadata: McpCallMetadata | undefined,
|
||||
correlationId: string,
|
||||
conversationIdForLog: string | undefined,
|
||||
}) {
|
||||
const { model, cachedMessages, toolsArg, stepLimit, common, startedAt, messages, mcpCallMetadata, correlationId, conversationIdForLog } = ctx;
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), 120_000);
|
||||
let result: Awaited<ReturnType<typeof generateText>>;
|
||||
try {
|
||||
result = await generateText({
|
||||
model,
|
||||
messages: cachedMessages,
|
||||
tools: toolsArg,
|
||||
abortSignal: controller.signal,
|
||||
stopWhen: stepCountIs(stepLimit),
|
||||
providerOptions: { openrouter: OPENROUTER_PROVIDER_OPTIONS },
|
||||
}).finally(() => clearTimeout(timeoutId));
|
||||
} catch (err) {
|
||||
logFailure({ common, startedAt, err });
|
||||
throw new StatusError(StatusError.BadGateway, USER_FACING_ERROR_MESSAGE);
|
||||
}
|
||||
|
||||
const contentBlocks = buildContentBlocks(result.steps);
|
||||
logSuccess({
|
||||
common,
|
||||
startedAt,
|
||||
steps: result.steps,
|
||||
text: result.text,
|
||||
usage: result.usage,
|
||||
providerMetadata: result.providerMetadata,
|
||||
});
|
||||
|
||||
let responseConversationId: string | undefined;
|
||||
if (mcpCallMetadata != null && conversationIdForLog != null) {
|
||||
responseConversationId = conversationIdForLog;
|
||||
logMcpCallAndReview({
|
||||
mcpCallMetadata,
|
||||
conversationId: conversationIdForLog,
|
||||
correlationId,
|
||||
messages,
|
||||
contentBlocks,
|
||||
finalText: result.text,
|
||||
stepCount: result.steps.length,
|
||||
startedAt,
|
||||
modelId: String(model.modelId),
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
statusCode: 200,
|
||||
bodyType: "json" as const,
|
||||
body: {
|
||||
content: contentBlocks,
|
||||
finalText: result.text,
|
||||
conversationId: responseConversationId ?? null,
|
||||
},
|
||||
};
|
||||
}
|
||||
@ -23,7 +23,7 @@ export async function getConnection(): Promise<DbConnection | null> {
|
||||
.onApplied(() => {
|
||||
resolve(connInstance);
|
||||
})
|
||||
.subscribe(["SELECT * FROM mcp_call_log", "SELECT * FROM ai_query_log"]);
|
||||
.subscribe(["SELECT * FROM mcp_call_log"]);
|
||||
})
|
||||
.onConnectError((_: unknown, err: Error) => {
|
||||
captureError("mcp-logger", err);
|
||||
|
||||
95
apps/backend/src/lib/ai/openrouter-usage.ts
Normal file
95
apps/backend/src/lib/ai/openrouter-usage.ts
Normal file
@ -0,0 +1,95 @@
|
||||
import type { OpenRouterUsageAccounting } from "@openrouter/ai-sdk-provider";
|
||||
|
||||
export type UsageFields = {
|
||||
inputTokens?: number,
|
||||
outputTokens?: number,
|
||||
cachedInputTokens?: number,
|
||||
costUsd?: number,
|
||||
};
|
||||
|
||||
type ProviderMetadata = { openrouter?: { usage?: OpenRouterUsageAccounting } };
|
||||
|
||||
export function extractOpenRouterCost(meta: unknown): number | undefined {
|
||||
return (meta as ProviderMetadata | null | undefined)?.openrouter?.usage?.cost;
|
||||
}
|
||||
|
||||
export function extractCachedTokens(meta: unknown): number | undefined {
|
||||
return (meta as ProviderMetadata | null | undefined)?.openrouter?.usage?.promptTokensDetails?.cachedTokens;
|
||||
}
|
||||
|
||||
type RawUsage = {
|
||||
input_tokens?: number,
|
||||
output_tokens?: number,
|
||||
cache_read_input_tokens?: number,
|
||||
cache_creation_input_tokens?: number,
|
||||
prompt_tokens?: number,
|
||||
completion_tokens?: number,
|
||||
prompt_tokens_details?: { cached_tokens?: number },
|
||||
cost?: number,
|
||||
};
|
||||
|
||||
type SseEvent = {
|
||||
usage?: RawUsage,
|
||||
message?: { usage?: RawUsage },
|
||||
delta?: { usage?: RawUsage },
|
||||
};
|
||||
|
||||
const emptyUsage = (): UsageFields => ({});
|
||||
const isUsageEmpty = (u: UsageFields): boolean =>
|
||||
u.inputTokens == null && u.outputTokens == null && u.cachedInputTokens == null && u.costUsd == null;
|
||||
|
||||
function readUsageBlock(usage: RawUsage, into: UsageFields): void {
|
||||
// Anthropic splits prompt tokens across three buckets; sum for parity with OpenAI's `prompt_tokens`.
|
||||
if (usage.input_tokens != null) {
|
||||
into.inputTokens = usage.input_tokens + (usage.cache_read_input_tokens ?? 0) + (usage.cache_creation_input_tokens ?? 0);
|
||||
} else {
|
||||
into.inputTokens = usage.prompt_tokens ?? into.inputTokens;
|
||||
}
|
||||
into.outputTokens = usage.output_tokens ?? usage.completion_tokens ?? into.outputTokens;
|
||||
into.cachedInputTokens = usage.cache_read_input_tokens ?? usage.prompt_tokens_details?.cached_tokens ?? into.cachedInputTokens;
|
||||
into.costUsd = usage.cost ?? into.costUsd;
|
||||
}
|
||||
|
||||
function mergeUsageFromEvent(event: unknown, into: UsageFields): void {
|
||||
if (event == null || typeof event !== "object") return;
|
||||
const e = event as SseEvent;
|
||||
if (e.usage) readUsageBlock(e.usage, into);
|
||||
if (e.message?.usage) readUsageBlock(e.message.usage, into);
|
||||
if (e.delta?.usage) readUsageBlock(e.delta.usage, into);
|
||||
}
|
||||
|
||||
export function extractOpenRouterUsage(obj: unknown): UsageFields | undefined {
|
||||
const acc = emptyUsage();
|
||||
mergeUsageFromEvent(obj, acc);
|
||||
return isUsageEmpty(acc) ? undefined : acc;
|
||||
}
|
||||
|
||||
export async function scanSseForUsage(stream: ReadableStream<Uint8Array>): Promise<UsageFields | undefined> {
|
||||
const reader = stream.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
const acc = emptyUsage();
|
||||
let buffer = "";
|
||||
try {
|
||||
while (true) {
|
||||
const { value, done } = await reader.read();
|
||||
if (done) break;
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
let boundary: number;
|
||||
while ((boundary = buffer.indexOf("\n\n")) !== -1) {
|
||||
const block = buffer.slice(0, boundary);
|
||||
buffer = buffer.slice(boundary + 2);
|
||||
for (const line of block.split("\n")) {
|
||||
if (!line.startsWith("data:")) continue;
|
||||
const dataStr = line.slice(5).trim();
|
||||
if (!dataStr || dataStr === "[DONE]") continue;
|
||||
try {
|
||||
mergeUsageFromEvent(JSON.parse(dataStr), acc);
|
||||
} catch { /* malformed event — skip */ }
|
||||
}
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
reader.releaseLock();
|
||||
}
|
||||
return isUsageEmpty(acc) ? undefined : acc;
|
||||
}
|
||||
@ -1,6 +1,6 @@
|
||||
import { useUser } from "@stackframe/stack";
|
||||
import { clsx } from "clsx";
|
||||
import { useState } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { AddManualQa } from "../components/AddManualQa";
|
||||
import { Analytics } from "../components/Analytics";
|
||||
import { CallLogDetail } from "../components/CallLogDetail";
|
||||
@ -13,13 +13,31 @@ import { makeMcpReviewApi } from "../lib/mcp-review-api";
|
||||
import type { AiQueryLogRow, McpCallLogRow } from "../types";
|
||||
|
||||
type Tab = "calls" | "knowledge" | "analytics" | "usage";
|
||||
const TAB_STORAGE_KEY = "internal-tool-active-tab";
|
||||
const VALID_TABS: readonly Tab[] = ["calls", "knowledge", "analytics", "usage"];
|
||||
|
||||
function readInitialTab(): Tab {
|
||||
// sessionStorage is per-tab: reload preserves the active tab, but a brand-new
|
||||
// browser tab gets the default ("calls").
|
||||
if (typeof window === "undefined") return "calls";
|
||||
const saved = window.sessionStorage.getItem(TAB_STORAGE_KEY);
|
||||
if (saved != null && (VALID_TABS as readonly string[]).includes(saved)) {
|
||||
return saved as Tab;
|
||||
}
|
||||
return "calls";
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
const user = useUser({ or: process.env.NODE_ENV === "development" ? "redirect" : "return-null" });
|
||||
const [selectedRow, setSelectedRow] = useState<McpCallLogRow | null>(null);
|
||||
const [selectedUsageRow, setSelectedUsageRow] = useState<AiQueryLogRow | null>(null);
|
||||
const [showAddQa, setShowAddQa] = useState(false);
|
||||
const [tab, setTab] = useState<Tab>("calls");
|
||||
const [tab, setTab] = useState<Tab>(readInitialTab);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === "undefined") return;
|
||||
window.sessionStorage.setItem(TAB_STORAGE_KEY, tab);
|
||||
}, [tab]);
|
||||
const { rows, connectionState } = useMcpCallLogs();
|
||||
const { rows: usageRows, connectionState: usageConnectionState } = useAiQueryLogs();
|
||||
|
||||
@ -77,8 +95,8 @@ export default function App() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
<header className="bg-white border-b border-gray-200 px-6 py-3 flex items-center justify-between">
|
||||
<div className="h-screen flex flex-col bg-gray-50">
|
||||
<header className="shrink-0 bg-white border-b border-gray-200 px-6 py-3 flex items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<h1 className="text-lg font-semibold text-gray-900">MCP Review Tool</h1>
|
||||
{/* Tabs */}
|
||||
@ -154,82 +172,90 @@ export default function App() {
|
||||
/>
|
||||
)}
|
||||
|
||||
{tab === "calls" && (
|
||||
<div className="flex">
|
||||
<main className="flex-1 p-6">
|
||||
<CallLogList
|
||||
rows={rows}
|
||||
connectionState={connectionState}
|
||||
onSelect={setSelectedRow}
|
||||
selectedId={selectedRow?.id}
|
||||
/>
|
||||
</main>
|
||||
{currentSelectedRow && (
|
||||
<aside className="w-[480px] border-l border-gray-200 bg-white overflow-y-auto h-[calc(100vh-57px)]">
|
||||
<CallLogDetail
|
||||
row={currentSelectedRow}
|
||||
allRows={rows}
|
||||
onClose={() => setSelectedRow(null)}
|
||||
onSaveCorrection={(correlationId, correctedQuestion, correctedAnswer, publish) => {
|
||||
<div className="flex-1 overflow-hidden flex">
|
||||
{tab === "calls" && (
|
||||
<>
|
||||
<main className="flex-1 overflow-y-auto p-6">
|
||||
<CallLogList
|
||||
rows={rows}
|
||||
connectionState={connectionState}
|
||||
onSelect={setSelectedRow}
|
||||
selectedId={selectedRow?.id}
|
||||
/>
|
||||
</main>
|
||||
{currentSelectedRow && (
|
||||
<aside className="w-[480px] shrink-0 border-l border-gray-200 bg-white overflow-y-auto">
|
||||
<CallLogDetail
|
||||
row={currentSelectedRow}
|
||||
allRows={rows}
|
||||
onClose={() => setSelectedRow(null)}
|
||||
onSaveCorrection={(correlationId, correctedQuestion, correctedAnswer, publish) => {
|
||||
getApi()
|
||||
.then(api => api.updateCorrection({ correlationId, correctedQuestion, correctedAnswer, publish }))
|
||||
.catch(() => { /* errors are surfaced by UI state */ });
|
||||
}}
|
||||
onMarkReviewed={(correlationId) => {
|
||||
getApi()
|
||||
.then(api => api.markReviewed({ correlationId }))
|
||||
.catch(() => { /* errors are surfaced by UI state */ });
|
||||
}}
|
||||
/>
|
||||
</aside>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{tab === "knowledge" && (
|
||||
<main className="flex-1 overflow-y-auto">
|
||||
<div className="p-6 max-w-4xl mx-auto">
|
||||
<KnowledgeBase
|
||||
rows={rows}
|
||||
onSave={(correlationId, question, answer, publish) => {
|
||||
getApi()
|
||||
.then(api => api.updateCorrection({ correlationId, correctedQuestion, correctedAnswer, publish }))
|
||||
.then(api => api.updateCorrection({ correlationId, correctedQuestion: question, correctedAnswer: answer, publish }))
|
||||
.catch(() => { /* errors are surfaced by UI state */ });
|
||||
}}
|
||||
onMarkReviewed={(correlationId) => {
|
||||
onDelete={(correlationId) => {
|
||||
getApi()
|
||||
.then(api => api.markReviewed({ correlationId }))
|
||||
.then(api => api.delete({ correlationId }))
|
||||
.catch(() => { /* errors are surfaced by UI state */ });
|
||||
}}
|
||||
/>
|
||||
</aside>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{tab === "knowledge" && (
|
||||
<main className="p-6 max-w-4xl mx-auto">
|
||||
<KnowledgeBase
|
||||
rows={rows}
|
||||
onSave={(correlationId, question, answer, publish) => {
|
||||
getApi()
|
||||
.then(api => api.updateCorrection({ correlationId, correctedQuestion: question, correctedAnswer: answer, publish }))
|
||||
.catch(() => { /* errors are surfaced by UI state */ });
|
||||
}}
|
||||
onDelete={(correlationId) => {
|
||||
getApi()
|
||||
.then(api => api.delete({ correlationId }))
|
||||
.catch(() => { /* errors are surfaced by UI state */ });
|
||||
}}
|
||||
/>
|
||||
</main>
|
||||
)}
|
||||
|
||||
{tab === "analytics" && (
|
||||
<main className="p-6 max-w-6xl mx-auto">
|
||||
<Analytics rows={rows} />
|
||||
</main>
|
||||
)}
|
||||
|
||||
{tab === "usage" && (
|
||||
<div className="flex">
|
||||
<main className="flex-1 p-6 max-w-6xl mx-auto">
|
||||
<Usage
|
||||
rows={usageRows}
|
||||
connectionState={usageConnectionState}
|
||||
onSelect={setSelectedUsageRow}
|
||||
selectedId={selectedUsageRow?.id}
|
||||
/>
|
||||
</div>
|
||||
</main>
|
||||
{selectedUsageRow && (
|
||||
<aside className="w-[480px] border-l border-gray-200 bg-white overflow-hidden h-[calc(100vh-57px)]">
|
||||
<UsageDetail
|
||||
row={usageRows.find(r => r.id === selectedUsageRow.id) ?? selectedUsageRow}
|
||||
onClose={() => setSelectedUsageRow(null)}
|
||||
/>
|
||||
</aside>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
)}
|
||||
|
||||
{tab === "analytics" && (
|
||||
<main className="flex-1 overflow-y-auto">
|
||||
<div className="p-6 max-w-6xl mx-auto">
|
||||
<Analytics rows={rows} />
|
||||
</div>
|
||||
</main>
|
||||
)}
|
||||
|
||||
{tab === "usage" && (
|
||||
<>
|
||||
<main className="flex-1 overflow-y-auto">
|
||||
<div className="p-6 max-w-6xl mx-auto">
|
||||
<Usage
|
||||
rows={usageRows}
|
||||
connectionState={usageConnectionState}
|
||||
onSelect={setSelectedUsageRow}
|
||||
selectedId={selectedUsageRow?.id}
|
||||
/>
|
||||
</div>
|
||||
</main>
|
||||
{selectedUsageRow && (
|
||||
<aside className="w-[480px] shrink-0 border-l border-gray-200 bg-white overflow-y-auto">
|
||||
<UsageDetail
|
||||
row={usageRows.find(r => r.id === selectedUsageRow.id) ?? selectedUsageRow}
|
||||
onClose={() => setSelectedUsageRow(null)}
|
||||
/>
|
||||
</aside>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -676,9 +676,7 @@ function SortHeader({
|
||||
|
||||
function formatUsd(value: number): string {
|
||||
if (value === 0) return "$0";
|
||||
if (value < 0.01) return `$${value.toFixed(4)}`;
|
||||
if (value < 1) return `$${value.toFixed(3)}`;
|
||||
return `$${value.toFixed(2)}`;
|
||||
return `$${value.toFixed(4)}`;
|
||||
}
|
||||
|
||||
function MetricCard({ label, value, valueClass }: { label: string, value: string, valueClass?: string }) {
|
||||
|
||||
@ -103,7 +103,7 @@ export function UsageDetail({ row, onClose }: { row: AiQueryLogRow, onClose: ()
|
||||
<> (cached {row.cachedInputTokens.toLocaleString()})</>
|
||||
)}
|
||||
{" · "}out {row.outputTokens?.toLocaleString() ?? "?"} tok
|
||||
{row.costUsd != null && <>{" · "}{row.costUsd < 0.01 ? `$${row.costUsd.toFixed(4)}` : `$${row.costUsd.toFixed(3)}`}</>}
|
||||
{row.costUsd != null && <>{" · "}${row.costUsd.toFixed(4)}</>}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
|
||||
Loading…
Reference in New Issue
Block a user