@superpenguin/js
v0.17.3
Published
SuperPenguin JavaScript SDK for AI cost attribution across native provider SDKs, Vercel AI SDK, and OpenTelemetry
Readme
SuperPenguin JavaScript SDK
TypeScript SDK for request-level AI cost attribution. It wraps native provider
SDKs for OpenAI, Anthropic, Google Gemini, AWS Bedrock, Deepgram, and
ElevenLabs, and still supports Vercel AI SDK helpers plus OpenTelemetry span
ingestion. Use it in server code only. Do not expose SP_API_KEY to browsers.
Install
npm install @superpenguin/js
npm install openai @anthropic-ai/sdk @google/genai @deepgram/sdk @elevenlabs/elevenlabs-jsInstall only the provider package(s) you actually use. Provider SDKs are
optional peer dependencies so @superpenguin/js does not force all of them into
your app.
Live spend reads
Read the latest spend observed for your organization before starting another AI request. The same SDK key authorizes ingestion and spend reads.
import { init, SuperPenguinSpendError } from "@superpenguin/js";
const superpenguin = init({ apiKey: process.env.SP_API_KEY });
try {
const current = await superpenguin.spend.get({
window: "1d",
});
console.log(current.spendUsd, current.asOf, current.stalenessMs);
} catch (error) {
if (error instanceof SuperPenguinSpendError && error.retryable) {
// Decide whether your application should retry, queue, or continue.
}
}Supported windows are 1d through 7d, 14d, and month. These are UTC
calendar-day windows: 1d is today, numbered windows include today plus the
preceding N - 1 days, and month is the current calendar month through today.
They are not rolling-hour windows. Filter by feature, model, or both:
const filtered = await superpenguin.spend.get({
window: "7d",
feature: "document-processing",
model: "gpt-5",
});spendMicros is the exact USD-micro total as an integer string; spendUsd is a
convenience number. window contains the resolved UTC start, exclusive end,
type, and timeZone. asOf is when the reader loaded the newest snapshot
used by the response, and stalenessMs conservatively estimates how far it may
trail usage already accepted by SuperPenguin. There is no public freshThrough
field. The result is advisory, not a budget reservation.
The SDK flushes its local queue before reading by default, but ingestion and
materialization remain asynchronous. Set flush: false to skip that flush.
Identical concurrent reads in the same process share one flush and HTTP request.
Production spend reads use https://spend.superpenguin.ai automatically.
spendBaseUrl or SP_SPEND_BASE_URL is an optional advanced override that
affects only spend reads; usage flushes continue to use baseUrl or
SP_BASE_URL. An explicit custom baseUrl is the fallback for both paths
unless a spend-only override is set.
SuperPenguinSpendError exposes code, retryable, and optional HTTP status.
The SDK makes one bounded spend request and does not retry automatically. Codes
are invalid_request, invalid_api_key, rate_limited, spend_unavailable,
and request_failed.
Native Provider SDKs
import OpenAI from "openai";
import { init, wrap } from "@superpenguin/js";
init({ apiKey: process.env.SP_API_KEY });
const openai = wrap(new OpenAI(), {
metadata: {
environment: "production",
feature: "support_agent",
},
});
await openai.chat.completions.create({
model: "gpt-4o",
messages: [{ role: "user", content: "Write a concise reply." }],
spMetadata: {
customer_id: "cust_acme_123",
},
});For AWS Bedrock, wrap a BedrockRuntimeClient (AWS SDK v3). The client uses a
command-dispatch surface, so wrap() patches client.send and tracks
Converse / ConverseStream commands:
import { BedrockRuntimeClient, ConverseCommand } from "@aws-sdk/client-bedrock-runtime";
import { init, wrap } from "@superpenguin/js";
init({ apiKey: process.env.SP_API_KEY });
const bedrock = wrap(new BedrockRuntimeClient({ region: "us-east-1" }), {
metadata: { environment: "production", feature: "support_agent" },
});
await bedrock.send(
new ConverseCommand({
modelId: "anthropic.claude-3-5-sonnet-20241022-v2:0",
messages: [{ role: "user", content: [{ text: "Write a concise reply." }] }],
}),
);wrap() detects supported clients automatically. Provider-specific exports are
also available: wrapOpenAI, wrapAnthropic, wrapGoogleGenAI,
wrapBedrock, wrapDeepgram, and wrapElevenLabs.
Supported methods in 0.5.0:
- OpenAI:
chat.completions.create,completions.create,embeddings.create,responses.create, including async iterable streams where the SDK returns usage. - Anthropic:
messages.createandmessages.stream().finalMessage(). - Google GenAI:
models.generateContentandmodels.generateContentStream. - AWS Bedrock:
BedrockRuntimeClient.send()forConverseCommandandConverseStreamCommand(other commands pass straight through). Bedrock is priced server-side against SuperPenguin'saws_bedrockrate card. - Deepgram:
listen.v1.media.transcribeUrlandtranscribeFile. - ElevenLabs:
textToSpeech.convert,textToSpeech.stream, plus sound effectsconvert/streamwhen present.
Use spMetadata or sp_metadata on a request for per-call attribution. The
wrapper removes those SuperPenguin-only fields before forwarding the provider
request. The provider endpoint host is also captured automatically as base_url
(handy for gateways/proxies).
LiteLLM proxy
Point an OpenAI client at your LiteLLM proxy and wrap it. Pass
provider: "litellm" unless the proxy's host name contains "litellm" (then it
is detected automatically). SuperPenguin records the call under the litellm
route and uses LiteLLM's own per-request cost: the x-litellm-response-cost
header for normal calls, and usage.cost on the last chunk for streams.
import OpenAI from "openai";
import { wrapOpenAI } from "@superpenguin/js";
const client = wrapOpenAI(
new OpenAI({ apiKey: process.env.LITELLM_API_KEY, baseURL: "http://localhost:4000/v1" }),
{ provider: "litellm" },
);Metadata fields
Recognized first-class attribution keys (anything else is stored as custom tags):
| Field | Purpose |
|-------|---------|
| customer_id | End-customer or account consuming the call |
| feature | Product feature name |
| team | Internal team owning the feature |
| environment | production, staging, dev, etc. |
| prompt_key | Identifier for the prompt template |
| prompt_version | Version of the prompt template |
| session_id | Groups one conversation (used by content capture) |
| turn_id | Groups one turn, which may span multiple model calls (used by content capture) |
Batch APIs
Keep wrap() on a shared OpenAI-compatible client used for both live and
deferred requests. The wrapper skips successful non-streaming job
acknowledgements that have missing or null usage, or a queued /
in_progress status. It still records terminal responses with an explicit
zero-valued usage object and all failed requests.
When deferred work finishes, submit each completed item with
getClient().submit(...) or a track*Batch helper and a stable
idempotency_key.
Provider Batch APIs return results asynchronously, so there is no live call to
wrap. After a batch job completes, pass the retrieved batch to the matching
tracker and one attribution row per request is recorded with batch=true:
import OpenAI from "openai";
import { init, trackOpenAIBatch } from "@superpenguin/js";
init({ apiKey: process.env.SP_API_KEY });
const openai = new OpenAI();
const batch = await openai.batches.retrieve("batch_abc");
const count = await trackOpenAIBatch(openai, batch, {
metadata: { feature: "nightly-eval" },
});
// Also: trackAnthropicBatch(client, batch, ...) and trackGoogleBatch(...)Batch and flex results with prompt samples (0.13.0)
submit() and track*Batch record usage only, so batch work produces no
prompt samples. If you already loop over results yourself (for example Sail
batch or flex jobs), call trackBatchResult instead of submit(). It submits
the usage event you already build, unchanged, and also samples the request and
response the way wrap() does (when your organization has content capture on):
import { flush, init, trackBatchResult } from "@superpenguin/js";
init({ apiKey: process.env.SP_API_KEY });
for (const line of outputLines) { // the job's output JSONL, parsed
const customId = line.custom_id;
const event = buildUsageEvent(line); // the object you pass to submit() today
await trackBatchResult({
request: requestLines[customId].body, // the matching input line's body
response: line.response.body,
usageEvent: event,
});
}
await flush();Chat (messages) and Responses (instructions, input, output) bodies are
both read, including the system prompt. Sampling is decided before any text is
read, and nothing is read when your organization's capture setting is off. The
sample is stamped with the usage event's created_at (or completedAt when
the event has none), carries its feature and other attribution (a
withMetadata scope cannot override them), and reporting the same result twice
stores one sample. Capture errors never affect the usage event. Without
usageEvent, pass provider, batchId and the line's customId (taken from
whole input or output lines automatically) and the SDK builds the same event
trackOpenAIBatch would.
Batch prompt samples without storing prompts (0.15.0)
Batch output files hold only responses, so the call above means keeping every prompt until its result returns. Instead, send each half when you already have it, and the server joins them on the line's idempotency key:
import { flush, init, trackBatchRequest, trackBatchResult } from "@superpenguin/js";
init({ apiKey: process.env.SP_API_KEY });
// At submit time, once the batch id is known: the prompt half.
for (const line of inputLines) {
await trackBatchRequest({
key: `${batch.id}:${line.custom_id}`, // the key the usage event will carry
request: line, // the input line (its body is read)
metadata: { feature: "ai_extractor" },
provider: "sail_research",
});
}
// On completion: usage, plus the output half. No request needed.
for (const line of outputLines) {
const event = buildUsageEvent(line); // idempotency_key = `${batch.id}:${custom_id}`
await trackBatchResult({ usageEvent: event, response: line.response.body });
}
await flush();Only sampled lines send anything. Both calls hash the key against your
organization's stable batch rate, so they agree without shared state, and an
unsampled line reads no text and makes no network call. The usage event is
submitted unchanged, exactly as with submit(). The same opt-in, redaction and
encryption apply as for every sample. A prompt half whose result never arrives
is discarded after 14 days.
Content capture (opt-in)
Off by default. The SDK sends only cost metadata, never prompt or completion
text, unless your organization opts in from Settings (Pro or Enterprise).
Once enabled, a sampled subset of calls is captured automatically and
encrypted at rest. A sample holds the whole call: the request exactly as
you passed it (every message in order with its role, system and developer
instructions, tool calls and tool results, tool definitions and model
parameters) and the full response (every choice or output item, tool calls,
reasoning summaries and plain thinking text, usage). Streamed responses are
captured as the assembled response. Nothing is truncated. Images, audio and
other binary data are replaced by their size ([STRIPPED_BINARY:<bytes>]),
encrypted provider blobs (thinking signatures, encrypted_content) by their
length, and transport settings (API keys, headers, timeouts, abort signals) are
never captured.
Capture never slows down or changes your calls. On your call's path the SDK
only decides whether to sample and copies the top-level request; redaction,
serialization and gzip compression run in the background, yielding to the
event loop. Usage and spend events are sent first and are identical with
capture on or off. Memory is bounded: at most 32 sampled calls wait for
processing and at most 32 MiB of compressed samples (and 200 samples) wait for
upload; beyond that a sample is dropped and counted, never blocking. One
sample is capped at 64 MiB of uncompressed content. Samples are sent gzip
compressed; a sample over 3.5 MiB compressed is uploaded in parts. In a
serverless function, call await flushContentCapture() (or client.flush())
before returning so sampled calls are sent.
Before a sample is sent, the SDK replaces secrets (provider API keys, tokens,
private keys, Azure and Google Cloud keys and signed-link signatures, passwords
in URLs and password=-style assignments, secret-named tool arguments) with [REDACTED_SECRET], and emails, phone numbers, payment
cards, US SSNs, IBANs and public IP addresses with [REDACTED_EMAIL],
[REDACTED_PHONE], [REDACTED_CARD], [REDACTED_SSN], [REDACTED_IBAN] and
[REDACTED_IP]. This covers every string in the request and the response,
including tool-call arguments, however long. Names, addresses and metadata
values are not redacted. The exact list is in the
content capture docs.
Group multi-turn conversations with session_id (one conversation) and
turn_id (one turn). Each model call is its own captured row, so a turn with a
tool call (model -> tool -> model) spans two rows; reuse the same turn_id
across them to group. If turn_id is omitted, each row gets a fresh random one
and will not group.
await openai.chat.completions.create({
model: "gpt-4o",
messages,
spMetadata: {
session_id: conversationId,
turn_id: turnId, // reuse across a tool round-trip to group calls
customer_id: "cust_acme_123",
},
});configureContentCapture() can only reduce capture (it never enables it,
the Settings opt-in is always required):
import { configureContentCapture } from "@superpenguin/js";
configureContentCapture({
allow: true, // false vetoes ALL capture from this process
recordPrompt: true, // false drops inputs, keeps outputs
recordOutcome: true, // false drops outputs, keeps inputs
sampleRateMultiplier: 1, // scale the server sample rate down (0-1)
redactor: (text) => text, // extra rules, run on each string after the built-in redaction
maxQueueBytes: 32 * 1024 * 1024, // compressed bytes allowed to wait for upload
});getContentCaptureStats() returns counters: samples queued and sent, and
samples dropped because a queue was full (droppedQueueFull), over the 64 MiB
ceiling (droppedSampleCeiling), too large to send in a runtime without
CompressionStream (droppedNoCompression), or that failed to build
(serializeErrors). Drop counts are also reported to SuperPenguin with the
next upload.
Upgrading to full content capture (0.16.0)
0.16.0 stores the whole sampled call (content schema 3). Earlier versions kept only the system text and the last user message and cut every field at 64 KB. To upgrade:
npm install @superpenguin/js@latest. No other change is needed for calls that already go throughwrap(), the Vercel AI SDK middleware or helpers,createLangChainCallback,trackPiStreamFn,submitSpanortrackBatchResult: they now send the full request and response.- Set
session_id(one conversation or agent run) andturn_id(one turn, reused across the model calls of a tool round-trip) from whichever source your integration uses (examples below). Samples without them cannot be grouped into conversations. - In serverless handlers (Vercel, Lambda, Cloudflare Workers), call
await flushContentCapture()before returning. Capture runs in the background after your call returns; a frozen or terminated function drops what has not been sent. - Use exactly one reporting path per model call. Do not both wrap a client
(or model) and report the same call with
trackUsage: that counts its usage twice.
Each stored document also carries a blocks index (a SHA-256 fingerprint per
system prompt, tool list, message and output item, computed after redaction)
so repeated conversation prefixes can be deduplicated later. You do not need
to do anything with it.
Content protocol 4 (0.17.0): deduplicated capture. When SuperPenguin turns on content protocol 4 for your organization, 0.17.0 uploads and stores each large piece of text in a call (any JSON string of 1 KiB or more, such as a file the agent read, a tool output or the system prompt) once per conversation per UTC day, instead of with every call. A stored call still rebuilds to exactly the document 0.16.0 would send. No code change is needed. Until protocol 4 is on, 0.17.0 captures exactly as 0.16.0 does.
- Sampling. A whole conversation is sampled or not for the day, from its
conversation key:
session_id, or a field SuperPenguin sets for your organization. Every process makes the same choice. - Session ids. Use one stable
session_idper agent run, shared by its subagents, and never a user id. A call without a conversation key is still captured, but gets no dedupe. - Process affinity. Dedupe works per process: a process uploads a conversation's texts the first time it sees them that day. Routing a run's calls to one process uploads the least.
- The usage stamp. A captured call's usage event gains one field,
content_sample_id, which links it to its content. No cost or usage value changes. - Options.
forceSampleandsampleRateMultiplierdo not apply under protocol 4.allow: falsestill stops all capture. Counters for protocol 4 are ingetContentCaptureStats().v4.
wrap() (OpenAI, Anthropic, Google GenAI, Bedrock and compatible
clients). Defaults on the wrapper, per call with spMetadata, or for a whole
run with withMetadata:
import OpenAI from "openai";
import { flushContentCapture, init, withMetadata, wrap } from "@superpenguin/js";
init({ apiKey: process.env.SP_API_KEY });
const openai = wrap(new OpenAI(), { metadata: { feature: "support_agent" } });
await withMetadata({ session_id: conversationId, customer_id: "cust_acme_123" }, async () => {
await openai.chat.completions.create({
model: "gpt-4o",
messages,
tools,
spMetadata: { turn_id: turnId },
});
});
await flushContentCapture(); // serverless only, before returningVercel AI SDK middleware (AI SDK 6 "v3", AI SDK 7 "v4"). The
middleware cannot see generateText options, so put the ids in
withMetadata (or in the middleware's metadata defaults):
import { generateText, wrapLanguageModel } from "ai";
import { openai } from "@ai-sdk/openai";
import { superpenguinMiddleware, withMetadata } from "@superpenguin/js";
const model = wrapLanguageModel({
model: openai("gpt-4o"),
middleware: superpenguinMiddleware({ specificationVersion: "v3", metadata: { feature: "chat" } }),
});
await withMetadata({ session_id: chatId, turn_id: messageId }, () =>
generateText({ model, messages, tools }),
);With trackGenerateText / trackStreamText, pass the ids in the call's
metadata option; ids in the call's telemetry.metadata or
experimental_telemetry.metadata are also used for samples. With
submitSpan, span attributes sp.session_id / sp.turn_id or
ai.telemetry.metadata.session_id / .turn_id are used.
LangChain / LangGraph and Pi. Pass the ids in the invocation's metadata
(LangChain) or establish them around the run (both):
await graph.invoke(input, {
callbacks: [createLangChainCallback({ metadata: { feature: "research" } })],
metadata: { session_id: threadId, turn_id: stepId },
});
await withMetadata({ session_id: sessionId }, () => agent.prompt(text)); // Pi with trackPiStreamFnBatch helpers. trackBatchResult({ request, response, usageEvent })
samples batch and flex lines; pass metadata: { session_id, turn_id } (or set
them on the usage event) to group them. See "Batch and flex results" above.
Custom agent harnesses (trackUsage). If your harness calls the provider
itself (raw fetch, a vendored client) and reports usage by hand, pass the
exact request body you sent and the full response body you received. Usage is
submitted first and is identical with or without them; the bodies then go
through the same sampled, redacted, untruncated capture as wrap(), and are
never read for unsampled calls:
import { flushContentCapture, trackUsage } from "@superpenguin/js";
const body = { model: "gpt-4.1", messages, tools, temperature: 0 };
const res = await fetch("https://api.openai.com/v1/chat/completions", {
method: "POST",
headers: { Authorization: `Bearer ${process.env.OPENAI_API_KEY}`, "Content-Type": "application/json" },
body: JSON.stringify(body),
});
const json = await res.json();
await trackUsage(
{
provider: "openai",
model: json.model,
usage: { inputTokens: json.usage.prompt_tokens, outputTokens: json.usage.completion_tokens },
statusCode: res.status,
request: body, // the provider request body, in the provider's JSON shape
response: json, // the full provider response body
},
{ metadata: { session_id: runId, turn_id: stepId, prompt_key: "coder" } },
);
await flushContentCapture(); // serverless only, before returningFor a streamed call, pass the response you assembled from the stream (all
text, tool calls and usage). request alone or response alone is accepted;
only the given half is stored.
Vercel AI SDK
import { init, trackGenerateText } from "@superpenguin/js";
import { generateText } from "ai";
init({ apiKey: process.env.SP_API_KEY });
const trackedGenerateText = trackGenerateText(generateText, {
provider: "vercel_ai_gateway",
functionId: "support-reply",
metadata: {
environment: "production",
feature: "support_agent",
},
});
export async function POST(request: Request) {
const { prompt, customerId } = await request.json();
const result = await trackedGenerateText({
model: "xai/grok-4.1-fast-non-reasoning",
prompt,
metadata: {
customer_id: customerId,
},
telemetry: {
functionId: "support-reply",
},
});
return Response.json({ text: result.text });
}For Vercel AI Gateway calls, set AI_GATEWAY_API_KEY where the Vercel AI SDK
runs. Set SP_API_KEY alongside it so SuperPenguin can ingest attribution rows.
Streaming
import { init, trackStreamText } from "@superpenguin/js";
import { streamText } from "ai";
init({ apiKey: process.env.SP_API_KEY });
const trackedStreamText = trackStreamText(streamText, {
provider: "vercel_ai_gateway",
functionId: "chat-stream",
});
const result = trackedStreamText({
model: "xai/grok-4.1-fast-non-reasoning",
messages,
metadata: {
customer_id: "cust_acme_123",
feature: "chat",
},
});The stream wrapper waits for usage in the background. Starting in 0.11.0, the text helpers emit one event per result step when steps are available, preserving multi-step agent usage. Prefer model middleware for agents.
OpenTelemetry
import { submitSpan } from "@superpenguin/js";
await submitSpan(span, {
metadata: {
environment: "production",
},
});Vercel AI SDK telemetry fields such as functionId and OpenTelemetry trace IDs
are stored under custom_tags unless they map to a first-class attribution
column.
With content capture on, sampled spans that carry the prompt and response
(ai.prompt* / ai.response.* or gen_ai.* attributes) are captured too.
Samples take session_id / turn_id from metadata, else from the span
attributes sp.session_id / sp.turn_id or ai.telemetry.metadata.session_id
/ .turn_id. Span export often runs outside your request's withMetadata
scope, so set the ids on the span (for example through the Vercel AI SDK's
experimental_telemetry.metadata) rather than relying on the scope.
Runtime Behavior
- Tracking never delays your model call. A wrapped call returns, and a
wrapped stream ends, as soon as the provider is done: the event is queued
and sent in the background, and nothing on the call's path waits for
SuperPenguin's ingest or for content capture (since 0.17.3; earlier
versions waited for the event's request).
submitWaitMsis deprecated and has no effect. - Events start sending at once by default (
flushMode: "immediate"). - Short-lived runtimes: on Vercel (including Next.js on Vercel) the SDK
registers each pending send with the request's
waitUntil, so the function stays alive until its events are sent (through retries, for at most 30 s), without a dependency on@vercel/functions. On other platforms that offerwaitUntil(for example a Cloudflare Worker'sctx.waitUntil), pass it asinit({ waitUntil: (p) => ctx.waitUntil(p) }). In Node, pending events are flushed onbeforeExit. In a script, a CLI, an AWS Lambda handler or any runtime that may freeze or exit right after your code returns, callawait flush()(orawait client.flush()) before returning or exiting. With content capture on,flush()also uploads sampled content. - Memory is bounded: queued and in-flight usage events hold at most 16 MiB of
event JSON (
maxQueueBytes), roughly 10,000 to 30,000 events. When the queue is full, the new event is dropped and counted; events already waiting keep their place. - A backlog is sent in batches of up to 500 events, with up to 4 requests at
once (
maxConcurrentRequests); otherwise one request at a time. - Each request times out after 15 s (
requestTimeoutMs) and is tried 3 times. If it still fails with a network error, timeout, 408, 429 or 5xx, its events go back to the front of the queue and are retried in the background (1 s to 30 s backoff). A resend keeps each event'sidempotency_key, so the server counts it once. - Every dropped usage event is counted by reason (
queue_full,rejectedfor a permanent 4xx,too_largefor an event over the request limit) and reported to SuperPenguin with the next successful request.client.stats()shows the queue and the counters. - Attribution failures never fail or delay your model call. With
throwOnError: true, send errors are raised from the nextflush()instead;onErroris called for each one either way. - For long-lived workers, pass
flushMode: "interval"to batch events and callflush()before shutdown.
Cohere
Available in the published package since JavaScript SDK 0.11.0.
Wrap CohereClientV2 from the optional cohere-ai dependency (version 8.1
or later in the 8.x series). The wrapper captures usage for text Embed, Rerank,
Chat and streaming Chat.
import { CohereClientV2 } from "cohere-ai";
import { init, wrap, flush } from "@superpenguin/js";
init({ apiKey: "YOUR_SUPERPENGUIN_KEY" });
const cohere = wrap(new CohereClientV2({ token: "YOUR_COHERE_KEY" }));
await cohere.embed({
model: "embed-v5.0-fast",
texts: ["A small penguin."],
inputType: "search_document",
embeddingTypes: ["float"],
});
await flush();You can also wrap an OpenAI client pointed at
https://api.cohere.ai/compatibility/v1. Use a Cohere API key and omit
input_type for Embed on that endpoint. Use the native Cohere client when you
need to specify the input type or call Rerank.
For AWS Bedrock, wrap an AWS SDK v3 client and use promise-based send.
Callback-style send does not capture usage. AWS calls are attributed to
Bedrock and retain their region.
Cost requires provider-reported billing quantities and a matching model price. Bedrock Rerank responses without billed search units leave per-request cost unknown. Unknown cost does not mean free usage.
Usage tracking for batch jobs, image or multimodal Embed, Parse, Transcribe and SageMaker is not supported. The provider guide lists endpoint restrictions and retired Bedrock Chat models separately from requests with unknown cost.
See the Cohere provider guide for verified models, both API examples and supported operations.
Agent SDKs and dynamic metadata (0.11.0)
Use withMetadata({ customer_id, custom_tags: { ticket_id } }, () => agentRun())
to attach dynamic metadata to model calls in a Node.js execution. Static defaults
belong on the wrapped client, superpenguinMiddleware or
createLangChainCallback. Nested scopes merge custom tags; explicit per-call
metadata overrides scope and defaults. Concurrent runs are isolated.
- Vercel AI SDK 6/7: pass
superpenguinMiddleware()towrapLanguageModel, usingspecificationVersion: "v4"for AI SDK 7 and"v3"for AI SDK 6. - OpenAI Agents: inject
wrap(new OpenAI(), { metadata })intonew OpenAIProvider({ openAIClient: client }), then use that provider in a Runner. - LangGraph/LangChain: use
createLangChainCallback({ metadata })in invocationcallbacks; invocationmetadatasupplies dynamic overrides. - Pi: wrap the model stream function with
trackPiStreamFn; establish dynamic metadata aroundagent.prompt()or the embedded session invocation. - OpenCode stable V1: install
createOpenCodePluginin the server and resolve dynamic metadata withmetadataForSession(sessionID). The remote HTTP SDK alone cannot install provider instrumentation. - Custom harnesses: inject wrapped clients/models, or report each model call
explicitly using
trackUsage({ provider, model, usage, request, response })(request/responseare optional and feed content capture; see "Upgrading to full content capture" above).
Each invocation must have exactly one usage capture path. Consume agent streams inside the metadata scope, and reestablish the scope after queue/process boundaries or checkpoint resumes. Missing usage cannot be reconstructed, and opaque SDKs need an integration hook. The HTTP OpenAI Agents path does not cover Responses WebSocket mode or Realtime.
Opt-in content capture records each model call's request as the framework
received it (messages or prompt, system, tools, settings) and its full result,
including tool calls, tool results and plain reasoning text the provider
returns; encrypted reasoning is replaced by its length. Positive
provider-reported reasoning counts are stored as custom_tags.reasoning_tokens,
already included in output totals.
Run the bounded local analytics example from sdk/js with
npx tsx examples/analytics-agent.ts; it uses fake model and ingest transports.
Complete examples and support limits are included in the npm package's agent guide, generated from the canonical public documentation during packaging.
Decision APIs (available since 0.12.0)
This release is published. Production readiness is recorded in PR #815, and package publication is recorded in PR #819.
wrap(client) detects OpenAI and TypeSafe clients. For OpenAI, the same wrapped
client captures generation through responses.create / chat.completions.create
and decisions through decisions.create; no separate decision wrapper or
per-call tracking helper is needed. The explicit wrapOpenAI(client) and
wrapTypeSafe(client) helpers are also available. Each decision request emits
one event.
OpenAI decisions and TypeSafe System One preserve returned model identity and
reported usage. Locally qualified with OpenAI 7.30.0 and @typesafe-ai/sdk 0.6.0
using mocked HTTP. Older OpenAI clients without a decisions resource still work.
Decision output usage is retained even when the reviewed rate is zero; missing
usage remains unknown. Decision content capture is disabled.
Microsoft Foundry decisions can be captured automatically with a wrapped fetch:
import { wrapMicrosoftDecision } from "@superpenguin/js";
// Initialize SuperPenguin normally, then configure once.
const decisionFetch = wrapMicrosoftDecision(fetch, {
region: "eastus", deploymentType: "global",
metadata: { feature: "routing" },
});
const response = await decisionFetch(
`${process.env.AZURE_FOUNDRY_ENDPOINT}/providers/microsoft/v1/systemone`,
{
method: "POST",
headers: {
"api-key": process.env.AZURE_FOUNDRY_API_KEY!,
"content-type": "application/json",
},
body: JSON.stringify({
model: process.env.AZURE_DECISION_DEPLOYMENT,
state: "The parcel arrived intact.",
questions: { intact: { type: "noul", instructions: "Is the parcel intact?" } },
}),
},
);
const decision = await response.json(); // Original body remains readable.Only POSTs to the exact Microsoft decision path on *.services.ai.azure.com
are captured. JSON response usage is read from a clone. The returned model wins;
a JSON-string request body supplies the deployment fallback when usage is absent
or the request fails. Request objects/streams are never consumed for that fallback;
use the model option when a fallback is needed with those request-body forms.
The wrapper does not mutate global fetch or add retries. Non-JSON responses retain
unknown usage. Region and deployment type must match the reviewed serving route.
Other HTTP calls pass through untouched, including Azure generation requests;
continue using wrapOpenAI/wrap for their normal instrumentation.
Do not also call trackDecision for the same request. Explicit reporting remains
available for other HTTP stacks. No prompt, answers, headers or key are captured.
These APIs are not yet published. Production pricing/schema and provider qualification must pass the repository release runbook before publication.
