Skip to content

Commit 8c9fee3

Browse files
authored
feat(sdk): add AI SDK 7 support (#3833)
## Summary Adds support for Vercel AI SDK 7. The `ai` peer range now includes v7, and the `chat.agent` / chat surfaces work against v7's ESM-only build. v5 and v6 keep working unchanged, so this is additive. ## Telemetry on v7 On v7, model-call spans moved out of `ai` core into the separate `@ai-sdk/otel` adapter, so `experimental_telemetry` alone produces nothing until an integration is registered. Install `@ai-sdk/otel` alongside `ai@7` and the SDK registers it once per worker at chat agent boot, so spans keep flowing into run traces with no extra setup. If you (or a library you import) already register `@ai-sdk/otel`, the SDK detects the existing integration and skips its own registration, so you won't get duplicate spans. Set `TRIGGER_AI_SDK_OTEL_AUTOREGISTER=0` to disable auto-registration entirely. ## Notes `ai@7` is ESM-only, which tripped TS1479 in the SDK's CommonJS build. Runtime value imports from `ai` are isolated behind a paired ESM/CJS shim so both module formats resolve the right form; type-only imports stay as direct `import type` at their use sites.
1 parent 8d5cf31 commit 8c9fee3

12 files changed

Lines changed: 396 additions & 36 deletions

File tree

.changeset/ai-sdk-7-support.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@trigger.dev/sdk": patch
3+
---
4+
5+
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.

packages/core/package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -182,7 +182,7 @@
182182
"bundle-vendor": "node scripts/bundle-superjson.mjs",
183183
"build": "pnpm run bundle-vendor && tshy && node scripts/bundle-superjson.mjs --copy && pnpm run update-version",
184184
"dev": "pnpm run bundle-vendor && tshy --watch",
185-
"typecheck": "pnpm run bundle-vendor && tsc --noEmit -p tsconfig.src.json",
185+
"typecheck": "pnpm run bundle-vendor && tsc --noEmit -p tsconfig.src.json && tsc --noEmit -p tsconfig.ai-v7.json",
186186
"pretest": "pnpm run bundle-vendor",
187187
"test": "vitest",
188188
"check-exports": "attw --pack ."
@@ -233,6 +233,7 @@
233233
"@types/lodash.get": "^4.4.9",
234234
"@types/readable-stream": "^4.0.14",
235235
"ai": "^6.0.0",
236+
"ai-v7": "npm:ai@7.0.0-canary.159",
236237
"defu": "^6.1.4",
237238
"esbuild": "^0.23.0",
238239
"rimraf": "^6.0.1",

packages/core/tsconfig.ai-v7.json

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
{
2+
// Typechecks core's src against AI SDK 7 (the `ai-v7` aliased devDep). Core's
3+
// `ai` surface is small (ChatSnapshotV1's UIMessage constraint, ToolTaskParameters'
4+
// Schema), but it ships in the public type surface, so it gets the same v7 gate
5+
// as the SDK. See packages/trigger-sdk/tsconfig.ai-v7.json for the `paths`
6+
// file-direct rationale.
7+
"extends": "./tsconfig.src.json",
8+
"compilerOptions": {
9+
"baseUrl": ".",
10+
"paths": {
11+
"ai": ["./node_modules/ai-v7/dist/index.d.ts"]
12+
}
13+
}
14+
}

packages/trigger-sdk/package.json

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,7 @@
6565
"clean": "rimraf dist .tshy .tshy-build .turbo",
6666
"build": "tshy && pnpm run update-version",
6767
"dev": "tshy --watch",
68-
"typecheck": "tsc --noEmit",
68+
"typecheck": "tsc --noEmit && tsc --noEmit -p tsconfig.ai-v7.json",
6969
"test": "vitest",
7070
"update-version": "tsx ../../scripts/updateVersion.ts",
7171
"check-exports": "attw --pack ."
@@ -91,6 +91,7 @@
9191
"@types/slug": "^5.0.3",
9292
"@types/ws": "^8.5.3",
9393
"ai": "^6.0.116",
94+
"ai-v7": "npm:ai@7.0.0-canary.159",
9495
"encoding": "^0.1.13",
9596
"rimraf": "^6.0.1",
9697
"tshy": "^3.0.2",
@@ -99,11 +100,15 @@
99100
"zod": "3.25.76"
100101
},
101102
"peerDependencies": {
102-
"ai": "^5.0.0 || ^6.0.0",
103+
"@ai-sdk/otel": ">=1.0.0-0 <2",
104+
"ai": "^5.0.0 || ^6.0.0 || >=7.0.0-canary <8",
103105
"react": "^18.0 || ^19.0",
104106
"zod": "^3.0.0 || ^4.0.0"
105107
},
106108
"peerDependenciesMeta": {
109+
"@ai-sdk/otel": {
110+
"optional": true
111+
},
107112
"ai": {
108113
"optional": true
109114
},
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
// CJS variant of ./ai-runtime.ts — tshy swaps this in for the CommonJS build.
2+
// `require("ai")` of an ESM-only package is supported on Node >=20.19 / >=22.12.
3+
4+
// @ts-ignore
5+
const ai = require("ai");
6+
7+
// @ts-ignore
8+
module.exports.convertToModelMessages = ai.convertToModelMessages;
9+
// @ts-ignore
10+
module.exports.dynamicTool = ai.dynamicTool;
11+
// @ts-ignore
12+
module.exports.generateId = ai.generateId;
13+
// @ts-ignore
14+
module.exports.getToolName = ai.getToolName;
15+
// @ts-ignore
16+
module.exports.isToolUIPart = ai.isToolUIPart;
17+
// @ts-ignore
18+
module.exports.jsonSchema = ai.jsonSchema;
19+
// @ts-ignore
20+
module.exports.readUIMessageStream = ai.readUIMessageStream;
21+
// @ts-ignore
22+
module.exports.stepCountIs = ai.stepCountIs;
23+
// @ts-ignore
24+
module.exports.tool = ai.tool;
25+
// @ts-ignore
26+
module.exports.zodSchema = ai.zodSchema;
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
// Runtime VALUE imports from `ai`, isolated behind a paired ESM/CJS shim.
2+
//
3+
// `ai@7` is ESM-only (no `require` export). Under NodeNext + TS < 5.8 a value
4+
// import of an ESM-only package emitted to a CJS file raises TS1479, which
5+
// would break the SDK's CommonJS build. tshy maps `ai-runtime-cjs.cts` -> the
6+
// CJS build and this `.ts` -> the ESM build, so each dialect gets the right
7+
// form. `require(esm)` is stable on Node >=20.19 / >=22.12 (both our targets),
8+
// so the CJS variant works at runtime. Mirrors `imports/uncrypto{,-cjs.cts}`.
9+
//
10+
// VALUES only — type-only imports from `ai` erase and don't trip TS1479, so
11+
// they stay as direct `import type { … } from "ai"` at their use sites.
12+
13+
// @ts-ignore
14+
import {
15+
convertToModelMessages,
16+
dynamicTool,
17+
generateId,
18+
getToolName,
19+
isToolUIPart,
20+
jsonSchema,
21+
readUIMessageStream,
22+
stepCountIs,
23+
tool,
24+
zodSchema,
25+
} from "ai";
26+
27+
// @ts-ignore
28+
export {
29+
convertToModelMessages,
30+
dynamicTool,
31+
generateId,
32+
getToolName,
33+
isToolUIPart,
34+
jsonSchema,
35+
readUIMessageStream,
36+
stepCountIs,
37+
tool,
38+
zodSchema,
39+
};

packages/trigger-sdk/src/v3/ai.ts

Lines changed: 35 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -40,29 +40,42 @@ import {
4040
} from "@trigger.dev/core/v3";
4141
import type {
4242
FinishReason,
43+
LanguageModelUsage,
4344
ModelMessage,
45+
Tool,
4446
ToolSet,
4547
UIMessage,
4648
UIMessageChunk,
4749
UIMessageStreamOptions,
48-
LanguageModelUsage,
4950
} from "ai";
5051
import type { ChatSnapshotV1, StreamWriteResult } from "@trigger.dev/core/v3";
52+
// Runtime VALUES go through the ESM/CJS shim so the CJS build can `require`
53+
// ESM-only `ai@7` (see ../imports/ai-runtime.ts).
5154
import {
5255
convertToModelMessages,
5356
dynamicTool,
5457
generateId as generateMessageId,
5558
getToolName,
5659
isToolUIPart,
5760
jsonSchema,
58-
JSONSchema7,
5961
readUIMessageStream,
60-
Schema,
6162
tool as aiTool,
62-
Tool,
63-
ToolCallOptions,
6463
zodSchema,
65-
} from "ai";
64+
} from "../imports/ai-runtime.js";
65+
import type { JSONSchema7, Schema } from "ai";
66+
67+
// `ToolCallOptions` is defined locally rather than imported from `ai`: v7
68+
// renamed/removed that export (it's `ToolExecutionOptions<CONTEXT>` now), so a
69+
// direct import breaks on v7. This structural shape is wider than both majors'
70+
// and reads the user-context field under both names (`experimental_context` on
71+
// v6, `context` on v7).
72+
type ToolCallOptions = {
73+
toolCallId: string;
74+
messages?: ModelMessage[];
75+
abortSignal?: AbortSignal;
76+
experimental_context?: unknown;
77+
context?: unknown;
78+
};
6679
import { type Attributes, trace } from "@opentelemetry/api";
6780
import { auth } from "./auth.js";
6881
import { locals } from "./locals.js";
@@ -88,6 +101,7 @@ import {
88101
type SessionSubscribeOptions,
89102
} from "./sessions.js";
90103
import { createTask } from "./shared.js";
104+
import { ensureAiSdkTelemetry } from "./aiAutoTelemetry.js";
91105
import { resourceCatalog, type SessionTriggerConfig } from "@trigger.dev/core/v3";
92106
import { tracer } from "./tracer.js";
93107

@@ -117,6 +131,8 @@ function toModelMessages(messages: UIMessage[]): Promise<ModelMessage[]> {
117131
export type ToolCallExecutionOptions = {
118132
toolCallId: string;
119133
experimental_context?: unknown;
134+
/** v7 name for the user context (`experimental_context` on v6). */
135+
context?: unknown;
120136
/** Chat context — only present when the tool runs inside a chat.agent turn. */
121137
chatId?: string;
122138
turn?: number;
@@ -893,9 +909,14 @@ function createTaskToolExecuteHandler<
893909
const toolMeta: ToolCallExecutionOptions = {
894910
toolCallId: toolOpts?.toolCallId ?? "",
895911
};
896-
if (toolOpts?.experimental_context !== undefined) {
912+
// v6 passes user context as `experimental_context`, v7 as `context`. Read
913+
// whichever is set and stamp both so subtasks reading either name work.
914+
const toolContext = toolOpts?.context ?? toolOpts?.experimental_context;
915+
if (toolContext !== undefined) {
897916
try {
898-
toolMeta.experimental_context = JSON.parse(JSON.stringify(toolOpts.experimental_context));
917+
const serialized = JSON.parse(JSON.stringify(toolContext));
918+
toolMeta.experimental_context = serialized;
919+
toolMeta.context = serialized;
899920
} catch {
900921
/* non-serializable */
901922
}
@@ -5147,6 +5168,12 @@ function chatAgent<
51475168
) => {
51485169
locals.set(chatAgentRunContextKey, ctx);
51495170

5171+
// On AI SDK 7, register the `@ai-sdk/otel` integration (once per process)
5172+
// so `experimental_telemetry` spans flow into the run trace. Awaited here
5173+
// at run boot — before any `streamText` — and a no-op on v5/v6 or when the
5174+
// optional `@ai-sdk/otel` peer isn't installed. See ./aiAutoTelemetry.ts.
5175+
await ensureAiSdkTelemetry();
5176+
51505177
// Bind the run to its backing Session so every module-level helper
51515178
// (chat.stream, chat.messages, chat.stopSignal) resolves to this
51525179
// chat's `.in` / `.out` channels.
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
/**
2+
* Auto-register `@ai-sdk/otel` so AI SDK 7 emits OpenTelemetry spans into the
3+
* Trigger.dev run trace with no customer setup.
4+
*
5+
* AI SDK 6 emitted spans from `ai` core, so `experimental_telemetry` (set by
6+
* `chat.toStreamTextOptions({ telemetry })`) was enough. v7 moved span emission
7+
* into the separate `@ai-sdk/otel` adapter, so on v7 `experimental_telemetry`
8+
* alone produces nothing until an integration is registered. We register it once
9+
* per worker process at chat.agent run boot. `@ai-sdk/otel` writes to the global
10+
* OpenTelemetry tracer, which is the same provider the Trigger worker installs
11+
* (the `@opentelemetry/api` global is a `globalThis` singleton keyed by major
12+
* version, so the separate copies still share it), so spans land in the trace.
13+
*
14+
* Fully guarded and best-effort — telemetry must never break a run:
15+
* - `registerTelemetry` only exists in v7 `ai` (no-op on v5/v6).
16+
* - `@ai-sdk/otel` is an OPTIONAL peer. The specifier is computed so the task
17+
* bundler doesn't hard-require it (v5/v6 users never install it).
18+
* - We detect an already-registered `@ai-sdk/otel` integration and skip, so a
19+
* customer (or a library they import) that registers it themselves doesn't
20+
* get duplicate spans. `registerTelemetry` is append-only, so without this
21+
* guard a second integration would double every span.
22+
* - To disable our auto-register entirely (e.g. you register `@ai-sdk/otel`
23+
* yourself after this boot, or via a custom integration our detection can't
24+
* see), set the env var `TRIGGER_AI_SDK_OTEL_AUTOREGISTER=0`.
25+
*/
26+
let registration: Promise<void> | null = null;
27+
28+
/** Registers the AI SDK OTel integration once per process. Safe to call on every run. */
29+
export function ensureAiSdkTelemetry(): Promise<void> {
30+
if (!registration) {
31+
registration = register();
32+
}
33+
return registration;
34+
}
35+
36+
async function register(): Promise<void> {
37+
try {
38+
if (isAutoRegisterDisabled()) {
39+
return; // opted out via TRIGGER_AI_SDK_OTEL_AUTOREGISTER
40+
}
41+
const aiMod: any = await import("ai");
42+
if (typeof aiMod.registerTelemetry !== "function") {
43+
return; // v5 / v6 — `ai` core emits spans itself, nothing to wire.
44+
}
45+
// Computed specifier keeps the optional peer out of static bundler
46+
// resolution; resolves at runtime only when the customer installed it.
47+
const otelSpecifier = ["@ai-sdk", "otel"].join("/");
48+
const otelMod: any = await import(otelSpecifier).catch(() => null);
49+
if (typeof otelMod?.OpenTelemetry !== "function") {
50+
return; // optional peer not installed
51+
}
52+
if (hasAiSdkOtelIntegration(otelMod.OpenTelemetry)) {
53+
return; // already registered by the customer or a library they import
54+
}
55+
aiMod.registerTelemetry(new otelMod.OpenTelemetry());
56+
} catch {
57+
// never throw from telemetry setup
58+
}
59+
}
60+
61+
function isAutoRegisterDisabled(): boolean {
62+
const value = process.env.TRIGGER_AI_SDK_OTEL_AUTOREGISTER?.toLowerCase();
63+
return value === "0" || value === "false";
64+
}
65+
66+
/**
67+
* True if an `@ai-sdk/otel` integration is already in v7's global telemetry
68+
* registry (`globalThis.AI_SDK_TELEMETRY_INTEGRATIONS`, a documented public
69+
* global that `registerTelemetry` appends to). `instanceof` matches a same-copy
70+
* registration; the constructor-name fallback catches a separate copy of
71+
* `@ai-sdk/otel`.
72+
*/
73+
function hasAiSdkOtelIntegration(OpenTelemetry: any): boolean {
74+
const integrations = (globalThis as any).AI_SDK_TELEMETRY_INTEGRATIONS;
75+
if (!Array.isArray(integrations)) {
76+
return false;
77+
}
78+
return integrations.some(
79+
(integration: any) =>
80+
(typeof OpenTelemetry === "function" && integration instanceof OpenTelemetry) ||
81+
integration?.constructor?.name === "OpenTelemetry"
82+
);
83+
}

packages/trigger-sdk/src/v3/chat-client.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,9 @@
1818

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

0 commit comments

Comments
 (0)