Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 1 addition & 3 deletions apps/backend/.env.development
Original file line number Diff line number Diff line change
Expand Up @@ -57,9 +57,7 @@ STACK_VERCEL_SANDBOX_TOKEN=vercel_sandbox_disabled_for_local_development
STACK_OPENAI_API_KEY=mock_openai_api_key
STACK_STRIPE_SECRET_KEY=sk_test_mockstripekey
STACK_STRIPE_WEBHOOK_SECRET=mock_stripe_webhook_secret

STACK_OPENROUTER_API_KEY=mock-openrouter-api-key

STACK_OPENROUTER_API_KEY=FORWARD_TO_PRODUCTION
# Email monitor configuration for tests
STACK_EMAIL_MONITOR_VERIFICATION_CALLBACK_URL=http://localhost:8101/handler/email-verification
STACK_EMAIL_MONITOR_PROJECT_ID=internal
Expand Down
6 changes: 4 additions & 2 deletions apps/backend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -52,10 +52,12 @@
"seed": "pnpm run db-seed-script"
},
"dependencies": {
"@ai-sdk/openai": "^1.3.23",
"@ai-sdk/mcp": "^1.0.21",
"@ai-sdk/openai": "^3.0.29",
"@aws-sdk/client-s3": "^3.855.0",
"@clickhouse/client": "^1.14.0",
"@node-oauth/oauth2-server": "^5.1.0",
"@openrouter/ai-sdk-provider": "2.2.3",
"@opentelemetry/api": "^1.9.0",
"@opentelemetry/api-logs": "^0.53.0",
"@opentelemetry/auto-instrumentations-node": "^0.67.3",
Expand Down Expand Up @@ -83,7 +85,7 @@
"@vercel/functions": "^2.0.0",
"@vercel/otel": "^1.10.4",
"@vercel/sandbox": "^1.2.0",
"ai": "^4.3.17",
"ai": "^6.0.0",
"bcrypt": "^5.1.1",
"cel-js": "^0.8.2",
"chokidar-cli": "^3.0.0",
Expand Down
122 changes: 122 additions & 0 deletions apps/backend/src/app/api/latest/ai/query/[mode]/route.ts
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),
});
Comment thread
aadesh18 marked this conversation as resolved.
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 apps/backend/src/app/api/latest/internal/ai-chat/[threadId]/route.tsx
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
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why is this file still required?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The 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).

Expand Down
Loading
Loading