Skip to content

Fix/minja missing string methods#1083

Open
samuel100 wants to merge 4 commits into
mainfrom
fix/minja-missing-string-methods
Open

Fix/minja missing string methods#1083
samuel100 wants to merge 4 commits into
mainfrom
fix/minja-missing-string-methods

Conversation

@samuel100

Copy link
Copy Markdown
Collaborator

Replacement for #1082

Summary

Adds the three missing string-method handlers — .replace, .upper, .lower — to the vendored minja parser at shared/api/minja.hpp. These are standard Jinja2 string methods that Hugging Face transformers chat templates rely on, and the most impactful gap (.replace) currently breaks any model whose chat_template.jinja uses it — most visibly smollm3-3b, where OrtxApplyChatTemplate throws:

Unknown method: replace at row 23, column 48:
{%- set custom_instructions = system_message.replace("/no_think", "").replace("/think", "").rstrip() -%}
                                               ^

Fixes #1081
Refs microsoft/Foundry-Local#800

Diff against upstream

Comparing shared/api/minja.hpp against current upstream google/minja:

String method Upstream minja Before this PR After this PR
.strip / .rstrip / .lstrip
.split
.title
.endswith / .startswith / .capitalize
.replace
.upper
.lower

(Note: lower was already available as a global filter — {{ x \| lower }} — but not as the method form x.lower() that HF templates actually use.)

Implementation notes

Handlers were added in MethodCallExpr::do_evaluate next to the existing string-method dispatch:

  • .replace(before, after[, count]) — left-to-right substring replacement matching Python's str.replace and upstream minja. Defaults to all occurrences when count is omitted. Empty before returns the string unchanged (matches upstream behavior; avoids an infinite loop).
  • .upper() / .lower()std::transform with an unsigned char-typed lambda around std::toupper / std::tolower to avoid undefined behavior on signed-char platforms.

I deliberately limited the scope of this PR to the methods needed to unblock real HF templates today; a full re-sync of minja.hpp against current upstream would also be reasonable but is out of scope here.

Tests

Adds two regression tests in test/pp_api_test/test_tokenizer_chat.cc, following the inline-template_str pattern introduced in #1070 (MinjaStringSliceOOBClamped):

  • MinjaStringReplace — single replace, chained replace + rstrip (the exact smollm3-3b pattern), three-arg count, empty before, no-match.
  • MinjaStringUpperLower.upper() and .lower() on mixed-case input, plus idempotent cases.

Local verification

I verified the fix end-to-end with a small standalone harness that compiles shared/api/minja.hpp directly and renders the exact failing line from smollm3-3b's chat_template.jinja:

template:  {% set custom_instructions = system_message.replace('/no_think', '').replace('/think', '').rstrip() %}{{ custom_instructions }}
context:   { "system_message": "You are helpful /no_think.   " }

before this PR: ERROR: Unknown method: replace at row 1, column 6
after  this PR: OK: "You are helpful ."

I did not build the full pp_api_test binary locally (would require ORT + sentencepiece + onnxruntime-extensions deps setup on macOS); the new gtest cases are written to existing conventions and will run in CI.

Follow-up (out of scope here)

It would be worth adding a periodic re-sync of shared/api/minja.hpp with current upstream google/minja, and/or a regression suite that renders the chat templates of a handful of popular HF models (SmolLM3, Qwen3, Llama-3.x, Phi-4) against OrtxApplyChatTemplate, to catch this kind of drift earlier. I'm happy to follow up with that in a separate PR if maintainers want.

samuel100 and others added 4 commits June 12, 2026 10:15
The vendored copy of minja in `shared/api/minja.hpp` was missing handlers
for several standard Jinja string methods that current upstream
`google/minja` supports. The most impactful gap is `.replace()`, which
caused `OrtxApplyChatTemplate` to throw `Unknown method: replace` on
chat templates shipped with widely-used Hugging Face models (e.g.
`smollm3-3b`'s `chat_template.jinja` uses
`system_message.replace("/no_think", "").replace("/think", "").rstrip()`).

This adds the three missing string-method handlers — `.replace`,
`.upper`, `.lower` — to the string-method dispatch in
`MethodCallExpr::do_evaluate`, with semantics matching upstream minja
and Python's `str` methods:

- `.replace(before, after[, count])` performs left-to-right substring
  replacement, defaulting to all occurrences when no count is given.
  An empty `before` returns the original string (matches upstream).
- `.upper()` / `.lower()` apply `std::toupper` / `std::tolower` via
  `std::transform` with an `unsigned char` lambda to avoid UB on
  signed-char platforms.

Tests
-----
Adds `MinjaStringReplace` and `MinjaStringUpperLower` regression tests
under `test/pp_api_test/test_tokenizer_chat.cc`, following the same
inline-template pattern introduced in #1070
(`MinjaStringSliceOOBClamped`). Coverage includes:

- single and chained `.replace()`
- replace with optional `count` argument
- replace with empty `before` (no-op)
- replace with no match (unchanged)
- `.upper()` and `.lower()` on mixed-case input

Verified locally with a standalone harness that compiles `minja.hpp`
directly and renders the exact failing line from
`smollm3-3b`'s `chat_template.jinja` — previously thrown with
`Unknown method: replace`, now renders correctly.

Fixes #1081
Refs microsoft/Foundry-Local#800

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
- replace(): treat count < 0 as "replace all" to match Python str.replace
  semantics (previously a negative count silently produced zero
  replacements via `count-- > 0`).
- upper() / lower(): switch from std::toupper / std::tolower to an
  explicit ASCII-only mapping. This is deterministic across locales
  (std::toupper/tolower are locale-dependent) and avoids the trap of
  per-process locale changing template rendering. Non-ASCII bytes are
  passed through unchanged, matching upstream minja behavior on most
  locales. Full Unicode case folding is out of scope.
- Test helper: assert OrtxTensorResultGetAt and OrtxGetTensorData
  return kOrtxOK and that the data pointer is non-null, so test
  failures point at the actual cause rather than a downstream
  EXPECT_STREQ on undefined memory.
- New regression tests for replace count=-1 (replace all) and count=0
  (no replacements) to lock in the Python semantics.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…tring-methods

# Conflicts:
#	test/pp_api_test/test_tokenizer_chat.cc
@samuel100 samuel100 requested a review from a team as a code owner June 18, 2026 10:06
Copilot AI review requested due to automatic review settings June 18, 2026 10:06

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Not ready to approve

The new replace() implementation has a small but real correctness/robustness issue (default count can overflow int64_t on very large strings and the loop does unnecessary work when count == 0) that should be fixed before approval.

Pull request overview

This PR updates the vendored minja template engine to support additional Jinja2-style string methods (.replace(), .upper(), .lower()) that are required by common Hugging Face chat templates, and adds regression tests to ensure OrtxApplyChatTemplate can render templates using these methods.

Changes:

  • Add string method dispatch for .upper(), .lower(), and .replace() in shared/api/minja.hpp.
  • Add regression tests covering .replace() semantics (including chaining + rstrip and optional count) and .upper()/.lower() in test/pp_api_test/test_tokenizer_chat.cc.
File summaries
File Description
shared/api/minja.hpp Adds missing string-method handlers in MethodCallExpr::do_evaluate to unblock real-world HF templates.
test/pp_api_test/test_tokenizer_chat.cc Adds regression tests validating the new string methods via OrtxApplyChatTemplate.

Copilot's findings

  • Files reviewed: 2/2 changed files
  • Comments generated: 1

Note

Your feedback helps us improve the quality of this feature.
Please use 👍 or 👎 to tell us whether this assessment is correct.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread shared/api/minja.hpp
Comment on lines +2248 to +2267
// Python str.replace semantics: count < 0 means "replace all";
// count >= 0 limits the number of replacements; omitted argument
// also means "replace all".
int64_t count = static_cast<int64_t>(res.length());
if (vargs.args.size() == 3)
{
auto requested = vargs.args[2].get<int64_t>();
if (requested >= 0)
{
count = requested;
}
}
size_t start_pos = 0;
while ((start_pos = res.find(before, start_pos)) != std::string::npos &&
count-- > 0)
{
res.replace(start_pos, before.length(), after);
start_pos += after.length();
}
return Value(res);
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Vendored minja.hpp missing .replace() and other common Jinja string methods — chat templates from popular HF models fail to render

3 participants