Integrations
Every recipe on this page is the same three lines wearing different clothes:
after the AI call, hand track the provider, the response-reported
model, and the response's usage object pasted as-is, plus your
fields. Marginal detects each provider's usage dialect
server-side — you never convert token counts yourself. The only part that
needs care is streaming, where usage arrives at the end of the stream and
each provider hands it over differently.
All snippets assume the quickstart setup: a marginal client
constructed once with your API key.
Let your AI assistant do it
The fastest integration is the one your coding assistant writes. Paste this into Claude Code, Cursor, or any agent with access to your codebase:
Read https://marginalhq.com/llms.txt and instrument every AI/LLM
call site in this codebase with Marginal event tracking, following that
guide exactly. My Marginal API key is in the MARGINAL_API_KEY env var.
Before writing code, list the call sites you found and the fields
(customer / feature / environment) you plan to attach to each, and ask me
to confirm./llms.txt is this entire page plus the wire contract in one
plain-text document, written for machines. The recipes below are the same
material, written for you.
OpenAI
Chat Completions — the usage object carries the cached/uncached split; paste it whole:
const completion = await openai.chat.completions.create({ model: "gpt-4o", messages });
marginal.track({
provider: "openai",
model: completion.model,
usage: completion.usage,
fields: { customer: "acme", feature: "chat" },
});completion = client.chat.completions.create(model="gpt-4o", messages=messages)
marginal.track(
provider="openai",
model=completion.model,
usage=completion.usage.model_dump(),
fields={"customer": "acme", "feature": "chat"},
)The Responses API is the same recipe — response.model and
response.usage — and its different usage shape is detected automatically.
Streaming needs one opt-in: without stream_options, OpenAI never sends
usage at all. The final chunk carries it:
const stream = await openai.chat.completions.create({
model: "gpt-4o",
messages,
stream: true,
stream_options: { include_usage: true }, // without this, no usage arrives
});
let model = "";
let usage;
for await (const chunk of stream) {
if (chunk.usage) ({ model, usage } = chunk); // final chunk only
// …handle chunk.choices[0]?.delta as usual
}
marginal.track({ provider: "openai", model, usage, fields: { customer: "acme" } });Python is identical: pass stream_options={"include_usage": True} and read
chunk.usage from the last chunk (chunk.usage.model_dump()).
Anthropic
message.usage already includes the cache fields
(cache_read_input_tokens, cache_creation_input_tokens) — paste it whole:
const message = await anthropic.messages.create({
model: "claude-sonnet-5",
max_tokens: 1024,
messages,
});
marginal.track({
provider: "anthropic",
model: message.model,
usage: message.usage,
fields: { customer: "acme", feature: "chat" },
});message = client.messages.create(model="claude-sonnet-5", max_tokens=1024, messages=messages)
marginal.track(
provider="anthropic",
model=message.model,
usage=message.usage.model_dump(),
fields={"customer": "acme", "feature": "chat"},
)Streaming: Anthropic splits usage across stream events (input counts in
message_start, output counts in message_delta). Don't reassemble it —
the SDK's stream helper does that for you:
const stream = anthropic.messages.stream({ model: "claude-sonnet-5", max_tokens: 1024, messages });
for await (const text of stream.textStream) {
// …forward text
}
const message = await stream.finalMessage(); // fully-merged usage
marginal.track({ provider: "anthropic", model: message.model, usage: message.usage, fields: { customer: "acme" } });with client.messages.stream(model="claude-sonnet-5", max_tokens=1024, messages=messages) as stream:
for text in stream.text_stream:
... # forward text
message = stream.get_final_message()
marginal.track(provider="anthropic", model=message.model, usage=message.usage.model_dump(), fields={"customer": "acme"})Vercel AI SDK
The AI SDK normalizes usage across all its providers, so one small mapper
covers every model you route through it. Its noCacheTokens /
cacheReadTokens / cacheWriteTokens split maps directly onto Marginal's
canonical shape:
import { generateText, streamText, type LanguageModelUsage } from "ai";
// AI SDK (v7) usage → Marginal's canonical usage shape.
function toMarginalUsage(usage: LanguageModelUsage) {
return {
input_tokens: usage.inputTokenDetails.noCacheTokens ?? usage.inputTokens ?? 0,
cache_read_tokens: usage.inputTokenDetails.cacheReadTokens ?? 0,
cache_write_tokens: usage.inputTokenDetails.cacheWriteTokens ?? 0,
output_tokens: usage.outputTokens ?? 0,
};
}
const result = await generateText({ model: openai("gpt-4o"), prompt });
marginal.track({
provider: "openai", // whichever provider you routed to
model: result.response.modelId,
usage: toMarginalUsage(result.usage), // summed across tool-loop steps
fields: { customer: "acme", feature: "chat" },
});Streaming is the same mapper — usage and response resolve once the
stream finishes:
const result = streamText({ model: openai("gpt-4o"), prompt });
for await (const text of result.textStream) {
// …forward text
}
marginal.track({
provider: "openai",
model: (await result.response).modelId,
usage: toMarginalUsage(await result.usage),
fields: { customer: "acme", feature: "chat" },
});On AI SDK 6 and earlier the usage object is flat — map
input_tokens: usage.inputTokens - (usage.cachedInputTokens ?? 0),
cache_read_tokens: usage.cachedInputTokens ?? 0, and
output_tokens: usage.outputTokens instead.
Google Gemini
The JS SDK's usageMetadata is already the wire shape — paste it whole
(thoughtsTokenCount and cachedContentTokenCount are billed correctly
server-side):
const response = await ai.models.generateContent({ model: "gemini-2.5-flash", contents });
marginal.track({
provider: "gemini",
model: response.modelVersion ?? "gemini-2.5-flash",
usage: response.usageMetadata,
fields: { customer: "acme", feature: "chat" },
});The Python SDK renames the same counts to snake_case attributes — send them under their wire (camelCase) names:
response = client.models.generate_content(model="gemini-2.5-flash", contents=contents)
um = response.usage_metadata
marginal.track(
provider="gemini",
model=response.model_version or "gemini-2.5-flash",
usage={
"promptTokenCount": um.prompt_token_count or 0,
"candidatesTokenCount": um.candidates_token_count or 0,
"thoughtsTokenCount": um.thoughts_token_count or 0,
"cachedContentTokenCount": um.cached_content_token_count or 0,
},
fields={"customer": "acme", "feature": "chat"},
)When streaming, the last chunk's usageMetadata carries the totals.
AWS Bedrock
Converse responses don't echo the model back, so send the modelId you
requested; the usage object is pasted as-is:
import { ConverseCommand } from "@aws-sdk/client-bedrock-runtime";
const modelId = "anthropic.claude-sonnet-5";
const response = await client.send(new ConverseCommand({ modelId, messages }));
marginal.track({
provider: "bedrock",
model: modelId,
usage: response.usage, // inputTokens / outputTokens / cache fields
fields: { customer: "acme", feature: "chat" },
});InvokeModel responses carry the model vendor's native usage instead (an
Anthropic-shaped object for Claude models) — paste that as-is too; the
dialect is detected from its shape, not from the provider string.
Azure OpenAI and OpenAI-compatible providers
Groq, Mistral, xAI, DeepSeek, Together, Fireworks, Ollama, and Azure OpenAI
all return OpenAI-shaped responses — the OpenAI recipes above work verbatim.
The only change is the provider string, which namespaces the price lookup:
marginal.track({ provider: "groq", model: completion.model, usage: completion.usage, fields: { customer: "acme" } });Use azure, groq, mistral, deepseek, xai, together_ai, … —
pricing resolves provider/model first, then the bare model string, so
OpenAI-compatible hosts price correctly either way.
Everything else
For a self-hosted or custom model, send Marginal's canonical usage shape —
input_tokens is the uncached input count — and add a custom price on
the Models page so it prices:
marginal.track({
provider: "self-hosted",
model: "my-fine-tune",
usage: { input_tokens: 812, output_tokens: 204 },
fields: { customer: "acme", feature: "chat" },
});And for spend that isn't token-shaped at all — voice minutes, image generation, a cost you already computed — send the dollars directly:
marginal.track({ cost: 0.05, fields: { customer: "acme", feature: "voice" } });Check it landed
Send one request through each instrumented call site, then open the
project's Ingest log — every API request appears there, tagged
success, warn, or error, with rejected events (including the payload
you sent), stripped field keys, and unpriced models spelled out per
request. Accepted events land in the Explorer with their model, cost,
and fields. The same warnings also reach your code through the SDK's
onError / on_error: ignored-fields means a field key isn't
registered (it was stripped, not silently kept), and
unpriced-models means the model needs a custom price on the Models
page. Nothing fails silently — a success row in the Ingest log plus the
event in the Explorer means the integration is done.