feat(chat): add chat.prompt API with provider registry support
This commit is contained in:
@@ -0,0 +1 @@
|
||||
{"sessionId":"a0e063d3-034b-40fe-90d0-7a6aff597e26","pid":12327,"procStart":"Tue Apr 28 13:47:55 2026","acquiredAt":1777384775033}
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
Generated
+511
-226
File diff suppressed because it is too large
Load Diff
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user