@silverprotocol/vercel-ai
v0.12.3
Published
AgJSON normalizer for the Vercel AI SDK (fullStream TextStreamPart → AgEvent[]).
Readme
@silverprotocol/vercel-ai
AgJSON normalizer for the Vercel AI SDK —
ai (streamText fullStream
TextStreamPart → AgEvent[]). It sits on top of your streamText
call and translates the stream parts it yields into framework-neutral
AgJSON; it doesn't replace the SDK you already use.
Install
npm install @silverprotocol/vercel-ai ai@silverprotocol/core is a required peer (pulled in automatically). ai is
the Vercel AI SDK itself — you need it to produce the stream parts. (The
normalizer guards TextStreamParts structurally, so if you only feed it
already-captured parts it's just a type-level peer.)
Upstream compatibility
Supported peer range: ai >=6.0.0 <8 (npm-enforced, optional — this package never imports the SDK).
Wire shapes outside the verified set never crash the normalizer, and the
fixture-drift gate (scripts/check-fixture-drift.mjs) turns every newly-appeared
upstream field into a tracked triage disposition rather than an unnoticed drop —
carried losslessly where a carry channel exists (see sdk-surface.json for this
facet's per-field dispositions, including its disclosed silently-dropped gaps).
| ai | verified | with | evidence |
| --- | --- | --- | --- |
| 7.0.111 | 2026-09-23 | 0.6.3 | ai 7.0.100 -> 7.0.111 with @ai-sdk/openai 4.0.66 -> 4.0.72, @ai-sdk/mcp 2.0.49 -> 2.0.55 and gateway 4.0.81 -> 4.0.89, moving as ONE group (all pin @ai-sdk/provider 4.0.17 / provider-utils 5.0.45). TYPED SURFACE BYTE-IDENTICAL across all eleven ai patches: the TextStreamPart union and every type it references; the @ai-sdk/openai Responses language-model source is byte-identical too. Two runtime changes on the fullStream path, both fixture-only because the capture agent sets neither toolChoice nor tool approval. (1) ai 7.0.108 (ccf98e7) changed the toolChoice-violation 'wrong tool' shape: the wrong tool is NO LONGER executed and the run ends in turn.error (carrying finish.totalUsage), so the 0.6.2 shape-B fixture described a runtime that no longer exists; fixture and facet docs updated. (2) ai 7.0.102 (8b92ba9) emits tool-output-denied INSIDE the step for auto-denied approvals; the facet frame-carried it, but SPEC section 8 item 22 routes a frame with an AgJSON home to that home, so it now maps to tool.done{outcome:"denied"}. A denial for a call id this turn never opened resolves exactly like the initial pass's prior-call tool-result / tool-error, as a bare tool.done; KNOWN GAP shared by all three initial-pass arms: a bare tool.done before the first start-step sets the Reducer's needsResync. gpt-6-sol and gpt-6-luna are absent from @ai-sdk/openai's model-id unions; its regex capability classifier treats them like gpt-6-astra, so this stack cannot send them a reasoning effort outside low..max. LIVE at 7.0.111: echo-gpt56 and echo-gpt6astra refreshed; NEW echo-gpt6sol (24 reasoning tokens) and echo-gpt6luna (chose not to reason). No GPT-6 'commentary' phase observed on any leg. Census ZERO drops / ZERO new fields. Facet tests 47 -> 63. |
| 7.0.100 | 2026-09-15 | 0.6.2 | ai 7.0.93->7.0.100 with @ai-sdk/openai 4.0.59->4.0.66, @ai-sdk/mcp 2.0.45->2.0.49 and gateway 4.0.75->4.0.81; the four move as ONE group (all pin @ai-sdk/provider 4.0.14 + provider-utils 5.0.40). Typed streamed surface BYTE-IDENTICAL: the TextStreamPart union and all 26 arms, plus LanguageModelUsage, the Typed/Static/Dynamic ToolCall|Result|Error families, StepResultPerformance, LanguageModelResponseMetadata, ProviderMetadata and FinishReason. ONE fullStream behaviour change, ai 7.0.94: streamText now enforces toolChoice 'required'/'tool' by enqueuing an IN-BAND error part carrying a ToolChoiceViolationError and closing the run as finish{finishReason:'error'} with totalUsage populated. It is inert for the capture config (the agent never sets toolChoice, so prepareToolChoice stays at its 'auto' default) but it newly routes runs that used to close turn.done{usage} into the facet's error arm - which DISCARDED finish.totalUsage even though AgTurnError carries an optional usage slot the openai-agents facet already fills. FIX: the error close now forwards the usage through the facet's single existing mapper, so an errored turn reports the tokens it actually burned; absent usage stays absent (no empty object), pinned by two negative controls asserting every non-terminal event stays byte-identical. Fixture coverage added for BOTH enforcement shapes, since they differ on the axis that matters: (A) zero tool calls -> error, finish-step{error}, finish{error,totalUsage} through the error arm; (B) a DIFFERENT tool called -> the step still continues and the turn eventually closes turn.done{finishReason:'stop'}, making it the first shape in this facet where a standalone AgJSON error event precedes message.end on a turn that then succeeds - asserted as a full ordered event list, with tool-result preceding error because the violation is backpressured behind the step's tool execution. Checked and cleared, no action: @ai-sdk/openai 4.0.66's two new hasFunctionCall sites (apply-patch and completed custom tool calls - the e2e agent passes only MCP-derived plain function tools, so neither can fire), createOpenAI's factory return type narrowing to a supertype in an argument position that only ever needed LanguageModel, and convert-to-openai-responses-input's explicitMessageItemType (off for createOpenAI). LIVE at 7.0.100: echo-gpt56 and echo-gpt6astra refreshed - census ZERO drops / ZERO new fields on both. Facet tests 38 -> 47. |
| 7.0.93 | 2026-09-05 | 0.6.1 | ai 7.0.90->7.0.93 with @ai-sdk/openai 4.0.56->4.0.59 and @ai-sdk/mcp 2.0.43->2.0.45 (provider-utils 5.0.36 / provider 4.0.10 unchanged; gateway 4.0.75) - typed streamed surface BYTE-IDENTICAL (TextStreamPart union + 26 members, LanguageModelUsage incl. raw, GeneratedFile, FinishReason, StreamProviderError); every ai d.ts delta is off the fullStream path (streamRetries + onError retry callback - inert when omitted, onAbort callback type, Output.array bounds, ToolLoopAgent callbacks). ONE fullStream-visible runtime delta: @ai-sdk/openai 4.0.57 preserves the complete Responses usage object on finish-step usage.raw, so usage.raw.total_tokens appears (registered + allowlisted: ai computes totalTokens itself as input + output, so the raw provider total is a near-duplicate). @ai-sdk/mcp 2.0.44/2.0.45: tool annotations into toolMetadata, structuredContent-only results synthesized - facet-neutral. gpt-6-astra is in both @ai-sdk/openai model-id unions; the provider's regex capability parser classifies it as a reasoning model (Responses route, no temperature/top_p sent by default, no effort sent) - the capture agent sends none of the rejected parameters. LIVE: echo-gpt56 REFRESHED at 7.0.93 (gpt-5.6-sol) and the FIRST gpt-6-astra seed echo-gpt6astra captured (reasoning item rs_* with no text + 13 reasoning tokens; providerMetadata.openai.reasoningContext 'all_turns' echoed) - census ZERO drops / ZERO new fields beyond the registered raw.total_tokens on both. No facet code change. |
| 7.0.90 | 2026-09-02 | 0.5.4 | ai 7.0.66->7.0.90 with the aligned trio @ai-sdk/openai 4.0.42->4.0.56 + @ai-sdk/mcp 2.0.32->2.0.43 (all on provider-utils 5.0.36 / provider 4.0.10; gateway 4.0.72) - typed streamed surface BYTE-IDENTICAL (TextStreamPart union + all 26 members, LanguageModelUsage, FinishReason, Typed/Static/Dynamic ToolCall|Result|Error, ProviderMetadata; provider LanguageModelV4StreamPart family unchanged); the one nested delta is GeneratedFile gaining optional providerMetadata (file/reasoning-file parts, frame-carried). TWO RUNTIME changes on the fullStream path, both empirically reproduced: (1) since 7.0.80 every provider mid-stream error payload arrives wrapped in a StreamProviderError Error instance (own type/code/statusCode/isRetryable/data; @ai-sdk/openai emits them for Responses error SSE events and response.failed) - the facet now populates AgJSON error.code (from code; type is deliberately NOT a fallback: on the wire it is the SSE envelope name 'error') and error.retriable (from isRetryable, which the SDK may INFER from statusCode 408/409/429/>=500) on the error event and on turn.error in the finish{error} and flush arms; the vercel capture agent projects Error instances to {name,message,...own} before the JSON round-trip so cassettes keep message (live==replay); (2) 7.0.70 stops automatic tool execution after finishReason length/content-filter/error/other and 7.0.76 remaps duplicate text/reasoning part ids across steps - facet-neutral (streams keyed per message). @ai-sdk/mcp 2.0.33+ probes server/discover (MCP-Protocol-Version 2026-07-28) before initialize; against the stateless @modelcontextprotocol/sdk 1.30.0 mock this yields one 400 (-32000 with id:null, which the client's JSON-RPC parser rejects, so it falls through the generic transport-error path and then legacy-inits) + one extra GET 405 - silent today (no onUncaughtError / transport.onerror wired). @ai-sdk/openai 4.0.56 adds Responses parallel wrapper expansion (providerMetadata.openai.parallelToolCall; synthesized ${id}_${index} tool ids) and schema-invalid-known-event -> error signalling; no reasoning-summary/encrypted/compaction changes. LIVE: echo-gpt56/vercel RE-CAPTURED at 7.0.90 (gpt-5.6-sol; provenance had been 7.0.51 since 2026-08-04) - census ZERO drops / ZERO new fields; the 2.0.43 discovery->legacy fallback verified live. Nightly leg 'force [email protected]' green with the coherent 2.0.42/4.0.55 family (run 33564341176, job 100044006021); 7.0.90/4.0.56/2.0.43 are deps-only over it (pu 5.0.36 = undici CVE-2026-13697 patch). 11 facet tests + 3 capture-agent smoke tests added. |
| 7.0.66 | 2026-08-15 | 0.4.4 | ai 7.0.64->7.0.66 with the aligned pu 5.0.27 trio (mcp 2.0.32, @ai-sdk/openai 4.0.42; gateway 4.0.52/provider 4.0.7 transitives - pu pin UNCHANGED across the bump, so no lockstep skew). 7.0.65+7.0.66 crossed the nightly's family-forced leg silently (zero [peer-compat] filings); wire-no-op, full gate suite green. |
| 7.0.64 | 2026-08-13 | 0.4.2 | ai 7.0.58->7.0.64 (six patches) with the aligned pu 5.0.27 trio (mcp 2.0.31, @ai-sdk/openai 4.0.41; gateway 4.0.51/provider 4.0.7 transitives). The whole streak passed the nightly's family-forced leg silently — zero [peer-compat] filings, the lockstep group-force + skew guard working as designed. Wire-no-op; full gate suite green. |
| 7.0.58 | 2026-08-08 | 0.4.1 | peer catch-up #2 of the vercel rapid-patch streak (typescript-sdk#14): ai 7.0.56->7.0.58 with @ai-sdk/mcp 2.0.29 + @ai-sdk/openai 4.0.36 (aligned provider-utils 5.0.25 trio; gateway 4.0.46/provider 4.0.7 transitives). Mirror lockfile rebuilt from fresh resolution (pnpm clean --lockfile — the stale-young-entry policy deadlock: replaced exclusions un-exclude still-young lockfile entries and install rejects at READ; fresh rebuild is the sanctioned path). @google/adk caret pin tightened to exact 1.5.0 after the fresh resolution floated it to the unvetted 1.6.0 minor. Full gate suite green (949 tests incl. corpus replay + fold gates); wire-no-op, no census demands. |
| 7.0.56 | 2026-08-08 | 0.4.1 | peer catch-up (typescript-sdk#12): ai 7.0.51->7.0.56 with @ai-sdk/mcp 2.0.27 + @ai-sdk/openai 4.0.34 — the aligned provider-utils 5.0.23 trio (7.0.52+ against the old mcp 2.0.24/pu 5.0.20 pins installed duplicate unique-symbol-branded Schema copies and broke typecheck in-range, the nightly's [peer-compat] finding). Full gate suite green at the new pins (build/typecheck/949 tests incl. corpus replay + fold gates); wire-no-op — no facet change, no census demands, existing echo-gpt56 cassette replays byte-stable. Lockfile verified single provider-utils resolution (5.0.20 orphans pruned). |
| 7.0.51 | 2026-08-04 | 0.3.9 | 7.0.41→7.0.51 audit: streamed-surface d.ts (TextStreamPart union + UIMessageChunk) byte-identical across the 7.0.44→7.0.51 tarball chain and unchanged since 7.0.34 — the one WIRE change is behavioral, not typed: 7.0.42 (changeset 6de2ec1, runtime-diff confirmed) relaxed the empty-text guard to text.length > 0 \|\| providerMetadata != null, so an empty text-delta now reaches consumers when a chunk-level providerMetadata bag is its whole payload. Carried on text.delta's first-class providerMetadata slot (Reducer merges it onto the sealed block), scoped to EMPTY deltas so pre-7.0.42-shaped streams normalize byte-identically — non-empty deltas keep the census's disclosed-drop disposition (5 fixture tests: carry + clean Reducer fold, bare-empty-delta unchanged, 7.0.41-shaped negative control, hostile non-object bag). Companion pins moved in the ai↔@ai-sdk/mcp provider-utils lockstep pairing (the pairing that fixed the e2e CI typecheck). Live re-capture pending — this cohort's capture step. |
| 7.0.41 | 2026-07-29 | 0.3.7 | 7.0.34→7.0.41 verified a UI-message-stream no-op by full d.ts diff of the npm-packed tarballs: UIMessage, every UIMessagePart, the complete UIMessageChunk union and uiMessageChunkSchema byte-identical; the deltas are server-side only (pipe* void→Promise, firstChunkMs timeout config, tool-approval HMAC payload format — signature field stays opaque-string, experimental speech-translation stream — separate modality). Live re-capture echo-gpt56/vercel at ai 7.0.41 + gpt-5.6-sol (explicit snapshot id, de-aliased from floating gpt-5.6): replay + census gates green, zero new fields, zero drops. Facet code untouched; e2e harness ToolSet cast updated for the tightened 7.0.41 tool generics. |
| 7.0.34 | 2026-07-25 | 0.3.6 | FIRST full census triage of the vercel wire (echo-gpt56 enrolled as a CI-standing replay seed under a new VERCEL_SEEDS gate list — enrollment repair): 138 drops / 78 new fields classified into 24 transforms, 55 vercel-scoped allowlist entries and 78 registry paths; the ONE genuine loss exposed — providerMetadata..reasoningEncryptedContent, the replay-load-bearing analog of claude's signature / adk's thoughtSignature — is now carried as reasoning.opaque {kind:'encrypted'} on reasoning-end (provider-agnostic, fixture tests added); replay + census gates green |
| 7.0.34 | 2026-07-22 | 0.3.3 | nightly watchdog flagged [email protected] (issue #3): e2e-harness-only type skew between [email protected] and @ai-sdk/[email protected] ToolSet — normalizer package unaffected (never imports the SDK); fixed by aligning companion pins (@ai-sdk/mcp 2.0.16, @ai-sdk/openai 4.0.17); live gpt-5.6 tool-loop re-capture at 7.0.34 clean (reasoning parts included, zero ext carries), replay gate green |
| 7.0.31 | 2026-07-22 | 0.3.3 | provider-breadth probe via @ai-sdk/google on day-one gemini-3.6-flash: live streamText tool loop plus thinking-forced run (thinkingConfig.includeThoughts), zero unparsed and zero frame carries, clean two-step reduce — first non-OpenAI provider live-verified through this facet |
| 7.0.31 | 2026-07-20 | 0.3.3 | LIVE conformance capture: echo-gpt56 corpus (gpt-4o-mini via @ai-sdk/openai, official @ai-sdk/mcp Streamable-HTTP client against the harness mock) — landed as a standing replay seed; full two-step tool loop normalized (tool.start/args/assembled/done -> text step -> turn.done), zero unparsed/frame carries; deterministic replay green across the corpus. Census dispositions seeded from the capture (disclosed drops: tool-input-start.toolMetadata.clientName, dynamic flag on input-start) |
| 7.0.26 | 2026-07-20 | 0.3.3 | full drive switch + 20-test fixture suite replaying REAL mock-captured fullStream sequences at [email protected] (text/reasoning/two-step tool with id correlation, a per-step message model, three error arms, abort ordering, content-filter mapping, forward-compat frame carries) incl. Reducer folds; contract finals empirically verified; live conformance capture pending |
Usage
import { streamText } from "ai";
import { openai } from "@ai-sdk/openai";
import { createVercelNormalizer } from "@silverprotocol/vercel-ai";
// Call streamText as usual — this package normalizes what it emits.
const result = streamText({
model: openai("gpt-5.6"),
tools: { echo: echoTool },
prompt: "call the echo tool",
});
const n = createVercelNormalizer();
const agEvents = [];
// fullStream is the typed TextStreamPart stream (on ai v7 it is aliased to
// `result.stream` — both names work across the supported range).
for await (const native of result.fullStream)
agEvents.push(...n.push(native)); // one TextStreamPart → 0+ AgEvents
agEvents.push(...n.flush()); // seal anything still openpush() returns the AgEvent[] synthesized from each native
TextStreamPart; flush() drains buffered end-of-stream state. Any
async/sync iterable of TextStreamParts works — a live streamText run, or
parts you captured earlier.
Use one normalizer per streamText run. Turn and message ids must stay unique
across every run you fold into one reducer, and the fullStream carries no id
until the step finishes, so each normalizer draws a random id stem by default.
For deterministic ids (tests, replaying captured parts), pass
createVercelNormalizer({ invokeId }); keep the value unique per run within a
fold, or the reducer parks on the repeated turn.
Pass the host's own thread id as createVercelNormalizer({ threadId }). The
normalizer stamps it as the threadId of every turn and message it opens: the
partition root a key-value store writes each unit under (spec §1.2). The AI
SDK's fullStream has no thread concept, so with no threadId option the facet
stamps the fixed label "vercel" as a facet-local placeholder, not a partition
root; a host that persists by threadId supplies its own.
Then fold the resulting AgEvents into messages and turns with
@silverprotocol/core's reduce() — the same client code regardless of which
framework produced the stream.
Spec: silverprotocol.io/AgJSON — canonical
in silverprotocol/AgJSON; wire
version 1.0.0-draft.8.
