Release management

Every version, both languages.

Cendor ships in Python (cendor.* on PyPI) and TypeScript (@cendor/* on npm). The two release on independent cadences — the parity matrix, not matching version numbers, is the contract.

Latest — SDK + libraries

Structured output is captured, a raw response can be recorded, and the SDK’s own helper methods survive instrumentation · cendor-core 1.14.2 / @cendor/core 0.16.2 · cendor-cassette 1.1.1 · cendor-guardrails 1.6.1 · cendor-acttrace 1.13.1 / @cendor/core 0.16.1 · cendor-acttrace 1.13.1 / @cendor/acttrace 0.14.1 · 2026-07-27

Six capture and integrity repairs, every one found by driving a real Microsoft 365 pro-code agent against the published shelf rather than by reading the code. Under cassette replay an await on an async OpenAI or Anthropic client used to raise TypeError, because those SDKs put an async def behind a sync decorator and the replay seam handed the recorded value straight back — a replayed stream was worse, neither awaitable nor async for-able, so no app-side shim could have covered it. A call made through with_raw_response — the documented way to read response headers, and the path Microsoft Agent Framework drives OpenAI through — was captured with no usage and no cost while the identical plain call priced exactly. responses.parse emitted nothing at all. An async tool behind a decorator recorded its own coroutine object, and a recorder persisted that into the cassette. In TypeScript, instrument() quietly removed the SDK's asResponse()/withResponse() accessors while the preserved type kept promising them — it type-checked, then threw. And two live AuditLogs on one chain file interleaved two hash chains into it, so verify() failed at the first divergence, discovered only when someone audited. All six are fixed; none adds public API.

Earlier — One scope, one trace (core 1.14.0 / 0.16.0 · acttrace 1.13.0 / 0.14.0 · sdk 1.20.0 / 0.24.0 · Cendor Monitor 0.14.1, 2026-07-26):

core.trace("id") now opens a real parent span, so a unit of work that spans several calls arrives as one trace instead of N. Before this it only stamped an ambient id, and every call inside still arrived as its own root span — measured against Cendor Monitor, a scope around a chat call and a tool call produced two unrelated rows sharing one id, with the governance fanned out across both, while the console cheerfully advised reaching for the very lever that could not move. It is a behaviour change with a one-env-var opt-out (CENDOR_TRACE_SPAN=off, or span=False per scope) for anyone whose backend groups by trace id today. Nothing is emitted when there is nobody to emit to, and no span is opened inside a cendor-sdk run — that run already owns its trace, so the calls attach to it rather than compete with it. Agent identity: Agent(id=…) rides the semconv gen_ai.agent.id, because a name is a label — two apps can share one, and a rename loses that agent's history — and three products that do own a real agent id (Bedrock Agents, OpenAI Assistants, Azure AI Foundry) now have adapter scopes that map it. Give no id and the attribute is omitted: never a hash, never a placeholder. Every governance row names its actor: a new read of core's ambient registry lets a governance.* span and the audit.* mirror name the acting agent even on the entry types that carry no agent field — a budget block above all. Measured before this release: 13 of 386 governance rows named their agent. Cendor Monitor 0.14.1 renders all of it: a trace() scope is one Calls row with STEPS = N, the Agents page shows an id beside a name, the Libraries door surfaces the agent dimension when your framework provided one, the Governance page leads with verdicts instead of trajectory and folds nine action spellings into five outcomes, and every spend table now says out loud that cost attribution is fleet-wide (a metric datapoint carries no run scope on the wire, so both doors show the same breakdown). 0.14.1 adds two corrections found by measuring the console against a real store rather than reasoning about it: a run's governance count now always equals what its own journey shows (28 of 235 measured rows said 0 in the header while the body listed three — the shared-scope events belong to the scope, and the chip that says so is the honest place to put them), and an MCP handshake no longer creates an empty run row (it fires at connect time, outside any run; it is still recorded on the MCP page). See the Observability guide.

Configure an OpenTelemetry provider the way you already would, and Cendor's telemetry flows — you write none. Governed calls become gen_ai.* spans the moment you call instrument(); spend becomes counters through an internal additive tap (your use_sink slot stays yours); run() opens its own agent.run tree with your session id as the conversation key; AuditLog(system=…) auto-attaches its mirror; and budget blocks and guardrail verdicts arrive as governance.* spans (cendor.gov.*) — so a monitoring user sees why a run stopped without adopting the evidence library. Cendor still has no endpoint, no exporter and no key: it emits into your provider, which is why on-by-default is safe. The off switch is one env var — CENDOR_TELEMETRY=off (process-wide, no code change), and CENDOR_DEBUG_TELEMETRY=1 prints one line saying what was detected. With OpenTelemetry absent, or no provider configured, nothing is emitted and nothing is even subscribed; prompt/response content stays opt-in. Explicit attachments still win, and the audit mirror always beats the ops spans — one decision, one rendering. Rule 6 holds by construction: governance.* spans carry no audit.* vocabulary and no reason string (a rule’s reason — an llm_judge verdict especially — can carry input-derived text). Also fixed on the way: @cendor/tokenguard's OTelSink bound a permanent no-op counter when constructed before your provider (the JS metrics API has no proxy), and @cendor/core's live-spans latch + the SDK's scope registry were module globals — one open scope silenced every concurrent flow, and concurrent runs mis-parented each other's steps. Cendor Monitor 0.12.0 maps the new spans into the same Governance board and inline run verdicts, keeps zero-correlation governance instead of dropping it (v0.11.0), and stores metric deltas so spend no longer inflates once per export cycle (measured 6×). Patched the same day — @cendor/core 0.15.1 / @cendor/sdk 0.23.1: the new latch used AsyncLocalStorage.enterWith, which only scopes as intended on node ≥ 24; on node 20 / 22 a closed scope left the emitter suppressed process-wide and two concurrent runs shared one scope. The automatic run scope now uses AsyncLocalStorage.run() — verified identical on node 20.20 / 22.23 / 24.18 — while a hand-closed liveSpans() handle stays process-wide while open, which is what that shape can honestly guarantee. Fixed 2026-07-26 — cendor-sdk 1.19.1 / @cendor/sdk 0.23.2: the automatic scope learned which run it belonged to from the first event on the process-wide bus, so two overlapping runs rendered one run’s call twice, dropped the other’s, and stamped both roots with one run id; and the TypeScript streamed scope was bound around the generator’s creation, which binds nothing. Both fixed, each pinned by a test verified failing first. The lesson is a test shape, not a code rule: a telemetry test with an instant stub finishes one run before the next starts, so it proves nothing about a server. Cendor Monitor 0.12.1 makes the metric-stream bound a real LRU (it evicted the first-seen stream — often the busiest — and re-baselined it), and 0.12.2 stores the money column as a bare decimal (a currency suffix from an older producer made the Postgres cost sort throw). @cendor/sdk 0.23.3 is the producer half of that: the cost span attribute is now the bare amount, matching @cendor/core and both Python paths. See the Observability guide.

SDK findings closure (cendor-sdk 1.17.0 / @cendor/sdk 0.21.1) — a fix and a parity extension, both backward-compatible. Python run.astream(checkpoint=…) was accepted and documented but never forwarded — streamed-async checkpointing was silently a no-op; fixed, now at parity with run.stream and the TypeScript twin (red-first). The multi-agent pipeline shapes sequential / parallel / parallel_async gain the honored per-run governance surface (retry / on_step / guardrailsResult.guardrail_decisions) and supervisor delegates to the full team runner with session / checkpoint, matching TypeScript 1:1 (session / checkpoint stay team-only for the pipe shapes; guardrail_mode is single-agent-only). The @cendor/sdk 0.21.1 patch is a truth-up: the README now states automatic token/cost capture is live for every provider and documents the full 0.21 surface (reaskOnOutputTrip / streamCheckWindow / streamed checkpoints / conversationId / the six telemetry domains), plus A2A serve() HTTP, SqliteSessionStore disk, and resilience-matrix test coverage. See the multi-agent guide + the parity matrix.

The SDK now emits structural telemetry, and the monitor splits into two doors — two stores, two UX modes — so libraries-only and SDK telemetry each read as their own surface. cendor-sdk 1.16 / 0.21 adds opt-in cendor.sdk child spans for RAG (rag.assemble / rag.compress), memory (memory.load / save), orchestration handoffs, checkpoints, a first-class tool domain (source local|mcp, outcome ok|error|blocked), and MCP server attribution — zero-core, both languages, content rules unchanged (labels/ids/counts, never bodies). The optional self-hosted Cendor Monitor 0.9 renders them: each door is its own store (/data/libs.db + /data/sdk.db, or Postgres per door — mixed mode supported), the console has two full UX modes with the door in the URL (/libs/…, /sdk/…) and a persistent switcher, the SDK mode gains Orchestration / Tools / MCP / RAG / Memory / Checkpoints pages, and a live channel streams updates over Server-Sent Events (live steps, not tokens). Same wire, same honesty — the SDK door adds identity and structure, never "more governance". Cendor Monitor stays optional dev tooling (like cendor-mcp); your own OTLP backend remains the default. See Cendor Monitor + the Observability guide.

Library patches — @cendor/core 0.12.2 · cendor-tokenguard 1.5.1 / @cendor/tokenguard 0.6.2 · @cendor/cassette 0.3.3 · cendor-squeeze 1.1.1 · 2026-07-24: a remediation wave — no new capability, all additive and backward-compatible. core instrument() now detects a boto-shaped converse_stream as an always-stream Bedrock target, closing the last undocumented instrument() detection gap with Python. tokenguard QueueSink gains drop observability — an on_drop_error / onDropError callback plus a dropped_rows / droppedRows() counter — so a row a failing durable sink throws on is counted and surfaced instead of silently swallowed. cassette anchors its rerecord drift buffer on the global symbol registry (two loaded copies share one buffer). squeeze now exports MemoryStore / SQLiteStore at the package top level (Python, matching the TypeScript index).

Earlier — App & agent identity (core 1.11.1 / 0.12.1 · @cendor/sdk 0.20.1 · Cendor Monitor 0.8.0, 2026-07-23): Framework agent-name adapters + a monitor "Apps" top level — identity surfaced where it is already true, never invented by core. core 1.11 / 0.12 adds two optional framework adapters that carry a third-party framework's agent identity onto the bus (so a monitor's Agents page fills for framework-driven stacks), mirroring the shipped langchain handler: cendor.core.openai_agents / @cendor/core/openai-agents for the OpenAI Agents SDK (the agent's model calls ride the standard OpenAI client, so instrument() still captures tokens/cost/streaming — the adapter supplies only the name), and cendor.core.foundry / @cendor/core/foundry for Azure AI Foundry Agents (stamps agent + conversation_id, attribution-only since the model runs server-side). Core carries no identity of its own; importing an adapter registers nothing until you attach. Cendor Monitor 0.8 adds the Apps page — the libs door's top level, grouping runs by their OTel service.name with a distinct-instance count from service.instance.id; an app's identity is its standard OTel resource (set OTEL_SERVICE_NAME), not anything Cendor invents. @cendor/sdk 0.20.1 re-pins @cendor/core ^0.12.0 so a fresh install resolves a single core. Additive and backward-compatible. See the core adapters + parity matrix.

Earlier — Streaming truth + mid-stream breaker (core 1.10.0 / 0.11.0 · tokenguard 1.5.0 / 0.6.0 · @cendor/sdk 1.15.0 / 0.20.0, 2026-07-23): SDK Phase-S follow-up (@cendor/sdk 1.15.0 / 0.20.0) closes the parity items the provider-capabilities wave deferred — both languages, on the same core 1.10 / tokenguard 1.5 shelf. Streamed and multi-agent runs now stamp conversation.id from a keyed session (a monitor groups the runs of one thread); run.stream / run.astream take a checkpoint (per-turn + per-segment saves; a done-resume replays a lone RunComplete, an unfinished resume continues without re-showing prior deltas); TypeScript gains bounded output-block re-ask + an incremental streaming output-window check; the TS span tree reaches Python parity (provider, latency, finish reason, streamed flag, error, tool arg-names; live children backdated by latency; a 3-level per-agent tree); and Bedrock gains forced-toolChoice structured output, gated to tool-less agents. Streaming re-ask is offered in neither language (a streamed answer’s deltas can’t be unshown). Additive and backward-compatible. See the parity matrix.

A budget can now stop a runaway stream mid-flight, streamed estimates see visible thinking, and Anthropic streams token-by-token in both languages. core 1.10 / 0.11 adds a per-chunk stream-observer seam (raising aborts the stream, finalizing once with the partial estimated usage) and counts visible thinking into streamed estimates (Anthropic thinking_delta, Ollama message.thinking, OpenAI-compat reasoning_content, Bedrock reasoningContent); it also captures Bedrock converse_stream (Py) and repairs a misdetected async client's usage. tokenguard 1.5 / 0.6 rides that seam for on_exceed="break" — cut a streamed call the instant its running output estimate crosses the cap (you keep the partial output; the provider bills to the cut — it stops the meter, it does not un-bill), and clamp now injects the ceiling on Bedrock / Ollama / dict-config Gemini too. The SDK 1.14 / 0.19 turns those into Anthropic incremental streaming + ThinkingDelta, native Anthropic structured output (output_config.format), and Ollama/Bedrock data-URL images — with Bedrock run.aio no longer blocking the loop. Every number is measured; the breaker stops the meter within ~one chunk + one RTT, it does not un-bill the provider. See the tokenguard and Observability guides.

Earlier — Ambient metadata seam (core 1.9.0 / 0.10.0 · @cendor/sdk 1.13.0 / 0.18.0, 2026-07-22): Run context is now stamped onto every event the moment it is built — not read back later, when the scope may already be gone. A new core-owned pre-emit capture point (add_ambient_provider() / addAmbientProvider()) attaches the ambient run context — agent, conversation id, budget frames, decision id, cassette session — to each event at construction, so acttrace, cassette, and live_spans read it from the event instead of a delivery-time ambient read that can arrive too late. This closes a class of "capture read too late" bugs: tokenguard streamed spend that drained out of scope now accrues, enforces, and attributes (cumulative caps hold even under block), and Python stream generators no longer leak a run scope into the consumer's next call. BudgetEvent gains a trace_id, and a new additive ThinkingDelta stream event surfaces model thinking token-by-token. Additive and backward-compatible; evidence, not a compliance guarantee. See the Observability guide.

Earlier — Governance→run linkage (core 0.9.0 · acttrace 1.9.0 / 0.10.0 · @cendor/sdk 0.17.0, 2026-07-22): A governance event now links back to the run that produced it. Inside live_spans() / liveSpans, the run's root span is the active context span for the whole run — so acttrace stamps each audit entry with the run's trace id (cendor.audit.otel_trace_id) and the audit.* mirror spans nest in the run's trace. TypeScript @cendor/sdk 0.17 makes liveSpans activate the run root (Python live_spans always did), closing a TS-only gap. acttrace 1.9 / 0.10 also stamps run_idcendor.audit.run_id, the fallback a trace-aware monitor joins on when no OTel span was active (a post-hoc span_tree, or an app with no context manager). core 0.9's otel.span() activates its span too (TS parity). Additive and backward-compatible; the audit file stays the sole verify() evidence; a no-op without OpenTelemetry. See the Observability guide.

Earlier — Emission-truth wave (core 1.8.0 / 0.8.0 · sdk 1.12.0 / 0.16.0, 2026-07-21): Truth on a governed journey — time-to-first-token, estimated-vs-real streamed tokens, and the run's agents, so a monitor never over-claims. The SDK's span_tree / live_spans now stamp cendor.ttft_ms on a streamed chat span (TTFT inside a real governed journey, not just a bare libs-only call) and cendor.usage_estimated="true" when a streamed token count was recovered by an offline estimate rather than reported by the provider — so a monitor renders those tokens as est. instead of exact. live_spans stamps cendor.run.agents on the run root at close (parity with span_tree), so an Agents view fills for live-streamed runs. core 1.8 / 0.8's libs-only use_span_emitter() carries the same estimated flag. Additive and backward-compatible; a no-op without OpenTelemetry. See the Observability guide.

Earlier — Journey-view wave (core 1.7.0 / 0.7.0 · squeeze 1.1.0 / 0.3.0 · acttrace 1.8.0 / 0.9.0 · sdk 1.11.0 / 0.15.0, 2026-07-20):

Opt-in content on the wire — prompts, responses, thinking, tool values — OFF by default, standards-native, and never in the audit chain. Turn it on with otel.capture_content() / captureContent() (core 1.7 / 0.7) or the standard OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT env var, and live_spans / span_tree stamp gen_ai.input.messages / output.messages (including parsed thinking) + system_instructions + tool arg/result — masked (fail-closed) and byte-capped. Because it's the semconv standard, the same content renders in Langfuse or Braintrust too. run(session=…) auto-groups a multi-turn conversation (gen_ai.conversation.id from the session key); squeeze emits a metadata-only CompressionEvent so compression stops being dark; streamed calls stamp cendor.ttft_ms. Content lands only where your OTLP goes — Cendor never receives it, and audit.* spans stay content-free (rule 6). Additive and backward-compatible; a no-op without OpenTelemetry. See the Observability guide.

Earlier — Monitor-truth wave (tokenguard 1.3.0 / 0.4.1 · acttrace 1.7.0 · guardrails 1.6.0 / 0.7.1 · sdk 1.10.0, 2026-07-20):

The audit mirror now carries the answers, not just labels — so a monitor can show which budget acted, what it blocked, and which guardrail fired at which stage. budget(name=…, description=…) gives a budget a human identity (tokenguard 1.3 / 0.4), and acttrace 1.7 / 0.8 mirrors it as cendor.audit.budget alongside the numeric projected-vs-cap figures (money as strings), plus llm_call usage/latency, guardrail severity + policy version, and context-assembly block counts. Two native governance counters — cendor.tokenguard.budget.events and cendor.guardrails.decisions — make block/flag rates chartable. The SDK's live_spans / span_tree now name the agent + number the step on every call span, reach parity between live and post-hoc, and accept an opt-in label=cendor.run.label (never derived from the prompt). Additive and backward-compatible; a no-op without OpenTelemetry — Cendor stays local-first. See the Observability guide.

Earlier — OpenTelemetry observability export (acttrace 1.6.0 / 0.7.0 · tokenguard 1.2.0 / 0.3.0 · sdk 1.8.0 / 0.12.0, 2026-07-19):

Governance events now reach your observability stack — Azure Monitor, CloudWatch, Datadog, Grafana, or any OTLP backend — with no Cendor-specific exporter. Attach AuditLog(mirror=OTelMirror()) and every chained entry — decisions, guardrail actions, budget breaches, human oversight — is also emitted as an audit.<type> OpenTelemetry span, an operational copy for monitoring and alerting; the hash-chained file stays the sole verify() evidence. A pre-flight budget action (blocked/downgraded/clamped) now rides the bus as a BudgetEvent — the only signal a refused call ever leaves — and OTelSink dimensions spend by your track() tags. Entries carry otel_trace_id so you can pivot from an APM trace to the audit entry. The SDK re-exports OTelMirror + BudgetEvent. All of it is opt-in and a no-op without OpenTelemetry — Cendor stays local-first. See the new Observability guide.

The SDK's promise is that its governance is the libraries, re-exported. This wave makes that literally true and pins it in CI. guard is now the identical acttrace object (sdk.guard is acttrace.guard — acttrace 1.5.0 / 0.6.0's return is dual-shape: the raw interceptor is also the scope form). embed() is governed pre-flight: core 1.6.0 / 0.6.0 captures openai-shaped embeddings.create, so a keyless budget(usd=…, on_exceed="block") refuses an over-budget embedding call before it fires, and the snapshot prices the text-embedding-* ids. The TypeScript rules namespace reaches full Python parity (spotlight, the detection-tier adapters, groundedness/denied-topics), and the pii/secrets bridge now honors per-category policy actions — a gdpr special_category finding blocks even under action="redact". A new parity/identity test suite in both SDKs pins every re-export, so the next drift fails the build instead of shipping. See Architecture.

Follow-up — sdk 1.9.0 / 0.13.0 · 2026-07-20: live_spans / span_tree now accept an optional conversation_id that stamps gen_ai.conversation.id on the root agent.run span — so a backend can group the runs of one multi-turn conversation. Opt-in and additive; a no-op without OpenTelemetry.

Libraries

PackagePyPI (cendor-*)npm (@cendor/*)What it is
core1.21.0 (opens in a new tab)3.8.0 (opens in a new tab)1.21.0 / 3.8.0 — a NEGATIVE price rate is refused, not multiplied, and the refusal names the value it found. prices/1 never said a rate must be non-negative and the library happily multiplied one: measured on the published 1.20.0, prices.register('neg', {input: -1, output: -1}) then estimate('neg', 1M, 1M) returned -$2,000,000. That is worse than the fabricated $0.00 this line of work exists to remove rather than merely equal to it — a zero rate makes a USD budget cap FAIL TO BIND, while a negative one UN-BINDS it, because the spend counter goes down, so a negative-rate model pays for other calls and every call moves the cap further from firing. Two refusals, at the two reachable entrances. NEW InvalidRateError (a ValueError in Python, an Error in TypeScript) from register / register_model_price / register_deployment when any rate is negative, raised at the call that stated it — matching register_deployment(like=), which already fails at registration rather than on the first call, and leaving nothing in the table for a later estimate() to multiply. And MissingRateError from estimate() for a TABLE row stating a negative on any of input / output / cached / cache_write: no spec fallback rescues a rate that would subtract money, so unlike the zero rule there is no registered-value carve-out to make, because register() refuses one outright and a negative can therefore only have arrived from a table. A registered ZERO is still honoured and that is not an inconsistency: prices.register('llama3', {input: 0, output: 0}) still prices a local model free, because a zero is a price some models really have and a user registration outranks any table. No model ever cost a negative amount to call. The message names the rate it saw — it said 'the price table states a ZERO INPUT rate' for a -1, so a reader would grep their own table for a 0, never find one, and conclude the error was wrong about their data; it now says 'states a negative INPUT rate of -1'. The refusal was always right, the sentence was false. Reachable only through the two paths that are deliberately NOT mappers, register*() and a pass-through refresh(url=)/load() table: every mapped refresh(source=) already drops input <= 0, and the feed builder's own G2 already fails a negative rate — that asymmetry, the producer refusing to publish what the consumer would happily multiply, is what made this a real gap rather than a curiosity. OpenRouter is where a negative comes from: it serves -1 as its 'the price depends on which model gets routed' sentinel on openrouter/auto, auto-beta, bodybuilder, fusion and pareto-code, the model-router case that is never priceable. Spec: docs/specs/price-dataset.md, a second Changed 2026-08-02 note; the version string stays prices/1 for the same reason as last time, since same keys, same types and same optionality mean no conformant table can notice, and a reader that refuses where it used to multiply is strictly more conservative. A minor because InvalidRateError is a new public symbol and a new refusal on a shipped API; not a major because zero rows in either bundled snapshot or the feed carry a negative rate and the error is one the API already defines for this condition. ALSO in this release, the bundled snapshot no longer carries a cache rate taken from a different PRICE TIER. It is regenerated from the 2026-08-02 feed after a cendor-prices reconciler fix — same 849 rows, eight rate keys move, and six of them were shipping wrong numbers. reconcile() filled a rate key the winning source is silent about without checking that the donor prices the model at the same tier, so on a model served at two tiers it bolted the cheap tier's rate onto the dear tier's row: deepseek-v4-pro is $1.74/1M input from azure, first-party, and its published cache read came from a $0.435-tier row, a 480x discount no vendor offers. Now deepseek-v4-pro.cached GAINS the dear tier's own $0.145/1M at an ordinary 0.083 ratio, because the correct donor was loaded all along and simply not chosen; glm-5.cached corrects $0.138 to $0.20 per 1M; and six keys are WITHHELD rather than published wrong — deepseek-v3.cached and .cache_write, deepseek-v4-pro.cache_write, glm-4.7.cache_write, qwen3-coder-next.cached, qwen3-235b-a22b-thinking-2507.cached. Three of those were cache_write: 0, which is a fabricated FREE cache write. A withheld key falls back to the rate prices/1 states, cache reads at the input rate and cache writes at 1.25x input, so those rows now OVER-estimate a cached call instead of under-estimating it — the direction this line of work always chooses. If you price cache reads or writes on any of those seven models your figures moved; every other model, and every input and output rate, is untouched. 1.20.1 / 3.7.1 — refresh(source='modelsdev') returned a HOST's deployment price where the lab's own listing was available. The mapper walks its provider allowlist in reverse precedence so the highest-precedence provider is written last and wins, but the guard that stops a host-namespaced id overwriting a bare one inverted that ordering whenever two allowlisted providers both keyed a model bare: the lower-precedence one was written first, claimed the key, and the higher-precedence one was skipped. Measured against the live models.dev payload, four rows were affected and every one of them was a host's listing displacing the lab's — gpt-5.6-luna $1/$6 per 1M (azure) becomes $0.2/$1.2 (openai), gpt-5.6-terra $2.5/$15 becomes $2/$12, deepseek-v4-pro $1.74/$3.48 becomes $0.435/$0.87, deepseek-v4-flash $0.19/$0.51 becomes $0.14/$0.28. Only the modelsdev source is affected: the DEFAULT refresh() (the cendor-prices feed) and the bundled snapshot were both already correct, so this reaches only a caller who names that source explicitly. The litellm mapper keeps the plain guard on purpose — its payload is a flat dict with no precedence order to appeal to, so 'the first bare id wins' is the only rule available there. Caught by the feed builder's day-over-day swing gate (gpt-5.6-luna moved 5.00x on all four rate keys), not by any offline test: nothing had two allowlisted providers keying one model bare. The bundled snapshot is also regenerated from the 2026-08-02 feed — same 849 rows, three rates move, all of them a suppression the feed now makes rather than a price change. 1.20.0 / 3.7.0 — an absent price rate is UNKNOWN, never zero. prices/1 read a missing output rate as 0, which is right for an embedding (it genuinely bills no output tokens) and wrong for a chat model whose rate merely failed to parse — and downstream the two are indistinguishable, so estimate() reported a fabricated $0.00 as a FACT and a USD budget cap under-counted by the entire output side. 1.19.2 / 3.6.2 closed the DATA half (the feed can no longer publish such a row); this closes the two halves a data fix cannot reach, the spec and the library, so a table that did not come from us can no longer make the same mistake. Measured on 1.19.2 itself, through a documented API: refresh(source='litellm') supplied 10 rows with no output rate, and estimate('gpt-image-1', 1M, 1M) answered $5.00 where OpenAI's own published rates ($5/1M text in, $40/1M image out) make it $45.00; refresh(source='azure') supplied one more. NEW MissingRateError is a SUBCLASS of UnknownModelError (and so of KeyError in Python), so every existing handler is unaffected — instrument(), otel, the LangChain handler and tokenguard all already catch it and fall back to an honest None/null plus a warn-once. estimate() refuses an unpriceable rate object whenever it prices the model, not only when the call carries output tokens: a table that cannot price a model cannot price it, and learning that on the first output-bearing call rather than the first call is a late, partial signal. Three shapes are refused — no input, a TABLE-stated zero input (previously a silent $0.00; a missing input was a bare KeyError), and no output. An explicit output of 0 is honoured forever, because 18 rows in the bundled snapshot are real embeddings and depend on it, and a zero YOU registered is honoured too: prices.register('llama3', {input: 0, output: 0}) still prices a local model free, since a user registration outranks any table. register_deployment(like=) / registerDeployment({like}) now fail at registration rather than on the first call when the base they copy cannot price one. A mapped refresh(source=…) drops rows it cannot price — the library mirror of the feed's own rule — so such a model is honestly ABSENT (the plain UnknownModelError a caller already handles) instead of surviving half-priced; a pass-through refresh(url=…) is a TABLE, not a mapper, and keeps every row a user's own table states while estimate() refuses the unpriceable ones by name. There is deliberately NO switch back to guessing zero: the escape is to state the rate. Why a minor and not a major — the shape of prices/1 is unchanged, the function's contract ('the cost of this call') is unchanged, the error is one the API already defines for this condition, and zero rows are affected in either bundled snapshot, in the feed, or from refresh(source='modelsdev'). It is the same call 1.19.0 made when it removed llama3 at 0.0/0.0 and turned estimate('llama3', 1000) from $0.00 into an UnknownModelError — one field over. Spec: docs/specs/price-dataset.md, Changed 2026-08-02. 1.19.2 / 3.6.2 — a missing output rate no longer prices a chat model as free. prices/1 reads an absent output rate as zero, which is right for an embedding (no output tokens are billed) and wrong for a chat model whose rate never parsed, because estimate() then reports the output side as a fact of $0.00 and a USD budget cap under-counts by the entire output cost — the same failure the format already forbids for input rates. 14 rows in the previous snapshot were affected. Three now carry a real rate: claude-3-haiku $0.00 to $1.25/1M, claude-3-sonnet $0.00 to $15.00/1M, gpt-image-2 $0.00 to $30.00/1M, so estimate('claude-3-haiku', 1M, 1M) returned 0.25 and now returns 1.50. If you budget or report on any of those three your figures were low by the output side; the input side, and every other model, was always correct. Twelve rows no source prices an output rate for are now absent rather than free, which renders as an honest None plus a warn-once — claude-2-0, claude-2-1, claude-instant, az-gpt4-turbo-128k, gpt-image-1-mini and seven others. An output rate a source explicitly states as 0 is untouched, so real embeddings keep theirs. Upstream in the feed, the reconciler now fills a rate key the winning source is silent about from another source that states it, rather than taking one source's rate object wholesale — which is how a first-party catalog with partial coverage outranked a complete aggregator and published Claude 3 Haiku with no output rate while two other sources said $1.25/1M in the same build. The snapshot also stops naming the raw.githubusercontent feed URL that 404s now the repo is private. Snapshot 861 to 849 rows. Everything in 1.19.1 / 3.6.1 stands: the feed URL moves to GitHub Pages (https://cendorhq.github.io/cendor-prices/prices.json). The cendorhq/cendor-prices repo is private — the builder, the curation policy and the run history are internal — so the raw.githubusercontent URL 1.19.0 / 3.6.0 shipped needs auth and 404s; a data-only gh-pages branch publishes the file itself, keyless, and Pages serves it as application/json rather than raw text/plain. Anyone on 1.19.0 / 3.6.0 should upgrade: there a bare prices.refresh() fails and, because refresh() is contractually never-raise, returns a silent False. The rates are fine; only the default refresh target is unreachable. Everything in 1.19.0 / 3.6.0 stands: live pricing. Three new refresh() sources and a rewritten Azure one, all measured live on 2026-08-01 in both languages with identical results. aws: the Bedrock public price files, Amazon's own billing catalog, keyless and dated, one region (default us-east-1) — it unions BOTH offer codes because AmazonBedrock alone carries only legacy Claude, while Claude Sonnet 4 and 4.5 exist only in AmazonBedrockService, so a single-offer client silently misses every current Claude rate; rate keys come from usagetype rather than inferenceType, which marks the half-price batch meter as plain 'Input tokens'. modelsdev: models.dev (MIT), the widest keyless catalog found, per-1M converted exactly with per-row dates, restricted to a first-party provider allowlist because the same id appears under 11 providers between $1.07 and $1.25 per MTok and the biggest are all resellers. vercel: the AI Gateway — resale prices like OpenRouter's, base rates only, undatable. azure REWRITTEN to serviceName eq 'Foundry Models' with a mandatory region and pagination: the pre-rename productName filter still returned rows, which is exactly why the coverage 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 — end to end it now maps 104 models where the old filter mapped 23, and 'opt' is finally read as OUTPUT (141 rows spell it that way, so every GPT-5.x family had an input rate and no output rate). Visibility: prices.explain(model) gives the resolved id, how it resolved, the rates, the table's and the row's provenance, the age, and honest notes (a registration in effect, a gateway resale source, an undatable table); prices.save(path)/load(path) are explicit opt-in persistence carrying provenance through — never an implicit cache; refresh(required=True) raises PriceRefreshError instead of returning False, while refresh() stays never-raise. SNAPSHOT_URL now points at the new cendorhq/cendor-prices feed — dated, per-row provenance, reconciled daily behind validation gates — and the bundled snapshot is GENERATED from it rather than hand-typed: 44 rows becomes 861, ending a drift that had gpt-5.6-luna 5x off every other source. A zero input rate is no longer published: llama3 (0/0, inherited from litellm) made exactly one local model report a fabricated $0.00 while every other reported None, and $0.00 as a fact means a USD cap silently never binds.
tokenguard1.8.0 (opens in a new tab)3.2.0 (opens in a new tab)1.8.0 / 3.2.0 — StalePriceTableWarning, warned once per process when a USD budget estimates from a price table older than 45 days, plus configure(on_stale_prices=, stale_prices_after_days=) / configure({ onStalePrices, stalePricesAfterDays }) and, in TypeScript, an onStalePricesWarning(listener) channel matching onUnpricedWarning. A USD cap is only as right as the rates behind it and the direction matters: after a price CUT a stale table over-estimates and the cap binds early, which is conservative; after a price RISE it under-estimates and the cap binds LATE, so you overspend — that second case is why it exists. Nothing is blocked and nothing is re-estimated: it is a signal, not a behaviour change. An undatable table is never called stale, because litellm, openrouter and vercel publish no as-of date at all and inventing an age would defeat the signal. The fix is prices.refresh(), not a bigger threshold.
guardrails1.7.0 (opens in a new tab)3.1.1 (opens in a new tab)1.7.0 / 3.1.1 — the npm patch is the same one-line type fix as @cendor/tokenguard 3.1.1, on useMeter(meter): the counter's add was declared as a property (contravariant parameters), so a real OpenTelemetry Meter did not typecheck on the injection seam built for it. It is a method now; the documented call compiles with no cast; runtime never changed. Pinned by the same type test. Python 1.7.0 stands untouched — the use_meter seam and the guarded decisions counter (a broken meter can no longer fail a guardrail) are unchanged.
contextkit1.1.0 (opens in a new tab)3.1.0 (opens in a new tab)1.1.0 / 3.1.0 — Context(on_missing_compressor=…) / new Context({ onMissingCompressor }) chooses how loud it is when a block asks for evict=compress and no compressor is available. That block is TRUNCATED instead, and truncation is a different operation rather than a slightly worse one: it discards content and gives you no Handle to .expand(), which is the whole point of a squeeze compression. The substitution has always been recorded as a note on the block's BlockDecision — but a note lives inside the AssemblyReport and nothing obliges a caller to read one, so a forgotten contextkit[squeeze] extra (or a missing @cendor/squeeze) quietly degraded every compress block while the assembly still reported success. note is the historical behaviour and remains the DEFAULT, so no existing assembly changes; warn adds a MissingCompressorWarning; error raises MissingCompressorError naming every way out (install the extra, pass compressor=, call use_compressor(...), or accept truncation explicitly). It fires only when the compressor is genuinely missing — a block that asked for truncate, or one that fitted the budget and was never evicted, is untouched in every mode. 2.0.4 (npm) is a cascaded core re-pin (@cendor/core ^0.11.0). Earlier: assemble · evict · order (1.0.8 is an npm-only core re-pin; npm 2.0.0 is a code-identical major — the @cendor/squeeze peer went out of its ^0.2.x range at 0.3.0, which changesets treats as a major). 2.0.3 (npm) is a cascaded core re-pin (@cendor/core ^0.10.0)
squeeze1.1.2 (opens in a new tab)3.1.0 (opens in a new tab)1.1.2 / 3.1.0 — compress() stops paying for a CompressionEvent nobody is listening to. Since the event shipped (1.1.0 / 3.0.0), every compress() ran tokens.count() twice — over the original AND the compressed text — to fill the metadata-only event BEFORE bus.emit, whether or not anything was subscribed. Measured on the 90 KB benchmark payload with zero subscribers: 20.29 ms/call with the event vs 1.42 ms without — 93% of the call — and tokenizing is linear in payload size, so every large compress paid it, including contextkit's evict=compress path, per block. The emit now returns before any counting when the bus has no subscribers (core >= 1.18 / 3.5). An event with no subscriber is unobservable, so nothing observable changes; with an audit log or a monitor attached the event is emitted exactly as before — same fields, same counts, same duck-typed compression audit entry. Honest limit: the check is 'is anyone on the bus', so an app with, say, tokenguard armed still computes the counts — the cost of visibility, now paid only when something can see it. Verified post-fix: 1.43 ms/call · 61.7 MB/s at zero subscribers, right beside the published benchmark row, which was measured before the event existed and is honest again. The npm 3.1.0 minor also widens decompress() to accept any handle that can expand — contextkit's BlockDecision.handle is core's Compressor PROTOCOL handle ({ expand(): unknown }), deliberately the smallest thing contextkit needs to know, and narrowing decompress to squeeze's concrete class made the obvious contextkit-to-squeeze line a compile error on identical runtime objects. Python never had this (BlockDecision.handle is Any there).
cassette1.1.1 (opens in a new tab)3.0.0 (opens in a new tab)0.3.3 (npm): _drift is anchored on the global symbol registry (Symbol.for) so two loaded copies of @cendor/cassette share one drift buffer instead of each splitting off its own — dual-copy safety, no API change. 0.3.1 (npm) is a cascaded core re-pin (@cendor/core ^0.11.0). Earlier: record once · replay forever (0.2.8 is an npm-only core re-pin). 1.1.0 / 0.3.0: a pre-flight session stamp — the cassette session id rides the ambient seam onto every event at construction, so a run’s calls attribute to their session without a delivery-time ambient read
acttrace1.14.0 (opens in a new tab)3.1.0 (opens in a new tab)1.14.0 / 3.1.0: a chain names the format it implements and the library that opened it. A new chain’s audit_open entry carries format (acttrace-chain/1) and producer (cendor-acttrace/<version>, or @cendor/acttrace/<version> in TypeScript), both INSIDE the hashed payload — so provenance is part of the tamper-evident chain and cannot be edited afterwards. Verification is unchanged: the hashed body is still exactly {seq, ts, type, payload}, so chains written before this release verify untouched and a file mixing old and new entries verifies end to end. No new format version, no migration. Honest limits: self-reported provenance in a tamper-evident chain is not proof of origin (a forged file can claim anything from the outset); a resume writes no second audit_open, so a file names the version that OPENED it; and when the version cannot be read the field is omitted rather than guessed. producer deliberately differs between the ports — separate packages on independent version lines — and cross-language verification is unaffected, proved by a regenerated conformance vector plus a permanently kept pre-provenance vector. 1.13.1 / 0.14.1 (patch, both languages): two LIVE AuditLogs on one chain file are refused instead of silently corrupting it. Reopening a path has resumed the chain since 1.2.2 and verifies green — that is the restart case. What was never guarded is two logs alive at the same time on one path: both subscribe to the process-global bus, so one LLMCall is auto-captured twice and each appends at its own seq/prev_hash (identical right after the reopen), so the file ends up holding two interleaved chains and verify() reports "broken link at seq N: prev_hash mismatch" — discovered whenever someone finally audits, the worst possible moment for a governance artifact. Constructing the second one now raises, naming the way out: detach() the first log, rotate to a file per process lifetime, or reuse the log you have. Measured: nothing ever restarted from GENESIS (the defect was reported as a broken resume; it was a double writer), and zero of 485 in-repo tests were doing this, so nothing legitimate breaks. Claims are weakly held and path-less logs are never registered. Honest limit: two separate PROCESSES appending to one file cannot be detected from inside either — one writer per chain file. Earlier — 1.13.0 / 0.14.0: every mirrored entry names the agent that produced it. OTelMirror stamped cendor.audit.agent only on a guardrail_decision — the one entry type whose payload carries an agent — so measured against Cendor Monitor, 13 of 386 governance rows named their agent and “which agent was blocked” was answerable only by inferring it from step ordering. The mirror now reads the acting agent (and its id) from cendor-core's ambient registry and stamps cendor.audit.agent / cendor.audit.agent_id on EVERY entry, including the types with no agent field at all: a budget block, a decision record, an llm_call. The entry's own payload always wins, acttrace still imports no sibling tool (the SDK registers a provider, core merges it, the mirror reads it), and an older core without that read degrades to today's behaviour. Nothing about the hash-chained evidence file changes — this is the operational copy. Earlier — 1.12.0 / 0.13.0 + 1.11.0 / 0.12.0: governance is one line. AuditLog(...) with no mirror= now auto-attaches an OTelMirror (when OpenTelemetry is installed and CENDOR_TELEMETRY isn’t off) — you declared governance, so its operational copy reaches the backend you already configured; mirror=False / { mirror: false } is the per-log opt-out, and an explicit mirror is used verbatim. Nothing ever CREATES an AuditLog for you, and the chain / file / verify() are untouched. 1.12.0 / 0.13.0 tells core when a wire-mirror is attached (refcounted, released on detach) so core’s Option C governance.* ops spans stand down while the richer chained audit.* spans are on the wire — one decision, one rendering. A custom non-OTel mirror deliberately does not suppress them. 0.11.2 (npm) is a cascaded core re-pin (@cendor/core ^0.11.0) that also declares @opentelemetry/api as an optional peer. Earlier: tamper-evident audit chain + optional NER redaction; guard() is dual-shape (raw interceptor + scope form) and exports resolve_findings — evidence, not a guarantee. 1.6.0 / 0.7.0: AuditLog(mirror=OTelMirror()) streams the chain to any OpenTelemetry backend as an operational copy (the file stays the sole verify() evidence), the new budget_event entry chains tokenguard breaches, and entries carry otel_trace_id/otel_span_id for APM correlation. 1.7.0 / 0.8.0: mirror completeness — audit.budget_event spans carry the budget name + numeric projected-vs-cap (money as strings), llm_call spans carry usage/latency/replayed, guardrail_decision spans carry agent/tool + nested severity/policy_version/policy_hash, and context_assembly spans carry budget/used + per-action block counts. 1.8.0 / 0.9.0: a squeeze CompressionEvent is chained as a compression entry + audit.compression mirror span (metadata only). 0.9.2 (TypeScript only): the OTelMirror emits llm_call latency_ms as a number (it was serialized as a stringified float wrapper). 1.9.0 / 0.10.0: audit entries appended inside a run scope stamp run_id (Cendor’s ambient run id) → cendor.audit.run_id, the fallback a trace-aware monitor joins on to link a governance event to its run when no OTel span was active at append time (a post-hoc span_tree, or an app with no OTel context manager). No-op outside a run scope, so the default chain stays byte-identical. 1.10.0 / 0.11.0: run_id and decision_id are now read from the event (stamped there by core’s ambient seam) rather than a delivery-time ambient read, and a chained budget_event entry carries the breach’s run_id — so a run’s governance events attribute correctly even when the audit append happens after the run scope closes. 1.10.1 / 0.11.1: reset_detectors() / resetDetectors() restores the built-in detector registry — the public inverse of enable_entropy_detector / register_detector (turn an opt-in detector back off, reconfigure, isolate tests); register_detector is now idempotent
libs (umbrella)1.2.0 (opens in a new tab)3.0.0 (opens in a new tab)npm 0.2.24 is the auto-cascade of the trace-span + agent-identity wave (core 0.16.0 + acttrace 0.14.0, with tokenguard/guardrails/contextkit/squeeze/cassette re-pinned to @cendor/core ^0.16.0 so one core dedupes). npm 0.2.23 / 0.2.2x are the auto-cascades of the zero-telemetry-code wave (core 0.13→0.15, tokenguard 0.7→0.8.1, acttrace 0.12→0.13 + the re-pinned guardrails/contextkit/squeeze/cassette, all on @cendor/core ^0.15.0 so one core dedupes). the whole stack in one install — the PyPI umbrella 1.2.0 is unchanged (its floors already admit the new members); npm 0.2.19 is the auto-cascade of the gaps-closure wave, pinning @cendor/core ^0.11.0 (core 0.11.0 + tokenguard 0.6.0 + the cascaded re-pins acttrace 0.11.2 / guardrails 0.7.5 / contextkit 2.0.4 / squeeze 0.3.4 / cassette 0.3.1). Earlier: the whole stack in one install — seven libraries (the PyPI umbrella 1.2.0 is unchanged — its floors already admit the new members; npm 0.2.18 cascades the ambient-seam shelf: core 0.10.0 + acttrace 0.11.0 + tokenguard 0.5.0 + cassette 0.3.0 + the cascaded re-pins guardrails 0.7.4 / contextkit 2.0.3 / squeeze 0.3.3, all pinning @cendor/core ^0.10.0 so one core dedupes)
cendor (alias)1.1.0 (opens in a new tab)brand alias → cendor-libs (PyPI only for now)

SDK

PackagePyPI (cendor-*)npm (@cendor/*)What it is
sdk1.22.2 (opens in a new tab)3.2.2 (opens in a new tab)1.22.2 / 3.2.2 — resuming an unfinished checkpoint whose transcript already ends with the final assistant answer no longer re-invokes the model. Every run path saves the answering turn with done:false BEFORE the done:true save lands, so a crash in that window leaves an 'unfinished' checkpoint that is finished in substance — and re-asking a model to continue its own complete conversation invites it to re-do the task, completed tool calls included. Measured live by the external suite: a resumed transcript ending in its own answer re-ran a completed tool — the exact failure checkpoint= exists to prevent, since a resumed run is by definition one interrupted mid-side-effects. The resume now settles that shape and returns the stored answer with ZERO model and ZERO tool invocations, done-resume parity included (same trace id, empty steps), across all resume paths in both languages: run, async run, both streams, and the team runners. Conservative predicate: only a trailing assistant message with non-empty content and no tool_calls settles — a transcript ending at a tool result still resumes through the loop, and an empty-content tail keeps the old behaviour. Documented honestly alongside: on a genuinely mid-run resume the SDK replays the saved messages (tool results included) and never re-executes a completed tool itself, but whether the MODEL re-issues the same call is its own sampling decision, and no framework can promise it won't — make tools you use under checkpoint= idempotent.

Dev tooling (optional — no library depends on it)

PackagePyPI (cendor-*)npm (@cendor/*)What it is
mcp0.1.7 (opens in a new tab)0.1.7 (opens in a new tab)the read-only MCP docs server — remote mcp.cendor.ai + local npx/uvx; five tools serve Cendor’s docs + correct call-shapes to agent-mode assistants. 0.1.6 refreshes the bundled index to the 2026-07-27 shelf and retires the releases.astro regex scraper: versions are now read from the site’s JSON source, so a Windows checkout can no longer silently produce an index with zero canonical examples. No library depends on it.
init0.3.0 (opens in a new tab)0.4.1 (opens in a new tab)offline init + doctor scaffolder — writes the assistant rules files, can add the MCP config, and scaffolds a correct starter; doctor static-checks wiring for CI. 0.3.0 (PyPI) / 0.4.0 (npm) adds doctor --online (opt-in: compares against the live https://cendor.ai/releases.json instead of the snapshot bundled in the CLI — without the flag there is still NO network call, asserted by a test) and lockfile detection (a uv.lock / package-lock.json can pin Cendor low while the declared range looks perfectly wide, and the build stays green the whole time; doctor now names the lock). No key.

Cendor Monitor ships as a Docker image, not a registry package: ghcr.io/cendorhq/cendor-monitor:0.15.0public, multi-arch (amd64 + arm64), no login needed. The optional, self-hosted journey view over standard OTLP — agents → sessions → runs, the full run journey (prompts/responses/thinking/tokens/cost, content opt-in) with governance verdicts inline, and 7 per-library proof pages. v0.15 adds the downgrade guard: an image that opens a store written by a newer image now refuses to start — naming the door, the schema version it found, and the one it supports — instead of silently stamping schema_version backwards while reading columns it does not know, which left the recorded version a lie for every later migration to misfire on. The check runs before any DDL, so a refusal leaves the store exactly as found, and a store with no version row is a normal first boot rather than a downgrade. Rolling an image back is a legitimate operator action; corrupting the version track while doing it is not — and because startup behaviour changes, it is a minor, not a patch. The operations docs gain the upgrade contract, leading with the answer to the question people actually ask: your audit evidence was never in this container — the hash-chained file on your app host is the only thing verify() reads. Upgrading is additive and lossless; retention is what removes data (7 days per door by default), and :latest moves, so pin a tag. v0.14.2 styles the sidebar’s pinned Settings / Docs pair, which had never matched the other nav items and collided into one word below 820px. v0.14 makes a libs scope a real call group and treats an Agent(id=…) as identity — emitted only when you give one, never invented (schema v2); v0.14.1 stopped a run row contradicting its own journey, and an MCP handshake no longer invents an empty run. v0.13 gives each governance outcome one word and puts the verdicts before the trajectory. v0.12 adds the agent edge, the libs agent dimension, and an honest money table (every spend surface says what it counts). v0.10 gives the container its own door-less page, so every setting belongs to exactly one door; v0.10.1 removed the demo app — the monitor ships no sample data and fabricates nothing. v0.9 split the two doors into two stores (Libraries / SDK), path-routed and never mixed. v0.4 added the filter bar + indexed full-text search over content, dependency-free trend charts, the "what Cendor saved" value strip, and cost attribution by feature/user. v0.3 dropped Tempo/Prometheus/Perses for a Cendor ingest + store (SQLite default / external Postgres); the image is Apache-2.0 (code) with OFL-1.1 fonts. No library depends on it. Full page: /monitor.

Per-release changelogs live with each package: Python on cendor-libs (opens in a new tab) / cendor-sdk (opens in a new tab), TypeScript on cendor-libs-js (opens in a new tab) / cendor-sdk-js (opens in a new tab). The unscoped cendor npm alias ships later.

How often each of these is installed: /downloads — per package, per registry, recorded daily. PyPI figures exclude index mirrors; npm publishes no mirror filter, so the two are never summed.