@keld/keld
v0.5.1
Published
OpenAI-compatible Node client for Keld, OpenAI, Anthropic and Bedrock, with opt-in usage telemetry to a Keld Atlas backend over OTLP logs.
Readme
@keld/keld
An OpenAI-compatible client for Node that reports what your inference costs. Keld
subclasses the official openai client — same methods, same types, same responses, reached
through openai's own prototypes — and an atlasToken adds usage telemetry to
Keld Atlas.
import Keld from "@keld/keld"; // was: import OpenAI from "openai"
const client = new Keld({ // was: new OpenAI({ ... })
baseURL: "https://api-gateway.keld.co", // or OpenAI's, Anthropic's, Bedrock's
apiKey: process.env.KELD_TOKEN,
atlasToken: process.env.ATLAS_INGEST_TOKEN, // omit -> no telemetry, plain inference client
});
const response = await client.chat.completions.create({ model, messages });That is the whole integration; no call site changes.
Install
npm install @keld/keldNode 18 or newer (the SDK uses the global fetch). openai is a dependency, not a peer, and its
own root exports (ClientOptions, APIError, APIPromise, toFile, ...) are re-exported from
@keld/keld, so the import swap is invisible to TypeScript. Both CJS (dist/index.cjs) and ESM
(dist/index.mjs) ship from one root entry with type declarations for each: require and
import both work, nothing to configure, no loader flag.
Options
KeldClientOptions extends ClientOptions, so everything the official client takes, this one
takes.
| Option | Env fallback | Notes |
|---|---|---|
| baseURL, apiKey | OPENAI_BASE_URL, OPENAI_API_KEY | openai's. Which endpoint, and your inference provider's credential — this SDK only passes it through |
| timeout, maxRetries, defaultHeaders, fetch, ... | openai's own | openai's, unchanged — no request or response handling is overridden |
| atlasToken | none, deliberately | Keld Atlas agent key (kagt_...), minted on the Atlas Integrations page. Passing it starts telemetry; omitting it means none at all. Not read from the environment: the option is the opt-in, so a token sitting in a shell cannot silently start reporting |
| atlasEndpoint | ATLAS_ENDPOINT | Where telemetry goes. Defaults to https://atlas.keld.co; the SDK POSTs gzipped to {endpoint}/v1/logs |
| atlasMetadata | none, deliberately | Record<string, string> of reporting dimensions, on every call this client makes. Each key becomes one attribute named metadata.<key> |
atlasMetadata: { environment: "production", repo: "acme/checkout-service", team: "payments" }To tag one call, use openai's own withOptions — the tags go on the calls made through that
copy, on top of the client's, and a key both set takes the call's value:
await client
.withOptions({ atlasMetadata: { customer: "acme", feature: "summary" } })
.chat.completions.create({ model, messages });Each client reports its own tags, so two clients in one process can be tagged differently. Tags
go to Atlas only; the request to the provider is the same with or without them. They reach Atlas
as metadata.<key> (metadata.environment, metadata.customer), so no tag can be mistaken for
a field the SDK or Atlas owns, and any key is allowed, model included.
A new key needs no SDK release. Atlas does not read the prefixed names yet, so until it does an
environment tag is stored but no longer drives Atlas's environment (CapEx/OpEx). An entry that
cannot become a well-formed attribute is dropped with a warning rather than thrown — a blank key
or a non-string value; an empty value means unset and is omitted silently. See .env.example.
What the SDK does on its own
Appends
/v1to abaseURLthat carries no path. openai-node appendschat/completionsverbatim, so a bare gateway host would 404. A URL that already names a path is left alone.Injects
stream_options: { include_usage: true }into streamed chat completions — the one outgoing request it modifies, because OpenAI omits token usage from a stream without it. It merges with anystream_optionsyou set (an explicitinclude_usage: falseis respected), and every chunk, including the usage-only one with emptychoices, reaches your code unchanged.Resolves
providerfrom the endpoint's host, so nothing at the call site changes per provider:|
baseURL| Reportedprovider| |---|---| | any.keld.cohost, e.g.https://api-gateway.keld.co|keld| | omitted (openai's default) |openai| |https://api.anthropic.com/v1/|anthropic| |https://bedrock-runtime.{region}.amazonaws.com/openai/v1|bedrock| | OpenRouter, Together, Groq, DeepSeek, xAI, Mistral, Fireworks, Ollama (port 11434) | their own names | | anything else |hosted_vllm, with a one-time warning |An unknown host is not guessed and there is no override: a confidently-wrong label prices at $0 while looking like real data, so the warning is the point.
Never breaks inference. Recording cannot throw, the queue is bounded at 2048 records and drops the oldest under backpressure, and export is fire-and-forget — Atlas being down, slow or rate-limiting is invisible to your app. The call goes straight to your
baseURL.Covers every openai client in the process, because instrumentation is applied to openai's prototypes rather than instances: a plain
new OpenAI()from your own import is reported too, and a secondnew Keld({ atlasToken })shares one core, so each call is reported once.Flushes every 2s and on
beforeExit, so a short-lived script reports instead of exiting with its batch in memory. A process killed before the event loop drains needsflush():import { flush } from "@keld/keld"; process.on("SIGTERM", async () => { await flush(); process.exit(0); });The SDK installs no signal handlers of its own — attaching one to
SIGTERM/SIGINTsuppresses Node's default terminate-on-signal behaviour and would change how your app shuts down.
What gets reported
One OTLP log record per inference call: provider, model, input_tokens, output_tokens,
cache_read_tokens, cache_creation_tokens, duration_ms and request_id, plus session.id,
event.sequence and prompt.id identifying the process and the call's place in it. Every value
is read from the provider's own response, never inferred. On a chat completion, cache_read_tokens
is OpenAI's usage.prompt_tokens_details.cached_tokens, and input_tokens is the uncached rest
(prompt_tokens minus it), because Atlas prices the two at different rates. A failed call is still recorded, with
zero tokens and an error attribute, before the exception propagates unchanged. Batch-level
attributes carry the SDK's name, language and version, and the calling client's atlasMetadata;
a request's own tags from withOptions are on its record.
trace.id and span.id are the call's own OpenTelemetry span. If your app runs its own OTel
tracing with a context manager registered (the Node SDK does this), the call's span sits inside
your current trace, so trace.id is the one your tracing tool shows for that request.
Never reported: prompt or response content, or your API keys. The SDK never prices a call
either. cost_usd is sent only when the provider itself reports what the call cost — a Keld
gateway returns it as usage.cost, streamed or not. Otherwise there is no cost_usd attribute
and Atlas derives cost server-side from a price table, so stale client-side pricing cannot
silently under- or over-report spend.
Capture is through @opentelemetry/instrumentation-openai (pinned 0.20.0), covering
chat.completions.create and responses.create, streamed and not — and with them
chat.completions.stream, responses.stream and .withResponse() / .asResponse(), which
route through those two, returning the exact object the SDK produced. Nothing else is captured:
images.*, audio.*, videos.*, batches, fineTuning, beta.threads.* and beta.realtime.*
bill per image, per character or per second and return no token block, and reporting them at zero
would look like real data rather than an absence; embeddings spans are deliberately not mapped;
legacy completions is not instrumented. Nor is anything that skips the client — a raw fetch,
another vendor's SDK, inference behind an internal service.
LangChain and LangGraph
For an app built on LangChain.js or LangGraph.js, one call reports every chat-model call the
app makes, whichever provider package the model comes from (@langchain/anthropic,
@langchain/openai, @langchain/aws, @langchain/google-genai, ...), inside a graph or not.
npm install @keld/keld @langchain/core # most apps have @langchain/core alreadySupported LangChain versions
| | Supported | Tested with (oldest / newest at release) |
|---|---|---|
| @langchain/core | >=1.3.0 <2 | 1.3.0 / 1.3.1 |
| @langchain/openai | any that supports the core above | 1.7.1 / 1.8.0 |
| @langchain/anthropic | same | 1.6.0 / 1.6.0 |
| @langchain/langgraph | same | 1.5.3 / 1.5.3 |
| Node | 22 or newer | 22, 24 |
CI re-checks the newest 1.x of each every week. If instrument() finds an unsupported
@langchain/core, or a part of LangChain it relies on is missing, it says so once on
console.error: spend from LangChain calls may not reach Atlas until you move to a supported
version.
Use the app's own key, from Atlas → Teams → Agents & Apps. instrument() does not read the
environment itself: pass the key in.
A · Telemetry only. The models stay on your own providers; each call is reported under its provider.
import { instrument } from "@keld/keld/langchain";
import { ChatAnthropic } from "@langchain/anthropic";
import { createReactAgent } from "@langchain/langgraph/prebuilt";
instrument({
atlasToken: process.env.KELD_APP_KEY, // kagt_...
atlasMetadata: { team: "payments" }, // optional: workstreams
});
const claude = new ChatAnthropic({ model: "claude-sonnet-5-5" });
await claude.invoke("Summarise this ticket: ..."); // reported
const agent = createReactAgent({ llm: claude, tools: [getWeather] });
await agent.invoke({ messages: [{ role: "user", content: "Weather in Paris?" }] }); // every call reportedB · Inference on Keld, with telemetry. Point LangChain's OpenAI wrapper at Keld's gateway and
keep instrument() on. Those calls are reported as keld, at the cost the gateway returns. The
same app key works for both.
import { instrument } from "@keld/keld/langchain";
import { ChatOpenAI } from "@langchain/openai";
const key = process.env.KELD_APP_KEY;
instrument({ atlasToken: key });
const model = new ChatOpenAI({
model: "deepseek/deepseek-v4-flash-0731", // any model Keld serves
apiKey: key,
configuration: { baseURL: "https://api.keld.co/v1" },
});
await model.invoke("Summarise this ticket: ..."); // runs on Keld, reported as KeldThe two mix: some models on Keld, the rest on your own providers, each call reported once, also
beside a Keld client.
instrument() takes atlasToken, atlasEndpoint and atlasMetadata, which mean what they do
on the client. Call it once, wherever your app sets up telemetry: the top of the entry file, or
a framework's startup hook such as Next.js's instrumentation.ts. Later calls change nothing.
Each call is reported with the same fields as a client's call; prompts and completions are never
read. Before a forced shutdown, await flush() from @keld/keld.
| LangChain provider | Reported provider |
|---|---|
| anthropic | anthropic |
| openai (ChatOpenAI and the wrappers built on it) | from the model's address, by the client's host table: keld for Keld's gateway, openai with no address, hosted_vllm with a warning for an unknown host |
| azure, azure_openai | azure_openai |
| amazon_bedrock, bedrock, bedrock_converse | bedrock |
| google_genai, google_vertexai | gemini |
| mistral, mistralai | mistral |
| fireworks, together | fireworks_ai, together_ai |
| groq, ollama, xai, deepseek | their own names |
| anything else | LangChain's own name, with a one-time warning; Atlas shows it as unpriced |
Leave streamUsage on for a streamed ChatOpenAI (it is the default): without it the call
reports 0 tokens, with a one-time warning.
Known limits
Measured, not assumed.
- Native
@anthropic-ai/sdkand@aws-sdk/client-bedrock-runtimeclients are not instrumented. Both providers are covered through their OpenAI-compatible endpoints, reached with abaseURLabove; a call made with a vendor's own SDK is reported nowhere. - Bedrock's OpenAI-compatible endpoint does not serve Claude. Live Claude model ids return
404 - doesn't exist or doesn't support this API; only the OpenAI and open-weight families are reachable there. - A private or self-hosted host reports
hosted_vllmand prices at $0, with no override. An arbitrary hostname cannot be covered by a table, so the fix is a host entry here — open an issue rather than working around it. - An endpoint that rejects
stream_optionswill reject every streamed call. Every endpoint this SDK is tested against accepts it. Breaking loudly is the deliberate trade against silent zero-token billing. chat.completions.parse()andresponses.parse()throw under telemetry. Upstream bug in@opentelemetry/instrumentation-openai0.20.0: its promise tap reads the response body, andparse()'s own read then fails with "Body is unusable". Pinned bytests/caller-fidelity.test.ts; fixed by opentelemetry-js-contrib #3667.openaiv7+ produces no telemetry. The instrumentation supports>=4.19.0 <7and emits nothing outside it. This package depends onopenai@^6and warns loudly at a v7 install.
Development
npm install
npm test # vitest — unit and contract tests
npm run build # tsup — dist/*.cjs, dist/*.mjs, dist/*.d.ts
npm run typechecktests/fixtures/atlas-sdk-otlp-export.golden.json is this package's copy of the wire contract
pinned by keld-atlas and must stay structurally identical to it, with
sdk.language: "typescript" as the one intended difference; tests/contract.test.ts asserts it.
Releasing. Bump version in package.json and SDK_VERSION in src/core.ts (a test fails
if they disagree), move the Unreleased notes into a new CHANGELOG.md section for that version,
and merge to main. Nothing else: no tag to push. .github/workflows/ci.yml runs on every pull
request: it tests on Node 18/20/22/24, checks CHANGELOG.md has a section for the current
version, runs publint and attw on the packed tarball, and imports it from both CJS and ESM. On
each push to main, .github/workflows/release.yml asks npm whether that version exists. If it
does, the run stops there. If not, it runs the same CI on the merged commit, publishes via npm
trusted publishing, then tags vX.Y.Z and creates a GitHub release from the changelog section.
Its comments carry the one-time npmjs.com setup.
LangChain demo. scripts/langchain-demo.mjs runs one LangGraph graph against real Anthropic
(a ReAct agent with a cached system prompt), OpenAI (streamed) and Keld's gateway through the
built @keld/keld/langchain, and prints the records it exported beside each provider's own usage
figures. It needs ANTHROPIC_API_KEY, OPENAI_API_KEY, KELD_PROD_TOKEN and KELD_MODEL
(KELD_PROD_GATEWAY_URL defaults to https://api.keld.co/v1); with ATLAS_INGEST_TOKEN (and optionally ATLAS_ENDPOINT)
the records also go to Atlas, without it nothing leaves the machine.
npm run build && node scripts/langchain-demo.mjsSmoke test. scripts/smoke.mjs drives the built client against a local OpenAI-compatible
stub, so the only real network call is the OTLP export. It makes a plain, a streaming and a
failing call, asserts no prompt text or cost_usd is in the payload, and flushes. The stub is on
127.0.0.1, an unknown host, so the hosted_vllm warning it prints is part of the test.
npm run build
ATLAS_INGEST_TOKEN=kagt_... ATLAS_ENDPOINT=http://localhost:8000 node scripts/smoke.mjsIn Atlas, expect three source=atlas_sdk rows for the printed session.id, with dedup_key
suffixes :1 :2 :3 and cost_usd computed server-side.
