npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

schema-envoy

v0.1.4

Published

Convert a JSON Schema to an LLM provider's accepted subset and get a report of every value whose validity changed.

Readme

schema-envoy

Every LLM provider accepts a subset of JSON Schema, not JSON Schema. Every library that talks to those providers closes the gap the same way: it walks the schema and deletes the keywords the provider does not list. For most of them, that deletion is silent: the resulting schema is valid, the API call succeeds, and the schema now accepts values your own validator rejects. That is not a bug anyone is going to fix, because the deletion is the documented behaviour of the provider rather than a defect in it.

As of 14 September 2026, that has changed for one provider in one SDK, not the ecosystem, and it changed more thoroughly than a warning. vercel/ai#20757 removed the Vercel AI SDK's lossy Google conversion outright rather than reporting on it, sending the schema natively through parametersJsonSchema for tools and responseJsonSchema for structured output, in @ai-sdk/google 4.0.70 and @ai-sdk/google-vertex 5.0.82. For that one combination there is no deletion left to report or to validate against. The provider's own accepted subset is unchanged: what moved is one SDK's handling of it, and everywhere else the deletion is still silent.

A warning is not a validator, though. It tells you widening happened; it does not stop a widened value from passing. schema-envoy's primary claim is the residual() export: a compiled validator over precisely the constraints the provider dropped, so you can reject the widened value instead of merely learning that it widened.

build npm license provenance

schema-envoy performs the conversion and tells you exactly what it cost. Three things come back:

  1. the provider-valid schema,
  2. a report naming every keyword removed or rewritten, with a concrete witness value whose accept/reject status changed,
  3. a residual validator, a compiled validator over precisely the constraints the provider dropped, so the constraints you wrote are enforced somewhere, at the boundary where the model's output comes back.

By default adapt() throws when the conversion changes the accepted value set. Silence is opt-in, not the default.

npm install schema-envoy
import { adapt, explain, residual } from "schema-envoy";

const source = {
  type: "object",
  properties: {
    email: { type: "string", format: "email" },
    score: { type: "integer", minimum: 0, maximum: 10 },
  },
  required: ["email", "score"],
  additionalProperties: false,
};

// adapt(source, "openai.strict") would throw SchemaDivergenceError here,
// because strict mode will not enforce `format`, `minimum` or `maximum`.
// Opt into a report to inspect the cost instead of throwing.
const { schema, report } = adapt(source, "openai.strict", { onDivergence: "report" });

console.log(explain(report)); // every keyword dropped, with a witness value

// Send `schema` to the provider, then check what comes back
// against the constraints the provider dropped.
const guard = residual(source, "openai.strict");
const modelOutput = { email: "not-an-email", score: 99 }; // what the provider let through
if (!guard.validate(modelOutput)) console.log(guard.errors(modelOutput));

Output:

schema-envoy: 3 divergences converting to openai.strict
source: https://developers.openai.com/api/docs/guides/structured-outputs (documented behaviour as of 2026-08-24, captured 2026-08-24)
corpus: checked 4 - agreed 0, widened 4, narrowed 0
keywords: 3 removed, 0 rewritten
divergences:
  [widen] #/properties/email format (documented unsupported) evidence=value - format is documented as unsupported by OpenAI structured outputs (strict); it is dropped and the provider will not enforce it - witness {"email":"__envoy_invalid_format__","score":0}
  [widen] #/properties/score minimum (documented unsupported) evidence=value - minimum is documented as unsupported by OpenAI structured outputs (strict); it is dropped and the provider will not enforce it - witness {"email":"a","score":-1}
  [widen] #/properties/score maximum (documented unsupported) evidence=value - maximum is documented as unsupported by OpenAI structured outputs (strict); it is dropped and the provider will not enforce it - witness {"email":"a","score":11}
not equivalent: at least one value changed accept/reject status.
[ '/email must match format "email"', '/score must be <= 10' ]

What the report says

report.removed;      // { pointer, keyword, documented }[]
report.rewritten;    // { pointer, from, to }[]
report.warnings;     // { pointer, message }[]  request-time limits, not value changes
report.divergences;  // { pointer, keyword, effect, evidence, documented, reason, witness? }[]
report.differential; // { checked, agreed, widened, narrowed, ...Witnesses }
report.equivalent;   // divergences.length === 0

Every divergence carries an effect of widen, narrow or unrepresentable, and an evidence of value or keyword. evidence: "value" means a concrete input was found whose accept/reject status differs between the two schemas, so the divergence is proved by construction. evidence: "keyword" means the constraint was demonstrably dropped but no witness could be built for it, usually because the surrounding schema is not seedable.

documented is the difference between two very different claims:

  • documented: true. The provider's published source names this keyword as unsupported. The removal is the provider's stated behaviour.
  • documented: false. The provider's published source does not mention the keyword either way. This package drops it conservatively rather than gambling that it is honoured, and says so. explain() renders the two differently for exactly this reason.

equivalent: true means no witness was found, not that none exists. The differential is evidence over a finite corpus, not a proof over all values. Nothing in this package claims semantic equivalence.

Targets

| id | surface | source | |---|---|---| | gemini.parametersJsonSchema | responseJsonSchema / parametersJsonSchema | @google/[email protected] | | gemini.functionDeclarationParameters | legacy FunctionDeclaration.parameters | @google/[email protected] | | openai.strict | structured outputs with strict: true | OpenAI structured-outputs guide |

Each profile carries the URL, the version and the capture date it was read from, and is a frozen object you can inspect:

import { target, targets } from "schema-envoy";

targets().map((profile) => profile.id);
target("openai.strict").deny;        // the 19 keywords documented as unsupported
target("openai.strict").sourceUrl;

A profile is an assertion about somebody else's API. There is no target here that was not read from a citable published source with an enumerated keyword list, and adding one requires the same.

Measured

Numbers below were produced by running the code against the versions named, and are asserted as exact integers by the test suite. corpus is an explicit enumeration; widened counts values the source rejects and the converted schema accepts.

| baseline | target | corpus | agreed | widened | narrowed | |---|---|---|---|---|---| | B1 | gemini.functionDeclarationParameters | 720 | 648 | 72 | 0 | | B2 | naive strict required-fill | 12 | 1 | 0 | 11 | | B3 | gemini.parametersJsonSchema | 96 | 2 | 94 | 0 | | B4 | oneOf read as anyOf | 4 | 3 | 1 | 0 | | B5 | openai.strict | 72 | 36 | 35 | 1 |

B5 is a five-property schema that accepted 2 of its 72-value corpus before conversion and 36 after. Strict mode constrains structure: it does not enforce pattern, format, minimum, maximum, multipleOf, minItems, maxItems or uniqueItems. B2 is the naive conversion, required-filling without re-typing the newly required properties as nullable, and shows why openai.strict performs that rewrite: without it the conversion narrows, rejecting 11 of 12 values the source accepted.

Reproduce all five yourself:

npx --yes schema-envoy@latest --self-check

Output:

B1 gemini.functionDeclarationParameters ok checked=720 agreed=648 widened=72 narrowed=0
B2 naive strict required-fill ok checked=12 agreed=1 widened=0 narrowed=11
B3 gemini.parametersJsonSchema ok checked=96 agreed=2 widened=94 narrowed=0
B4 oneOf read as anyOf ok checked=4 agreed=3 widened=1 narrowed=0
B5 openai.strict ok checked=72 agreed=36 widened=35 narrowed=1

See VERIFY.md.

API

adapt(schema, target, options?): { schema, report }
differential(source, converted, values): DifferentialResult
residual(schema, target): ResidualResult
explain(report): string
targets(): readonly TargetProfile[]
target(id): TargetProfile

AdaptOptions:

| option | default | meaning | |---|---|---| | onDivergence | "throw" | "report" returns the report instead of throwing | | corpus | [] | extra values, evaluated in addition to the generated witnesses | | witnessBudget | 512 | candidate cap; hard maximum 4096 |

Errors: SchemaEnvoyError is the base. SchemaDivergenceError carries the full report. UnknownTargetError carries the id. InvalidSchemaError carries the pointer. SchemaProbeError carries a detail and means the differential could not be performed at all. It is never downgraded to a finding, and an empty divergence list caused by a probe failure is never returned as equivalent: true.

adapt, differential, residual and explain deep-clone their inputs and never mutate an argument. Witness generation is deterministic: no pseudo-random generator is used anywhere in this package, and no module reads the clock.

How it works

  1. Normalize. Draft-07 is lifted to draft 2020-12: definitions to $defs, boolean exclusiveMinimum/exclusiveMaximum to their numeric form, tuple items to prefixItems with additionalItems becoming items, and $schema recorded and dropped.
  2. Convert. The normalized schema is walked depth-first in source key order. Each keyword is passed through, rewritten, or dropped according to the profile.
  3. Generate witnesses. A seed instance is built from the source schema, then mutated at every pointer where a keyword changed. A candidate becomes a witness only if it actually flips; candidates that agree on both sides are discarded silently.
  4. Differential. Both schemas are compiled with separate ajv instances, with ajv-formats registered so format is genuinely evaluated, and the corpus is classified into agreed, widened and narrowed.
  5. Sanity floor. A conversion that deleted keywords and then reported perfect equivalence is a broken harness, not a clean result, and fails loudly.

CLI

schema-envoy --target <id> [file]   # explain the conversion; --json for machine output
schema-envoy --targets              # id, source version, allow count, deny count
schema-envoy --self-check           # reproduce the five baselines

Exit codes: 0 equivalent (or self-check passed), 1 divergences found (or self-check failed), 2 usage error, 3 probe failure. The CLI is a debugging and demonstration surface; the library is the product.

Scope

The input is JSON Schema, so this package depends on no schema library and no provider SDK: it works with zod, valibot, arktype, typebox, hand-written schemas and raw MCP tool schemas alike. It converts and reports; it never makes the provider call, and it never rewrites your source schema.

Related

schema-fit solves the mirror problem. It rewrites a schema so a provider will accept it while guaranteeing it never widens what the schema allows. It narrows instead, and tells you where.

Use schema-fit when widening is unacceptable and you would rather lose values than gain them. Use schema-envoy when the provider's subset forces widening anyway and you need to know exactly which values it let through.

License

MIT