Skip to content
Open
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
5 changes: 5 additions & 0 deletions .changeset/ai-sdk-7-support.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@trigger.dev/sdk": patch
---

Adds AI SDK 7 support. The `ai` peer range now includes v7, and the `chat.agent` / chat surfaces work against v7's ESM-only build. On v7, install `@ai-sdk/otel` alongside `ai` and the SDK registers it for you so `experimental_telemetry` spans keep flowing into your run traces (v7 stopped emitting them from `ai` core). v5 and v6 keep working unchanged.
Comment thread
ericallam marked this conversation as resolved.
3 changes: 2 additions & 1 deletion packages/core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -182,7 +182,7 @@
"bundle-vendor": "node scripts/bundle-superjson.mjs",
"build": "pnpm run bundle-vendor && tshy && node scripts/bundle-superjson.mjs --copy && pnpm run update-version",
"dev": "pnpm run bundle-vendor && tshy --watch",
"typecheck": "pnpm run bundle-vendor && tsc --noEmit -p tsconfig.src.json",
"typecheck": "pnpm run bundle-vendor && tsc --noEmit -p tsconfig.src.json && tsc --noEmit -p tsconfig.ai-v7.json",
"pretest": "pnpm run bundle-vendor",
"test": "vitest",
"check-exports": "attw --pack ."
Expand Down Expand Up @@ -233,6 +233,7 @@
"@types/lodash.get": "^4.4.9",
"@types/readable-stream": "^4.0.14",
"ai": "^6.0.0",
"ai-v7": "npm:ai@7.0.0-canary.159",
"defu": "^6.1.4",
"esbuild": "^0.23.0",
"rimraf": "^6.0.1",
Expand Down
14 changes: 14 additions & 0 deletions packages/core/tsconfig.ai-v7.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
{
// Typechecks core's src against AI SDK 7 (the `ai-v7` aliased devDep). Core's
// `ai` surface is small (ChatSnapshotV1's UIMessage constraint, ToolTaskParameters'
// Schema), but it ships in the public type surface, so it gets the same v7 gate
// as the SDK. See packages/trigger-sdk/tsconfig.ai-v7.json for the `paths`
// file-direct rationale.
"extends": "./tsconfig.src.json",
"compilerOptions": {
"baseUrl": ".",
"paths": {
"ai": ["./node_modules/ai-v7/dist/index.d.ts"]
}
}
}
9 changes: 7 additions & 2 deletions packages/trigger-sdk/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@
"clean": "rimraf dist .tshy .tshy-build .turbo",
"build": "tshy && pnpm run update-version",
"dev": "tshy --watch",
"typecheck": "tsc --noEmit",
"typecheck": "tsc --noEmit && tsc --noEmit -p tsconfig.ai-v7.json",
"test": "vitest",
"update-version": "tsx ../../scripts/updateVersion.ts",
"check-exports": "attw --pack ."
Expand All @@ -91,6 +91,7 @@
"@types/slug": "^5.0.3",
"@types/ws": "^8.5.3",
"ai": "^6.0.116",
"ai-v7": "npm:ai@7.0.0-canary.159",
"encoding": "^0.1.13",
"rimraf": "^6.0.1",
"tshy": "^3.0.2",
Expand All @@ -99,11 +100,15 @@
"zod": "3.25.76"
},
"peerDependencies": {
"ai": "^5.0.0 || ^6.0.0",
"@ai-sdk/otel": ">=1.0.0-0 <2",
"ai": "^5.0.0 || ^6.0.0 || >=7.0.0-canary <8",
"react": "^18.0 || ^19.0",
"zod": "^3.0.0 || ^4.0.0"
},
"peerDependenciesMeta": {
"@ai-sdk/otel": {
"optional": true
},
"ai": {
"optional": true
},
Expand Down
26 changes: 26 additions & 0 deletions packages/trigger-sdk/src/imports/ai-runtime-cjs.cts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
// CJS variant of ./ai-runtime.ts — tshy swaps this in for the CommonJS build.
// `require("ai")` of an ESM-only package is supported on Node >=20.19 / >=22.12.

// @ts-ignore
const ai = require("ai");

// @ts-ignore
module.exports.convertToModelMessages = ai.convertToModelMessages;
// @ts-ignore
module.exports.dynamicTool = ai.dynamicTool;
// @ts-ignore
module.exports.generateId = ai.generateId;
// @ts-ignore
module.exports.getToolName = ai.getToolName;
// @ts-ignore
module.exports.isToolUIPart = ai.isToolUIPart;
// @ts-ignore
module.exports.jsonSchema = ai.jsonSchema;
// @ts-ignore
module.exports.readUIMessageStream = ai.readUIMessageStream;
// @ts-ignore
module.exports.stepCountIs = ai.stepCountIs;
// @ts-ignore
module.exports.tool = ai.tool;
// @ts-ignore
module.exports.zodSchema = ai.zodSchema;
39 changes: 39 additions & 0 deletions packages/trigger-sdk/src/imports/ai-runtime.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
// Runtime VALUE imports from `ai`, isolated behind a paired ESM/CJS shim.
//
// `ai@7` is ESM-only (no `require` export). Under NodeNext + TS < 5.8 a value
// import of an ESM-only package emitted to a CJS file raises TS1479, which
// would break the SDK's CommonJS build. tshy maps `ai-runtime-cjs.cts` -> the
// CJS build and this `.ts` -> the ESM build, so each dialect gets the right
// form. `require(esm)` is stable on Node >=20.19 / >=22.12 (both our targets),
// so the CJS variant works at runtime. Mirrors `imports/uncrypto{,-cjs.cts}`.
//
// VALUES only — type-only imports from `ai` erase and don't trip TS1479, so
// they stay as direct `import type { … } from "ai"` at their use sites.

// @ts-ignore
import {
convertToModelMessages,
dynamicTool,
generateId,
getToolName,
isToolUIPart,
jsonSchema,
readUIMessageStream,
stepCountIs,
tool,
zodSchema,
} from "ai";

// @ts-ignore
export {
convertToModelMessages,
dynamicTool,
generateId,
getToolName,
isToolUIPart,
jsonSchema,
readUIMessageStream,
stepCountIs,
tool,
zodSchema,
};
43 changes: 35 additions & 8 deletions packages/trigger-sdk/src/v3/ai.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,29 +40,42 @@ import {
} from "@trigger.dev/core/v3";
import type {
FinishReason,
LanguageModelUsage,
ModelMessage,
Tool,
ToolSet,
UIMessage,
UIMessageChunk,
UIMessageStreamOptions,
LanguageModelUsage,
} from "ai";
import type { ChatSnapshotV1, StreamWriteResult } from "@trigger.dev/core/v3";
// Runtime VALUES go through the ESM/CJS shim so the CJS build can `require`
// ESM-only `ai@7` (see ../imports/ai-runtime.ts).
import {
convertToModelMessages,
dynamicTool,
generateId as generateMessageId,
getToolName,
isToolUIPart,
jsonSchema,
JSONSchema7,
readUIMessageStream,
Schema,
tool as aiTool,
Tool,
ToolCallOptions,
zodSchema,
} from "ai";
} from "../imports/ai-runtime.js";
import type { JSONSchema7, Schema } from "ai";

// `ToolCallOptions` is defined locally rather than imported from `ai`: v7
// renamed/removed that export (it's `ToolExecutionOptions<CONTEXT>` now), so a
// direct import breaks on v7. This structural shape is wider than both majors'
// and reads the user-context field under both names (`experimental_context` on
// v6, `context` on v7).
type ToolCallOptions = {
toolCallId: string;
messages?: ModelMessage[];
abortSignal?: AbortSignal;
experimental_context?: unknown;
context?: unknown;
};
Comment thread
coderabbitai[bot] marked this conversation as resolved.
import { type Attributes, trace } from "@opentelemetry/api";
import { auth } from "./auth.js";
import { locals } from "./locals.js";
Expand All @@ -88,6 +101,7 @@ import {
type SessionSubscribeOptions,
} from "./sessions.js";
import { createTask } from "./shared.js";
import { ensureAiSdkTelemetry } from "./aiAutoTelemetry.js";
import { resourceCatalog, type SessionTriggerConfig } from "@trigger.dev/core/v3";
import { tracer } from "./tracer.js";

Expand Down Expand Up @@ -117,6 +131,8 @@ function toModelMessages(messages: UIMessage[]): Promise<ModelMessage[]> {
export type ToolCallExecutionOptions = {
toolCallId: string;
experimental_context?: unknown;
/** v7 name for the user context (`experimental_context` on v6). */
context?: unknown;
/** Chat context — only present when the tool runs inside a chat.agent turn. */
chatId?: string;
turn?: number;
Expand Down Expand Up @@ -893,9 +909,14 @@ function createTaskToolExecuteHandler<
const toolMeta: ToolCallExecutionOptions = {
toolCallId: toolOpts?.toolCallId ?? "",
};
if (toolOpts?.experimental_context !== undefined) {
// v6 passes user context as `experimental_context`, v7 as `context`. Read
// whichever is set and stamp both so subtasks reading either name work.
const toolContext = toolOpts?.context ?? toolOpts?.experimental_context;
if (toolContext !== undefined) {
try {
toolMeta.experimental_context = JSON.parse(JSON.stringify(toolOpts.experimental_context));
const serialized = JSON.parse(JSON.stringify(toolContext));
toolMeta.experimental_context = serialized;
toolMeta.context = serialized;
} catch {
/* non-serializable */
}
Expand Down Expand Up @@ -5147,6 +5168,12 @@ function chatAgent<
) => {
locals.set(chatAgentRunContextKey, ctx);

// On AI SDK 7, register the `@ai-sdk/otel` integration (once per process)
// so `experimental_telemetry` spans flow into the run trace. Awaited here
// at run boot — before any `streamText` — and a no-op on v5/v6 or when the
// optional `@ai-sdk/otel` peer isn't installed. See ./aiAutoTelemetry.ts.
await ensureAiSdkTelemetry();

// Bind the run to its backing Session so every module-level helper
// (chat.stream, chat.messages, chat.stopSignal) resolves to this
// chat's `.in` / `.out` channels.
Expand Down
83 changes: 83 additions & 0 deletions packages/trigger-sdk/src/v3/aiAutoTelemetry.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
/**
* Auto-register `@ai-sdk/otel` so AI SDK 7 emits OpenTelemetry spans into the
* Trigger.dev run trace with no customer setup.
*
* AI SDK 6 emitted spans from `ai` core, so `experimental_telemetry` (set by
* `chat.toStreamTextOptions({ telemetry })`) was enough. v7 moved span emission
* into the separate `@ai-sdk/otel` adapter, so on v7 `experimental_telemetry`
* alone produces nothing until an integration is registered. We register it once
* per worker process at chat.agent run boot. `@ai-sdk/otel` writes to the global
* OpenTelemetry tracer, which is the same provider the Trigger worker installs
* (the `@opentelemetry/api` global is a `globalThis` singleton keyed by major
* version, so the separate copies still share it), so spans land in the trace.
*
* Fully guarded and best-effort — telemetry must never break a run:
* - `registerTelemetry` only exists in v7 `ai` (no-op on v5/v6).
* - `@ai-sdk/otel` is an OPTIONAL peer. The specifier is computed so the task
* bundler doesn't hard-require it (v5/v6 users never install it).
* - We detect an already-registered `@ai-sdk/otel` integration and skip, so a
* customer (or a library they import) that registers it themselves doesn't
* get duplicate spans. `registerTelemetry` is append-only, so without this
* guard a second integration would double every span.
* - To disable our auto-register entirely (e.g. you register `@ai-sdk/otel`
* yourself after this boot, or via a custom integration our detection can't
* see), set the env var `TRIGGER_AI_SDK_OTEL_AUTOREGISTER=0`.
*/
let registration: Promise<void> | null = null;

/** Registers the AI SDK OTel integration once per process. Safe to call on every run. */
export function ensureAiSdkTelemetry(): Promise<void> {
if (!registration) {
registration = register();
}
return registration;
}

async function register(): Promise<void> {
try {
if (isAutoRegisterDisabled()) {
return; // opted out via TRIGGER_AI_SDK_OTEL_AUTOREGISTER
}
const aiMod: any = await import("ai");
if (typeof aiMod.registerTelemetry !== "function") {
return; // v5 / v6 — `ai` core emits spans itself, nothing to wire.
}
// Computed specifier keeps the optional peer out of static bundler
// resolution; resolves at runtime only when the customer installed it.
const otelSpecifier = ["@ai-sdk", "otel"].join("/");
const otelMod: any = await import(otelSpecifier).catch(() => null);
if (typeof otelMod?.OpenTelemetry !== "function") {
return; // optional peer not installed
}
if (hasAiSdkOtelIntegration(otelMod.OpenTelemetry)) {
return; // already registered by the customer or a library they import
}
aiMod.registerTelemetry(new otelMod.OpenTelemetry());
} catch {
// never throw from telemetry setup
}
}

function isAutoRegisterDisabled(): boolean {
const value = process.env.TRIGGER_AI_SDK_OTEL_AUTOREGISTER?.toLowerCase();
return value === "0" || value === "false";
}

/**
* True if an `@ai-sdk/otel` integration is already in v7's global telemetry
* registry (`globalThis.AI_SDK_TELEMETRY_INTEGRATIONS`, a documented public
* global that `registerTelemetry` appends to). `instanceof` matches a same-copy
* registration; the constructor-name fallback catches a separate copy of
* `@ai-sdk/otel`.
*/
function hasAiSdkOtelIntegration(OpenTelemetry: any): boolean {
const integrations = (globalThis as any).AI_SDK_TELEMETRY_INTEGRATIONS;
if (!Array.isArray(integrations)) {
return false;
}
return integrations.some(
(integration: any) =>
(typeof OpenTelemetry === "function" && integration instanceof OpenTelemetry) ||
integration?.constructor?.name === "OpenTelemetry"
);
Comment thread
ericallam marked this conversation as resolved.
}
4 changes: 3 additions & 1 deletion packages/trigger-sdk/src/v3/chat-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,9 @@

import type { SessionTriggerConfig, Task } from "@trigger.dev/core/v3";
import type { ModelMessage, UIMessage, UIMessageChunk } from "ai";
import { readUIMessageStream } from "ai";
// `readUIMessageStream` is a runtime value — via the ESM/CJS shim so the CJS
// build can `require` ESM-only `ai@7` (see ../imports/ai-runtime.ts).
import { readUIMessageStream } from "../imports/ai-runtime.js";
import {
apiClientManager,
controlSubtype,
Expand Down
Loading
Loading