feat(chat): add chat.prompt API with provider registry support

This commit is contained in:
Eric Allam
2026-03-23 10:06:47 +00:00
parent 99f58bbb1a
commit fd1fbe6fd3
4 changed files with 706 additions and 247 deletions
+1
View File
@@ -0,0 +1 @@
{"sessionId":"a0e063d3-034b-40fe-90d0-7a6aff597e26","pid":12327,"procStart":"Tue Apr 28 13:47:55 2026","acquiredAt":1777384775033}
+126
View File
@@ -21,6 +21,7 @@ import { type Attributes, trace } from "@opentelemetry/api";
import { auth } from "./auth.js";
import { locals } from "./locals.js";
import { metadata } from "./metadata.js";
import type { ResolvedPrompt } from "./prompt.js";
import { streams } from "./streams.js";
import { createTask } from "./shared.js";
import { tracer } from "./tracer.js";
@@ -404,6 +405,118 @@ const chatUIStreamStaticKey = locals.create<ChatUIMessageStreamOptions>("chat.ui
/** Per-turn UIMessageStream options, set via chat.setUIMessageStreamOptions(). @internal */
const chatUIStreamPerTurnKey = locals.create<ChatUIMessageStreamOptions>("chat.uiMessageStreamOptions.perTurn");
// ---------------------------------------------------------------------------
// chat.prompt — store and retrieve a resolved prompt for the current run
// ---------------------------------------------------------------------------
/**
* A resolved prompt stored via `chat.prompt.set()`. Either a full `ResolvedPrompt`
* from `prompts.define().resolve()`, or a lightweight wrapper around a plain string.
*/
export type ChatPromptValue = ResolvedPrompt | {
text: string;
model: undefined;
config: undefined;
promptId: string;
version: number;
labels: string[];
toAISDKTelemetry: (additionalMetadata?: Record<string, string>) => {
experimental_telemetry: { isEnabled: true; metadata: Record<string, string> };
};
};
/** @internal */
const chatPromptKey = locals.create<ChatPromptValue>("chat.prompt");
/**
* Store a resolved prompt (or plain string) for the current run.
* Call from any hook (`onPreload`, `onChatStart`, `onTurnStart`) or `run()`.
*/
function setChatPrompt(resolved: ResolvedPrompt | string): void {
if (typeof resolved === "string") {
locals.set(chatPromptKey, {
text: resolved,
model: undefined,
config: undefined,
promptId: "",
version: 0,
labels: [],
toAISDKTelemetry: () => ({
experimental_telemetry: { isEnabled: true, metadata: {} },
}),
});
} else {
locals.set(chatPromptKey, resolved);
}
}
/**
* Read the stored prompt. Throws if `chat.prompt.set()` has not been called.
*/
function getChatPrompt(): ChatPromptValue {
const prompt = locals.get(chatPromptKey);
if (!prompt) {
throw new Error(
"chat.prompt() called before chat.prompt.set(). Set a prompt in onPreload, onChatStart, onTurnStart, or run() first."
);
}
return prompt;
}
/**
* Options for {@link toStreamTextOptions}.
*/
export type ToStreamTextOptionsOptions = {
/** Additional telemetry metadata merged into `experimental_telemetry.metadata`. */
telemetry?: Record<string, string>;
/**
* An AI SDK provider registry (from `createProviderRegistry`) or any object
* with a `languageModel(id)` method. When provided and the stored prompt has
* a `model` string, the resolved `LanguageModel` is included in the returned
* options so `streamText` uses it directly.
*
* The model string should use the `"provider:model-id"` format
* (e.g. `"openai:gpt-4o"`, `"anthropic:claude-sonnet-4-6"`).
*/
registry?: { languageModel(modelId: string): unknown };
};
/**
* Returns an options object ready to spread into `streamText()`.
*
* Includes `system`, `experimental_telemetry`, and any config fields
* (temperature, maxTokens, etc.) from the stored prompt.
*
* When a `registry` is provided and the prompt has a `model` string,
* the resolved `LanguageModel` is included as `model`.
*
* If no prompt has been set, returns `{}` (no-op spread).
*/
function toStreamTextOptions(options?: ToStreamTextOptionsOptions): Record<string, unknown> {
const prompt = locals.get(chatPromptKey);
if (!prompt) return {};
const result: Record<string, unknown> = {
system: prompt.text,
};
// Resolve model via registry if both are present
if (options?.registry && prompt.model) {
result.model = options.registry.languageModel(prompt.model);
}
// Spread config (temperature, maxTokens, etc.)
if (prompt.config) {
Object.assign(result, prompt.config);
}
// Add telemetry (forward additional metadata from caller)
const telemetry = prompt.toAISDKTelemetry(options?.telemetry);
Object.assign(result, telemetry);
return result;
}
/**
* Options for `pipeChat`.
*/
@@ -2302,6 +2415,19 @@ export const chat = {
MessageAccumulator: ChatMessageAccumulator,
/** Create a chat session (async iterator). See {@link createChatSession}. */
createSession: createChatSession,
/**
* Store and retrieve a resolved prompt for the current run.
*
* - `chat.prompt.set(resolved)` — store a `ResolvedPrompt` or plain string
* - `chat.prompt()` — read the stored prompt (throws if not set)
*/
prompt: Object.assign(getChatPrompt, { set: setChatPrompt }),
/**
* Returns an options object ready to spread into `streamText()`.
* Reads the stored prompt and returns `{ system, experimental_telemetry, ...config }`.
* Returns `{}` if no prompt has been set.
*/
toStreamTextOptions,
};
/**
+511 -226
View File
File diff suppressed because it is too large Load Diff
+68 -21
View File
@@ -1,6 +1,6 @@
import { chat, ai, type ChatTaskWirePayload } from "@trigger.dev/sdk/ai";
import { logger, schemaTask, task } from "@trigger.dev/sdk";
import { streamText, tool, dynamicTool, stepCountIs, generateId } from "ai";
import { logger, schemaTask, task, prompts } from "@trigger.dev/sdk";
import { streamText, tool, dynamicTool, stepCountIs, generateId, createProviderRegistry } from "ai";
import type { LanguageModel, Tool as AITool, UIMessage } from "ai";
import { openai } from "@ai-sdk/openai";
import { anthropic } from "@ai-sdk/anthropic";
@@ -17,6 +17,30 @@ import { DEFAULT_MODEL, REASONING_MODELS } from "@/lib/models";
const turndown = new TurndownService();
const registry = createProviderRegistry({ openai, anthropic });
const systemPrompt = prompts.define({
id: "ai-chat-system",
model: "openai:gpt-4o",
config: { temperature: 0.7 },
variables: z.object({ name: z.string(), plan: z.string() }),
content: `You are a helpful AI assistant for {{name}} on the {{plan}} plan.
## Guidelines
- Be concise and friendly. Prefer short, direct answers unless the user asks for detail.
- When using tools, explain what you're doing briefly before invoking them.
- If you don't know something, say so — don't make things up.
## Capabilities
You can inspect the execution environment, fetch web pages, and perform multi-URL deep research.
When the user asks you to research a topic, use the deep research tool with relevant URLs.
## Tone
- Match the user's formality level. If they're casual, be casual back.
- Use markdown formatting for code blocks, lists, and structured output.
- Keep responses under a few paragraphs unless the user asks for more.`,
});
const MODELS: Record<string, () => LanguageModel> = {
"gpt-4o-mini": () => openai("gpt-4o-mini"),
"gpt-4o": () => openai("gpt-4o"),
@@ -263,6 +287,13 @@ export const aiChat = chat.task({
const tools = await prisma.userTool.findMany({ where: { userId: clientData.userId } });
userToolDefs.init({ value: tools });
// Resolve prompt — versioned, overridable from dashboard
const resolved = await systemPrompt.resolve({
name: user.name,
plan: user.plan as string,
});
chat.prompt.set(resolved);
// Create chat record and session
await prisma.chat.upsert({
where: { id: chatId },
@@ -305,6 +336,13 @@ export const aiChat = chat.task({
const tools = await prisma.userTool.findMany({ where: { userId: clientData.userId } });
userToolDefs.init({ value: tools });
// Resolve prompt — versioned, overridable from dashboard
const resolved = await systemPrompt.resolve({
name: user.name,
plan: user.plan as string,
});
chat.prompt.set(resolved);
if (!continuation) {
await prisma.chat.upsert({
where: { id: chatId },
@@ -361,9 +399,10 @@ export const aiChat = chat.task({
userContext.preferredModel = clientData.model;
}
// Use preferred model if none specified
const modelId = clientData?.model ?? userContext.preferredModel ?? undefined;
const useReasoning = REASONING_MODELS.has(modelId ?? DEFAULT_MODEL);
// Client-specified or user-preferred model overrides the prompt default
const modelOverride = clientData?.model ?? userContext.preferredModel ?? undefined;
const effectiveModel = modelOverride ?? chat.prompt().model ?? DEFAULT_MODEL;
const useReasoning = REASONING_MODELS.has(effectiveModel);
// Build dynamic tools from user's DB-configured tools (loaded in onPreload/onChatStart)
const dynamicTools: Record<string, AITool<unknown, unknown>> = {};
@@ -380,8 +419,13 @@ export const aiChat = chat.task({
}
return streamText({
model: getModel(modelId),
system: `You are a helpful assistant for ${userContext.name} (${userContext.plan} plan). Be concise and friendly.`,
// Registry resolves the prompt's model (e.g. "openai:gpt-4o").
// Client override takes precedence when provided.
...chat.toStreamTextOptions({
registry,
telemetry: clientData?.userId ? { userId: clientData.userId } : undefined,
}),
...(modelOverride ? { model: getModel(modelOverride) } : {}),
messages,
tools: {
inspectEnvironment,
@@ -398,10 +442,6 @@ export const aiChat = chat.task({
...(useReasoning ? { thinking: { type: "enabled", budgetTokens: 10000 } } : {}),
},
},
experimental_telemetry: {
isEnabled: true,
metadata: clientData?.userId ? { userId: clientData.userId } : undefined,
},
});
},
});
@@ -427,6 +467,13 @@ async function initUserContext(userId: string, chatId: string, model?: string) {
const tools = await prisma.userTool.findMany({ where: { userId } });
userToolDefs.init({ value: tools });
// Resolve prompt for the run
const resolved = await systemPrompt.resolve({
name: user.name,
plan: user.plan as string,
});
chat.prompt.set(resolved);
await prisma.chat.upsert({
where: { id: chatId },
create: { id: chatId, title: "New chat", userId: user.id, model: model ?? DEFAULT_MODEL },
@@ -485,8 +532,9 @@ export const aiChatRaw = task({
userContext.preferredModel = turnClientData.model;
}
const modelId = turnClientData?.model ?? userContext.preferredModel ?? undefined;
const useReasoning = REASONING_MODELS.has(modelId ?? DEFAULT_MODEL);
const modelOverride = turnClientData?.model ?? userContext.preferredModel ?? undefined;
const effectiveModel = modelOverride ?? chat.prompt().model ?? DEFAULT_MODEL;
const useReasoning = REASONING_MODELS.has(effectiveModel);
const combinedSignal = AbortSignal.any([runSignal, stop.signal]);
const dynamicTools: Record<string, AITool<unknown, unknown>> = {};
@@ -503,8 +551,8 @@ export const aiChatRaw = task({
}
const result = streamText({
model: getModel(modelId),
system: `You are a helpful assistant for ${userContext.name} (${userContext.plan} plan). Be concise and friendly.`,
...chat.toStreamTextOptions({ registry }),
...(modelOverride ? { model: getModel(modelOverride) } : {}),
messages,
tools: {
inspectEnvironment,
@@ -521,7 +569,6 @@ export const aiChatRaw = task({
...(useReasoning ? { thinking: { type: "enabled", budgetTokens: 10000 } } : {}),
},
},
experimental_telemetry: { isEnabled: true },
});
let response: UIMessage | undefined;
@@ -605,12 +652,13 @@ export const aiChatSession = task({
userContext.messageCount++;
if (turnClientData?.model) userContext.preferredModel = turnClientData.model;
const modelId = turnClientData?.model ?? userContext.preferredModel ?? undefined;
const useReasoning = REASONING_MODELS.has(modelId ?? DEFAULT_MODEL);
const modelOverride = turnClientData?.model ?? userContext.preferredModel ?? undefined;
const effectiveModel = modelOverride ?? chat.prompt().model ?? DEFAULT_MODEL;
const useReasoning = REASONING_MODELS.has(effectiveModel);
const result = streamText({
model: getModel(modelId),
system: `You are a helpful assistant for ${userContext.name} (${userContext.plan} plan). Be concise and friendly.`,
...chat.toStreamTextOptions({ registry }),
...(modelOverride ? { model: getModel(modelOverride) } : {}),
messages: turn.messages,
tools: {
inspectEnvironment,
@@ -626,7 +674,6 @@ export const aiChatSession = task({
...(useReasoning ? { thinking: { type: "enabled", budgetTokens: 10000 } } : {}),
},
},
experimental_telemetry: { isEnabled: true },
});
await turn.complete(result);