-
Notifications
You must be signed in to change notification settings - Fork 98
feat(vercel-ai-sdk): add AI SDK v7 integration #794
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
eb83d6b
df19078
29bfbed
1d95618
86e4879
13cc435
3c9cbe9
4018e14
190feb0
81b643f
81f1546
0c3c378
1a86c7a
fbcae9f
00a5bd3
501e5c3
bb4a350
918b77f
40be5b9
69d08ad
148389c
6ad8555
77fb543
7a40b16
8864776
39eff5f
7bede0f
52d42b6
dfd4159
c800441
dbdebb1
91d849f
f79096a
b51f773
538fb91
5f88c8c
81bfa1f
beb0ff7
1f4d6a9
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -7,6 +7,7 @@ import { | |
| getGlobalLogger, | ||
| uploadMedia, | ||
| } from "@langfuse/core"; | ||
| import type { MediaContentType } from "@langfuse/core"; | ||
| import { ReadableSpan } from "@opentelemetry/sdk-trace-base"; | ||
|
|
||
| export class MediaService { | ||
|
|
@@ -96,8 +97,8 @@ export class MediaService { | |
| } | ||
| } | ||
|
|
||
| // Handle media from Vercel AI SDK | ||
| if (span.instrumentationScope.name === "ai") { | ||
| // Handle media from Vercel AI SDK v6 and AI SDK v7. | ||
| if (["ai", "gen_ai"].includes(span.instrumentationScope.name)) { | ||
|
hassiebp marked this conversation as resolved.
Comment on lines
+100
to
+101
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔴 AI SDK v6/v7 media handling is skipped whenever a user passes a custom Extended reasoning...What the bug is
The AI SDK semantic-convention attribute keys ( But Step-by-step proof
Replacing the check with Why it survived reviewThe Suggested fixIn const isAiSdkSpan = Object.keys(span.attributes).some(
(k) => k.startsWith("gen_ai.") || k.startsWith("ai."),
);
if (isAiSdkSpan) { ... }or reuse the existing helper from import { isGenAISpan } from "./span-filter.js";
// ...
if (isGenAISpan(span)) { ... }The latter has the bonus of staying consistent with the rest of the export pipeline's classification. 🔬 also observed by chatgpt-codex-connector |
||
| const aiSDKMediaAttributes = ["ai.prompt.messages", "ai.prompt"]; | ||
|
|
||
| for (const mediaAttribute of aiSDKMediaAttributes) { | ||
|
|
@@ -121,54 +122,34 @@ export class MediaService { | |
| for (const part of contentParts) { | ||
| if (part["type"] === "file") { | ||
| let base64Content: string | null = null; | ||
| const mediaType = part["mediaType"]; | ||
| // FilePart | ||
| if ( | ||
| part["data"] != null && | ||
| part["mediaType"] != null && | ||
| typeof part["data"] !== "object" && // skip URL instances | ||
| !String(part["data"]).startsWith("http") // skip URL strings | ||
| typeof part["data"] === "string" && | ||
| !part["data"].startsWith("http") // skip URL strings | ||
| ) { | ||
| base64Content = part["data"]; | ||
| } | ||
|
|
||
| //ImagePart | ||
| if ( | ||
| part["image"] != null && | ||
| part["mediaType"] != null && | ||
| typeof part["image"] === "string" && | ||
| !part["image"].startsWith("http") // skip URLs | ||
| ) { | ||
| base64Content = part["image"]; | ||
| } | ||
|
|
||
| if (!base64Content) continue; | ||
|
|
||
| const media = new LangfuseMedia({ | ||
| contentType: part["mediaType"], | ||
| contentBytes: base64ToBytes(base64Content), | ||
| source: "bytes", | ||
| }); | ||
|
|
||
| const langfuseMediaTag = await media.getTag(); | ||
|
|
||
| if (!langfuseMediaTag) { | ||
| this.logger.warn( | ||
| "Failed to create Langfuse media tag. Skipping media item.", | ||
| ); | ||
|
|
||
| if (!base64Content || typeof mediaType !== "string") { | ||
| continue; | ||
| } | ||
|
|
||
| this.scheduleUpload({ | ||
| mediaReplacedValue = await this.replaceBytesMedia({ | ||
| span, | ||
| media, | ||
| mediaReplacedValue, | ||
| base64Content, | ||
| contentType: mediaType, | ||
| field: "input", | ||
| }); | ||
|
|
||
| // Replace original attribute with media escaped attribute | ||
| mediaReplacedValue = mediaReplacedValue.replaceAll( | ||
| base64Content, | ||
| langfuseMediaTag, | ||
| ); | ||
| } | ||
| } | ||
| } | ||
|
|
@@ -183,9 +164,111 @@ export class MediaService { | |
| ); | ||
| } | ||
| } | ||
|
|
||
| // Handle media from AI SDK v7 OpenTelemetry semantic-convention | ||
| // attributes emitted by @ai-sdk/otel. | ||
|
hassiebp marked this conversation as resolved.
|
||
| const aiSDKV7MediaAttributes = [ | ||
| { attribute: "gen_ai.input.messages", field: "input" }, | ||
| { attribute: "gen_ai.output.messages", field: "output" }, | ||
| ] as const; | ||
|
|
||
| for (const { attribute, field } of aiSDKV7MediaAttributes) { | ||
| const value = span.attributes[attribute]; | ||
|
|
||
| if (!value || typeof value !== "string") { | ||
| continue; | ||
| } | ||
|
|
||
| let mediaReplacedValue = value; | ||
|
|
||
| try { | ||
| const parsed = JSON.parse(value); | ||
|
|
||
| if (Array.isArray(parsed)) { | ||
| for (const message of parsed) { | ||
| if (!Array.isArray(message["parts"])) { | ||
| continue; | ||
| } | ||
|
|
||
| for (const part of message["parts"]) { | ||
| const content = part["content"]; | ||
| const mediaType = part["mime_type"]; | ||
|
|
||
| if ( | ||
| part["type"] !== "blob" || | ||
| typeof content !== "string" || | ||
| !content || | ||
| typeof mediaType !== "string" || | ||
| content.startsWith("http") | ||
|
hassiebp marked this conversation as resolved.
|
||
| ) { | ||
| continue; | ||
| } | ||
|
|
||
| const normalizedContent = normalizeBase64Content(content); | ||
|
|
||
| if (!normalizedContent) { | ||
| continue; | ||
| } | ||
|
|
||
| mediaReplacedValue = await this.replaceBytesMedia({ | ||
| span, | ||
| mediaReplacedValue, | ||
| base64Content: normalizedContent.base64Content, | ||
| contentToReplace: content, | ||
| contentType: mediaType, | ||
| field, | ||
| }); | ||
| } | ||
| } | ||
| } | ||
|
|
||
| span.attributes[attribute] = mediaReplacedValue; | ||
| } catch (err) { | ||
| this.logger.warn( | ||
| `Failed to handle media for AI SDK v7 attribute ${attribute} for span ${span.spanContext().spanId}`, | ||
| err, | ||
| ); | ||
| } | ||
| } | ||
| } | ||
| } | ||
|
|
||
| private async replaceBytesMedia(params: { | ||
| span: ReadableSpan; | ||
| mediaReplacedValue: string; | ||
| base64Content: string; | ||
| contentToReplace?: string; | ||
| contentType: string; | ||
| field: string; | ||
| }): Promise<string> { | ||
| const media = new LangfuseMedia({ | ||
| contentType: params.contentType as MediaContentType, | ||
| contentBytes: base64ToBytes(params.base64Content), | ||
| source: "bytes", | ||
| }); | ||
|
|
||
| const langfuseMediaTag = await media.getTag(); | ||
|
|
||
| if (!langfuseMediaTag) { | ||
| this.logger.warn( | ||
| "Failed to create Langfuse media tag. Skipping media item.", | ||
| ); | ||
|
|
||
| return params.mediaReplacedValue; | ||
| } | ||
|
|
||
| this.scheduleUpload({ | ||
| span: params.span, | ||
| media, | ||
| field: params.field, | ||
| }); | ||
|
|
||
| return params.mediaReplacedValue.replaceAll( | ||
| params.contentToReplace ?? params.base64Content, | ||
| langfuseMediaTag, | ||
| ); | ||
| } | ||
|
|
||
| private scheduleUpload(params: { | ||
| span: ReadableSpan; | ||
| field: string; | ||
|
|
@@ -234,3 +317,25 @@ export class MediaService { | |
| } | ||
| } | ||
| } | ||
|
|
||
| function normalizeBase64Content( | ||
| content: string, | ||
| ): { base64Content: string } | undefined { | ||
| if (!content.startsWith("data:")) { | ||
| return { base64Content: content }; | ||
| } | ||
|
|
||
| const base64Start = content.indexOf(";base64,"); | ||
|
|
||
| if (base64Start === -1) { | ||
| return; | ||
| } | ||
|
|
||
| const base64Content = content.slice(base64Start + ";base64,".length); | ||
|
|
||
| if (!base64Content) { | ||
| return; | ||
| } | ||
|
|
||
| return { base64Content }; | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,102 @@ | ||
|  | ||
|
|
||
| # @langfuse/vercel-ai-sdk | ||
|
|
||
| This package provides a Langfuse-owned telemetry integration for AI SDK v7 (`ai@7`) using the new callback-based telemetry system. | ||
|
|
||
| It delegates AI SDK-compatible OpenTelemetry span creation to Vercel's `@ai-sdk/otel` package so it works with the existing Langfuse OTEL ingestion pipeline. Runtime context keys included via AI SDK telemetry are attached as Langfuse observation metadata. The only special runtime context key is `langfusePrompt`, which links Langfuse prompt name and version to model-call observations. | ||
|
|
||
| Trace-level attributes such as user ID, session ID, tags, trace name, and trace metadata should be set with `propagateAttributes` from `@langfuse/tracing`. | ||
|
|
||
| ## Compatibility | ||
|
|
||
| This integration targets AI SDK v7 GA. Install it together with `ai@^7`; the package depends on the matching `@ai-sdk/otel` integration internally. | ||
|
|
||
| ```sh | ||
| pnpm add @langfuse/vercel-ai-sdk ai | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Useful? React with 👍 / 👎. |
||
| ``` | ||
|
|
||
| ## Usage | ||
|
|
||
| ```ts | ||
| import { generateText, registerTelemetry } from "ai"; | ||
| import { propagateAttributes } from "@langfuse/tracing"; | ||
| import { LangfuseVercelAiSdkIntegration } from "@langfuse/vercel-ai-sdk"; | ||
|
|
||
| registerTelemetry(new LangfuseVercelAiSdkIntegration()); | ||
|
|
||
| await propagateAttributes( | ||
| { | ||
| userId: "user-123", | ||
| sessionId: "session-456", | ||
| tags: ["production", "chat"], | ||
| metadata: { | ||
| feature: "assistant", | ||
| }, | ||
| }, | ||
| () => | ||
| generateText({ | ||
| model, | ||
| prompt: "Explain RAG in one paragraph", | ||
| runtimeContext: { | ||
| route: "support-chat", | ||
| langfusePrompt: { | ||
| name: "assistant/default", | ||
| version: 3, | ||
| isFallback: false, | ||
| }, | ||
| }, | ||
| telemetry: { | ||
| functionId: "chat-assistant", | ||
| includeRuntimeContext: { | ||
| route: true, | ||
| langfusePrompt: true, | ||
| }, | ||
| }, | ||
| }), | ||
| ); | ||
| ``` | ||
|
|
||
| AI SDK v7 excludes `runtimeContext` from telemetry events unless each top-level key is explicitly included. This integration maps every included runtime context key to Langfuse observation metadata, except `langfusePrompt`, which is used for prompt linking and is not added as metadata. | ||
|
|
||
| You can also pass the integration on a single call: | ||
|
|
||
| ```ts | ||
| import { generateText } from "ai"; | ||
| import { LangfuseVercelAiSdkIntegration } from "@langfuse/vercel-ai-sdk"; | ||
|
|
||
| await generateText({ | ||
| model, | ||
| prompt: "Summarize this article", | ||
| runtimeContext: { | ||
| feature: "article-summary", | ||
| }, | ||
| telemetry: { | ||
| functionId: "article-summary", | ||
| includeRuntimeContext: { | ||
| feature: true, | ||
| }, | ||
| integrations: new LangfuseVercelAiSdkIntegration(), | ||
| }, | ||
| }); | ||
| ``` | ||
|
|
||
| ## Packages | ||
|
|
||
| | Package | NPM | Description | Environments | | ||
| | --------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------- | ------------ | | ||
| | [@langfuse/client](https://github.com/langfuse/langfuse-js/tree/main/packages/client) | [](https://www.npmjs.com/package/@langfuse/client) | Langfuse API client for universal JavaScript environments | Universal JS | | ||
| | [@langfuse/tracing](https://github.com/langfuse/langfuse-js/tree/main/packages/tracing) | [](https://www.npmjs.com/package/@langfuse/tracing) | Langfuse instrumentation methods based on OpenTelemetry | Node.js 20+ | | ||
| | [@langfuse/otel](https://github.com/langfuse/langfuse-js/tree/main/packages/otel) | [](https://www.npmjs.com/package/@langfuse/otel) | Langfuse OpenTelemetry export helpers | Node.js 20+ | | ||
| | [@langfuse/openai](https://github.com/langfuse/langfuse-js/tree/main/packages/openai) | [](https://www.npmjs.com/package/@langfuse/openai) | Langfuse integration for OpenAI SDK | Universal JS | | ||
| | [@langfuse/langchain](https://github.com/langfuse/langfuse-js/tree/main/packages/langchain) | [](https://www.npmjs.com/package/@langfuse/langchain) | Langfuse integration for LangChain | Universal JS | | ||
| | [@langfuse/vercel-ai-sdk](https://github.com/langfuse/langfuse-js/tree/main/packages/vercel-ai-sdk) | [](https://www.npmjs.com/package/@langfuse/vercel-ai-sdk) | Langfuse integration for AI SDK v7 | Universal JS | | ||
|
|
||
| ## Documentation | ||
|
|
||
| - Docs: https://langfuse.com/docs/sdk/typescript | ||
| - Reference: https://js.reference.langfuse.com | ||
|
|
||
| ## License | ||
|
|
||
| [MIT](LICENSE) | ||
Uh oh!
There was an error while loading. Please reload this page.