@polarityinc/polarity-keystone
v0.3.4
Published
TypeScript/JavaScript SDK for the Polarity agent evaluation + sandboxed-execution platform (legacy product name: Keystone)
Readme
Polarity SDK for TypeScript / JavaScript
TypeScript client for the Polarity agent evaluation + sandboxed-execution platform. Shares a single pricing + prompt SSOT with the Python and Go SDKs — byte-identical cost estimates and prompt rendering across all three runtimes.
Rebrand note: this SDK is now called Polarity. The npm package name (
@polarityinc/polarity-keystone), legacy class name (Keystone), and legacy env vars (KEYSTONE_*) all continue to work indefinitely. New code should preferPolarity/POLARITY_*.
Install
npm install @polarityinc/polarity-keystoneZero runtime dependencies — uses only the standard Node APIs (fetch,
AsyncLocalStorage). Node ≥ 18.
Environment variables
Both the new Polarity-branded names and the legacy Keystone-branded names are honored; the new name wins when both are set.
| Preferred | Legacy (still works) | Purpose |
| --- | --- | --- |
| POLARITY_API_KEY | KEYSTONE_API_KEY | API key for authentication |
| POLARITY_BASE_URL | KEYSTONE_BASE_URL | Override default server URL |
| POLARITY_SANDBOX_ID | KEYSTONE_SANDBOX_ID | Injected by the platform inside sandboxes |
| POLARITY_NO_FLUSH | KEYSTONE_NO_FLUSH | Skip vitest dashboard flush (set to 1) |
Default server URL: https://plr.sh. API keys begin with plr_live_…
going forward; existing ks_live_… keys keep working.
60-second quick start: Eval()
The shortest path from "I have an agent" to "I have an evaluation":
import {
Eval,
Factuality,
AnswerRelevancy,
} from '@polarityinc/polarity-keystone';
const result = await Eval('summarisation-quality', {
data: [
{ input: 'Long article about whales...', expected: 'Whales are mammals.' },
{ input: 'Article about Java GC...', expected: 'Java GC reclaims memory.' },
],
task: async (input) => myAgent(input), // your agent / prompt
scores: [
new Factuality({ model: 'paragon-fast' }),
new AnswerRelevancy({}),
],
maxConcurrency: 4,
});
console.log(result.summary); // p50/p95/mean per scorerIf POLARITY_API_KEY (or the legacy KEYSTONE_API_KEY) is set, the run
is also recorded to your dashboard; otherwise it stays purely local.
Same shape in Python and Go.
Build a dataset from production traces
Turn failing prod traces into a regression eval set in one call:
const result = await ks.datasets.fromTraces({
filter: { tool: 'summarize', since: '24h' },
name: 'summarize-regressions',
});
console.log(result.rowsAdded, result.sampleRows);Idempotent on span_id within a single call. Events missing input /
expected are skipped and counted under result.skipped. Pass
dryRun: true to inspect the rows without writing.
Vitest plugin: eval-as-tests
Import score and call it inside a vitest test. Pair with
test.globals: true in vitest.config.ts so the helper can read the
running test's name and path.
// vitest.config.ts
import { defineConfig } from 'vitest/config';
export default defineConfig({ test: { globals: true } });import { test } from 'vitest';
import { score } from '@polarityinc/polarity-keystone/vitest';
import { Factuality } from '@polarityinc/polarity-keystone';
test('summary', async () => {
const out = await myAgent('Long text…');
await score(new Factuality({ expected: 'Short summary' }),
{ input: 'Long text…', output: out, min: 0.8 });
});If min is set and the score falls below it, the test fails. Each call
posts a /v1/traces eval event when KEYSTONE_API_KEY is set
(KEYSTONE_NO_FLUSH=1 skips the post for CI without dashboard side-effects).
Sandbox-as-a-tool ergonomics
create() / get() / list() return a bound SandboxHandle so an agent
loop can call the sandbox without threading the ID:
const sb = await ks.sandboxes.create({ spec_id: 'spec-123' });
await sb.exec('python script.py');
await sb.write('/tmp/input.json', JSON.stringify(payload));
const out = await sb.read('/tmp/output.json');
const diff = await sb.diff();
await sb.destroy();Same pattern on ExperimentHandle and AgentSnapshotHandle:
const exp = await ks.experiments.create({ name: 'nightly', spec_id: 's' });
const results = await exp.runAndWait({
scores: [new Factuality({}), new ExactMatch({ expectedKey: 'expected' })],
});
const cmp = await exp.compare(otherExp); // handle or string ID
const m = await exp.metrics();
const snap = await ks.agents.upload({ name: 'codex', /* ... */ });
await snap.delete();The handles still implement the underlying Sandbox / Experiment /
AgentSnapshot shape, so reading sb.id, exp.status, snap.version keeps
working unchanged. The old service-level methods (ks.sandboxes.runCommand(id, …),
ks.experiments.run(id)) stay too — handle methods just delegate.
Auto-instrument every LLM client at once
import { autoInstrument } from '@polarityinc/polarity-keystone';
autoInstrument({
openai, // import OpenAI from 'openai'
anthropic, // import Anthropic from '@anthropic-ai/sdk'
aiSdk: { generateText, streamText }, // Vercel AI SDK
langchainCallbackManager: cm, // LangChain.js
sandboxId: process.env.KEYSTONE_SANDBOX_ID,
});Wraps OpenAI, Anthropic, Mistral, Google GenAI, LiteLLM, Claude Agent SDK, DSPy, LangChain in one call — every prompt, token count, and tool call shows up in your dashboard with no other code changes.
Manual tracing when you want it
import { traced, TracedSpan } from '@polarityinc/polarity-keystone';
// 1. As a function decorator (auto-spans every call)
const fetchUser = traced(async (id: string) => db.users.find(id), { name: 'fetchUser' });
// 2. As a one-shot wrapper
await traced('embed-doc', async () => await openai.embeddings.create({ ... }));
// 3. Class-based for finer control
const span = new TracedSpan({ name: 'planning' });
try { /* ... */ } finally { span.end(); }Spans automatically nest using AsyncLocalStorage — no need to plumb a
context object through your code.
Multi-provider gateways / proxies — recordLLMCall()
ks.wrap(client) patches a client object's .create() method. If your
code is a gateway / proxy / custom routing layer that calls upstream LLMs
through raw fetch() — switching across Anthropic, OpenAI, OpenRouter,
Gemini, etc. per request — there's no client object to wrap. Use
ks.recordLLMCall(opts) to emit the same llm_call event shape wrap()
produces internally:
import { Keystone } from '@polarityinc/polarity-keystone';
const ks = new Keystone();
// Inside your gateway handler, after the upstream call settles:
const start = Date.now();
const upstream = await fetch(upstreamUrl, { method: 'POST', body: JSON.stringify(req) });
const json = await upstream.json();
ks.recordLLMCall({
provider: 'openrouter', // free-form label
model: json.model, // resolved upstream model
requestedModel: req.model, // what the caller asked for
inputTokens: json.usage.prompt_tokens,
outputTokens: json.usage.completion_tokens,
durationMs: Date.now() - start,
inputMessages: req.messages, // truncated to ~4KB on the wire
outputText: json.choices[0].message.content ?? '',
toolCalls: json.choices[0].message.tool_calls?.map((tc) => ({
name: tc.function.name,
id: tc.id,
arguments: tc.function.arguments,
})),
metadata: { 'gen_ai.proxy.fell_back': false }, // any custom OTel-style attrs
});Fire-and-forget. Never throws. Same on-the-wire shape as wrap() events,
so traces emitted from a gateway and from a wrapped SDK client land in the
dashboard with identical schema. Sandbox routing follows the same rules as
wrap() (explicit sandboxId → KEYSTONE_SANDBOX_ID env → agent mode).
If you also wrap a client locally on the caller side, you'll get one
event per call from each side. Pick one, or distinguish them with
metadata.gen_ai.proxy.recorded_by to dedup server-side.
What's in the SDK
- 9 client services —
sandboxes,specs,experiments,alerts,agents,datasets,scoring,export,prompts - 3 bound handles —
SandboxHandle,ExperimentHandle,AgentSnapshotHandlewith delegated methods - 29 built-in scorers (5 families):
- Heuristic (6):
ExactMatch,Levenshtein,NumericDiff,JSONDiff,JSONValidity,SemanticListContains - LLM-judge (9):
Factuality,Battle,ClosedQA,Humor,Moderation,Summarization,SQLJudge,Translation,Security - RAG (8):
ContextPrecision,ContextRecall,ContextRelevancy,ContextEntityRecall,Faithfulness,AnswerRelevancy,AnswerSimilarity,AnswerCorrectness - Embedding (1):
EmbeddingSimilarity - Sandbox invariants (5):
FileExists,FileContains,CommandExits,SQLEquals,LLMJudge
- Heuristic (6):
scorer(fn, opts?)— wrap any(scenario) → scorefunction as a custom scorerEval(name, { data, task, scores })— Braintrust-style one-call eval primitive- Tracing —
traced(fn, { name? })decorator +TracedSpanclass-based form +AsyncLocalStorageparent linking wrapClient+ per-provider helpers (wrapOpenAI,wrapAnthropic,wrapMistral,wrapGoogleGenAI,wrapClaudeAgentSDK,wrapAISDK,wrapMastraAgent)ks.recordLLMCall(opts)— gateway/proxy entry point: emitllm_callevents without a wrappable SDK client objectautoInstrument— patches OpenAI, Anthropic, Mistral, Google GenAI, LiteLLM, Claude Agent SDK, DSPy, LangChain in one call- Prompt management —
ks.prompts.create/get/list/delete,Prompt.render(vars), byte-identical renderer matching Python & Go - Bulk export —
ks.export.{traces,spans,scenarios,scores}(filter, pageSize)returningAsyncIterables;ks.export.experiment(id, { format })for JSON or NDJSON - OpenTelemetry bridge —
wrap()emitsgen_ai.*metadata on LLM spans;registerOtelFlush(cb)hook
Versioning
Semver. Currently on 2.0.0-alpha while the Python/Go/TS parity surface stabilises.
License
MIT.
