Languages & parity
Cendor ships in two languages: Python (cendor.*, the reference implementation, on PyPI) and
TypeScript/JavaScript (@cendor/*, on npm — ESM-only, Node LTS first, edge runtimes
supported). Both are implementations of the same versioned format specs (opens in a new tab),
so the artifacts that matter — cassettes, audit chains, price tables, bus events — are
byte-for-byte interoperable, checked by committed conformance vectors in both CIs — each
language verifies artifacts written by the other (the TypeScript CI replays Python-written
fixtures; the Python CI verifies a JS-written audit chain).
pip install cendor-libs # the whole stack; `cendor` is an alias
# Using uv? Same names, same extras: `uv add` instead of `pip install`.
from cendor.core import instrument
client = instrument(OpenAI())
npm i @cendor/libs # the whole stack (umbrella)
import { instrument } from '@cendor/core';
const client = instrument(new OpenAI());
One API, two spellings
The TypeScript API is derived from the Python public API, mechanically:
| Rule | Python | TypeScript |
|---|---|---|
| Names | snake_case (max_turns, on_exceed) | camelCase (maxTurns, onExceed) |
| Scopes | context managers (with budget(...):) | async callbacks (withBudget(cfg, fn)) / decorators (budget(cfg)(fn)) |
| Money | Decimal — never a float | decimal.js — never a float; value-equal across languages |
| Errors | BudgetExceeded, PolicyViolation, … | identical names |
| Defaults | the same | the same |
| Tool schemas (SDK) | derived from type hints + docstring | declared with zod (opens in a new tab) |
Cross-language guarantees
These are tested by committed conformance vectors, not promised:
- A cassette recorded in Python replays in TypeScript (and vice-versa) — same request hashes, same wire format.
- An audit chain written in TypeScript
verify()s in Python — identical canonical bytes and HMAC inputs. - Prices and token counts match exactly — same bundled snapshot,
tiktoken↔js-tiktokenparity, decimal-exact math. - Bus events (
LLMCall/ToolCall/Usage/Money) share one schema across languages.
Versioning and support
SemVer, per package, independent per language. Each library versions on its own cadence — a
cendor-core release does not force a cendor-squeeze release — and the Python and TypeScript
numbers are deliberately not coupled. cendor-core 1.14 and @cendor/core 1.0 can be the same
capability. The parity matrix below is the contract, not matching version numbers.
What a version number promises
| Change | You get | What we do |
|---|---|---|
Patch (1.2.3 → 1.2.4) | a fix; nothing you call changes shape | released whenever it’s ready |
Minor (1.2 → 1.3) | new capability, additive; existing calls keep working | batched into a release window, roughly every two weeks |
Major (1.x → 2.x) | something was removed or changed shape | announced at least 30 days ahead, with a migration note |
Deprecation
A public symbol warns in-band for at least two minors before it can be removed, and removal
happens only in a major. In practice you see it before it can break you: a deprecated symbol is a
compile error or an editor warning that names its replacement, not a silent behaviour change. This is
the mechanism the trap sheet documents — a decoy overload, a @deprecated
redirect alias, or a module __getattr__ that raises with the right shape in the message.
Support window
- Fixes land on the latest minor of the current major.
- When a new major ships, the previous major keeps getting security fixes for 6 months.
- Older majors are not patched.
/releasesand/releases.jsonalways state what is current.
We never move you
Cendor does not auto-upgrade, does not check for updates at runtime, and opens no socket you did not ask for. Your package manager owns your versions — see Staying up to date. A governance library that quietly changed what it blocks under a running system would be the opposite of useful, and an audit trail you cannot tie to a known version is worth less as evidence.
Why
@cendor/*went to 1.0 — and why one package is3.x. The TypeScript port spent its pre-1.0 life on0.x, where a caret never crosses a minor:^0.15.0will not accept0.16.0. That made every@cendor/coreminor fragment the family until each sibling was republished, and two resolved copies of@cendor/coremeans two event buses — cross-library cooperation stops with no error at all. At1.xa caret spans the whole major, exactly like Python’s>=1,<2, and that entire class of failure disappears. 1.0.0 is a stability declaration, not a breaking change — no API moved, so there is no migration.@cendor/contextkitwas already past 1.0 (it took an accidental major years earlier when a peer range widened), so it continues from where it was rather than counting backwards.
Parity matrix — libraries
Legend: ✅ ported · 🚧 partial/scoped · Py-only deliberately not ported.
| Capability | Python | TypeScript | Notes |
|---|---|---|---|
Money (decimal, never float) | ✅ | ✅ | Decimal ↔ decimal.js; value-equal across langs |
Usage / LLMCall / ToolCall | ✅ | ✅ | snake_case ↔ camelCase fields; type names identical |
| Event bus | ✅ | ✅ | subscribe/emit/unsubscribe/has_subscribers; error isolation |
Price table + estimate() | ✅ | ✅ | same bundled snapshot, generated from the cendor-prices feed (861 rows with per-row provenance, not the old hand-fed 44); refresh() async in TS |
prices.register() / per-1M convenience | ✅ (≥ 1.15.0) | ✅ | Now real in both. Python core gained a public prices.register(model, rates) and prices.register_model_price(model, input=…, output=…, per="1M"); cendor.sdk.register_model_price is a thin re-export, so a libraries-door user no longer needs the SDK distribution to price a deployment. TS core has both too: prices.register (per-token) and, since @cendor/core 3.4.0, prices.registerModelPrice(model, { input, output }) (per-1M) — closing the last pricing asymmetry, since before that a libs-only TS app could not reach the per-1M form at all (@cendor/sdk’s twin still works). Registrations survive refresh() in both |
prices.refresh(source="azure") | ✅ | ✅ | Rewritten in both. The filter is now serviceName eq 'Foundry Models' with a mandatory region and pagination: the pre-rename productName eq 'Azure OpenAI' still returned rows — which is why the loss was invisible — but saw 462 of eastus2’s 1,526 meters and no GPT-5, DeepSeek, Grok, Mistral, Llama, Phi, Kimi, Qwen or Cohere at all. Measured end to end in both languages: 104 mapped models where the old filter mapped 23. Earlier fix, still true: the URL must be percent-encoded or Python’s urllib refuses it and the never-raise contract turns that into a silent False (TS fetch encodes) — the two URLs are byte-identical so they can be diffed |
prices.refresh(source="aws") | ✅ | ✅ | New in both. The Bedrock public price files, one region (region=, default us-east-1), unioning both offer codes — AmazonBedrock alone carries only legacy Claude; Sonnet 4 and 4.5 exist only in AmazonBedrockService. 75 models, dated from publicationDate |
prices.refresh(source="modelsdev") | ✅ | ✅ | New in both. models.dev (MIT), first-party providers only — the same id appears under 11 providers at $1.07–$1.25/MTok and the biggest are resellers, so the allowlist is load-bearing. 433 models with per-row dates |
prices.refresh(source="vercel") | ✅ | ✅ | New in both. Vercel AI Gateway — resale prices, base rates only, undatable. 201 language models |
Bare prices.refresh() target | ✅ | ✅ | Both now fetch the cendor-prices feed (2,586 rows, dated, per-row provenance) instead of the cendor-libs snapshot |
prices.explain(model) | ✅ | ✅ | Same fields, snake_case in Python / camelCase in TS (row_source ↔ rowSource) — the documented naming divergence, as with snapshot_date ↔ snapshotDate |
prices.save() / prices.load() | ✅ sync | ✅ async | A documented divergence, the same one refresh() has: Python is urllib/pathlib, TS is fetch/node:fs/promises. TS is Node-only (it dynamically imports node:fs/promises, so a browser or Worker bundle never pulls it in) |
refresh(required=…) | ✅ | ✅ | Raises PriceRefreshError / throws PriceRefreshError instead of returning False. Never the default in either |
tokenguard StalePriceTableWarning | ✅ | ✅ | Once per process. Python is a UserWarning subclass (filterable with simplefilter); TS uses the warning-listener channel (onStalePricesWarning), the same shape as onUnpricedWarning |
| Token counting | ✅ | ✅ | tiktoken ↔ js-tiktoken — exact counts match |
instrument() providers | ✅ 6 (OpenAI, Anthropic, HuggingFace, google-genai, Bedrock, Ollama) | ✅ 6 (OpenAI, Anthropic, HuggingFace, google-genai, Bedrock, Ollama) | Bedrock auto-detects a boto-shaped converse() and converse_stream (an always-stream target — TS since @cendor/core 0.12.2, Python since core 1.10). aws-sdk-v3 send(ConverseCommand) / send(ConverseStreamCommand) is detected directly in TypeScript since @cendor/core 3.3.0 — no SDK and no shim needed; Python reaches Bedrock through boto3, which exposes converse() already |
instrument() streaming / interceptors | ✅ | ✅ | |
instrument() Gemini streaming (generate_content_stream / generateContentStream) | ✅ (≥ 1.15.0) | ✅ (≥ 3.1.0) | google-genai streams through a separate method, not a stream=True flag — so it needs its own always-stream target (same machinery as Bedrock converse_stream). Below those versions a streamed Gemini call emitted nothing at all (measured live, both languages). Usage comes from the last chunk’s usage_metadata/usageMetadata (Gemini reports running totals on every chunk), falling back to a flagged offline estimate; budget(..., on_exceed="break") cuts mid-stream |
instrument() structured output (responses.parse, chat.completions.parse) | ✅ (responses.parse ≥ 1.14.1, chat.completions.parse ≥ 1.14.2) | ✅ (≥ 0.16.2) | Captured in both — by different mechanisms. In Python both entrypoints POST their own request, so each is its own instrumented target; before those versions a structured-output call emitted nothing at all, which is how langchain-openai’s with_structured_output() went unseen. In TypeScript the same names are helpers built on create (create(...)._thenUnwrap(...)), so the wrapped create already captures them exactly once and making them targets would double-count — 0.16.1 briefly did, and the real SDK then threw Body is unusable because the derived promise re-read the body; 0.16.2 memoizes the SDK’s parse step and drops the target |
instrument() raw-response envelope | ✅ (≥ 1.14.1) | n/a — different SDK shape | Python: responses.with_raw_response.create(...) (headers + an un-parsed body; what Microsoft Agent Framework drives OpenAI through) is captured and priced — usage is recovered from the body, marked metadata["raw_response_envelope"]. TS: the JS SDKs expose no withRawResponse namespace — the raw response is reached with .asResponse()/.withResponse() on the returned promise, so create() always resolves to the parsed body and capture was never affected |
The SDK’s own promise accessors survive instrument() | n/a — a separate entrypoint | ✅ (≥ 0.16.1) | TS only: openai/anthropic return an APIPromise whose asResponse()/withResponse() read response headers. Below 0.16.1 an instrumented client returned a native promise and both were undefined — while instrument<T>(client: T): T kept the type saying otherwise, so it type-checked and threw. Since 0.16.2 they work on a streamed call too: withResponse() returns the SDK’s response with cendor’s counting stream as data, which is what anthropic’s own messages.stream() helper needs — below 0.16.2 that helper threw withResponse is not a function on an instrumented client. Honest limit: a replayed call has no HTTP response, so neither accessor exists there |
core otel spans / ingest() | ✅ | ✅ | span() + ingest(); @opentelemetry/api optional peer — span is a no-op without it |
| Observability export (Observability) | ✅ | ✅ | OTelSink (spend metrics, dimensioned by track tags), SDK live_spans/span_tree (accept conversation_id/conversationId → gen_ai.conversation.id; a label/label → cendor.run.label; every child carries gen_ai.agent.name + a 1-based cendor.step; a streamed chat span carries cendor.ttft_ms + cendor.usage_estimated="true" when the count was estimated offline; the live root reaches parity with the post-hoc tree incl. cendor.run.agents; since SDK ≥ 1.15 / 0.20 the TS span tree matches Python’s finer set — gen_ai.system, gen_ai.latency_ms, finish_reason, streamed flag, error, tool arg_names; live child spans backdated by latency; a 3-level root → per-agent → call tree), and AuditLog(mirror=OTelMirror()) — governance/audit → any OTel backend; export to Azure Monitor / CloudWatch / Datadog / OTLP with zero Cendor-specific exporter |
Automatic telemetry (CENDOR_TELEMETRY; core ≥ 1.13 / 0.15, sdk ≥ 1.19 / 0.22) | ✅ | ✅ | With OpenTelemetry installed and a provider configured by your app, emission needs no code: the call-span emitter arms at the first instrument()/ingest(), tokenguard writes spend through an internal additive tap (the use_sink slot stays yours), AuditLog(...) auto-attaches its OTelMirror (mirror=False/mirror: false opts out), run() opens the run scope itself, and budget/guardrail decisions ride governance.* spans (cendor.gov.*, no audit.* vocabulary, no reason — rule 6; suppressed while a mirror is on the wire). CENDOR_TELEMETRY=off kills all of it; CENDOR_DEBUG_TELEMETRY=1 explains what was detected. Explicit attachments still win. Parity note: the predicate is ProxyTracerProvider (Py) vs the proxy’s getDelegate() (TS); the live-spans latch is a ContextVar in Python (its live_spans is a context manager, so it is always scoped) but has two mechanisms in TS, where a liveSpans() handle is closed by hand: process-wide while a manual handle is open, and AsyncLocalStorage.run()-scoped for the SDK’s automatic run scope. ⚠️ @cendor/core 0.13.0–0.15.0 used enterWith, which only scopes as intended on node ≥ 24 — on node 20 / 22 a closed scope left the emitter suppressed process-wide; fixed in core 0.15.1 + @cendor/sdk 0.23.1 (verified on node 20.20 / 22.23 / 24.18) ⚠️ cendor-sdk < 1.19.1 / @cendor/sdk < 0.23.2 mis-attributed concurrent runs: a scope learned its run family from the first bus event it saw (a process-wide fanout), so two overlapping runs rendered one run’s call twice — once under each root — dropped the other’s, and stamped both roots with one cendor.run.id; a run-less libs call was adopted as the run’s step 1. Fixed: one owning scope per run family, and the automatic scope learns only from its own context. |
| Opt-in content capture (core ≥ 1.7 / 0.7) | ✅ | ✅ | otel.capture_content()/captureContent() (or OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT) — OFF by default. On, span_tree/live_spans stamp gen_ai.input.messages / output.messages (incl. parsed thinking parts) / system_instructions + tool arg/result; masked (fail-closed) + byte-capped; never on audit.* (rule 6). Auto gen_ai.conversation.id from run(session=…). Libs-only apps light up via otel.use_span_emitter(). Streamed calls stamp cendor.ttft_ms (now on the SDK span_tree/live_spans journeys too, not just the libs-only emitter) + cendor.usage_estimated="true" when the count was estimated offline |
| squeeze compression event (squeeze ≥ 1.1 / 0.3) | ✅ | ✅ | CompressionEvent on the bus after each compress() (metadata only — technique, tokens before/after, ratio, store kind, handle id; never text); acttrace (≥ 1.8 / 0.9) chains it as a compression entry + audit.compression mirror span |
acttrace budget_event chaining + mirror= completeness | ✅ | ✅ | tokenguard emits BudgetEvent (blocked/downgraded/clamped) on the bus, now carrying budget(name=…, description=…); acttrace chains it + optional OTelMirror. Since acttrace 1.7 / 0.8 the mirror carries the budget name (cendor.audit.budget) + numeric projected-vs-cap (money as strings), llm_call usage/latency/replayed, guardrail agent/tool + nested severity/policy_version/policy_hash, and context_assembly block counts. File stays the sole verify() evidence; otel_trace_id/otel_span_id correlation on entries. acttrace ≥ 1.9 / 0.10 additionally stamps run_id (Cendor’s ambient run id from the SDK run() scope) → cendor.audit.run_id, the fallback a trace-aware tool joins on when no OTel span was active (post-hoc span_tree / no context manager). @cendor/sdk ≥ 0.17 makes TS liveSpans activate the run root (Python live_spans always did), so a TS SDK run inside liveSpans gets during-run governance→run correlation at parity with Python |
| Native governance counters | ✅ | ✅ | cendor.tokenguard.budget.events (tg ≥ 1.3 / 0.4) + cendor.guardrails.decisions (gr ≥ 1.6 / 0.7) — no-op without OpenTelemetry; chart budget-block / guardrail-decision rates (*_total in Prometheus) |
LangChain CendorCallbackHandler | ✅ | ✅ | @cendor/core/langchain; recording-only in both; reads usage_metadata, correlates by root-run traceId |
openai-agents CendorAgentHooks / observeOpenAIAgents | ✅ | ✅ | extra [openai-agents] / subpath @cendor/core/openai-agents; sources the framework’s agent name per turn (set at start/handoff, cleared at end). The agent’s calls ride the standard OpenAI client, so instrument() still captures tokens/cost/streaming. Never-overwrite. Py = a RunHooks you pass to Runner.run(hooks=…); TS = observeOpenAIAgents(runner|agent). Mechanism (both): a process-wide holder read live at call construction (the Agents SDK isolates the model call’s async context from the hooks, so a contextvar / AsyncLocalStorage never reaches it) — honest limit: exact for sequential runs + handoffs, but concurrent Runner.run() in one process may cross-attribute the name |
| Foundry agent correlation adapter | ✅ | ✅ | extra [foundry] / subpath @cendor/core/foundry; observe_foundry_agents(client) / observeFoundryAgents(client) wraps thread-run creation to stamp agent + conversation_id. Attribution-only — the model runs server-side, so no per-step token/cost (documented honest limit). Duck-typed on .runs (no SDK import needed to import the adapter) |
trace() correlation | ✅ contextvars | ✅ AsyncLocalStorage | Full parity. |
trace() as a real parent span | ✅ (core ≥ 1.14.0) | ✅ (@cendor/core ≥ 0.16.0) | Same shape both languages: a cendor.trace <id> span (scope cendor.core, cendor.run.id + cendor.scope="trace") whose children are the calls, each with a 1-based cendor.step — one scope, one trace. Behaviour change: before those versions the scope only stamped an ambient id, so each call was its own root span. Opt out with CENDOR_TRACE_SPAN=off, or span=False / { span: false }. Mechanism: Python ContextVar + start_as_current_span; TS startActiveSpan (i.e. context.with, an AsyncLocalStorage.run() — never enterWith), verified in docker on node 20.20 / 22.23 / 24.18. TS-only fix riding along: the ambient id now isolates itself with a real AsyncLocalStorage by default on Node — before 0.16.0 it fell back to a module variable unless a host called installTraceContext, so two overlapping scopes shared one variable. |
gen_ai.agent.id | ✅ (core ≥ 1.14.0 / sdk ≥ 1.20.0) | ✅ (@cendor/core ≥ 0.16.0 / @cendor/sdk ≥ 0.24.0) | Agent(id=…) / new Agent({ id }); emitted on the live tree, span_tree, and the flat libs emitter. Only when given — absent, the attribute is omitted (never hashed, never a placeholder). No provider returns an agent id for a plain chat call. |
| Provider-native agent identity (adapters) | ✅ cendor.core.agent_ids (core ≥ 1.14.0) | ✅ @cendor/core/agent-ids (≥ 0.16.0) | bedrock_agent_scope / bedrockAgentScope (agentId[+alias] → gen_ai.agent.id, sessionId → gen_ai.conversation.id), openai_assistant_scope / openaiAssistantScope, and the generic agent_scope / agentScope. Foundry’s adapter now maps its agent_id onto gen_ai.agent.id too. Honest limit, both languages: all of these are attribution-only — mapping identity does not make a server-side runtime’s tokens or cost appear, because no model call passes through instrument() there. |
| The actor on a governance row | ✅ core ≥ 1.14.0 + acttrace ≥ 1.13.0 | ✅ @cendor/core ≥ 0.16.0 + @cendor/acttrace ≥ 0.14.0 | ambient_attrs() / ambientAttrs() lets a governance record name the acting agent (and its id) without acttrace/tokenguard importing the SDK. governance.* ops spans get cendor.gov.agent/_id; the OTelMirror stamps cendor.audit.agent/_id on every entry, including the types with no agent field (a budget block, a decision record, an llm_call). Measured before: 13 of 386 rows named their agent. |
| Ambient metadata seam (core ≥ 1.9 / 0.10) | ✅ | ✅ | add_ambient_provider(fn) / remove_ambient_provider (TS addAmbientProvider/removeAmbientProvider) — a (event) → metadata | None provider stamps run-scoped metadata (agent / conversation_id / decision_id …) onto every LLMCall/ToolCall at construction, before interceptors: never-raise, never-overwrite, registration order, zero-provider fast path. Core stays generic (learns no SDK vocabulary); metadata["agent"] → gen_ai.agent.name on the span, so a libs-only app surfaces the agent name too |
| tokenguard budgets / track / report / sinks | ✅ | ✅ | SQLite / Queue / OTel sinks in both |
| guardrails rules / stages / install / scoped / adapters | ✅ | ✅ | deterministic gate at 4 stages (input / tool_call / tool_output / output); block / redact / flag → guardrail_decision on the bus; apply / evaluate (+ async), install() interceptor, scoped() per-request gating (contextvars / AsyncLocalStorage), per-guardrail timeout + on_error, judge helpers, detection-tier adapters (classifier, language, openai_moderation). prompt_guard (transformers) is Python only — in TS wire a classifier via rules.classifier. @cendor/guardrails core is pure/all-runtime (no hard node:*). Naming exception: the custom-guardrail factory is Py guardrail(name, check) ↔ TS defineGuardrail(name, check) |
| guardrails hosted rails | ✅ | ✅ | bedrock_guardrail (AWS ApplyGuardrail), azure_content_safety (Prompt Shields), model_armor (Google) — duck-typed clients (no cloud SDK imported), metered by the vendor; every verdict still emits a local guardrail_decision (“cloud check, local evidence”) |
guardrails config-as-data (load_policy) | ✅ | ✅ | declare deterministic rules in a versioned JSON/YAML file; the content hash + version are stamped into every decision’s metadata (policy_hash / policy_version) so the audit chain proves which policy was active. YAML via the [yaml] extra (Py) / a BYO parser (TS) |
| guardrails groundedness / denied topics | ✅ | ✅ | groundedness / denied_topics over a bring-your-own embed(text) fn (cassette’s BYO-scorer precedent) — cosine similarity, no bundled model, no accuracy claim |
| guardrails matching maturity (G1) | ✅ | ✅ | keyword_deny(match="word", normalize=…) — opt-in Unicode word boundaries + NFKC/zero-width/casefold folding (default substring, byte-for-byte back-compatible); metadata["matched"] records the term. JS uses \p{L}\p{N}_ lookarounds (its \b is ASCII-only) |
| guardrails custom categories (G2) | ✅ | ✅ | custom_category(name, examples, embed=…) — semantic category-by-example (Azure “rapid custom categories” done local, $0); the paraphrase catch a deny-list misses. embed is BYO; no catch-rate claim |
| guardrails local embedder (G2) | ✅ local_embedder (model2vec, sync) | ✅ localEmbedder (transformers.js, async) | a zero-config offline embed behind an optional extra: Py embeddings.local_embedder() (the [embeddings] extra, model2vec static embeddings, numpy-only, sync); TS embeddings.localEmbedder() (the optional @huggingface/transformers peer, async). No maintained model2vec JS port exists, so the backends differ — and embed may be sync or async (an async embed gates via applyAsync/the SDK loop). No catch-rate claim |
| guardrails intent screening (G3) | ✅ | ✅ | rules.intent(intents, embed=…|classify=…, mode="deny"|"allow") — a first-class pre-LLM intent gate (deny topics you don’t serve / off-topic gate); judge.intent_prompt/intentPrompt is the LLM-judge backend. No accuracy claim, no bundled taxonomy |
| guardrails presets + policy schema (G4) | ✅ | ✅ | presets.PROMPT_INJECTION_EN / prompt_injection() (curated starter list — inline code, not detection, no coverage claim) + policy_schema()/policySchema() + load_policy(validate=True) (stdlib structural check, no jsonschema). Py ships policy.schema.json; TS ships the schema inline (all-runtime) |
| guardrails Azure adapter breadth (G5) | ✅ | ✅ | azure_content_safety(checks=("harm_categories",), harm_threshold=…, blocklist_names=…) now also wraps Azure’s analyze_text harm classifier (severity → metadata["severity"]) + blocklists, alongside Prompt Shields (default). Groundedness-as-a-service is a planned follow-up |
| guardrails red-team eval | ✅ | ✅ | run_redteam + load_corpus — trip rate + false-positive rate + per-category breakdown against a labeled corpus you supply (no vended data). Py reads a file path; TS takes text/array (no node:fs) |
guardrails spotlight (A1) | ✅ | ✅ | deterministic, $0, offline redact-action mitigation (inspired by Azure Spotlighting): wraps untrusted content (input / tool_output) in a trust-lowering delimiter (optionally base-64). Never blocks; a mitigation, not a detector |
| guardrails annotation-parity metadata (A2) | ✅ | ✅ | reserved GuardrailDecision.metadata keys (severity / detected / filtered / redacted / citation / license) — no event-shape change, no acttrace edit; a check attaches them via Verdict.metadata and the adapters populate them from the vendor result |
guardrails task_adherence (A3) | ✅ | ✅ | BYO-judge alignment check at the tool_call stage (does the proposed call match the user’s intent?), via judge.task_adherence/judge.taskAdherence + Context.instruction. The @cendor/guardrails helper is ported and the SDK-JS auto-threading of the instruction shipped in @cendor/sdk 0.7.0 |
| guardrails SDK re-ask / stream window | ✅ | ✅ (TS since SDK 0.20) | Agent(reask_on_output_trip=N)/reaskOnOutputTrip (bounded re-ask on an output block, non-streaming) + Agent(stream_check_window=N)/streamCheckWindow (incremental run.stream output check) — now in both languages. Streaming re-ask is intentionally offered in neither (a streamed answer’s deltas can’t be unshown to re-ask) |
| contextkit assemble / evict / order | ✅ | ✅ | TS collapses sync+async into one async assemble() |
| squeeze compress / decompress | ✅ | ✅ | deterministic; handle ids match |
| cassette record / replay | ✅ | ✅ | cross-language replay, vector-verified. ⚠️ Replaying an async client needs cendor-core ≥ 1.14.1: below that the recorded value came back synchronously, so await client.chat.completions.create(...) raised TypeError: … can't be used in 'await' expression and a replayed stream was not async for-able (data correct, async-ness lost). TypeScript was never affected — its wrapper always returns a promise |
cassette local_embedding_scorer (bundled model2vec) | ✅ | Py-only | no JS static-embedding package exists; TS uses the BYO embeddingScorer(embedFn) / openaiEmbeddingScorer seam instead. The TS localEmbeddingScorer symbol exists but throws with that guidance (a deliberate Type-Teach stub, not a silent absence) |
| cassette storage | fs | fs + memory | pluggable adapters — an in-memory adapter ships in TS; IndexedDB is implementable via the CassetteStorage interface but is not a shipped adapter |
| acttrace chain / verify / sign | ✅ | ✅ | cross-language verify (HMAC + _meta). One live writer per chain file, both languages — a restart resumes the chain on the same path (supported, verified), but two logs writing one file at once interleaved two chains into it and verify() failed at the first divergence; refused at construction since cendor-acttrace 1.13.1 / @cendor/acttrace 0.14.1. Two separate processes on one file cannot be detected in-process |
| acttrace detectors | ✅ regex + Presidio NER (the [ner] extra + a spacy download model) | ✅ regex/pattern (20 detectors) + NER | 🚧 NER via optional compromise (English-only, lighter than Presidio — not parity); nerAvailable() reports presence. Python’s [ner] needs a spaCy model installed separately (see Honest limits) |
Parity matrix — SDK
| Capability | Python | TypeScript |
|---|---|---|
Agent / tool / run / Result | ✅ | ✅ (zod tool schemas) |
| Providers | ✅ ten paths | ✅ ten paths (OpenAI, Anthropic, HuggingFace, Azure chat + responses, Foundry Local, Ollama, Gemini, Bedrock) — HF/Ollama/Gemini/Bedrock usage capture rides @cendor/core’s provider detection |
| Sessions & memory | ✅ (+ SQLite store) | ✅ (better-sqlite3 + memory adapters) |
| Handoff / supervisor / pipelines | ✅ | ✅ |
| Structured output | ✅ | ✅ — native Anthropic output_config.format json_schema on supported models (SDK ≥ 1.14 / 0.19), older models degrade to the JSON-instruction nudge; Bedrock forces a synthetic-tool toolChoice (SDK ≥ 1.15 / 0.20) gated to tool-less agents (can’t coexist with real tools on Converse), else the nudge |
| Streaming | ✅ incremental (OpenAI, Ollama, Anthropic SDK ≥ 1.14 / 0.19) | ✅ incremental (OpenAI, Ollama, Anthropic SDK ≥ 0.19); single-agent + multi-agent |
ThinkingDelta stream event (SDK ≥ 1.13 / 0.18) | ✅ | ✅ streamed reasoning, separate from TextDelta, for providers that stream it — Ollama think, OpenAI-compatible reasoning_content, and Anthropic thinking_delta (both langs, SDK ≥ 1.14 / 0.19) |
| Multimodal images (Ollama / Bedrock) | ✅ Ollama images[] + Bedrock Converse image blocks from data-URLs (SDK ≥ 1.14) | ✅ (SDK ≥ 0.19) — remote http(s) image URLs unsupported (no fetching), documented |
| Governance re-exports | ✅ | ✅ (the real @cendor/* objects) |
guard identity + scope form | ✅ sdk.guard is acttrace.guard; with guard(...): | ✅ Object.is; guard(opts, fn) — dual-shape acttrace ≥ 1.5.0 / 0.6.0, SDK ≥ 1.7.0 / 0.10.0 |
SDK rules = full library catalogue | ✅ all factories + the pii/secrets/entropy bridge | ✅ since 0.10.0 — spotlight, detection-tier adapters, and similarity checks included (payloadText/NORMALIZATIONS helpers stay library-only) |
| Embeddings capture + pre-flight governance | ✅ embed() rides instrument() (core ≥ 1.6.0) | ✅ (core ≥ 0.6.0) — a keyless USD budget blocks an embed before it fires |
EvalCase cassette normalizer passthrough | ✅ | ✅ |
downgrades() / clamps() re-export | ✅ | ✅ |
| Parity/identity CI (re-export drift fails the build) | ✅ tests/test_lib_parity.py | ✅ test/lib-parity.test.ts |
| Live progress / prompt caching / live OTel spans | ✅ | ✅ |
| MCP client (tools / prompts / resources) | ✅ | ✅ (@modelcontextprotocol/sdk optional peer) |
| Checkpoint / resume | ✅ | ✅ (atomic JSON; single + multi-agent; streamed runs too since SDK ≥ 1.15 / 0.20 — run.stream/run.astream take checkpoint, done-resume replays a lone RunComplete, an unfinished resume skips prepare and does not re-yield prior deltas. Py run.astream accepted checkpoint= but did not forward it until 1.17 — fixed, now at parity with run.stream and TS) |
| Structural telemetry spans (SDK ≥ 1.16 / 0.21) | ✅ | ✅ |
| A2A server / client | ✅ | ✅ (JSON-RPC; serve() on node:http) |
| Foundry / Bot-Framework adapter | ✅ | ✅ |
Runtime targets (TypeScript)
| Package | Node | Edge (Workers) | Browser |
|---|---|---|---|
@cendor/core | ✅ | ✅ | 🚧 types/bus/prices/tokens are pure; instrument wraps fetch SDKs |
@cendor/contextkit, @cendor/squeeze | ✅ | ✅ | ✅ pure compute |
@cendor/tokenguard | ✅ | ✅ | ⚠️ advisory only — enforcement is server-side |
@cendor/cassette | ✅ (fs) | ✅ (adapter) | ⚠️ memory/IndexedDB adapter |
@cendor/acttrace | ✅ | ✅ | ❌ never — signing keys can’t live in a client |
@cendor/sdk | ✅ | ✅ (HTTP/SSE transports; MCP via @modelcontextprotocol/sdk, Node) | ❌ keys-in-browser anti-pattern |
Governance is only real where the user can’t tamper with it. Budgets-as-enforcement, audit-as-evidence, and redaction-as-guarantee are server-side by definition — in every language. Browser builds of the pure-compute libraries are UX aids (live token/cost preview, context assembly in the chat UI), and that’s all they claim to be.
Honest limits
- Versions are independent across languages. Python and TypeScript release on their own cadence; this page — not matching version numbers — is the parity contract.
- A couple of surfaces remain Python-only — cassette’s bundled
local_embedding_scorer(bring your ownembedFnin TS), and Presidio-class NER (see below). The Bedrock detection asymmetry is closed:@cendor/core3.3.0 detects an aws-sdk-v3BedrockRuntimeClientand capturessend(new ConverseCommand(…))/send(new ConverseStreamCommand(…))at the libraries door, so a libs-only TypeScript Bedrock app no longer needs the SDK or a hand-writtenconverse()shim. Any other AWS command through the samesendpasses through untouched and emits nothing. (The LangChain / LangGraph callback handler is now in both languages — TS via@cendor/core/langchain; keyless Entra-ID auth for Azure is in both too — TS via theazureADTokenProvideroption.) - Anthropic’s
messages.stream()/messages.parse()are captured in both languages — by different mechanisms. In Python each POSTs its own request and so needs its owninstrument()target (added in core 1.17.0; before that both emitted zero events). In TypeScript both are helpers built oncreate, so the wrappedcreatealready captures them exactly once and a target there would double-count. Parity of behaviour, not of mechanism — the same shape as openai’sparse. - NER backends differ by language, and it’s not parity. Python uses Microsoft Presidio (spaCy
models); TypeScript uses the optional
compromiseengine (npm install compromise) — synchronous (acttrace’s tamper-evident append is sync, so an async transformer NER can’t plug in), English-only, and with lower recall/precision. Treat the TS NER as an extra layer, not a sole PII control. The Python[ner]extra installs Presidio + spaCy but not a language model — install one once (python -m spacy download en_core_web_sm);ner_available()returnsTrueonly when both are present andner_redactor()raises a clear error (never a pip auto-download) if the model is missing.nerAvailable()(TS) reports whethercompromiseis installed. - Docs code samples default to Python where a tab pair isn’t shown; the mapping rules above translate mechanically.