valora-sdk
v0.1.1
Published
Valora Node SDK for instrumenting agent executions.
Maintainers
Readme
valora-sdk
Know what your AI agents cost, and who to bill for it.
Wrap a function; Valora records every run — how long it took, whether it succeeded, which customer it was for, and what the LLM calls inside it cost.
import { track } from 'valora-sdk'
export const summarise = track(async (doc: string) => {
const reply = await openai.chat.completions.create({ /* ... */ })
return reply.choices[0].message.content
}, { agent: 'summariser' })That is the whole integration. Token counts and costs are picked up automatically — you do not pass them in.
- Node 20+, zero runtime dependencies
- Server-side only (why)
- It cannot break your app — see the guarantees
Install
npm install valora-sdkNode 20 or later. No runtime dependencies — HTTP goes through the global
fetch, and nothing else is pulled into your tree.
Installing from a local checkout instead? Use a packed tarball —
npm packhere, thennpm install <the-tarball>in your app — and notfile:../valora-sdk-node. Afile:dependency is a symlink, and Node resolves symlinks to their real path, which stops the SDK finding youropenaipackage and silently disables cost capture.
Configure
Two environment variables. The SDK reads them once, on first use — it takes no arguments, so a key never ends up in your source or your version control.
VALORA_API_KEY=vk_... # from your Valora dashboard
VALORA_BASE_URL=https://app.valora.technologyIf either is missing, the first tracked call throws a ConfigurationError
saying which. That is deliberate: an SDK that silently records nothing is worse
than one that tells you on your first run.
Track your first agent
track() wraps a function and gives you back one with the same signature:
import { track } from 'valora-sdk'
const research = track(async (topic: string) => {
// ... whatever your agent does
return findings
}, { agent: 'research-bot' })
await research('quarterly filings') // recordedThe wrapped function is always async, even around a synchronous one — the SDK
verifies your credentials before your code runs, and Node has no blocking HTTP.
If you wrap a synchronous function, its callers now need to await it.
The agent alias is an identity
agent: 'research-bot' names the agent. The SDK creates it on first use and
derives a stable id from it.
Changing that string forks the agent's history — the new name becomes a new agent and the old one stops receiving runs. Pick it once. If it happens anyway, the fix is merging the two, not renaming back.
Attribute a run to a customer
Executions carry a client so you can bill per customer. There are two ways, and you should use one of them per function.
A selector, when the customer id is in the arguments:
const chargeCustomer = track(async (order: Order) => {
// ...
}, {
agent: 'billing-bot',
client: ([order]) => order.customerId, // type-checked
})The selector receives the call's arguments as an array, and is type-checked against your function — so a typo is a build error rather than a silent null.
setClient(), when the id is only known once you are inside:
import { track, setClient } from 'valora-sdk'
const runBilling = track(async (payload: Payload) => {
const tenant = await resolveTenant(payload)
setClient(tenant.id)
// ...
}, { agent: 'billing-bot' })Pass a string, number, or something with a real toString() — an id, not the
object it came from. Passing a whole database row is the easy mistake: its
toString() is "[object Object]", which would file every customer under one
name. The SDK rejects that and tells you rather than inventing a client.
If you use both, the selector wins and the SDK warns. A run whose client cannot be resolved is still recorded — you never lose the execution over it.
Give your customers real names
You do not have to register anything. The id above is enough: the SDK creates the client the first time it sees one, so no usage is ever lost or waits on setup.
What you get without registering is a client named after its raw id —
cus_9f2a11, not Acme Corp. Registration exists to fix that, and there are two
entry points:
import { registerClient, registerClients } from 'valora-sdk'
// In your own signup flow, when you first have the company's real name:
await registerClient(tenant.id, tenant.companyName)
// Once, when you onboard, for the customers you already have:
const summary = await registerClients([
['cus_9f2a11', 'Acme Corp'],
['cus_4b7c03', 'Globex'],
])
// → { created: 2, alreadyExisted: 0, failures: [] }Do it before that customer's first tracked run. This is the one thing worth knowing: Valora keeps the name a client was created with, so registering a customer you have already billed for does not rename them. It is not an error and nothing is lost — the name simply stays as it was, and changing it becomes a job for the dashboard.
The two behave differently on failure, on purpose:
registerClientnever throws. It runs in your signup path, and Valora being unreachable must not fail a customer's signup. The cost of a swallowed failure is the display name, nothing more.registerClientsthrows on a bad credential and reports per-row failures in its summary. It is setup-time work you are watching, and a backfill that printed "done" having written nothing would be worse than one that stopped.
registerClients takes anything iterable of [id, name] — an array, a Map, or
Object.entries(yourObject) — paces itself between requests, and is safe to
re-run: it creates no duplicates and changes no names, so a run interrupted
half-way can just be started again.
LLM cost capture
If the openai package is installed, the
SDK patches it on your first tracked call and attaches token counts
automatically. There is nothing to configure and nothing to import — and
openai is never a dependency of this package, so nothing changes if you do not
use it.
This covers more than OpenAI itself:
- OpenAI-compatible providers — Moonshot, DeepSeek, Groq, Together, Azure —
used through the same client with a different
baseURL. - The Vercel AI Gateway, recorded with
channel: "vercel"so gateway spend is distinguishable from direct spend. - LangChain, whose
ChatOpenAIcalls out through this sameopenaipackage.
Both chat.completions.create and embeddings.create are captured. Your call's
return value is handed back exactly as the vendor produced it — the SDK
observes the result, it does not wrap or replace it, so withResponse() and
asResponse() keep working.
Streamed responses need one flag
Streamed calls are captured too — but only if you ask OpenAI to send the counts, because a stream carries no usage unless you do:
const stream = await client.chat.completions.create({
model: 'gpt-4o-mini',
messages,
stream: true,
stream_options: { include_usage: true }, // ← without this there are no counts
})The SDK does not add the flag for you. Injecting it would mean rewriting your
request, and a usage chunk your own code did not expect can break a consumer
that assumes every chunk has a choices[0].
Without it the execution is still recorded — it simply carries no token counts, and the SDK warns once telling you so.
Your stream is not buffered, copied, or delayed. The SDK reads each chunk as
it passes on its way to you and never pulls ahead, so a stream you abandon
part-way stops at the network exactly where it would have without us. The object
you receive is the vendor's own Stream, with tee() and toReadableStream()
intact.
It also does not matter where you consume it. Read the stream inside your tracked function and the counts ride out on the same request as everything else; return it and read it afterwards — from a route handler, say — and they follow shortly after, attached to the execution they belong to.
Before your process exits
Does your platform stop the process right after a response? If so, read this. If you run a long-lived server — Express, Fastify, a container, a VM — the background drain covers you and there is nothing to do.
The SDK drains what it has buffered when a process ends naturally. Two common situations skip that:
| | |
| --- | --- |
| process.exit() | Node runs no async work after it. Anything queued is lost. |
| serverless — Vercel, AWS Lambda, Cloudflare Workers, Netlify, Cloud Functions | the sandbox can be frozen the instant your handler returns |
This has nothing to do with which model provider you call. A handler using OpenAI directly on Lambda is affected; one using Vercel AI Gateway on a long-running server is not. What matters is where your code runs.
The fix is one line, and it works everywhere:
import { track, flush } from 'valora-sdk'
export async function POST(request: Request) {
const result = await tracked(request)
await flush() // waits for delivery; adds our request to yours
return Response.json(result)
}If your platform can keep the sandbox alive after the response, prefer that — it costs your users no latency:
import { after } from 'next/server' // Next.js 15.1+
after(() => flush())
// or, on other Vercel runtimes:
import { waitUntil } from '@vercel/functions'
waitUntil(flush())
// or, on Cloudflare Workers:
ctx.waitUntil(flush())If you forget, the SDK says so rather than losing your data quietly:
valora-sdk: exiting with 4 execution(s) still queued. They were NOT delivered.
Call `await flush()` before your process ends — see the 'Before your process
exits' section of the README.What it will never do to your app
These are guarantees, not intentions — each is enforced by tests.
It will not break your code. Everything the SDK does sits inside a boundary that catches its own failures. If Valora is unreachable, if we send something the API rejects, if our own code has a bug — your function still runs and still returns its value. The one deliberate exception is a missing or invalid API key, which throws on your first call so you find out immediately rather than after a week of recording nothing.
It will not slow you down. An execution is assembled in memory and delivered off your critical path, from a small bounded queue drained by one worker. Your tracked function returns without waiting for us.
It will not hold your process open. The queue's timers are unref'd, so Node exits when your work is done, not ours.
It will not grow without bound. Under a sustained outage the queue fills at 1000 executions and drops the oldest, counted and logged. Blocking your app instead would put our latency on your critical path; buffering forever could exhaust your memory.
Your errors stay yours. An exception your function throws propagates untouched — the SDK records the run as failed and rethrows the original.
Node only, never the browser
Two independent reasons, both hard:
- The SDK holds an API token. Bundling it into browser code publishes that token to anyone who opens devtools.
- Context propagation is built on
node:async_hooks, which has no browser equivalent.
There is no browser build and there will not be one.
What gets sent to Valora
Per execution: a client-generated id, the agent, start and end timestamps, the status, the client id where you supplied one, and token counts from LLM calls made inside it.
Not your arguments, not your return values, not your prompts or completions. The SDK never reads them.
If the SDK silences one of its own errors, it reports that separately so the failure is not invisible. Those reports carry the error's type and which internal boundary failed — never a stack trace, file path or line of your source. Message text is included only for the SDK's own error types, because we author those strings; a foreign error's message is blanked, since it routinely embeds the value that caused it.
Troubleshooting
No executions appear. Check VALORA_API_KEY and VALORA_BASE_URL, then look
for valora-sdk: lines in your logs — every warning carries that prefix. If your
process is short-lived or serverless, see
above.
Executions appear but have no cost data. Either the openai package is not
resolvable from where the SDK is installed, or you are streaming without
stream_options: { include_usage: true }.
Rows have no customer. A client value the SDK could not use is warned about
at the call site — look for valora-sdk: ignored a client value….
Errors this SDK can throw
All extend ValoraError, so one catch covers them:
| | |
| --- | --- |
| ConfigurationError | a missing or unusable VALORA_API_KEY / VALORA_BASE_URL |
| AuthenticationError | the key was rejected |
| TransportError | Valora was unreachable, or answered 5xx |
| ResolutionError | an agent or client could not be created |
| ExecutionError | an execution was rejected |
In practice only ConfigurationError and AuthenticationError reach your code;
the rest happen off your critical path and are handled internally.
Coming from the Python SDK?
valora_sdk (Python) is the reference implementation and this is a port, not a
fork: the wire payload and the derived ids are identical by contract, so the
same agent alias used from both languages resolves to the same agent rather than
forking into two billing histories.
One deliberate difference. Python's @track(client='customer') names a function
parameter and reads it by reflection. TypeScript cannot do that — and parameter
names are erased by minification in any production build, which would make
attribution fail silently in exactly the deployments this SDK targets. Node uses
a type-checked selector instead.
Everything else you know carries over. register_client / register_clients
are registerClient / registerClients, with the same semantics and the same
split between the forgiving signup path and the strict backfill; the backfill
takes an iterable of pairs here too, and returns a summary rather than raising.
The two packages have separate version numbers and always will. They are
separate implementations of one contract, and neither is a superset of the other
— so a matching number would promise a parity that does not exist. Do not read
anything into [email protected] for Node sitting next to valora-sdk==0.1.1 for
Python.
What is guaranteed across them is the part that would actually hurt to get wrong: the wire payload and the derived agent and client ids are identical by contract, pinned by shared test vectors. The same alias used from both languages resolves to the same agent, on any pair of versions.
Development
npm ci
npm run build # dual ESM + CJS into dist/
npm test # type-checks and runs the suite on the source
npm run smoke # loads the built artifact both ways, as a customer wouldnpm test and npm run smoke check different things and neither subsumes the
other — see the comment at the top of scripts/smoke.mjs.
