Skip to content

feat(vercel-ai-sdk): add AI SDK v7 integration#794

Open
hassiebp wants to merge 15 commits into
mainfrom
codex/add-vercel-ai-sdk-integration
Open

feat(vercel-ai-sdk): add AI SDK v7 integration#794
hassiebp wants to merge 15 commits into
mainfrom
codex/add-vercel-ai-sdk-integration

Conversation

@hassiebp
Copy link
Copy Markdown
Contributor

@hassiebp hassiebp commented Apr 22, 2026

What changed

  • add a new @langfuse/vercel-ai-sdk workspace package for AI SDK v7 telemetry integration
  • implement a Langfuse-owned AI SDK Telemetry integration that mirrors the upstream v7 OpenTelemetry span shape while adding Langfuse trace and prompt attributes
  • support per-call runtimeContext.langfuse, legacy metadata fallback, nested tool execution context, and older beta tool callback aliases
  • add package-local tests plus workspace wiring for docs, typedoc, Vitest, TypeScript path aliases, and lockfile updates

Why

AI SDK v7 moved to a callback-based telemetry model, so Langfuse needs its own maintained integration package instead of relying on the older native integration path.

Impact

  • gives Langfuse a first-class package for AI SDK v7 users
  • preserves compatibility with the existing Langfuse OTEL ingestion pipeline by keeping the ai instrumentation scope and AI SDK-style span attributes
  • makes globally registered integrations practical by allowing per-call Langfuse context to override constructor defaults

Validation

  • targeted TypeScript check for the new package source using a repo-local temp tsconfig
  • targeted TypeScript check for the new package test file using a repo-local temp tsconfig
  • direct tsx smoke test covering root spans, prompt linkage, per-call Langfuse override precedence, and nested tool-span context

Notes

  • I could not run the normal vitest and tsup package commands in this environment because Rollup's native binary (@rollup/rollup-darwin-arm64) fails to load locally with a code-signing / ERR_DLOPEN_FAILED issue.

Greptile Summary

This PR adds a new @langfuse/vercel-ai-sdk workspace package that provides a first-class Langfuse integration for AI SDK v7's callback-based telemetry model, replacing the older native OTEL path. The implementation uses a clean delegate pattern over the upstream @ai-sdk/otel OpenTelemetry class, injecting Langfuse-specific span attributes (prompt linkage, observation metadata) solely through the enrichSpan callback.

  • Core integration (LangfuseVercelAiSdkIntegration): all Telemetry interface methods forward to the upstream delegate; Langfuse context is resolved by merging constructor-level config with per-call runtimeContext.langfuse, with runtime values winning on conflicts.
  • Test suite: unit tests cover span delegation, prompt attribute scoping, metadata serialization, runtime override merge semantics, tool context propagation, and cross-call isolation; e2e tests cover generateText, streamText, streamObject, embed, prompt linking, and file/image attachments.
  • Test file imports: both test files import AI SDK symbols via hardcoded deep paths into packages/vercel-ai-sdk/node_modules/\u2026/dist/index.js, bypassing workspace module resolution and sensitive to pnpm hoisting configuration changes.

Confidence Score: 4/5

The new package and its production code are solid; the only concerns are in test infrastructure and would not affect published package behavior.

The core integration logic is clean and well-tested at the unit level. The e2e and integration test files import AI SDK modules via hardcoded paths deep inside packages/vercel-ai-sdk/node_modules, which will silently break under pnpm hoisting or if the dist/ layout of those canary packages changes. TestContextManager.with() also restores context synchronously before async callbacks complete, making it fragile for future tests that read context after an await. Both concerns are confined to the test layer and do not affect the published package.

tests/e2e/vercel-ai-sdk-v7.e2e.test.ts and tests/integration/vercel-ai-sdk.integration.test.ts for the fragile node_modules path imports; packages/vercel-ai-sdk/src/testUtils.ts for the async-unsafe TestContextManager.

Sequence Diagram

sequenceDiagram
    participant User
    participant AISDK as AI SDK v7
    participant LFVI as LangfuseVercelAiSdkIntegration
    participant OtelDelegate as OpenTelemetry (delegate)
    participant EnrichSpan as enrichSpan callback
    participant LFCtx as resolveLangfuseContext
    participant Tracer as OTel Tracer

    User->>AISDK: generateText / streamText / embed
    AISDK->>LFVI: onStart(event)
    LFVI->>OtelDelegate: onStart(event)
    OtelDelegate->>Tracer: startActiveSpan(invoke_agent)
    OtelDelegate->>EnrichSpan: "enrichSpan({ spanType, runtimeContext })"
    EnrichSpan->>LFCtx: resolveLangfuseContext(configured, runtime)
    LFCtx-->>EnrichSpan: merged LangfuseContext
    EnrichSpan-->>OtelDelegate: Langfuse span attributes
    AISDK->>LFVI: onLanguageModelCallStart(event)
    LFVI->>OtelDelegate: onLanguageModelCallStart(event)
    OtelDelegate->>Tracer: startActiveSpan(chat model-id)
    OtelDelegate->>EnrichSpan: "enrichSpan({ spanType: languageModel })"
    EnrichSpan-->>OtelDelegate: "OBSERVATION_PROMPT_NAME, OBSERVATION_METADATA.*"
    AISDK->>LFVI: onFinish(event)
    LFVI->>OtelDelegate: onFinish(event)
    OtelDelegate->>Tracer: span.end()
    Tracer-->>User: spans exported via OTel pipeline to Langfuse
Loading
Prompt To Fix All With AI
Fix the following 3 code review issues. Work through them one at a time, proposing concise fixes.

---

### Issue 1 of 3
tests/e2e/vercel-ai-sdk-v7.e2e.test.ts:13-17
**Fragile deep-`node_modules` imports**

These imports hardcode paths inside `packages/vercel-ai-sdk/node_modules`, bypassing the workspace module resolver. They will silently fail in any environment where pnpm uses a flat/hoisted layout (`--shamefully-hoist`) or if the internal `dist/` paths of `@ai-sdk/openai` or `ai` change between canary releases. The integration test file has the same pattern for `ai/dist/index.js` and `ai/dist/test/index.js`. Consider adding a proper workspace alias for the canary `ai`/`@ai-sdk/openai` versions in `vitest.config.ts` so the resolver handles version isolation automatically.

### Issue 2 of 3
packages/vercel-ai-sdk/src/testUtils.ts:42-58
**`with()` restores context before async callbacks resolve**

`TestContextManager.with()` stores context in a plain field and runs `finally` synchronously, so the context is restored as soon as the async callback returns its `Promise` — before any `await` inside that callback completes. Any context read after the first `await` point inside the callback will see the outer context, not the one set by `with()`. The `executeTool` test avoids this today because `activeSpanId` is read on the first synchronous tick of the execute function (before any `await`), but a test that awaits before reading active context would give a wrong result. A true `AsyncLocalStorage`-backed context manager would be more faithful.

### Issue 3 of 3
tests/e2e/vercel-ai-sdk-v7.e2e.test.ts:51-59
**New integration instance per `telemetry()` call**

`telemetry()` constructs a fresh `LangfuseVercelAiSdkIntegration()` (and thus a new `OpenTelemetry` delegate) on every AI SDK invocation. This works correctly for e2e tests but sets a pattern that, if copied into production code, would pay unnecessary construction cost per call. A single shared instance is the idiomatic approach.

Reviews (1): Last reviewed commit: "fix(vercel-ai-sdk): align canary telemet..." | Re-trigger Greptile

Greptile also left 2 inline comments on this PR.

@vercel
Copy link
Copy Markdown

vercel Bot commented Apr 22, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
langfuse-js Ready Ready Preview May 14, 2026 2:00pm

Request Review

@hassiebp hassiebp force-pushed the codex/add-vercel-ai-sdk-integration branch from 824ace2 to 53f0072 Compare May 14, 2026 13:06
@hassiebp hassiebp changed the title [codex] Add Vercel AI SDK v7 integration feat(vercel-ai-sdk): add AI SDK v7 integration May 14, 2026
@hassiebp hassiebp marked this pull request as ready for review May 14, 2026 13:06
@github-actions
Copy link
Copy Markdown

@claude review

Comment thread tests/e2e/vercel-ai-sdk-v7.e2e.test.ts
Comment thread packages/vercel-ai-sdk/src/testUtils.ts
Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 53f0072fe4

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread vitest.workspace.ts
Comment thread tests/e2e/vercel-ai-sdk-v7.e2e.test.ts
Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 83394e9706

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread packages/vercel-ai-sdk/src/testUtils.ts Outdated
Comment thread tests/integration/vercel-ai-sdk.integration.test.ts
Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 22c6d54403

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread packages/vercel-ai-sdk/src/LangfuseVercelAiSdkIntegration.ts
Comment thread packages/vercel-ai-sdk/README.md Outdated
Comment on lines +213 to +223
const base64Content = part["content"];
const mediaType = part["mime_type"];

if (
part["type"] !== "blob" ||
typeof base64Content !== "string" ||
typeof mediaType !== "string" ||
base64Content.startsWith("http")
) {
continue;
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 Empty blob content (content: "") in an AI SDK v7 gen_ai.input.messages / gen_ai.output.messages blob part bypasses the type guard at MediaService.ts:216-223 (typeof "" === "string") and reaches mediaReplacedValue.replaceAll(base64Content, langfuseMediaTag) at line 247. Per ECMA-262, String.prototype.replaceAll("", x) inserts x between every UTF-16 code unit, so the entire span attribute JSON is shredded with media-tag interleaving and becomes both unparseable and massively bloated. The pre-existing v6 branch at line 143 in the same file already guards against this with if (!base64Content) continue; — mirror that one line (or add || !base64Content to the OR chain at 216-222).

Extended reasoning...

Bug

In packages/otel/src/MediaService.ts, the new AI SDK v7 media handler (lines 213-247) only validates the content field with typeof base64Content !== "string" — which lets an empty string "" slip through (typeof "" === "string"). A few lines later, line 247 unconditionally calls mediaReplacedValue.replaceAll(base64Content, langfuseMediaTag). Per the ECMA-262 spec for String.prototype.replaceAll with an empty searchValue, the replacement is inserted between every UTF-16 code unit (and at start/end), so the entire gen_ai.input.messages / gen_ai.output.messages JSON attribute is corrupted.

Why existing guards don't catch it

Walking the four guards at lines 216-223 for part = { type: "blob", content: "", mime_type: "application/pdf" }:

  1. part["type"] !== "blob" → false (matches).
  2. typeof base64Content !== "string"typeof "" === "string", so false.
  3. typeof mediaType !== "string" → false.
  4. base64Content.startsWith("http")"".startsWith("http") returns false.

All pass. Execution proceeds to base64ToBytes(""), which calls atob("")"" → empty Uint8Array. The downstream media.getTag() is not short-circuited either: LangfuseMedia.getSha256Hash() checks if (!this._contentBytes) — but an empty Uint8Array is a truthy object, so it proceeds and crypto.subtle.digest returns the well-known SHA-256 of empty input. getId() yields a valid 22-char ID, the if (!langfuseMediaTag) continue guard at line 233 does not fire, and we reach replaceAll at line 247.

Step-by-step proof

Verified in Node: 'abc'.replaceAll('', 'X') === 'XaXbXcX'. Concretely, with value = '[{"role":"user","parts":[{"type":"blob","content":"","mime_type":"application/pdf"}]}]' and langfuseMediaTag = '@@@langfuseMedia:type=application/pdf|id=AAA…|source=bytes@@@':

  1. value.replaceAll("", langfuseMediaTag) returns a string where the media tag is inserted between every single character of the original JSON — @@@…@@@[@@@…@@@{@@@…@@@"@@@…@@@r@@@…@@@, and so on for every code unit.
  2. The resulting string is no longer valid JSON, is many times larger than the original, and is then written back to span.attributes[attribute] and exported to Langfuse.

Downstream consumers that expect to parse gen_ai.input.messages see total corruption of the prompt history, and the payload is also bloated by roughly attribute.length * langfuseMediaTag.length bytes.

Asymmetry with the v6 block

The v6 branch immediately above, at line 143, already defends against this exact case:

if (!base64Content) continue;

The v7 block was clearly modelled on the v6 pattern (same author, adjacent code, same replaceAll usage) but dropped this one defensive line during the port. The fix is trivial: mirror that guard, e.g. add if (!base64Content) continue; after the type check, or fold !base64Content into the existing OR chain at lines 216-222.

Trigger probability and impact

The trigger is narrow: @ai-sdk/otel would need to emit a blob part with content: "" — possible if an upstream serializer/redactor strips or defaults the field, or a future canary release changes the shape. The blast radius is catastrophic when it does fire (entire span attribute mangled, downstream parse failures, large payload inflation), and the v6 author considered it worth defending against; consistency alone justifies the one-line fix.

Comment on lines +551 to +561
const embeddingObservation = trace.observations.find(
(observation: any) =>
observation.type === "EMBEDDING" ||
observation.model === modelName ||
observation.name?.includes(modelName),
);

expect(embeddingObservation).toBeDefined();
expect(embeddingObservation!.model ?? embeddingObservation!.name).toContain(
modelName,
);
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 The embed() e2e assertion at tests/e2e/vercel-ai-sdk-v7.e2e.test.ts:551-561 is degenerate: the disjunctive find() includes observation.name?.includes(modelName), which matches the AI SDK v7 root span (named like embed text-embedding-3-small) before any EMBEDDING-type observation. The follow-up embeddingObservation!.model ?? embeddingObservation!.name then falls through to the root's name, which already contains modelName by construction — so both assertions trivially pass even if no EMBEDDING observation is emitted. Tighten to observation.type === "EMBEDDING" so the test fails when the typed observation is missing.

Extended reasoning...

What the bug is

In the new embed e2e test:

const embeddingObservation = trace.observations.find(
  (observation: any) =>
    observation.type === "EMBEDDING" ||
    observation.model === modelName ||
    observation.name?.includes(modelName),
);

expect(embeddingObservation).toBeDefined();
expect(embeddingObservation!.model ?? embeddingObservation!.name).toContain(modelName);

An embed() call under @ai-sdk/otel produces multiple spans. The package's own unit tests (packages/vercel-ai-sdk/src/index.test.ts:50) confirm the root span is named invoke_agent <model-id>, and the model-call branch yields a span whose name also embeds the model id (e.g. embedding text-embedding-3-small). Array.prototype.find returns the first observation matching any disjunct; if the root appears before the EMBEDDING-type observation in iteration order, the root is picked even though it has neither type === "EMBEDDING" nor a model attribute.

Why this masks real failures

The follow-up assertion uses model ?? name. The root has no model, so the fallback selects name — and name already contains modelName because that's exactly what allowed it to match the disjunct. toContain(modelName) is therefore tautologically true: the same property that made find return the root is the property the assertion checks.

Step-by-step proof

  1. modelName = "text-embedding-3-small".
  2. AI SDK v7 emits observations in order [{name: "embed text-embedding-3-small", type: "GENERATION" or "DEFAULT"}, {name: "embedding text-embedding-3-small", type: "EMBEDDING", model: "text-embedding-3-small"}].
  3. find evaluates the root first: type === "EMBEDDING" → false; model === modelName → false (no model); name?.includes(modelName)true ⇒ return the root.
  4. embeddingObservation = root (no model field).
  5. embeddingObservation!.model ?? embeddingObservation!.nameroot.name = "embed text-embedding-3-small".
  6. expect("embed text-embedding-3-small").toContain("text-embedding-3-small") ⇒ passes.

Now imagine the upstream integration regresses and stops emitting the EMBEDDING-type observation altogether (or emits it with the wrong model field). The root still exists, its name still contains the model id, both assertions still pass. The test cannot detect the failure mode it claims to test.

Addressing the refutation

The refutation argues this is intentional permissiveness since AI SDK v7 is in canary and the exact observation shape is in flux, and that result.embedding plus expectTraceAttributes cover the rest. That's partially right — the test still verifies the e2e plumbing works end-to-end — but it concedes the specific assertion that the EMBEDDING-type observation carries the right model is unverified. That is the only thing this block exists to check; everything else is already covered by expectTraceAttributes and expect(result.embedding).toBeDefined() above. Tightening to observation.type === "EMBEDDING" keeps the same canary tolerance (no change to other assertions, the GENERATION-style matcher used elsewhere remains permissive) while restoring the assertion's stated purpose.

How to fix

Replace the disjunctive matcher with a single typed check:

const embeddingObservation = trace.observations.find(
  (observation: any) => observation.type === "EMBEDDING",
);

expect(embeddingObservation).toBeDefined();
expect(embeddingObservation!.model).toContain(modelName);

This is test-quality only; no production code is affected.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant