Repository navigation
Forward model revision to the auto-loaded processing_class - #6978
Conversation
DPO, GRPO, KTO, RLOO, SFT, Distillation, and Reward trainers load the processing_class from the model's default revision even when the model itself is loaded from a pinned revision via model_init_kwargs. This can silently load the wrong chat template. Fixes huggingface#6974
Codex Review SummaryThis comment shows the latest Codex review activity on this pull request.
ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings. |
|
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. |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 332c18b3eb
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
…or bug TPO, SDFT, SDPO, and A2PO have the same automatic processing_class loading pattern as the stable trainers fixed earlier and the same missing revision forwarding. Fix them the same way, flagged by Codex review on the PR. Also switch the regression tests to a shared assert_processing_class_revision helper in testing_utils.py instead of ad hoc mocking per file, and scope the assertion to the model's own processing_class calls (ignoring e.g. a reward model's separate tokenizer load).
albertvillanova
left a comment
There was a problem hiding this comment.
Thanks for picking this up. The mechanism is right and I checked all 11 touched trainers on a6f3a181a: every path assigns model_revision before use, and the semantics are correct in the non-obvious case. Forwarding the revision only when the trainer itself loaded the model from a string is the right choice, since model_init_kwargs is documented as ignored for an already-instantiated model. The tempting one-liner (args.model_init_kwargs or {}).get("revision") (the idiom currently sitting in gold_trainer.py:806) would silently apply a stale revision there. Also revision=None passed explicitly is a no-op, cached_file and hf_hub_download both resolve None to main, so there is no behavior change for existing users.
One point in favour of this PR that is worth adding to the description: the seven main trainers you fixed are exactly the seven CLI entry points (trl/scripts/{sft,dpo,kto,grpo,rloo,reward,distillation}.py) that each build model_init_kwargs = dict(revision=model_args.model_revision, ...). So today trl sft --model_revision <sha> loads weights from <sha> and the tokenizer from main. This fixes a documented CLI flag, not just a niche API path.
A few things before this lands.
1. Eight copies of the same pattern are still unfixed
The Codex review named tpo, sdft, sdpo and a2po, and those are now handled, but that list was incomplete. On current main the identical pattern (string model loaded with model_init_kwargs, then processing_class auto-loaded from the same repo id without revision) is still live in:
| File | Line |
|---|---|
trl/experimental/cpo/cpo_trainer.py |
290 |
trl/experimental/bco/bco_trainer.py |
606 |
trl/experimental/orpo/orpo_trainer.py |
300 |
trl/experimental/ssd/ssd_trainer.py |
160 |
trl/experimental/gold/gold_trainer.py |
810 |
trl/experimental/iw_opd/iw_opd_trainer.py |
438 |
trl/experimental/async_grpo/async_grpo_trainer.py |
830 |
trl/experimental/async_distillation/async_distillation_trainer.py |
963 |
That is 4 of 12 experimental copies fixed. Two are self-evidently wrong in place:
iw_opd_trainer.py:533already doesteacher_revision = teacher_model_init_kwargs.get("revision", args.teacher_model_revision)and forwards it to the teacher tokenizer at :537. The student tokenizer 100 lines above does not. Same file, opposite behavior.gold_trainer.py:806computesself.model_revision = (args.model_init_kwargs or {}).get("revision")and then never uses it anywhere in the repo (single occurrence). The processing-class load that needs it is four lines below at :810.
Not affected, so correctly untouched: online_dpo (raises when processing_class is None), minillm and prm (no auto-load), gspo/papo (subclass GRPO and inherit the fix).
Either finish the sweep or state in the description that experimental coverage is partial and open a follow-up. As it stands the PR reads as complete when it is not, and per the consistency rule in AGENTS.md a partially propagated fix is the failure mode we most want to avoid.
2. model_init_kwargs help text is now inaccurate
TRL is already explicit about this for the neighbouring field: the trust_remote_code help in grpo_config.py:436-439 says "Forwarded to AutoModelForCausalLM.from_pretrained and AutoProcessor.from_pretrained". The model_init_kwargs help directly above it still mentions only AutoModelForCausalLM.from_pretrained, which is wrong for revision after this change. Worth updating in the affected configs (docstring and metadata["help"]).
3. Only revision is forwarded, which is fine but should be said
model_init_kwargs can equally carry token, cache_dir, local_files_only and subfolder, all of which affect where the processor is resolved from just as much as revision does. A private repo loaded with model_init_kwargs={"token": ...} still fails on the auto-processor load. The issue asked for the revision "at minimum", so revision-only is a defensible scope, but it is worth saying so explicitly, and token looks like a natural follow-up.
4. The test helper is heavier than it needs to be
assert_processing_class_revision plus wrap_classmethod_from_pretrained adds 67 lines to shared tests/testing_utils.py, patches four classes (AutoProcessor, AutoTokenizer, PreTrainedTokenizerBase, ProcessorMixin) through a hand-rolled classmethod re-wrapper, and carries an 11-line docstring about classmethod descriptor binding.
It is correct, I traced it: mock.patch.object reads cls.__dict__ first so the raw classmethod is restored, and the wrapper calls original_func(cls, ...) with the real call-time cls. But the complexity is self-inflicted: it only arises because the two base classes are patched as well. Each trainer calls exactly one of AutoProcessor / AutoTokenizer, and the trainer source tells you which, so a short inline spy does the same job:
with patch.object(AutoProcessor, "from_pretrained", wraps=AutoProcessor.from_pretrained) as spy:
SFTTrainer(model=..., args=SFTConfig(..., model_init_kwargs={"revision": "main"}), train_dataset=dataset)
assert spy.call_args.kwargs["revision"] == "main"Given the "leanness and simplicity above all" guidance in AGENTS.md, I would drop the helper. If it stays, wrap_classmethod_from_pretrained should be private, it has exactly one caller.
Smaller test notes:
expected_revision="main"is the default revision, so the assertion covers kwarg plumbing rather than which files are actually fetched. It does fail before the fix (None != "main"), so it is a valid regression test. A stronger one would pin a non-default revision on atrl-internal-testingfixture whose chat template differs and assert the resulting template, which is the failure the issue describes. Optional, and it needs a Hub fixture first.report_to="none"is missing in 8 of the 11 new tests (present only in a2po, sdft, sdpo). Harmless at init time, but it goes against the convention in these files.- If the per-trainer tests stay, they should cover the remaining experimental trainers too once point 1 is addressed, otherwise the suite advertises coverage it does not have.
Out of scope, for a separate issue
trl/generation/vllm_generation.py:348 calls LLM(model=model.name_or_path, ...) with no revision, so in colocate mode vLLM resolves config and tokenizer from the default branch. Weights are synced from the training model, so the exposure is a config/tokenizer mismatch rather than wrong weights. Not for this PR, but same bug class and worth tracking.
Happy to approve once the propagation in point 1 and the help text in point 2 are sorted. The Codex thread above is stale, it reviewed 332c18b and a6f3a181a addressed it, so a reply there would clear it.
qgallouedec
left a comment
There was a problem hiding this comment.
Trainer side is right.
I'd drop the tests though. assert_processing_class_revision is 67 lines patching four classes with a hand-rolled classmethod re-wrapper, and what it buys is an assertion that revision="main" comes back out after you passed revision="main" in. That only fails if someone deletes the kwarg, and nothing about the loaded processor differs, so it can't catch the thing the bug actually caused, which is the wrong chat template.
Testing it properly needs a fixture repo with two revisions whose tokenizers differ, then it's a four line test asserting on trainer.processing_class and no helper at all.
Happy either way on timing: push the fixture in this PR, or merge the fix now and do it as a follow-up. Your call.
One more thing, passing a model instance leaves model_revision = None, so that path still loads the processor from the default branch. fine by me, config._commit_hash isn't worth it IMO, just mention it in the PR description?
|
The fix is correct. Two propagation gaps beyond the ones already listed, and the fixture the test discussion assumed does not exist is already on the Hub. What I checkedRED/GREEN on all 11 new test ids. Base copy is the PR worktree with End-to-end reproduction of the issue,
Also confirmed there is no Findings1. The chat-template fixture already exists, so the strong test is writable in this PR. 2. 3. 4. One consistency note on the diff itself: eight trainers set Not reproducedNothing. Every claim above was executed or read at the cited line. UnevaluableWhether the auto-loaded processor and the model can disagree on anything other than the chat template at a pinned revision. Only the template differs between the two revisions I found on the fixture repo. |
|
Updated from current main and addressed the review feedback in 7546ad6 and 172e0f1: completed the experimental propagation sweep, forwarded reward and GOLD teacher revisions, replaced the shared mock helper with the pinned chat-template regression test, corrected RewardConfig, and documented the revision-only scope plus model-instance behavior. |
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes using default effort and found 1 potential issue.
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.
Want higher recall? High effort reviews run extra passes and find more bugs. A team admin can switch effort levels in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit 172e0f1. Configure here.
teacher_tokenizer_name_or_path is how ULD points at a different tokenizer repo, and a teacher commit does not exist there, so the load 404s. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
Test point addressed, thanks. Good catch on the ULD one. One thing left from my last comment: the description still doesn't mention that passing a model instance leaves Small nit, worth a comment next to |

What does this PR do?
When a trainer loads a model from a pinned revision through
model_init_kwargs, its automatically created processing class now uses the same revision. This prevents model weights from being paired with a tokenizer, processor, or chat template from the repository default branch.The fix covers the seven CLI-backed main trainers (SFT, DPO, KTO, GRPO, RLOO, Reward, and Distillation) and every duplicated experimental path (A2PO, SDFT, SDPO, TPO, CPO, BCO, ORPO, SSD, GOLD, IW-OPD, Async GRPO, and Async Distillation). It also forwards the effective revision to string-loaded reward-model tokenizers in GRPO, RLOO, SDPO, and Online DPO, and to GOLD teacher processors/tokenizers.
Only
revisionis forwarded from model initialization kwargs. Other values such astoken,cache_dir, andsubfolderremain out of scope. When a model is already instantiated, the processing-class revision remains unset because model initialization kwargs are ignored on that path.The regression test uses the existing pinned revision
8913f5819566oftrl-internal-testing/tiny-Qwen2ForCausalLM-2.5and checks the resulting chat template, reproducing the actual bug without mocks.Fixes #6974
Before submitting
AI writing disclosure
Who can review?
Anyone in the community is free to review the PR once the tests have passed.
Note
Medium Risk
Wide but mechanical init-path change across many trainers; mis-forwarding revision could break Hub loads or tokenization, though behavior is gated on string model loading and GOLD’s cross-repo guard.
Overview
Trainers that auto-load a tokenizer or processor from a Hub model id now pass the same
revisionused inmodel_init_kwargs(or GOLD’s teacher init kwargs), so pinned weights are not paired with default-branch chat templates or vocab.The change is applied consistently across main trainers (SFT, DPO, KTO, GRPO, RLOO, Reward, Distillation) and experimental trainers, with config help text updated to document the behavior. Revision is only forwarded when the model is loaded from a string; pre-instantiated models still leave revision unset for the processing class.
GOLD additionally pins teacher processors/tokenizers to the teacher revision, but drops that revision for ULD when
teacher_tokenizer_name_or_pathpoints at a different repo (avoiding invalid cross-repo commit refs). GRPO, RLOO, SDPO, and Online DPO apply the same revision when auto-loading reward-model tokenizers from string reward checkpoints.Tests assert the pinned Qwen2 revision’s chat-template length on SFT init and cover GOLD ULD same-repo vs cross-repo teacher tokenizer behavior.
Reviewed by Cursor Bugbot for commit 62560d9. Bugbot is set up for automated code reviews on this repo. Configure here.