Repository navigation
Add response template parsing - #1783
nico-martin wants to merge 16 commits into
Conversation
|
The docs for this PR live here. All of your documentation changes will be reflected on that endpoint. The docs are available until 30 days after the last update. |
|
in the process of reviewing this... but while we work on integration, I think we should work on updating tokenizer_config.json files upstream in https://huggingface.co/onnx-community |
|
initial review done with help from opus 5.5 👍 See list of changes madeChanges (under
Verification:
The biggest issue was a performance issue with a known inkling template/regex.
See slow snippetimport { ResponseParser } from './src/utils/chat_parsing/index.js';
// Inkling (TMLv0) response template, from transformers' tests/utils/test_chat_parsing.py.
// Both open patterns are regexes with no literal prefix: (?:<|message_model|>)?[^<]*<|content_KIND|>
const inkling = {
defaults: { role: 'assistant' },
start_anchor: '<|message_model|>',
fields: {
thinking: {
open_pattern: String.raw`(?:<\|message_model\|>)?[^<]*<\|content_thinking\|>`,
close: '<|end_message|>',
repeats: true,
join: '',
content_args: { strip: false },
},
content: {
open_pattern: String.raw`(?:<\|message_model\|>)?[^<]*<\|content_text\|>`,
close: '<|end_message|>',
repeats: true,
join: '',
content_args: { strip: false },
},
},
};
// A model that thinks for a while, streamed 4 characters at a time.
for (const length of [2000, 4000, 8000]) {
const thinking = 'lorem ipsum dolor sit amet, '.repeat(length / 28);
const text = `<|content_thinking|>${thinking}<|end_message|>`;
const parser = new ResponseParser(inkling, { prefix: '' });
const start = performance.now();
let halfway;
for (let i = 0; i < text.length; i += 4) {
const result = parser.feed(text.slice(i, i + 4));
if (i === 1000) halfway = result;
}
parser.finalize();
console.log(`${text.length} chars: ${(performance.now() - start).toFixed(0)} ms; feed() at char 1000 returned`, JSON.stringify(halfway));
}also:
follow-ups:
|
|
@xenova if you found an upstream bug in the |
|
I implemented response parsing for the protocol grammars below. Parser grammar support is not a capability declaration for every checkpoint in a model family. Model-side Checkpoint-specific audit — October 7, 2026Audited all 70 linked Hub PRs against checkpoint model cards and chat/output protocols:
Important corrections:
Fields remain optional for checkpoints that support a region but do not emit it in every response. Ordinary chat without a response template continues to use the existing Verification: all 50 retained templates pass template validation; 505 total validation and synthetic one-shot/streaming checks cover the corrected templates, split delimiters, assistant prefills, and preservation of unsupported-marker text. All 70 remote PR statuses and all 50 retained remote configs were re-read and verified. No model weights were regenerated, and these checks do not establish tool-use quality. The earlier protocol fixtures and generation tests remain useful for parser behavior, but they must not be read as end-to-end capability validation of every linked checkpoint. Parser protocol coverageThe table describes protocol grammars. In particular, the generic Qwen3 row includes thinking-capable Qwen3 checkpoints; the Instruct-2507 and VL-Instruct checkpoints use tool/content-only templates.
The
|
|
Made a PR to |
There was a problem hiding this comment.
hmm... this feels really hacky 😅
If we do have to support this, something we could do is add a cache breadcrumb which essentially refreshes already-cached tokenizer_config.json files exactly once.
e.g., I'd be happier to avoid changing the query parameter, and rather either setting the value in the config itself under some key before we cache (meaning we can detect if it's an older version, or is missing the response template parameter)
There was a problem hiding this comment.
maybe a better solution is a rework of the caching mechanism which essentially bundles the pre-flight checks we do with an API check for files with their hashes, which will help us keep things up to date.
Another, weaker proxy, could be file size differences (only would work for JSON cases like this where we materially change the size of the config file)
There was a problem hiding this comment.
I see your concerns, but imo versioned cache keys aren't really a hack. They are a well-established pattern to ensure users get the latest version of an asset without changing the filename itself.
Adding a breadcrumb to the config feels more complex since we'd be modifying the cached content and adding special detection/refetch logic. We also can't rely on a missing response_template, because many valid configs won't have one. File size seems too fragile as well.
The hash-based preflight idea could be a good general improvement, but it’s a much bigger caching rework and would add extra requests across clients. For this case, I think a targeted cache version is the simplest solution, and we can bump it again if we ever need to invalidate tokenizer metadata in the future.
There was a problem hiding this comment.
We could even take this further and establish cache versions for different file types, or potentially any cached asset. That would give us a consistent way to invalidate a specific class of files when needed, without clearing unrelated model files or changing their actual filenames.
There was a problem hiding this comment.
Sure, a more general solution instead of an if (name == 'tokenizer_config.json') statement is better.
we could have a registry, but then we'll eventually accumulate a long list of old tokenizer configs/models/... in the cache that are never purged (unless we add some mechanism to purge all previous versions... but that would need to be run each request).
There was a problem hiding this comment.
That’s fair. A registry might be the cleaner general solution, but it also feels like a broader cache architecture change for a PR focused on response_template. For this PR, I’d prefer keeping the targeted cache-key versioning and opening a follow-up to design a general versioning and cleanup mechanism properly, rather than introducing a partial registry now.
|
Python Transformers is standardizing parsed reasoning messages on The parser itself is still generic and returns whatever fields a model defines in its I also made a few related changes in the Transformers.js branch:
I opened follow-up PRs for all 22 merged ONNX configs that were returning DeepSeekQwen
Gemma 4
These also remove the old Other protocolsGPT-OSS now prefers |
|
this PR adds two things that Python I don't want to block this PR on it. If upstream ends up doing it differently, I'll align the JS side and the Hub templates. |
Summary
response_templatedefinitions from tokenizer configurationtokenizer.parse_response()region_open,region_chunk, andregion_closeevents withtokenizer.get_response_parser()reasoning_content,thinking, andreasoninginputs when rendering chat templates for backward compatibilityTextStreameras a raw text streamer so applications decide how to consume streaming parser eventstokenizer_config.jsonwith thetransformersjs_metadata_v2epoch without invalidating tokenizer files or model weightsAPI
The streaming API follows Python Transformers directly:
parser.feed(chunk)returns response eventsparser.initial_eventscontains events produced by prompt-prefilled regionsparser.finalize()returns[message, finalEvents]toolscan be passed to one-shot and streaming parsing to coerce tool arguments from their JSON schemasprocessor.parse_response()forwards one-shot parsing, while streaming usesprocessor.tokenizer.get_response_parser()tokenizer.response_templateis setPipeline usage
For chat input, the text-generation pipeline returns a parsed assistant message automatically when the tokenizer has a response template:
Plain string pipeline inputs keep returning strings.
TextStreameralso remains unparsed.One-shot usage
Manual parsing is useful when calling
model.generate()directly or when passingtoolsfor schema-based argument coercion:generatedTextmay also be one token-ID sequence or a 1D tensor. Batches of strings, token-ID sequences, or a 2D tensor return an array of messages. A single prefix is broadcast across a batch, or one prefix can be supplied per response.Streaming usage
Streaming consumers handle events from the prompt, each newly decoded chunk, and finalization:
dirty: falsechunks are directly renderable text-like content. Structured regions such as JSON emitdirty: trueraw chunks and provide their authoritative parsed value inregion_close.Notes
toolstoparse_response()for tool-argument coercion. Python's pipeline doesn't;parse_response()itself acceptstoolsin both.response_template, the pipeline decodes withskip_special_tokens: false, including for plain-string inputs, same as Python (text_generation.py:443-445). Since many onnx-community repos now ship templates, plain-string outputs from those models will include special tokens. Worth a release-notes line.get_response_parser()matches upstream (tokenization_utils_base.py:3517).ONNX response-template PRs
The corresponding
onnx-communitytokenizer configuration updates are tracked in:Cache invalidation
Cache keys for
tokenizer_config.jsonuse thetransformersjs_metadata_v2cache epoch: browser caches append?transformersjs_metadata_v=2, while filesystem caches add atransformersjs_metadata_v2/path prefix. This changes only the proposed cache key, not the Hub request URL or cache keys fortokenizer.json, model weights, and other resources.This ensures browsers that cached tokenizer metadata before
response_templatewas added can retrieve the updated configuration without invalidating large model assets.Python Transformers instead relies on the
huggingface_hubcontent-addressed cache. Mutable revisions such asmainare resolved or revalidated against a repository commit, and files are cached under that immutable commit snapshot, so an updatedtokenizer_config.jsonnaturally produces a new cached snapshot.Transformers.js uses an explicit metadata cache epoch because it commonly runs across many browser and client installations, where resolving revisions adds network requests and cache complexity. The epoch provides a one-time, targeted invalidation with no ongoing revalidation cost; it can be bumped again if a future release depends on newly added tokenizer metadata.
Python implementation
This implementation follows the declarative
response_templateformat introduced in Python Transformers by #45847, Streamable chat parsing, merged on June 24, 2026. It replaced the regex-heavyresponse_schemaprototype originally introduced by #40894 on October 21, 2025; the legacy format was subsequently removed by #47320.Python stores the template on
tokenizer.response_templateintokenizer_config.json. Applications can parse a completed generation withtokenizer.parse_response(output, prefix=prompt), while streaming applications create a stateful parser withtokenizer.get_response_parser(prefix=prompt). The parser emitsregion_open,region_chunk, andregion_closeevents beforefinalize()returns the complete message and final events. Python pipelines also use the template automatically when one is available.For streaming, feed only each newly decoded chunk to one parser instance:
Transformers.js follows the same model-side template format, required prompt-prefix semantics, event stream, finalization tuple, tool argument coercion, and automatic chat-pipeline parsing. JavaScript does not support callable tool definitions, and unsupported Python regex constructs are rejected during template loading rather than interpreted differently.
References: