Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ __pycache__
node_modules
deno.lock
package-lock.json
*.local.*

# Do not track build artifacts/generated files
packages/*/dist
Expand Down
58 changes: 58 additions & 0 deletions packages/transformers-response-constraint/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
# @huggingface/transformers-response-constraint

Experimental constrained-generation helpers for Transformers.js.

This dependency-free package exports `ResponseConstraint`, which turns a JSON-schema, JSON-object, or regex response format into the `logits_processor` and `stopping_criteria` objects accepted by Transformers.js generation.

The constraint engine is implemented specifically for Transformers.js and has no runtime dependencies.

```js
import { ResponseConstraint } from "@huggingface/transformers-response-constraint";

const constraint = ResponseConstraint.fromResponseFormat(tokenizer, {
type: "json_schema",
json_schema: {
type: "object",
properties: {
answer: { type: "string" },
},
required: ["answer"],
additionalProperties: false,
},
});

await model.generate({
...inputs,
logits_processor: constraint.logits_processor,
stopping_criteria: constraint.stopping_criteria,
});
```

Constraints currently support a single generated sequence at a time. Generation throws when the logical batch size is not `1` rather than sharing mutable grammar state across sequences.

## Supported constraints

The JSON engine implements a practical JSON Schema 2020-12 profile. This includes deep `const` and `enum`, exact decimal bounds and `multipleOf`, recognized string formats, tuple and homogeneous arrays, `contains`, deep `uniqueItems`, object property and dependency assertions, `allOf`/`anyOf`/`oneOf`/`not`, conditionals, local `$ref`, recursive `$defs`, draft-07 compatibility, and root-level `x-guidance` separators. External and dynamic references and unevaluated-property/item assertions remain unsupported.

The regex engine performs full-string matching and supports literals, UTF-8 literals, alternation, groups, character classes, `.`, `\\d`, `\\s`, `\\w`, anchors, and greedy `*`, `+`, `?`, and `{m,n}` quantifiers. Lookarounds, backreferences, lazy quantifiers, and Unicode character classes are intentionally unsupported.

When the generated bytes satisfy the constraint, the logits processor exposes only the tokenizer's EOS token as a valid completion. Sampling EOS updates the shared stopping criterion.

## Regex constraints

Use `type: "regex"` to constrain generation to a regular expression. For example, this only allows ISO-like dates in `YYYY-MM-DD` format:

```js
import { ResponseConstraint } from "@huggingface/transformers-response-constraint";

const constraint = ResponseConstraint.fromResponseFormat(tokenizer, {
type: "regex",
regex: "\\d{4}-\\d{2}-\\d{2}",
});

const output = await model.generate({
...inputs,
logits_processor: constraint.logits_processor,
stopping_criteria: constraint.stopping_criteria,
});
```
11 changes: 11 additions & 0 deletions packages/transformers-response-constraint/jest.config.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
/** @type {import('jest').Config} */
export default {
clearMocks: true,
collectCoverage: true,
coverageDirectory: "coverage",
coveragePathIgnorePatterns: ["node_modules", "tests"],
coverageProvider: "v8",
roots: ["./tests/"],
testTimeout: 32000,
transform: {},
};
70 changes: 70 additions & 0 deletions packages/transformers-response-constraint/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
{
"name": "@huggingface/transformers-response-constraint",
"version": "0.0.0",
"description": "Dependency-free constrained generation for Transformers.js",
"main": "./dist/index.cjs",
"types": "./types/index.d.ts",
"type": "module",
"exports": {
"import": {
"types": "./types/index.d.ts",
"default": "./dist/index.js"
},
"require": {
"types": "./types/index.d.ts",
"default": "./dist/index.cjs"
}
},
"scripts": {
"format": "prettier --write . --ignore-path ../../.prettierignore",
"format:check": "prettier --check . --ignore-path ../../.prettierignore",
"typegen": "tsc --build --force",
"dev": "node scripts/dev.mjs",
"build": "node scripts/build.mjs && pnpm typegen",
"performance": "node performance/run.mjs",
"pretest": "pnpm build",
"test": "node --experimental-vm-modules node_modules/jest/bin/jest.js --verbose",
"test:json-corpus": "node tests/corpus/run.mjs",
"test:json-corpus:original": "node tests/corpus/run.mjs --original"
},
Comment thread
Copilot marked this conversation as resolved.
"repository": {
"type": "git",
"url": "git+https://github.com/huggingface/transformers.js.git"
},
"keywords": [
"transformers",
"transformers.js",
"huggingface",
"constrained-generation",
"structured-output",
"json-schema"
],
"author": "Hugging Face",
"license": "Apache-2.0",
"bugs": {
"url": "https://github.com/huggingface/transformers.js/issues"
},
"homepage": "https://github.com/huggingface/transformers.js#readme",
"peerDependencies": {
"@huggingface/transformers": "^4.2.0"
},
"devDependencies": {
"@huggingface/transformers": "workspace:*",
"@types/jest": "^30.0.0",
"@types/node": "^24.1.0",
"esbuild": "^0.27.2",
"jest": "^30.2.0",
"typescript": "5.9.3"
},
"files": [
"src",
"dist",
"types",
"README.md",
"LICENSE",
"!**/*.tsbuildinfo"
],
"publishConfig": {
"access": "public"
}
}
27 changes: 27 additions & 0 deletions packages/transformers-response-constraint/scripts/build.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { build } from "esbuild";
import { rmSync } from "node:fs";

rmSync("dist", { recursive: true, force: true });
rmSync("types", { recursive: true, force: true });

const common = {
entryPoints: ["src/index.ts"],
bundle: true,
platform: "neutral",
target: "es2022",
sourcemap: true,
external: ["@huggingface/transformers"],
};

await Promise.all([
build({
...common,
format: "esm",
outfile: "dist/index.js",
}),
build({
...common,
format: "cjs",
outfile: "dist/index.cjs",
}),
]);
65 changes: 65 additions & 0 deletions packages/transformers-response-constraint/scripts/dev.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import { context } from "esbuild";
import { spawn } from "node:child_process";
import { rmSync } from "node:fs";

rmSync("dist", { recursive: true, force: true });
rmSync("types", { recursive: true, force: true });

const watchLogger = {
name: "watch-logger",
setup(build) {
let startTime = 0;

build.onStart(() => {
startTime = performance.now();
console.log(`[transformers-response-constraint] rebuilding ${build.initialOptions.outfile}...`);
});

build.onEnd((result) => {
const duration = (performance.now() - startTime).toFixed(2);
if (result.errors.length > 0) {
console.log(`[transformers-response-constraint] rebuild failed in ${duration}ms`);
} else {
console.log(`[transformers-response-constraint] rebuilt ${build.initialOptions.outfile} in ${duration}ms`);
}
});
},
};

const common = {
entryPoints: ["src/index.ts"],
bundle: true,
platform: "neutral",
target: "es2022",
sourcemap: true,
external: ["@huggingface/transformers"],
plugins: [watchLogger],
};

const contexts = await Promise.all([
context({
...common,
format: "esm",
outfile: "dist/index.js",
}),
context({
...common,
format: "cjs",
outfile: "dist/index.cjs",
}),
]);

await Promise.all(contexts.map((ctx) => ctx.watch()));

const tscWatch = spawn("tsc", ["--build", "--watch", "--preserveWatchOutput"], {
stdio: "inherit",
shell: true,
});

console.log("Watching @huggingface/transformers-response-constraint...");

process.on("SIGINT", async () => {
tscWatch.kill();
await Promise.all(contexts.map((ctx) => ctx.dispose()));
process.exit(0);
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
import { LogitsProcessor, LogitsProcessorList, StoppingCriteria, type Tensor } from '@huggingface/transformers';

import {
createTokenConstraint,
prepareTokenizer,
type JSONSchema,
type TokenConstraint,
type TokenizerSource,
} from './engine';
import { applyMask } from './utils/mask';

export type ResponseFormat =
| { type: 'json_object' }
| { type: 'json_schema'; json_schema: JSONSchema }
| { type: 'regex'; regex: string };

type GenerationState = {
completed: boolean;
constraint: TokenConstraint;
mask?: Uint32Array;
};

export class ResponseConstraint {
/**
* Precomputes the tokenizer-derived data structures used by every
* constraint. The first constraint per tokenizer otherwise pays this cost
* (hundreds of milliseconds for large vocabularies) inside
* `fromResponseFormat`; call this once after loading the model to pay it
* early instead.
*/
static warmup(tokenizer: TokenizerSource): void {
prepareTokenizer(tokenizer);
}

static fromResponseFormat(tokenizer: TokenizerSource, responseFormat: ResponseFormat) {
const state: GenerationState = {
completed: false,
constraint: createTokenConstraint(tokenizer, responseFormat),
};
const logits_processor = new LogitsProcessorList();
logits_processor.push(new ConstraintLogitsProcessor(state));
return {
logits_processor,
stopping_criteria: new ConstraintStoppingCriteria(state),
};
}
}

class ConstraintLogitsProcessor extends LogitsProcessor {
constructor(private readonly state: GenerationState) {
super();
}

_call(inputIds: bigint[][], logits: Tensor) {
assertSingleSequence(inputIds.length);
if (this.state.completed) return logits;
const logitsVocabSize = logits.dims.at(-1);
if (logitsVocabSize === undefined || !Number.isInteger(logitsVocabSize) || logitsVocabSize <= 0) {
throw new Error('ResponseConstraint requires logits with a vocabulary dimension.');
}
const words = Math.ceil(logitsVocabSize / 32);
if (this.state.mask?.length !== words) this.state.mask = new Uint32Array(words);
if (!this.state.constraint.fillMask(this.state.mask)) {
throw new Error('The constraint reached a dead end before producing a valid output.');
}
applyMask(logits, this.state.mask, this.state.constraint.vocabSize);
return logits;
}

onTokensSampled(tokenIds: number[], inputIds: bigint[][]) {
assertSingleSequence(tokenIds.length);
assertSingleSequence(inputIds.length);
if (!this.state.completed) this.state.completed = this.state.constraint.commit(tokenIds[0]);
}
}

class ConstraintStoppingCriteria extends StoppingCriteria {
constructor(private readonly state: GenerationState) {
super();
}

_call(inputIds: ArrayLike<unknown>[]) {
assertSingleSequence(inputIds.length);
return [this.state.completed];
}
}

function assertSingleSequence(batchSize: number): void {
if (batchSize !== 1) {
throw new Error(`ResponseConstraint currently supports batch size 1; received ${batchSize}.`);
}
}
Loading
Loading