-
Notifications
You must be signed in to change notification settings - Fork 513
AI analytics #1339
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: dev
Are you sure you want to change the base?
AI analytics #1339
Changes from all commits
66af47d
30d53e8
5078747
aaf49db
844e916
274c742
c7a3cca
ef2289f
d8065c4
3b27eee
b82efa4
158498b
b22d4b0
95ca0a2
fbab066
73152a1
e16040c
a07dbab
ef77edc
84dffa2
a0486e9
8c596ec
ef6963d
1c69185
f794bd6
59a060a
0485c73
97ee052
411f775
c514efd
516c424
b0e3341
a630be1
8c7bc54
7a54be9
bd3925d
ca461d4
224468c
042e616
149d6d7
574cc4a
3293845
d8e99d6
fa4c814
a4c3306
35739af
15e5879
140ee7e
afd84bc
b0a329f
c819537
7a2332f
83a37d1
4fb5154
30e3e5c
edd33b1
a43eb11
1ccef9c
4965534
9ba7b5e
ddde9c6
c329a46
26ce83f
f9386a8
dc5ab66
2532632
53a9f2c
0eff6b2
170b4fe
a0bab5d
3654af5
dbc7988
d8b7499
331d208
49d2c04
60c538b
0aea8ef
39facf4
45ff5f2
89d43b1
971bad9
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
|
nams1570 marked this conversation as resolved.
|
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,21 +1,44 @@ | ||
| import { logMcpCall } from "@/lib/ai/mcp-logger"; | ||
| import { | ||
| assertProjectAccess, | ||
| handleGenerateMode, | ||
| handleStreamMode, | ||
| } from "@/lib/ai/ai-query-handlers"; | ||
| import type { CommonLogFields, ModeContext } from "@/lib/ai/types"; | ||
| import { selectModel } from "@/lib/ai/models"; | ||
| import { getFullSystemPrompt } from "@/lib/ai/prompts"; | ||
| import { reviewMcpCall } from "@/lib/ai/qa-reviewer"; | ||
| import { getFullSystemPrompt, type SystemPromptId } from "@/lib/ai/prompts"; | ||
| import { requestBodySchema } from "@/lib/ai/schema"; | ||
| import { getTools } from "@/lib/ai/tools"; | ||
| import { getVerifiedQaContext } from "@/lib/ai/verified-qa"; | ||
| import { listManagedProjectIds } from "@/lib/projects"; | ||
| import { getVerifiedQaContext } from "@/lib/ai/qa/verified-qa"; | ||
| import { SmartResponse } from "@/route-handlers/smart-response"; | ||
| import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler"; | ||
| import { runAsynchronouslyAndWaitUntil } from "@/utils/background-tasks"; | ||
| import { validateImageAttachments } from "@stackframe/stack-shared/dist/ai/image-limits"; | ||
| import { ChatContent } from "@stackframe/stack-shared/dist/interface/admin-interface"; | ||
| import { KnownErrors } from "@stackframe/stack-shared/dist/known-errors"; | ||
| import { yupMixed, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields"; | ||
| import { getEnvVariable } from "@stackframe/stack-shared/dist/utils/env"; | ||
| import { StatusError } from "@stackframe/stack-shared/dist/utils/errors"; | ||
| import { Json } from "@stackframe/stack-shared/dist/utils/json"; | ||
| import { generateText, stepCountIs, streamText, type ModelMessage } from "ai"; | ||
| import type { ModelMessage } from "ai"; | ||
|
|
||
| function getStepLimit(systemPromptId: SystemPromptId, hasTools: boolean): number { | ||
| if (!hasTools) return 1; | ||
| if (systemPromptId === "docs-ask-ai" || systemPromptId === "command-center-ask-ai") return 50; | ||
| if (systemPromptId === "create-dashboard") return 12; | ||
| return 5; | ||
| } | ||
|
|
||
| async function buildSystemPrompt(systemPromptId: SystemPromptId): Promise<string> { | ||
| let systemPrompt = getFullSystemPrompt(systemPromptId); | ||
| const isDocsOrSearch = systemPromptId === "docs-ask-ai" || systemPromptId === "command-center-ask-ai"; | ||
| if (isDocsOrSearch) { | ||
| // Stuffing the entire verified QA corpus into the system prompt on every | ||
| // request is intentionally naive — it grows monotonically with each new | ||
| // QA pair and re-fetches/re-sends content that's unchanged across | ||
| // requests. Once the corpus is large enough to matter we should swap to | ||
| // a retriever based system (maybe something like an embedding-based retriever | ||
| // (top-k by query similarity)) and/or cache the assembled context, | ||
| // but for the current corpus size this is fine and lets the model see everything | ||
| systemPrompt += await getVerifiedQaContext(); | ||
| } | ||
| return systemPrompt; | ||
| } | ||
|
|
||
| export const POST = createSmartRouteHandler({ | ||
| metadata: { | ||
|
|
@@ -34,144 +57,81 @@ 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 imageValidationResult = validateImageAttachments(messages); | ||
| if (!imageValidationResult.ok) { | ||
| throw new StatusError(StatusError.BadRequest, imageValidationResult.reason); | ||
| const { failure } = imageValidationResult; | ||
| switch (failure.code) { | ||
| case "too_many": { | ||
| throw new KnownErrors.TooManyImageAttachments(failure.maxImages); | ||
| } | ||
| case "too_large": { | ||
| throw new KnownErrors.ImageAttachmentTooLarge(failure.maxBytes, failure.actualBytes); | ||
| } | ||
| } | ||
| } | ||
|
|
||
| const authenticatedApiKey = isAuthenticated | ||
| ? getEnvVariable("STACK_OPENROUTER_AUTHENTICATED_API_KEY", "") | ||
| : ""; | ||
| const model = selectModel(quality, speed, isAuthenticated, authenticatedApiKey || undefined); | ||
| const isDocsOrSearch = systemPromptId === "docs-ask-ai" || systemPromptId === "command-center-ask-ai"; | ||
| let systemPrompt = getFullSystemPrompt(systemPromptId); | ||
| if (isDocsOrSearch) { | ||
| systemPrompt += await getVerifiedQaContext(); | ||
| } | ||
| const systemPrompt = await buildSystemPrompt(systemPromptId); | ||
| const tools = await getTools(toolNames, { auth: fullReq.auth, targetProjectId: projectId }); | ||
| const toolsArg = Object.keys(tools).length > 0 ? tools : undefined; | ||
| const isCreateDashboard = systemPromptId === "create-dashboard"; | ||
| const isBuildAnalyticsQuery = systemPromptId === "build-analytics-query"; | ||
| const stepLimit = toolsArg == null | ||
| ? 1 | ||
| : isDocsOrSearch | ||
| ? 50 | ||
| : isCreateDashboard | ||
| ? 12 | ||
| : isBuildAnalyticsQuery | ||
| ? 5 | ||
| : 5; | ||
| const stepLimit = getStepLimit(systemPromptId, toolsArg != null); | ||
|
|
||
| const correlationId = crypto.randomUUID(); | ||
| const conversationIdForLog = body.mcpCallMetadata | ||
| ? body.mcpCallMetadata.conversationId ?? crypto.randomUUID() | ||
| : undefined; | ||
| const common: CommonLogFields = { | ||
| correlationId, | ||
| mode, | ||
|
Comment on lines
+89
to
+90
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Prompt To Fix With AIThis is a comment left during a code review.
Path: apps/backend/src/app/api/latest/ai/query/[mode]/route.ts
Line: 89-90
Comment:
**`mcpCorrelationId` is always equal to `correlationId`**
`correlationId` is the UUID freshly minted for this AI query, and `mcpCorrelationId` is set to that same value when `mcpCallMetadata` is present. In `logIfMcpToolCall`, the MCP call log row is also written with the same `correlationId`. So `ai_query_log.mcpCorrelationId` and `ai_query_log.correlationId` carry identical values, making the field redundant. The field name implies a _different_ ID (the MCP log's own correlation key), which will confuse future maintainers. Either give the MCP call log its own distinct UUID and store that here, or remove the `mcpCorrelationId` column and rely on the shared `correlationId`.
How can I resolve this? If you propose a fix, please make it concise. |
||
| systemPromptId, | ||
| quality, | ||
| speed, | ||
| modelId: String(model.modelId), | ||
| isAuthenticated, | ||
| projectId: projectId ?? undefined, | ||
| userId: fullReq.auth?.user?.id, | ||
| requestedToolsJson: JSON.stringify(toolNames), | ||
| messagesJson: JSON.stringify(messages), | ||
| mcpCorrelationId: body.mcpCallMetadata ? correlationId : undefined, | ||
| conversationId: conversationIdForLog, | ||
|
aadesh18 marked this conversation as resolved.
|
||
| }; | ||
| const startedAt = performance.now(); | ||
|
|
||
| const isAnthropic = model.modelId.startsWith("anthropic/"); | ||
| // Can be optimized: only opt into prompt caching for routes that are hit | ||
| // frequently enough to amortize the write. | ||
| const systemMessage: ModelMessage = { | ||
| role: "system", | ||
| content: systemPrompt, | ||
| ...(isAnthropic && { | ||
| providerOptions: { | ||
| openrouter: { cacheControl: { type: "ephemeral" } }, | ||
| }, | ||
| }), | ||
| }; | ||
|
Comment on lines
+110
to
+116
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I want to point out a few things. Here, we're caching for only 5 min (https://openrouter.ai/docs/guides/best-practices/prompt-caching#cache-ttl-options)
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. As we discussed, most of the routes are being hit at least once in 5 mins. however in future we should think about not having the cache for the routes that are not being hit too often. added a comment explain the same |
||
| // Cast: the schema narrows role and leaves content as unknown, but the | ||
| // AI SDK accepts a superset (role: "system" etc.). We've intentionally | ||
| // excluded `system` at the schema layer to prevent prompt-injection via | ||
| // client-supplied system messages — see schema.ts. | ||
| const modelMessages = messages as unknown as ModelMessage[]; | ||
| const cachedMessages: ModelMessage[] = [systemMessage, ...modelMessages]; | ||
|
|
||
| if (mode === "stream") { | ||
| const result = streamText({ | ||
| model, | ||
| system: systemPrompt, | ||
| messages: modelMessages, | ||
| tools: toolsArg, | ||
| stopWhen: stepCountIs(stepLimit), | ||
| }); | ||
| return { | ||
| statusCode: 200, | ||
| bodyType: "response" as const, | ||
| body: result.toUIMessageStreamResponse(), | ||
| }; | ||
| } else { | ||
| const startedAt = Date.now(); | ||
| const controller = new AbortController(); | ||
| const timeoutId = setTimeout(() => controller.abort(), 120_000); | ||
| const result = await generateText({ | ||
| model, | ||
| system: systemPrompt, | ||
| messages: modelMessages, | ||
| tools: toolsArg, | ||
| abortSignal: controller.signal, | ||
| stopWhen: stepCountIs(stepLimit), | ||
| }).finally(() => clearTimeout(timeoutId)); | ||
|
|
||
| const content: ChatContent = result.steps.flatMap((step) => { | ||
| const blocks: ChatContent = []; | ||
| if (step.text) { | ||
| blocks.push({ type: "text", text: step.text }); | ||
| } | ||
| const outById = new Map(step.toolResults.map((r) => [r.toolCallId, r.output as Json])); | ||
| for (const call of step.toolCalls) { | ||
| blocks.push({ | ||
| type: "tool-call", | ||
| toolName: call.toolName, | ||
| toolCallId: call.toolCallId, | ||
| args: call.input as Json, | ||
| argsText: JSON.stringify(call.input), | ||
| result: outById.get(call.toolCallId) ?? null, | ||
| }); | ||
| } | ||
| return blocks; | ||
| }); | ||
|
|
||
| let responseConversationId: string | undefined; | ||
| if (body.mcpCallMetadata != null) { | ||
| const correlationId = crypto.randomUUID(); | ||
| const conversationId = body.mcpCallMetadata.conversationId ?? crypto.randomUUID(); | ||
| 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(content.filter(b => b.type === "tool-call")); | ||
| const ctx: ModeContext = { model, cachedMessages, toolsArg, stepLimit, common, startedAt }; | ||
| const extras = { | ||
| messages, | ||
| mcpCallMetadata: body.mcpCallMetadata ?? undefined, | ||
| correlationId, | ||
| conversationIdForLog, | ||
| }; | ||
|
|
||
| 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, | ||
| finalText: result.text, | ||
| conversationId: responseConversationId ?? null, | ||
| }, | ||
| }; | ||
| if (mode === "stream") { | ||
| return handleStreamMode({ ...ctx, ...extras }); | ||
| } | ||
| return await handleGenerateMode({ ...ctx, ...extras }); | ||
| }, | ||
| }); | ||
Uh oh!
There was an error while loading. Please reload this page.