-
Notifications
You must be signed in to change notification settings - Fork 513
Unified ai endpoint #1217
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
Closed
Closed
Unified ai endpoint #1217
Changes from all commits
Commits
Show all changes
23 commits
Select commit
Hold shift + click to select a range
7afa78b
v1 working
aadesh18 a677486
cmd center ai chat working
aadesh18 0e71470
email templates, drafts and theme working
aadesh18 592c2c0
docs ai working
aadesh18 241f646
Merge remote-tracking branch 'origin/dev' into unified-ai-endpoint
aadesh18 bca8547
bug fix
aadesh18 8dbbf9b
pnpm update
aadesh18 4379976
More stringent caching mitigation
N2D4 68a5d8d
Merge branch 'dev' into unified-ai-endpoint
aadesh18 87c18e4
minor issues fixed
aadesh18 97dead3
minor bug fixes
aadesh18 c6caf88
bug fixes
aadesh18 a50240c
removed comment
aadesh18 e066cac
pr review changes
aadesh18 0c23229
refactoring changes
aadesh18 2b6bd54
pnpm changes
aadesh18 1b8652b
minor changes
aadesh18 a72c5bd
add comment
aadesh18 f2ec371
minor bug fix
aadesh18 250e000
pr changes
aadesh18 f8e4fec
minor bug fix
aadesh18 c7afb2f
more changes
aadesh18 33ce58b
updated test
aadesh18 File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
122 changes: 122 additions & 0 deletions
122
apps/backend/src/app/api/latest/ai/query/[mode]/route.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,122 @@ | ||
| import { forwardToProduction } from "@/lib/ai/forward"; | ||
| import { selectModel } from "@/lib/ai/models"; | ||
| import { getFullSystemPrompt } from "@/lib/ai/prompts"; | ||
| import { requestBodySchema } from "@/lib/ai/schema"; | ||
| import { getTools, validateToolNames } from "@/lib/ai/tools"; | ||
| import { SmartResponse } from "@/route-handlers/smart-response"; | ||
| import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler"; | ||
| 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, ModelMessage, stepCountIs, streamText } from "ai"; | ||
|
|
||
| export const POST = createSmartRouteHandler({ | ||
| metadata: { | ||
| hidden: true, | ||
| }, | ||
| request: yupObject({ | ||
| params: yupObject({ | ||
| mode: yupString().oneOf(["stream", "generate"]).defined(), | ||
| }), | ||
| body: requestBodySchema, | ||
| }), | ||
| response: yupMixed<SmartResponse>().defined(), | ||
| async handler({ params, body }, fullReq) { | ||
| const { mode } = params; | ||
|
|
||
| if (!validateToolNames(body.tools)) { | ||
| throw new StatusError(StatusError.BadRequest, `Invalid tool names in request.`); | ||
| } | ||
|
|
||
| const apiKey = getEnvVariable("STACK_OPENROUTER_API_KEY"); | ||
|
|
||
|
|
||
| if (apiKey === "FORWARD_TO_PRODUCTION") { | ||
| const prodResponse = await forwardToProduction(fullReq.headers, mode, body); | ||
| return { | ||
| statusCode: prodResponse.status, | ||
| bodyType: "response" as const, | ||
| body: prodResponse, | ||
| }; | ||
| } | ||
|
|
||
| const isAuthenticated = fullReq.auth != null; | ||
| const { quality, speed, systemPrompt: systemPromptId, tools: toolNames, messages } = body; | ||
|
|
||
| const model = selectModel(quality, speed, isAuthenticated); | ||
| const systemPrompt = getFullSystemPrompt(systemPromptId); | ||
| const tools = await getTools(toolNames, { auth: fullReq.auth }); | ||
| const toolsArg = Object.keys(tools).length > 0 ? tools : undefined; | ||
| const isDocsOrSearch = systemPromptId === "docs-ask-ai" || systemPromptId === "command-center-ask-ai"; | ||
| const stepLimit = toolsArg == null ? 1 : isDocsOrSearch ? 50 : 5; | ||
|
|
||
| if (mode === "stream") { | ||
| const result = streamText({ | ||
| model, | ||
| system: systemPrompt, | ||
| messages: messages as ModelMessage[], | ||
| tools: toolsArg, | ||
| stopWhen: stepCountIs(stepLimit), | ||
| }); | ||
| return { | ||
| statusCode: 200, | ||
| bodyType: "response" as const, | ||
| body: result.toUIMessageStreamResponse(), | ||
| }; | ||
| } else { | ||
| const controller = new AbortController(); | ||
| const timeoutId = setTimeout(() => controller.abort(), 120_000); | ||
| const result = await generateText({ | ||
| model, | ||
| system: systemPrompt, | ||
| messages: messages as ModelMessage[], | ||
| tools: toolsArg, | ||
| stopWhen: stepCountIs(stepLimit), | ||
| }).finally(() => clearTimeout(timeoutId)); | ||
|
|
||
| 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, | ||
| }); | ||
| }); | ||
| }); | ||
|
|
||
| return { | ||
| statusCode: 200, | ||
| bodyType: "json" as const, | ||
| body: { content: contentBlocks }, | ||
| }; | ||
| } | ||
| }, | ||
| }); | ||
140 changes: 1 addition & 139 deletions
140
apps/backend/src/app/api/latest/internal/ai-chat/[threadId]/route.tsx
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,144 +1,6 @@ | ||
| import { getChatAdapter } from "@/lib/ai-chat/adapter-registry"; | ||
| import { globalPrismaClient } from "@/prisma-client"; | ||
| import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler"; | ||
| import { createOpenAI } from "@ai-sdk/openai"; | ||
| import { adaptSchema, yupArray, yupMixed, yupNumber, yupObject, yupString, yupUnion } from "@stackframe/stack-shared/dist/schema-fields"; | ||
| import { getEnvVariable } from "@stackframe/stack-shared/dist/utils/env"; | ||
| import { generateText } from "ai"; | ||
| import { InferType } from "yup"; | ||
|
|
||
| const textContentSchema = yupObject({ | ||
| type: yupString().oneOf(["text"]).defined(), | ||
| text: yupString().defined(), | ||
| }); | ||
|
|
||
| const toolCallContentSchema = yupObject({ | ||
| type: yupString().oneOf(["tool-call"]).defined(), | ||
| toolName: yupString().defined(), | ||
| toolCallId: yupString().defined(), | ||
| args: yupMixed().defined(), | ||
| argsText: yupString().defined(), | ||
| result: yupMixed().defined(), | ||
| }); | ||
|
|
||
| const contentSchema = yupArray(yupUnion(textContentSchema, toolCallContentSchema)).defined(); | ||
|
|
||
| const messageSchema = yupObject({ | ||
| role: yupString().oneOf(["user", "assistant", "tool"]).defined(), | ||
| content: yupMixed().defined(), | ||
| }); | ||
|
|
||
| // Mock mode sentinel value - when API key is not configured, we return mock responses | ||
| const MOCK_API_KEY_SENTINEL = "mock-openrouter-api-key"; | ||
| const apiKey = getEnvVariable("STACK_OPENROUTER_API_KEY", MOCK_API_KEY_SENTINEL); | ||
| const isMockMode = apiKey === MOCK_API_KEY_SENTINEL; | ||
|
|
||
| // Only create OpenAI client if not in mock mode | ||
| const openai = isMockMode ? null : createOpenAI({ | ||
| apiKey, | ||
| baseURL: "https://openrouter.ai/api/v1", | ||
| }); | ||
|
|
||
| // AI request timeout in milliseconds (2 minutes) | ||
| const AI_REQUEST_TIMEOUT_MS = 120_000; | ||
|
|
||
| export const POST = createSmartRouteHandler({ | ||
| metadata: { | ||
| hidden: true, | ||
| }, | ||
| request: yupObject({ | ||
| auth: yupObject({ | ||
| type: yupString().oneOf(["admin"]).defined(), | ||
| tenancy: adaptSchema, | ||
| }), | ||
| params: yupObject({ | ||
| threadId: yupString().defined(), | ||
| }), | ||
| body: yupObject({ | ||
| context_type: yupString().oneOf(["email-theme", "email-template", "email-draft"]).defined(), | ||
| messages: yupArray(messageSchema).defined().min(1), | ||
| }), | ||
| }), | ||
| response: yupObject({ | ||
| statusCode: yupNumber().oneOf([200]).defined(), | ||
| bodyType: yupString().oneOf(["json"]).defined(), | ||
| body: yupObject({ | ||
| content: contentSchema, | ||
| }).defined(), | ||
| }), | ||
| async handler({ body, params, auth: { tenancy } }) { | ||
| // Mock mode: return a simple text response without calling AI | ||
| if (isMockMode) { | ||
| return { | ||
| statusCode: 200, | ||
| bodyType: "json", | ||
| body: { | ||
| content: [{ | ||
| type: "text", | ||
| text: "This is a mock AI response. Configure a real API key to enable AI features.", | ||
| }], | ||
| }, | ||
| }; | ||
| } | ||
|
|
||
| const adapter = getChatAdapter(body.context_type, tenancy, params.threadId); | ||
| // Model is configurable via env var; no default to surface missing config errors | ||
| const modelName = getEnvVariable("STACK_AI_MODEL"); | ||
|
|
||
| if (!openai) { | ||
| // This shouldn't happen since we check isMockMode above, but guard anyway | ||
| throw new Error("OpenAI client not initialized - STACK_OPENROUTER_API_KEY may be missing"); | ||
| } | ||
|
|
||
| // Validate messages structure before passing to AI | ||
| const validatedMessages = body.messages.map(msg => ({ | ||
| role: msg.role, | ||
| content: msg.content, | ||
| })) as any; // Cast needed: content is a mixed type from yup schema that doesn't map to AI SDK's strict typing | ||
|
|
||
| // Create abort controller for timeout | ||
| const controller = new AbortController(); | ||
| const timeoutId = setTimeout(() => controller.abort(), AI_REQUEST_TIMEOUT_MS); | ||
|
|
||
| try { | ||
| const result = await generateText({ | ||
| model: openai(modelName), | ||
| system: adapter.systemPrompt, | ||
| messages: validatedMessages, | ||
| tools: adapter.tools, | ||
| abortSignal: controller.signal, | ||
| }); | ||
|
|
||
| const contentBlocks: InferType<typeof contentSchema> = []; | ||
| result.steps.forEach((step) => { | ||
| if (step.text) { | ||
| contentBlocks.push({ | ||
| type: "text", | ||
| text: step.text, | ||
| }); | ||
| } | ||
| step.toolCalls.forEach(toolCall => { | ||
| contentBlocks.push({ | ||
| type: "tool-call", | ||
| toolName: toolCall.toolName, | ||
| toolCallId: toolCall.toolCallId, | ||
| args: toolCall.args, | ||
| argsText: JSON.stringify(toolCall.args), | ||
| result: "success", | ||
| }); | ||
| }); | ||
| }); | ||
|
|
||
| return { | ||
| statusCode: 200, | ||
| bodyType: "json", | ||
| body: { content: contentBlocks }, | ||
| }; | ||
| } finally { | ||
| clearTimeout(timeoutId); | ||
| } | ||
| }, | ||
| }); | ||
| import { adaptSchema, yupArray, yupMixed, yupNumber, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields"; | ||
|
|
||
| export const PATCH = createSmartRouteHandler({ | ||
| metadata: { | ||
|
Comment on lines
5
to
6
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. why is this file still required?
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. this file is being used to save a chat message (specifically for the email themes, templates, and drafts). |
||
|
|
||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.