diff --git a/.github/workflows/publish-dev.yml b/.github/workflows/publish-dev.yml index c77ed19a..7b25b578 100644 --- a/.github/workflows/publish-dev.yml +++ b/.github/workflows/publish-dev.yml @@ -1,14 +1,11 @@ name: Publish Dev -# Tag every commit on main as ``renderers-v.dev`` and publish the -# wheel to PyPI as a pre-release. ```` is the latest release tag with -# its patch bumped; ```` is the number of commits since that release so -# each main commit maps to a unique PEP 440 dev version. -# -# The checkout still points at the freshly-created tag, but pyproject.toml -# deliberately ignores automated ``.devN`` tags so local editable installs do -# not fail on them. The build step therefore passes the computed dev version -# explicitly to setuptools-scm. +# Every push to main publishes a pre-release wheel to PyPI. The version +# is derived at build time by hatch-vcs from ``git describe`` distance +# to the latest stable ``renderers-v..`` tag (see +# ``[tool.hatch.version.raw-options]`` in pyproject.toml) — no per-commit +# tag is created. ``local_scheme = "no-local-version"`` strips the +# ``+gHASH`` segment so PyPI accepts the wheel directly. on: push: @@ -19,74 +16,29 @@ concurrency: cancel-in-progress: false jobs: - tag: - runs-on: ubuntu-latest - permissions: - contents: write - outputs: - tag: ${{ steps.compute.outputs.tag }} - steps: - - uses: actions/checkout@v4 - with: - fetch-depth: 0 - - - name: Compute next dev tag - id: compute - run: | - set -euo pipefail - LATEST_RELEASE=$(git tag --list 'renderers-v*' --sort=-v:refname \ - | grep -Ev '(dev|rc|a[0-9]|b[0-9])' \ - | head -1) - if [ -z "$LATEST_RELEASE" ]; then - echo "No release tag matching 'renderers-v' found" >&2 - exit 1 - fi - BASE=${LATEST_RELEASE#renderers-v} - MAJOR=$(echo "$BASE" | cut -d. -f1) - MINOR=$(echo "$BASE" | cut -d. -f2) - PATCH=$(echo "$BASE" | cut -d. -f3) - NEXT="${MAJOR}.${MINOR}.$((PATCH + 1))" - N=$(git rev-list --count "${LATEST_RELEASE}..HEAD") - TAG="renderers-v${NEXT}.dev${N}" - echo "tag=${TAG}" >> "$GITHUB_OUTPUT" - echo "Computed tag: ${TAG} (base=${LATEST_RELEASE}, commits=${N})" - - - name: Create and push tag - env: - TAG: ${{ steps.compute.outputs.tag }} - run: | - set -euo pipefail - if git ls-remote --exit-code --tags origin "refs/tags/${TAG}" >/dev/null 2>&1; then - echo "Tag ${TAG} already exists on origin — nothing to do" >&2 - exit 0 - fi - git config user.name 'github-actions[bot]' - git config user.email '41898282+github-actions[bot]@users.noreply.github.com' - git tag -a "$TAG" -m "Automated dev release ${TAG}" - git push origin "$TAG" - + # Build (no OIDC) → publish (OIDC only). The build job runs ``uv build`` + # with ``contents: read`` only so a poisoned build-time dep cannot mint + # the OIDC token. The publish job has ``id-token: write`` and the + # pypi-prod environment but no source checkout — it only downloads the + # prebuilt artifact and runs the SHA-pinned pypa publish action. build: - needs: tag runs-on: ubuntu-latest permissions: contents: read steps: - uses: actions/checkout@v4 with: + # hatch-vcs needs the full tag history to resolve the version + # from the latest stable tag's distance. fetch-depth: 0 - ref: refs/tags/${{ needs.tag.outputs.tag }} - uses: astral-sh/setup-uv@v7 - name: Build renderers - env: - TAG: ${{ needs.tag.outputs.tag }} - run: | - set -euo pipefail - VERSION="${TAG#renderers-v}" - SETUPTOOLS_SCM_PRETEND_VERSION="$VERSION" \ - SETUPTOOLS_SCM_PRETEND_VERSION_FOR_RENDERERS="$VERSION" \ - uv build + run: uv build + + - name: Show derived version + run: ls -1 dist/ - name: Upload dist artifacts uses: actions/upload-artifact@v4 @@ -111,3 +63,5 @@ jobs: - name: Publish to PyPI uses: pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b # v1.14.0 + with: + skip-existing: true diff --git a/README.md b/README.md index d153163d..c0756711 100644 --- a/README.md +++ b/README.md @@ -40,7 +40,7 @@ next_prompt_ids = r.bridge_to_next_turn( ) ``` -Hand-coded renderers ship for `qwen3`, `qwen3-vl`, `qwen3.5`, `qwen3.6`, `glm-5`, `glm-5.1`, `glm-4.5`, `minimax-m2`, `deepseek-v3`, `kimi-k2`, `kimi-k2.5`, `nemotron-3`, `nemotron-3-ultra`, `gpt-oss`. Anything else falls back to `DefaultRenderer`, a generic `apply_chat_template` wrapper. +Hand-coded renderers ship for `qwen3`, `qwen3-vl`, `qwen3.5`, `qwen3.6`, `glm-5`, `glm-5.1`, `glm-4.5`, `minimax-m2`, `deepseek-v3`, `deepseek-r1`, `kimi-k2`, `kimi-k2.5` / `kimi-k2.6`, `nemotron-3`, `nemotron-3-ultra`, `llama-3`, and `gpt-oss`. Anything else falls back to `DefaultRenderer`, a generic `apply_chat_template` wrapper. ## API @@ -74,7 +74,7 @@ Each hand-coded bridge: r = create_renderer(tok) # AutoRendererConfig is the implicit default ``` -Auto-detect matches `tokenizer.name_or_path` against `MODEL_RENDERER_MAP` by **exact match**. Prefix matching is intentionally off — same architecture can ship different chat templates (base vs instruct, fine-tune renames). Fine-tunes must pass an explicit typed config (e.g. `Qwen3RendererConfig()`); unknown names fall back to `DefaultRenderer`. +Auto-detect matches `tokenizer.name_or_path` against `MODEL_RENDERER_MAP` by **exact match**. Prefix matching is intentionally off — same architecture can ship different chat templates (base vs instruct, fine-tune renames). Fine-tunes must pass an explicit typed config (e.g. `Qwen3RendererConfig()`). Unknown text-only names fall back to `DefaultRenderer`, unless `AutoRendererConfig(thinking_retention=...)` was set; the default renderer cannot implement that bridge policy. ### Pools @@ -95,7 +95,6 @@ For RL the trainer must see the exact token ids the sampler saw. The standard al - **Boolean round-trip.** Engine emits `false`; client parses to Python `bool(False)`; `apply_chat_template` re-renders via `str(False)` → `"False"`. Capital F. Reproducible on Qwen3.5-35B-A3B + mini-swe-agent-plus at ~50% break rate per rollout. - **BPE retokenization drift.** The same substring tokenizes differently depending on neighbouring bytes. `json` + `p` + `enderer` (3 tokens) vs `jsonp` + `enderer` (2 tokens) when whitespace shifts by one character. Every subsequent token is shifted from there on. - **Tool-call XML drift.** The engine emits a no-arg call with a stylistic empty ``; the Jinja re-render of the reconstructed dict drops it. Extension property broken at every such call. -- **Thinking stripped from non-latest assistants.** Some templates strip `` blocks from prior assistant turns when re-rendering. The recorded stream has the thinking; the next prompt does not. - **Max-seq-len truncation zeroing the anchor.** Client-side `max_seq_len` enforcement zeros `completion_ids` when `prompt_len > max_seq_len`. The bridge anchor is empty, falling back to full re-render — triggering every mode above. - **Scaffold-level history rewriting.** Some agent scaffolds (e.g. opencode's `experimental_repairToolCall`) rewrite tool calls before sending them back as history. The next turn's prompt contains a tool call the model never emitted. *A renderer cannot fix this — the drift happens before rendering.* @@ -110,7 +109,7 @@ Each break fragments a rollout into multiple training samples — every fragment ## Typed renderer configs -Each renderer accepts a typed pydantic config that pins its template-control kwargs at construction. `create_renderer` and `create_renderer_pool` take one positional `config` argument: +Each renderer accepts a typed pydantic config at construction. Some fields mirror chat-template kwargs; others configure renderer-only behavior such as image caching, parsers, or Harmony preamble construction. `create_renderer` and `create_renderer_pool` take one positional `config` argument and an optional keyword-only `chat_template_kwargs` mapping: ```python from renderers import ( @@ -121,13 +120,15 @@ from renderers import ( DefaultRendererConfig, ) -# Auto-resolve renderer from the tokenizer's model name. Carries the -# shared preserve_* flags; template kwargs require an explicit choice. +# Auto-resolve renderer from the tokenizer's model name. renderer = create_renderer(tokenizer) -renderer = create_renderer(tokenizer, AutoRendererConfig(preserve_all_thinking=True)) +renderer = create_renderer(tokenizer, AutoRendererConfig(thinking_retention="all")) +renderer = create_renderer( + tokenizer, + chat_template_kwargs={"enable_thinking": False}, +) -# Explicit choice — the typed config exposes exactly the fields that -# renderer's chat template honours. +# Explicit choice — use the renderer-specific fields it exposes. renderer = create_renderer(tokenizer, Qwen3RendererConfig(enable_thinking=False)) renderer = create_renderer(tokenizer, GLM5RendererConfig(clear_thinking=False)) @@ -142,21 +143,24 @@ renderer = create_renderer( Discriminated union: every per-renderer config is a variant of `RendererConfig`, dispatched on the `name` field. Bogus combinations (e.g. `add_vision_id` under `name="qwen3"`) error at construction with a `pydantic.ValidationError`. Downstream pydantic configs (prime-rl orchestrator, verifiers `ClientConfig`) hold a single field typed as `RendererConfig` and inherit the same strict-per-variant validation. -Two shared behaviour flags live on every variant via `_BaseRendererConfig`: +When `chat_template_kwargs` is passed with `config=None` / `AutoRendererConfig`, renderers first resolves the concrete renderer from the model name, then validates those kwargs against that renderer's config. `Auto + unknown model + chat_template_kwargs` fails loudly; use an explicit typed config or explicit `DefaultRendererConfig` for opaque fallback templates. + +One shared behaviour flag lives on typed renderer configs: `thinking_retention`, an optional bridge-policy override. Leave it unset to derive bridge behaviour from the chat template and its renderer-exposed kwargs. -- `preserve_all_thinking=True` — every past assistant's `reasoning_content` is kept, even when the chat template would drop it. -- `preserve_thinking_between_tool_calls=True` — reasoning is kept on assistants in the in-flight tool cycle (post-last-user A-T-…-A block when it contains a tool response). A new user turn closes the block and drops its thinking. +- `thinking_retention=None` (default) — derive from the chat template / renderer kwargs. +- `thinking_retention="tool_cycle"` — bridge within the in-flight tool cycle; a new user query falls back to a full re-render. +- `thinking_retention="all"` — bridge across user-query boundaries when the bridge is otherwise structurally valid. -These OR-compose with template-level toggles (e.g. GLM-5 `clear_thinking`, Nemotron-3 `truncate_history_thinking`): either flag saying "keep" wins. preserve_* can only ever *extend* retention — never override a template kwarg into a "drop" decision. The canonical use case is **compaction**: injecting a `user` turn like *"summarize the work so far"* puts every prior assistant in a past cycle, and `preserve_all_thinking=True` keeps reasoning visible end-to-end. +Generic `thinking_retention` does **not** change full `render()` output: a full re-render always follows the Python chat-template implementation. Only real template knobs can change full-render thinking behaviour. GLM-5 `clear_thinking=False`, Nemotron-3 `truncate_history_thinking=False`, Qwen3.6 `preserve_thinking=True`, and GPT-OSS `auto_drop_analysis=False` all imply bridge policy `"all"`; no-thinking generation knobs also imply `"all"` when `thinking_retention` is unset. Setting a direct keep/drop template knob and a contradictory `thinking_retention` raises at config-load. The full per-renderer mapping lives in [`docs/renderer-config.md`](docs/renderer-config.md). ## `DefaultRenderer` -Fallback for unsupported models. Wraps `apply_chat_template` and accepts `tool_parser` / `reasoning_parser` (vLLM convention) plus arbitrary Jinja kwargs via `DefaultRendererConfig`'s `extra="allow"`. `bridge_to_next_turn` returns `None` because the template's close is unknown, so multi-turn rollouts fall back to full re-render. Implementing a hand-coded renderer is a few hundred lines of Python (`render_ids` + `parse_response` + `bridge_to_next_turn`) and is the only path that closes the failure modes above by construction. +Fallback for unsupported text-only models. Wraps `apply_chat_template` and accepts `tool_parser` / `reasoning_parser` (vLLM convention) plus arbitrary Jinja kwargs via `DefaultRendererConfig`'s `extra="allow"`. Explicit `thinking_retention` is rejected: `bridge_to_next_turn` returns `None` because the template's close is unknown, so multi-turn rollouts fall back to full re-render. Implementing a hand-coded renderer is a few hundred lines of Python (`render_ids` + `parse_response` + `bridge_to_next_turn`) and is the only path that closes the failure modes above by construction. ## Roadmap -- **VLM support.** `ContentPart` is text-only today; `Qwen3VLRenderer` ships only because Qwen3-VL's text-only chat template differs from Qwen3's. Plan: add `ImagePart` / `VideoPart`, multimodal bridges, validate against a Qwen3-VL RL run. -- **Patched chat templates.** Some shipped templates re-tokenize history, normalize JSON, or auto-strip thinking — each breaks the extension property. Plan: a `use_patched` opt-in per renderer that renders the same surface form while avoiding known-bad patterns. +- **VLM expansion.** `ImagePart` support exists for Qwen3-VL and Qwen3.5-family multimodal templates. Remaining work: video support, broader VLM coverage, and more RL validation. +- **Patched chat templates.** Some shipped templates re-tokenize history or normalize JSON in ways that break token identity. Plan: a `use_patched` opt-in per renderer that renders the same surface form while avoiding known-bad patterns. (Auto-stripping thinking from past turns is *not* one of these — that's intended template behaviour the renderer reproduces; use `thinking_retention` to override it.) ## Testing diff --git a/docs/renderer-config.md b/docs/renderer-config.md index 3d5fa2ab..ce5bc84c 100644 --- a/docs/renderer-config.md +++ b/docs/renderer-config.md @@ -1,104 +1,156 @@ # Renderer config `renderers.RendererConfig` is the typed input to `create_renderer` and -`create_renderer_pool`. It pins the renderer choice and its template-control -kwargs at construction. +`create_renderer_pool`. It pins the renderer choice and its config at +construction time. ```python from renderers import create_renderer, Qwen35RendererConfig r = create_renderer(tokenizer, Qwen35RendererConfig(enable_thinking=False)) +r = create_renderer(tokenizer, chat_template_kwargs={"enable_thinking": False}) ``` -`RendererConfig` is a pydantic discriminated union (one variant per renderer, -dispatched on the `name` field). Selecting a variant exposes exactly the -fields that renderer's chat template honours; anything else raises a -`pydantic.ValidationError` at construction. +`RendererConfig` is a pydantic discriminated union, one variant per renderer, +dispatched on the `name` field. Most variants reject unknown fields at +construction. A field can either mirror a chat-template kwarg or configure a +renderer-only behavior such as parsing, image caching, or Harmony preamble +construction. ## Per-renderer configs -Each hand-coded renderer has a typed config class with the template kwargs -its Jinja chat template reads. For example: - -| Renderer | Config class | Template fields | -|----------------|--------------------------|----------------------------------------------------------------| -| Qwen3 | `Qwen3RendererConfig` | `enable_thinking` | -| Qwen3.5 / 3.6 | `Qwen35RendererConfig` | `enable_thinking`, `add_vision_id` | -| Qwen3-VL | `Qwen3VLRendererConfig` | `add_vision_id` | -| GLM-5 / 5.1 | `GLM5RendererConfig` | `enable_thinking`, `clear_thinking` | -| GLM-4.5 | `GLM45RendererConfig` | `enable_thinking` | -| Nemotron-3 | `Nemotron3RendererConfig`| `enable_thinking`, `truncate_history_thinking` | -| Kimi K2.5 | `KimiK25RendererConfig` | `thinking` | -| MiniMax-M2 | `MiniMaxM2RendererConfig`| `model_identity` | -| Laguna-XS.2 | `LagunaXS2RendererConfig`| `enable_thinking`, `render_assistant_messages_raw` | -| gpt-oss | `GptOssRendererConfig` | `reasoning_effort`, `conversation_start_date` | - -Field names mirror the upstream Jinja variable names. Passing -`Qwen3RendererConfig(add_vision_id=True)` raises — Qwen3 is text-only, so -the field doesn't exist on its config. Use -`type(config).template_field_names()` to introspect the fields that mirror -chat-template kwargs (parity is verified against `apply_chat_template` in -`tests/test_renderer_config_parity.py`). - -Configs are frozen. To override a field, construct a new instance or call -`config.model_copy(update={...})`. +Use `type(config).template_field_names()` to inspect the fields that mirror +chat-template kwargs. Those fields are covered by parity tests against +`apply_chat_template` in `tests/test_renderer_config_parity.py`. + +| Renderer | Config class | Template fields | Renderer-only fields | +| --- | --- | --- | --- | +| Qwen3 | `Qwen3RendererConfig` | `enable_thinking` | - | +| Qwen3.5 | `Qwen35RendererConfig` | `enable_thinking`, `add_vision_id` | `image_cache_max` | +| Qwen3.6 | `Qwen36RendererConfig` | `enable_thinking`, `add_vision_id`, `preserve_thinking` | `image_cache_max` | +| Qwen3-VL | `Qwen3VLRendererConfig` | `add_vision_id` | `image_cache_max` | +| GLM-5 / 5.1 | `GLM5RendererConfig` / `GLM51RendererConfig` | `enable_thinking`, `clear_thinking` | - | +| GLM-4.5 | `GLM45RendererConfig` | `enable_thinking` | - | +| gpt-oss | `GptOssRendererConfig` | `reasoning_effort`, `conversation_start_date` | `use_system_prompt`, `knowledge_cutoff`, `model_identity`, `auto_drop_analysis` | +| Kimi K2 | `KimiK2RendererConfig` | - | `enable_thinking` | +| Kimi K2.5 / 2.6 | `KimiK25RendererConfig` | `thinking` | `image_cache_max` | +| Laguna XS.2 | `LagunaXS2RendererConfig` | `enable_thinking`, `render_assistant_messages_raw` | - | +| Llama 3 | `Llama3RendererConfig` | `date_string`, `tools_in_user_message` | - | +| MiniMax M2 | `MiniMaxM2RendererConfig` | `model_identity` | - | +| Nemotron-3 Nano / Super | `Nemotron3RendererConfig` | `enable_thinking`, `truncate_history_thinking`, `low_effort` | - | +| Nemotron-3 Ultra | `Nemotron3UltraRendererConfig` | `enable_thinking`, `truncate_history_thinking`, `medium_effort` | - | +| DeepSeek V3 | `DeepSeekV3RendererConfig` | - | - | +| DeepSeek R1 | `DeepSeekR1RendererConfig` | - | - | + +Configs are frozen value objects. To override a field, construct a new instance +or call `config.model_copy(update={...})`. ## Auto-resolution -`create_renderer(tokenizer)` (no config) resolves the renderer from -`tokenizer.name_or_path` via `MODEL_RENDERER_MAP`: +`create_renderer(tokenizer)` resolves the renderer from `tokenizer.name_or_path` +via `MODEL_RENDERER_MAP`: ```python -r = create_renderer(tokenizer) # AutoRendererConfig() is the default -r = create_renderer(tokenizer, AutoRendererConfig(preserve_all_thinking=True)) +from renderers import AutoRendererConfig, GLM5RendererConfig + +r = create_renderer(tokenizer) +r = create_renderer(tokenizer, AutoRendererConfig(thinking_retention="all")) +r = create_renderer(tokenizer, GLM5RendererConfig(clear_thinking=False)) ``` -`AutoRendererConfig` carries only the shared `preserve_*` flags. Template -kwargs depend on the renderer, so overriding them requires naming the -renderer explicitly: +`AutoRendererConfig` carries only the shared `thinking_retention` override. +Callers that receive run-scoped chat-template kwargs can pass them separately: ```python -r = create_renderer(tokenizer, GLM5RendererConfig(clear_thinking=False)) +r = create_renderer( + tokenizer, + chat_template_kwargs={"enable_thinking": False}, +) +pool = create_renderer_pool( + "Qwen/Qwen3-8B", + chat_template_kwargs={"enable_thinking": False}, +) ``` -Auto-resolution fails loudly for VLMs that miss the exact-match lookup — -`DefaultRenderer` only knows `apply_chat_template` + text tokens, so silently -falling back for a VLM would produce token streams the trainer can't -reconstruct. Text-only fine-tunes without a registered renderer fall back to -`DefaultRenderer` and log the choice at INFO. +Renderers resolves auto configs before applying `chat_template_kwargs`, so the +kwargs validate against the concrete renderer config. Unknown kwargs, or kwargs +that conflict with an explicit `thinking_retention`, fail at construction. -## `preserve_*` flags +Auto-resolution fails loudly for VLMs without an exact registered renderer. +Text-only unknown models fall back to `DefaultRenderer`, unless +`AutoRendererConfig(thinking_retention=...)` was set. The default renderer +cannot implement selective bridge retention, so that combination raises. +`AutoRendererConfig` with `chat_template_kwargs` also raises for unknown models, +because renderers cannot validate those kwargs without a concrete renderer. +Use an explicit model-specific config, or `DefaultRendererConfig(...)` when you +intentionally want opaque `apply_chat_template` kwargs. -Every variant carries two renderer-agnostic flags on `_BaseRendererConfig`: +## `thinking_retention` -- `preserve_all_thinking: bool = False` — re-emit `reasoning_content` on - every past assistant turn, even when the chat template would drop it. -- `preserve_thinking_between_tool_calls: bool = False` — re-emit - `reasoning_content` only inside the in-flight tool cycle (the contiguous - A-T-…-A block after the most recent `user` message, when it contains at - least one `tool` response). A new user turn closes the block and drops - its thinking. +Every typed renderer config carries one shared optional bridge-policy override: -These OR-compose with template-level toggles. GLM-5's `clear_thinking` and -Nemotron-3's `truncate_history_thinking` already gate past thinking; the -`preserve_*` flags add to that: +```python +thinking_retention: Literal["tool_cycle", "all"] | None = None +``` -| `clear_thinking` | `preserve_all_thinking` | past thinking? | -|------------------|-------------------------|----------------| -| `True` (default — drop) | `False` (default) | dropped | -| `True` | `True` | kept | -| `False` (keep) | `False` | kept | -| `False` | `True` | kept | +| Value | Meaning | +| --- | --- | +| `None` | Derive the effective bridge policy from the renderer's template knobs and defaults. | +| `"tool_cycle"` | Bridge within the current tool cycle; re-render when the extension opens a new user query. | +| `"all"` | Allow bridging across user-query boundaries when the bridge is otherwise structurally valid. | + +`thinking_retention` affects `bridge_to_next_turn`, not full `render()`. +A full render always follows the Python chat-template implementation. Only real +template fields, such as `clear_thinking`, `preserve_thinking`, or +`truncate_history_thinking`, can change full-render historical thinking. + +Internally, renderers resolve an `effective_thinking_retention` at construction: + +| Internal policy | Bridge behavior | +| --- | --- | +| `"template"` | Decline bridging; caller falls back to a full re-render. | +| `"tool_cycle"` | Bridge unless `new_messages` introduces a user query. | +| `"all"` | Do not block bridging for thinking retention. | + +`"template"` is not a public config value. Leave `thinking_retention` unset to +get template-derived behavior. + +## Derived retention defaults + +When `thinking_retention` is unset, each renderer derives its bridge policy from +the knobs its template actually exposes: + +| Renderer | Derived policy | +| --- | --- | +| Qwen3 | `enable_thinking=False -> all`, else `tool_cycle` | +| Qwen3.5 | `enable_thinking=False -> all`, else `tool_cycle` | +| Qwen3.6 | `preserve_thinking=True -> all`; else `enable_thinking=False -> all`; else `tool_cycle` | +| GLM-5 / 5.1 | `clear_thinking=False -> all`; else `enable_thinking=False -> all`; else `tool_cycle` | +| GLM-4.5 | `enable_thinking=False -> all`, else `tool_cycle` | +| gpt-oss | `auto_drop_analysis=False -> all`, else `tool_cycle` | +| Kimi K2.5 / 2.6 | `thinking=False -> all`, else `tool_cycle` | +| Nemotron-3 | `truncate_history_thinking=False -> all`; else `enable_thinking=False -> all`; else `tool_cycle` | +| DeepSeek R1 | `template` | +| MiniMax M2 | `tool_cycle` | +| DeepSeek V3, Qwen3-VL, Kimi K2, Laguna XS.2, Llama 3 | `all` | + +Config construction raises when an explicit template knob directly contradicts +an explicit generic bridge policy. For example: -`preserve_*` can only extend retention, never force a drop. The canonical -use case is **compaction**: injecting a `user` turn like *"summarize the work -so far"* puts every prior assistant in a past cycle, and -`preserve_all_thinking=True` keeps reasoning visible end-to-end. +```python +GLM5RendererConfig(clear_thinking=False, thinking_retention="tool_cycle") +# ValueError: clear_thinking=False implies thinking_retention="all" +``` -## `DefaultRendererConfig` accepts arbitrary Jinja kwargs +Generation-only no-thinking knobs, such as `enable_thinking=False`, do not +conflict with an explicit conservative `thinking_retention="tool_cycle"`. They +only change the derived default when `thinking_retention` is unset. -`DefaultRenderer` wraps `tokenizer.apply_chat_template` for any model that -doesn't have a hand-coded renderer. Its config sets `extra="allow"`: +## `DefaultRendererConfig` + +`DefaultRenderer` wraps `tokenizer.apply_chat_template` for unsupported +text-only models. Its config sets `extra="allow"` so unknown fields are +forwarded as Jinja kwargs: ```python from renderers import create_renderer, DefaultRendererConfig @@ -106,58 +158,59 @@ from renderers import create_renderer, DefaultRendererConfig r = create_renderer( tokenizer, DefaultRendererConfig( - tool_parser="qwen3", # registered in renderers.parsers + tool_parser="qwen3", reasoning_parser="think", - enable_thinking=False, # forwarded to apply_chat_template - custom_jinja_kwarg=True, # ditto + enable_thinking=False, + custom_jinja_kwarg=True, ), ) ``` -`tool_parser` and `reasoning_parser` are typed because they configure -`DefaultRenderer`'s own parsing pipeline. Every other field lands in -`model_extra` and `DefaultRenderer._apply` forwards `model_extra` verbatim -to `apply_chat_template`. +`tool_parser` and `reasoning_parser` configure `DefaultRenderer` itself. Every +other extra field lands in `model_extra` and is forwarded to +`apply_chat_template`. + +`DefaultRenderer` rejects explicit `thinking_retention` and the removed +`preserve_*` flags. Its bridge always returns `None`, because the template's +turn-close structure is opaque to the renderer. ## Downstream integration -Downstream pydantic configs (`prime-rl` orchestrator, `verifiers` -`ClientConfig`) hold a single field typed as `RendererConfig`: +Downstream pydantic configs can hold a single field typed as `RendererConfig`: ```python from pydantic import BaseModel, Field from renderers import AutoRendererConfig, RendererConfig + class ClientConfig(BaseModel): renderer: RendererConfig = Field(default_factory=AutoRendererConfig) ``` -In TOML / YAML, the discriminator routes deserialization: +In TOML or YAML, the `name` discriminator selects the variant: ```toml [client.renderer] name = "qwen3.5" enable_thinking = false add_vision_id = true -preserve_all_thinking = true +thinking_retention = "all" ``` -Pydantic dispatches on `name = "qwen3.5"` to `Qwen35RendererConfig`. Bogus -combinations (e.g. `add_vision_id` under `name = "qwen3"`) raise at -config-load with a clear message naming the offending field and the variant -that rejected it. +Bogus combinations, such as `add_vision_id` under `name = "qwen3"`, raise at +config load with a pydantic validation error. -To construct a config from a renderer name string (e.g. from a CLI flag): +To construct a config from a renderer name string: ```python from renderers import config_from_name -cfg = config_from_name("glm-5") # → GLM5RendererConfig() with defaults -cfg = config_from_name("auto") # → None, the implicit "auto" form +cfg = config_from_name("glm-5") # GLM5RendererConfig() +cfg = config_from_name("auto") # None, the implicit auto form ``` ## Renaming a renderer is a breaking change The discriminator key is the renderer name string. Renaming `"qwen3.5"` to -something else would break any downstream config that references it by -name. Add new renderers; don't rename existing ones. +something else would break downstream configs that reference it by name. Add +new renderers instead of renaming existing ones. diff --git a/pyproject.toml b/pyproject.toml index cf993bce..dc753970 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -31,12 +31,6 @@ dependencies = [ # against 0.0.8) and ``tests/test_gpt_oss_harmony_parity.py`` passes on it, # so the older harmony is safe. "openai-harmony>=0.0.4", - # Crusoe's Rust BPE tokenizer; ~10x faster encode vs HF's tokenizers. - # ``load_tokenizer`` patches it in by default for every supported model - # except a small denylist (DeepSeek-V3 family). The patch is bracketed - # around ``from_pretrained``, so subsequent ``AutoTokenizer`` calls - # outside the renderers package stay vanilla. - "fastokens>=0.2.0", # ``BaseRendererConfig`` inherits from ``pydantic_config.BaseConfig`` so # the typed-config surface stays uniform with prime-rl / verifiers config # bases. Transitively brings pydantic, which ``renderers.configs`` also @@ -48,11 +42,9 @@ dependencies = [ source = "vcs" # Tags look like ``renderers-v0.1.8`` (prefix matches the publish.yml # release contract); strip the prefix to get a PEP 440 version. The -# regex accepts release/pre-release suffixes after the prefix, but ignores -# automated ``.devN`` tags. hatch-vcs/setuptools-scm cannot use arbitrary -# ``0.1.8.dev41`` tags as version bases, but it can derive dev versions -# from the latest non-dev tag. -tag-pattern = '^renderers-v(?P(?!.*\.dev\d+$).+)$' +# regex accepts any PEP 440-valid suffix after the prefix so we can +# tag pre-releases like ``renderers-v0.2.0rc1`` later if needed. +tag-pattern = '^renderers-v(?P.+)$' # Used when building from a context without VCS metadata (e.g. an # sdist consumed by a downstream that doesn't ship .git). Real # builds from a checkout get the resolved version; this fallback @@ -60,9 +52,20 @@ tag-pattern = '^renderers-v(?P(?!.*\.dev\d+$).+)$' fallback-version = "0.0.0" [tool.hatch.version.raw-options] -# Keep setuptools-scm from selecting automated dev tags before ``tag-pattern`` -# gets a chance to parse the selected tag. -git_describe_command = "git describe --dirty --tags --long --match renderers-v[0-9]* --exclude *dev*" +# Only stable ``renderers-v..`` tags participate in +# version resolution; legacy per-commit ``.devN`` tags are excluded. +# Untagged commits get a clean PEP 440 dev version derived from distance +# to the latest stable tag (e.g. ``0.1.8.dev46``), with no ``+gHASH`` +# local segment so PyPI accepts the wheel directly. setuptools-scm +# refuses to bump a pre-existing ``.devN`` tag, so matching only stable +# tags also unblocks local ``uv lock`` / ``uv build`` on untagged +# branches. Mirrors verifiers' setup. +local_scheme = "no-local-version" +git_describe_command = [ + "git", "describe", "--tags", "--long", + "--match", "renderers-v[0-9]*.[0-9]*.[0-9]*", + "--exclude", "*dev*", +] [tool.hatch.build.hooks.vcs] # Write the resolved version to a Python file so it can be inspected @@ -94,12 +97,6 @@ required-version = ">=0.11.1" exclude-newer = "7 days" [tool.uv.exclude-newer-package] -# fastokens 0.2.0 was published on 2026-05-17 and contains the -# ``unpatch_transformers`` fix (crusoecloud/fastokens#32) needed for -# MiniMax-M2's slow→fast tokenizer conversion path. Exempting it from -# the project-wide 7-day cutoff lets the lockfile pick it up immediately -# while the rest of the dependency graph stays gated. -fastokens = false # PrimeIntellect-published packages in this project's dependency closure — # fast-track so first-party releases can land same-day. Only packages that # appear in `uv tree` are listed. diff --git a/renderers/base.py b/renderers/base.py index f9544498..0731176c 100644 --- a/renderers/base.py +++ b/renderers/base.py @@ -1,8 +1,6 @@ from __future__ import annotations -import contextlib import enum -import io import logging import queue import threading @@ -20,7 +18,11 @@ ) if TYPE_CHECKING: - from renderers.configs import AutoRendererConfig, RendererConfig + from renderers.configs import ( + AutoRendererConfig, + RendererConfig, + ResolvedThinkingRetention, + ) logger = logging.getLogger("renderers.base") @@ -666,15 +668,12 @@ def render( """Render messages to token IDs with per-token message attribution. Behaviour around historical ``reasoning_content`` is owned by the - renderer instance — the ``preserve_all_thinking`` and - ``preserve_thinking_between_tool_calls`` flags are constructor - kwargs, not call-site kwargs. To render with a different - configuration, build a different renderer (or different pool). - Defaults preserve byte-identity with each model's chat template; - flipping a flag at construction restores ``reasoning_content`` - the template would otherwise drop. See - ``should_preserve_past_thinking`` for the per-message - classification. + renderer instance — the ``thinking_retention`` level is resolved at + construction, not passed per call. To render with a different + configuration, build a different renderer (or different pool). When + ``thinking_retention`` is left unset, full renders follow the model's + chat template and bridge policy is derived from that template's own + history-retention knobs. """ ... @@ -769,8 +768,12 @@ def bridge_to_next_turn( Return ``None`` whenever the renderer can't prove that contract holds — the caller falls back to a full re-render. In particular, bridges refuse assistant messages in ``new_messages`` (those would - re-tokenize model-sampled content). Hand-coded renderers know their - canonical close and synthesise it on truncated priors; + re-tokenize model-sampled content). They also follow the renderer's + resolved thinking-retention bridge policy: ``"template"`` always + re-renders, ``"tool_cycle"`` re-renders at a new user-query boundary, + and ``"all"`` allows extension when the rest of the structural bridge + checks pass. Hand-coded renderers know their canonical close and + synthesise it on truncated priors; DefaultRenderer always returns ``None`` because the template's close is unknown. """ @@ -1174,29 +1177,6 @@ def _model_has_vision_config(model_name: str) -> bool: } -# Models for which ``fastokens`` is known to diverge from vanilla -# ``transformers.AutoTokenizer`` and therefore must NOT be patched. -# Empirical audit ran each entry of ``MODEL_RENDERER_MAP`` through both -# backends. The entries below fail to load under fastokens (DeepSeek-V3 -# family — Metaspace pretokenizer not yet implemented). -FASTOKENS_INCOMPATIBLE: frozenset[str] = frozenset( - { - # fastokens: ``ValueError: pre-tokenizer error: unsupported - # pre-tokenizer type: Metaspace`` — DeepSeek's tokenizer uses - # SentencePiece-style Metaspace pretokenization which fastokens - # doesn't yet implement. - "deepseek-ai/DeepSeek-V3", - "deepseek-ai/DeepSeek-V3-Base", - "deepseek-ai/DeepSeek-R1", - "deepseek-ai/DeepSeek-R1-0528", - } -) - - -_FASTOKENS_PATCH_LOCK = threading.Lock() -_FASTOKENS_ANNOUNCED = False - - def _tokenizer_source_for(model_name_or_path: str) -> str: return TOKENIZER_SOURCE_OVERRIDES.get(model_name_or_path, model_name_or_path) @@ -1233,48 +1213,6 @@ def _preserve_requested_tokenizer_name( return tokenizer -def _patched_load(model_name_or_path: str, **kwargs): - """Run ``AutoTokenizer.from_pretrained`` with fastokens patched in - process-locally — patch around the load, unpatch right after. - - fastokens captures the loaded backend on a per-tokenizer basis, so - after we unpatch the returned tokenizer object continues to use - fastokens for ``encode``/``decode`` while subsequent - ``AutoTokenizer.from_pretrained`` calls (outside our control) go - back to vanilla. This keeps the global side effect minimal. - - fastokens itself prints ``[fastokens] patch_transformers: ...`` to - stdout on every patch/unpatch call. Building a pool of size N would - therefore emit ~N lines (more under thread contention, where some - threads see ``already patched``). We swallow those prints under a - lock — ``contextlib.redirect_stdout`` swaps ``sys.stdout`` - process-wide, so the lock keeps unrelated stdout writes from other - threads from disappearing into our buffer. The patch/unpatch calls - are cheap; only the brief patch+unpatch is serialized, the actual - ``from_pretrained`` still runs concurrently across pool slots. A - single ``logger.info`` is emitted on the first patch so the fast - path is still discoverable in logs. - """ - import fastokens - - global _FASTOKENS_ANNOUNCED - - with _FASTOKENS_PATCH_LOCK: - with contextlib.redirect_stdout(io.StringIO()): - fastokens.patch_transformers() - if not _FASTOKENS_ANNOUNCED: - logger.info( - "fastokens enabled — tokenizers load through the Rust BPE fast path (~10x encode speedup)." - ) - _FASTOKENS_ANNOUNCED = True - try: - return _load_tokenizer_via_auto(model_name_or_path, **kwargs) - finally: - with _FASTOKENS_PATCH_LOCK: - with contextlib.redirect_stdout(io.StringIO()): - fastokens.unpatch_transformers() - - def _load_fast_tokenizer_directly( model_name_or_path: str, revision: str | None ) -> Any | None: @@ -1334,36 +1272,14 @@ def _load_tokenizer_via_auto(model_name_or_path: str, **kwargs) -> Any: return tok -def load_tokenizer( - model_name_or_path: str, - *, - use_fastokens: bool = True, -): - """Load a tokenizer with the renderers-package security + perf policy. +def load_tokenizer(model_name_or_path: str): + """Load a tokenizer with the renderers-package security policy. - **Security** — default ``trust_remote_code=False``. Models listed in + Default ``trust_remote_code=False``. Models listed in ``TRUSTED_REVISIONS`` (Moonshot Kimi-K2 family) load with ``trust_remote_code=True`` AND a pinned ``revision=`` so transformers only executes the reviewed commit's tokenizer Python. - **Performance** — ``use_fastokens=True`` (default) routes the load - through ``fastokens.patch_transformers()`` so the resulting tokenizer - encodes ~10x faster than vanilla ``tokenizers``. The patch is - bracketed: it's applied before ``from_pretrained`` and removed - immediately after, so global ``AutoTokenizer.from_pretrained`` calls - elsewhere in the user's process are not affected. - - Models in ``FASTOKENS_INCOMPATIBLE`` (DeepSeek-V3 family) skip the - patch — fastokens currently fails to load them. Pass - ``use_fastokens=False`` to force the vanilla backend for any other - model. - - Unknown / fine-tuned model paths fall through to - ``trust_remote_code=False`` and the patched-load fast path. If - fastokens raises during the patched load (e.g. an unknown - pre-tokenizer type), we automatically retry with the vanilla - backend and emit an INFO log. - ``AutoTokenizer.from_pretrained`` eagerly builds the model config to resolve the tokenizer class. If that construction raises on a modeling-only concern the tokenizer doesn't need (e.g. RoPE @@ -1378,28 +1294,7 @@ def load_tokenizer( """ load_name_or_path = _tokenizer_source_for(model_name_or_path) kwargs = _tokenizer_load_kwargs(load_name_or_path) - - if not use_fastokens or load_name_or_path in FASTOKENS_INCOMPATIBLE: - tok = _load_tokenizer_via_auto(load_name_or_path, **kwargs) - return _preserve_requested_tokenizer_name( - tok, - requested_name_or_path=model_name_or_path, - loaded_name_or_path=load_name_or_path, - ) - - try: - tok = _patched_load(load_name_or_path, **kwargs) - except Exception as exc: - logger.info( - "fastokens could not load %r (%s: %s); falling back to vanilla " - "AutoTokenizer. Add this model to FASTOKENS_INCOMPATIBLE in " - "renderers.base to suppress the retry.", - load_name_or_path, - type(exc).__name__, - str(exc)[:160], - ) - tok = _load_tokenizer_via_auto(load_name_or_path, **kwargs) - + tok = _load_tokenizer_via_auto(load_name_or_path, **kwargs) return _preserve_requested_tokenizer_name( tok, requested_name_or_path=model_name_or_path, @@ -1458,6 +1353,7 @@ def create_renderer_pool( config: RendererConfig | None = None, *, size: int = 16, + chat_template_kwargs: Mapping[str, Any] | None = None, ) -> RendererPool: """Create a RendererPool with *size* independent tokenizer copies. @@ -1469,8 +1365,10 @@ def create_renderer_pool( :data:`renderers.RendererConfig`). Defaults to :class:`AutoRendererConfig`, which resolves to a concrete renderer via ``MODEL_RENDERER_MAP`` at construction time using the loaded - tokenizer's name. Every slot in the pool shares the same config; to - run a different config, build a different pool. + tokenizer's name. ``chat_template_kwargs`` are merged into the + resolved concrete config and validated before renderer construction. + Every slot in the pool shares the same config; to run a different + config, build a different pool. Tokenizers load via ``load_tokenizer`` — see its docstring for the ``trust_remote_code`` policy (default off; Moonshot Kimi-K2 family @@ -1479,7 +1377,11 @@ def create_renderer_pool( def factory() -> Renderer: tokenizer = load_tokenizer(tokenizer_name_or_path) - return create_renderer(tokenizer, config) + return create_renderer( + tokenizer, + config, + chat_template_kwargs=chat_template_kwargs, + ) return RendererPool(factory, size=size) @@ -1487,6 +1389,8 @@ def factory() -> Renderer: def create_renderer( tokenizer, config: RendererConfig | None = None, + *, + chat_template_kwargs: Mapping[str, Any] | None = None, ) -> Renderer: """Create a Renderer from a typed config. @@ -1502,33 +1406,77 @@ def create_renderer( template-control kwargs (e.g. ``enable_thinking``), pass the specific :class:`Qwen3RendererConfig`, :class:`GLM5RendererConfig` etc. and set those fields. + chat_template_kwargs: Optional per-run chat-template kwargs. When + ``config`` is auto/``None``, renderers first resolves the concrete + config from ``tokenizer.name_or_path`` and then validates these + kwargs against that config. Selecting the auto-renderer for a model without a registered renderer falls back to :class:`DefaultRenderer` for text-only models and raises for VLMs (where ``apply_chat_template`` would silently drop images). """ - from renderers.configs import AutoRendererConfig - _populate_registry() + config = _resolve_renderer_config( + tokenizer, + config, + chat_template_kwargs=chat_template_kwargs, + ) + cls = RENDERER_REGISTRY.get(config.name) + if cls is None: + raise ValueError( + f"Unknown renderer {config.name!r}. Available: {', '.join(sorted(RENDERER_REGISTRY))}" + ) + return cls(tokenizer, config) + + +def _merge_chat_template_kwargs( + config: RendererConfig, + chat_template_kwargs: Mapping[str, Any] | None, +) -> RendererConfig: + if not chat_template_kwargs: + return config + if not isinstance(chat_template_kwargs, Mapping): + raise TypeError("chat_template_kwargs must be a mapping.") + data: dict[str, Any] = {"name": config.name} + for field_name in config.__pydantic_fields_set__: + data[field_name] = getattr(config, field_name) + data.update(getattr(config, "model_extra", None) or {}) + data.update(dict(chat_template_kwargs)) + return type(config).model_validate(data) + + +def _resolve_renderer_config( + tokenizer, + config: RendererConfig | None, + *, + chat_template_kwargs: Mapping[str, Any] | None = None, +) -> RendererConfig: + """Resolve auto/default config and merge chat-template kwargs.""" + from renderers.configs import AutoRendererConfig + if config is None: config = AutoRendererConfig() - if not isinstance(config, AutoRendererConfig): - cls = RENDERER_REGISTRY.get(config.name) - if cls is None: - raise ValueError( - f"Unknown renderer {config.name!r}. Available: {', '.join(sorted(RENDERER_REGISTRY))}" - ) - return cls(tokenizer, config) + if isinstance(config, AutoRendererConfig): + return _resolve_auto_config( + tokenizer, + config, + chat_template_kwargs=chat_template_kwargs, + ) - return _resolve_auto(tokenizer, config) + return _merge_chat_template_kwargs(config, chat_template_kwargs) -def _resolve_auto(tokenizer, auto: AutoRendererConfig) -> Renderer: +def _resolve_auto_config( + tokenizer, + auto: AutoRendererConfig, + *, + chat_template_kwargs: Mapping[str, Any] | None = None, +) -> RendererConfig: """Map ``AutoRendererConfig`` → concrete typed config via the - tokenizer's ``name_or_path``, then instantiate the matching renderer. + tokenizer's ``name_or_path``. Fine-tunes and renamed checkpoints miss on purpose — their chat template may differ from the original even when the architecture @@ -1540,14 +1488,24 @@ def _resolve_auto(tokenizer, auto: AutoRendererConfig) -> Renderer: model_name = getattr(tokenizer, "name_or_path", "") renderer_name = MODEL_RENDERER_MAP.get(model_name) - preserve_carry = { - "preserve_all_thinking": auto.preserve_all_thinking, - "preserve_thinking_between_tool_calls": auto.preserve_thinking_between_tool_calls, - } + preserve_carry = {} + if auto.thinking_retention is not None: + preserve_carry["thinking_retention"] = auto.thinking_retention if renderer_name is not None: cfg_cls = _config_class_for(renderer_name) - return RENDERER_REGISTRY[renderer_name](tokenizer, cfg_cls(**preserve_carry)) + return _merge_chat_template_kwargs( + cfg_cls(**preserve_carry), + chat_template_kwargs, + ) + + if chat_template_kwargs: + raise ValueError( + "AutoRendererConfig cannot apply chat_template_kwargs for unknown " + f"model {model_name!r}. Pass an explicit model-specific renderer " + "config, or use DefaultRendererConfig explicitly for opaque " + "apply_chat_template kwargs." + ) # No match. For VLMs this must be fatal: DefaultRenderer only knows # ``apply_chat_template`` + text tokens, so it would silently drop @@ -1568,11 +1526,12 @@ def _resolve_auto(tokenizer, auto: AutoRendererConfig) -> Renderer: # Text-only fall back to default (apply_chat_template). For fine-tunes # with customized chat templates this is the *correct* choice, so we # don't warn. Note the pick at INFO and advertise the parser knobs. - if auto.preserve_all_thinking or auto.preserve_thinking_between_tool_calls: + if auto.thinking_retention is not None: raise NotImplementedError( "Auto-resolved DefaultRenderer can't selectively re-emit " "dropped reasoning_content. Pass an explicit typed renderer " - "config (model-specific) if you need preserve_*_thinking." + "config (model-specific) if you need thinking_retention != " + "'template'." ) logger.info( "No model-specific renderer matched %r. Using DefaultRenderer " @@ -1580,7 +1539,7 @@ def _resolve_auto(tokenizer, auto: AutoRendererConfig) -> Renderer: "reasoning_parser=...) to enable structured output parsing.", model_name or "", ) - return RENDERER_REGISTRY["default"](tokenizer, DefaultRendererConfig()) + return DefaultRendererConfig() # --------------------------------------------------------------------------- @@ -1731,104 +1690,28 @@ def trim_to_turn_close( return previous_ids -# Per-model offset-aware tokenizer cache. ``attribute_text_segments`` -# uses the fast HuggingFace tokenizer's ``offset_mapping`` to attribute -# each token to its source text segment under one BPE pass. Fastokens -# (the Rust BPE we patch in by default for ~10x faster encode) does not -# track character offsets — the patched tokenizer's -# ``return_offsets_mapping=True`` raises ``NotImplementedError``. So we -# keep a parallel vanilla tokenizer per model purely for offset queries. -# Memory cost is one extra tokenizer per *unique* model name across all -# pools / renderers (the cache is process-global), independent of pool -# size. -_offset_tokenizers: dict[str, Any] = {} -_offset_tokenizers_lock = threading.Lock() - - def _get_offset_tokenizer(tokenizer): - """Return a tokenizer that supports ``return_offsets_mapping=True``. - - If ``tokenizer`` itself supports offsets, returns it unchanged. - Otherwise loads a vanilla (non-fastokens) tokenizer from - ``tokenizer.name_or_path`` and caches it. Raises if the tokenizer - has no usable ``name_or_path`` — hand-coded renderers always pass - a tokenizer loaded via ``load_tokenizer`` which does set it. + """Assert ``tokenizer`` supports ``return_offsets_mapping=True``. + + Hand-coded renderers concatenate scaffold + body in one BPE pass to + preserve cross-boundary merges, then attribute each resulting token + back to its source segment via the fast tokenizer's + ``offset_mapping`` (see :func:`attribute_text_segments`). The + contract: every BYO tokenizer must be a fast tokenizer with offset + support. Tokenizers loaded via :func:`load_tokenizer` are + ``PreTrainedTokenizerFast`` instances that satisfy this trivially. """ - # Cheap probe: does this tokenizer already provide offsets? try: tokenizer("a", add_special_tokens=False, return_offsets_mapping=True) - return tokenizer - except (NotImplementedError, ValueError, TypeError): - pass - - name_or_path = getattr(tokenizer, "name_or_path", "") - if not name_or_path: + except (NotImplementedError, ValueError, TypeError) as exc: raise RuntimeError( - "Cannot construct an offset-aware tokenizer: the supplied " - "tokenizer has no ``name_or_path`` to fall back on. Pass a " - "tokenizer loaded via ``renderers.base.load_tokenizer``." - ) - - with _offset_tokenizers_lock: - cached = _offset_tokenizers.get(name_or_path) - if cached is not None: - return cached - - load_name_or_path = _tokenizer_source_for(name_or_path) - kwargs = _tokenizer_load_kwargs(load_name_or_path) - - def _has_offsets(tok) -> bool: - if not getattr(tok, "is_fast", False): - return False - try: - tok("a", add_special_tokens=False, return_offsets_mapping=True) - return True - except (NotImplementedError, ValueError, TypeError): - return False - - # We want HF's Rust tokenizer with offset tracking, not the fastokens - # shim. The shim is installed by a *process-global* monkeypatch that - # ``load_tokenizer`` toggles per pool-slot load, so a plain reload here - # can race a concurrent slot's open patch window and silently pick up - # the offset-less shim (then get cached, poisoning the process). So: - # load, verify offsets, and if missing, reload with the patch forced - # off — serialized against pool patch/unpatch via ``_FASTOKENS_PATCH_LOCK`` - # so no concurrent window can swap the shim back in mid-load — then - # restore the prior patch state. Never cache a non-offset tokenizer. - offset_tok = _load_tokenizer_via_auto(load_name_or_path, **kwargs) - offset_tok = _preserve_requested_tokenizer_name( - offset_tok, - requested_name_or_path=name_or_path, - loaded_name_or_path=load_name_or_path, - ) - if not _has_offsets(offset_tok): - import fastokens - - with _FASTOKENS_PATCH_LOCK: - was_patched = bool(getattr(fastokens, "_patched", False)) - if was_patched: - with contextlib.redirect_stdout(io.StringIO()): - fastokens.unpatch_transformers() - try: - offset_tok = _load_tokenizer_via_auto(load_name_or_path, **kwargs) - offset_tok = _preserve_requested_tokenizer_name( - offset_tok, - requested_name_or_path=name_or_path, - loaded_name_or_path=load_name_or_path, - ) - finally: - if was_patched: - with contextlib.redirect_stdout(io.StringIO()): - fastokens.patch_transformers() - if not _has_offsets(offset_tok): - raise RuntimeError( - f"Could not load an offset-capable tokenizer for {name_or_path!r}: " - "offset_mapping is unavailable even with the fastokens patch off. " - "Hand-coded renderers require a fast tokenizer for body/scaffold " - "attribution." - ) - _offset_tokenizers[name_or_path] = offset_tok - return offset_tok + "Hand-coded renderers require a fast tokenizer with " + "``return_offsets_mapping=True`` support for body/scaffold " + "attribution. Pass a tokenizer loaded via " + "``renderers.base.load_tokenizer``, or any " + "``transformers.PreTrainedTokenizerFast`` instance." + ) from exc + return tokenizer def attribute_text_segments( @@ -1852,14 +1735,13 @@ def attribute_text_segments( tokens (rare; usually pre-tokenizer artefacts) are attributed to the most recently entered segment. - Requires a HuggingFace fast tokenizer with offset tracking. The - ``fastokens`` patch ``load_tokenizer`` applies by default does - **not** track offsets — when that's the case we transparently load - a vanilla offset-capable tokenizer for the same model and cache it - (see :func:`_get_offset_tokenizer`). Hand-coded renderers are only - registered for model families that ship a fast tokenizer, so a - silent slow-tokenizer fallback isn't supported — BPE drift at the - wrap/body boundary would defeat the whole point. + Requires a HuggingFace fast tokenizer with offset tracking. Every + model in ``MODEL_RENDERER_MAP`` ships one, so the offset lookup + always succeeds for tokenizers obtained via :func:`load_tokenizer`. + BYO tokenizers must be a ``PreTrainedTokenizerFast`` (or anything + else exposing ``return_offsets_mapping=True``); slow tokenizers + aren't supported — BPE drift at the wrap/body boundary would + defeat the whole point. Empty input or empty joined text returns an empty list. """ @@ -1925,51 +1807,52 @@ def reject_assistant_in_extension(new_messages: list[Message]) -> bool: return any(m.get("role") == "assistant" for m in new_messages) -def should_preserve_past_thinking( - messages: list[Message], - msg_idx: int, +def _is_user_message(message: Message) -> bool: + return message.get("role") == "user" + + +def introduces_user_query( + new_messages: list[Message], *, - preserve_all_thinking: bool, - preserve_thinking_between_tool_calls: bool, + is_user_query: Callable[[Message], bool] = _is_user_message, ) -> bool: - """Should ``messages[msg_idx]``'s ``reasoning_content`` be emitted as - thinking even when the chat template would drop it? - - Returns ``True`` only as an override above the template default. Each - renderer ORs this into its own "render thinking?" condition; a result - of ``False`` means "follow the template" (drop or keep as the template - decides), not "force-drop". - - Override rules: - - - ``preserve_all_thinking`` — every past-asst's thinking is kept. - - ``preserve_thinking_between_tool_calls`` — keeps thinking only - inside the *current* tool cycle: the contiguous A-T-...-A block - after the most recent ``user`` message, and only if that block - contains at least one ``tool`` response. As soon as a new - ``user`` turn arrives, the previous block becomes "older" and - its thinking is dropped (template default), matching how most - chat templates already handle multi-turn contexts. Use - ``preserve_all_thinking`` if you need thinking on older blocks - to survive the user-turn boundary too. + """Return True if ``new_messages`` opens a new user-query turn. + + The generic boundary is any ``role="user"`` message. Renderers whose + chat templates define a narrower notion of query boundary can pass their + own predicate, but the shared default stays role-based. + """ + return any(is_user_query(m) for m in new_messages) + + +def resolve_thinking_retention( + config: Any, + implied: ResolvedThinkingRetention, +) -> ResolvedThinkingRetention: + """Resolve the effective bridge policy for a renderer instance. + + ``config.thinking_retention is None`` means "derive from template knobs"; + otherwise the explicit generic bridge policy wins. Conflicting explicit + template/generic knobs are rejected by the typed config validators. """ - if preserve_all_thinking: + requested = getattr(config, "thinking_retention", None) + if requested is None: + return implied + return requested + + +def should_rerender_for_thinking_retention( + thinking_retention: ResolvedThinkingRetention, + new_messages: list[Message], + *, + is_user_query: Callable[[Message], bool] = _is_user_message, +) -> bool: + """Return True when the resolved policy requires a full re-render.""" + if thinking_retention == "template": return True - if not preserve_thinking_between_tool_calls: + if thinking_retention == "all": return False - # Most recent user message (or -1 if none). - last_user = -1 - for j in range(len(messages) - 1, -1, -1): - if messages[j].get("role") == "user": - last_user = j - break - if msg_idx <= last_user: - return False - # The current segment must contain a tool response for it to count - # as an in-flight tool cycle. - return any( - messages[j].get("role") == "tool" for j in range(last_user + 1, len(messages)) - ) + return introduces_user_query(new_messages, is_user_query=is_user_query) def build_trajectory_step( diff --git a/renderers/configs.py b/renderers/configs.py index 7c185dce..cef2ce61 100644 --- a/renderers/configs.py +++ b/renderers/configs.py @@ -4,14 +4,14 @@ Each renderer accepts its own typed config; bad combinations (e.g. ``add_vision_id`` under ``name="qwen3"``) fail at config-load time with a pydantic ``ValidationError`` rather than at runtime via an allowlist -check. The shared ``preserve_*`` flags live on ``BaseRendererConfig`` -and OR-compose with template-level toggles (e.g. GLM-5 -``clear_thinking``) inside each renderer — they extend retention, never -override the template into a drop. +check. The shared ``thinking_retention`` flag is optional: ``None`` means +"derive bridge policy from this renderer's chat-template knobs"; an +explicit value is a bridge-policy override. ``AutoRendererConfig`` is a placeholder variant: ``create_renderer`` resolves it via ``MODEL_RENDERER_MAP`` and constructs the matching -typed config with the auto config's ``preserve_*`` fields carried over. +typed config with the auto config's ``thinking_retention`` field carried +over when one was explicitly supplied. ``DefaultRendererConfig`` uses ``extra="allow"`` to accept arbitrary Jinja kwargs as ``model_extra`` — ``DefaultRenderer`` doesn't know which @@ -22,10 +22,40 @@ from typing import Annotated, ClassVar, Literal, Union -from pydantic import ConfigDict, Field +from pydantic import ConfigDict, Field, model_validator from pydantic_config import BaseConfig +def _reject_thinking_retention_conflict( + config: BaseConfig, + kwarg_name: str, + *, + true_implies: "ResolvedThinkingRetention", + false_implies: "ResolvedThinkingRetention", +) -> None: + """Raise if explicit template and renderer retention knobs disagree.""" + fields_set = config.__pydantic_fields_set__ + requested = getattr(config, "thinking_retention", None) + if kwarg_name in fields_set and requested is not None: + implied = ( + false_implies if getattr(config, kwarg_name) is False else true_implies + ) + if requested == implied: + return + raise ValueError( + f"{kwarg_name}={getattr(config, kwarg_name)!r} implies " + f"thinking_retention={implied!r}, which conflicts with explicit " + f"thinking_retention={requested!r}." + ) + + +ThinkingRetention = Literal["tool_cycle", "all"] +"""User-facing historical thinking/analysis retention override.""" + +ResolvedThinkingRetention = Literal["template", "tool_cycle", "all"] +"""Internal bridge policy after template kwargs have been resolved.""" + + class BaseRendererConfig(BaseConfig): """Shared fields and config for every renderer config variant. @@ -35,28 +65,27 @@ class BaseRendererConfig(BaseConfig): this class adds ``frozen=True`` so configs are hashable value objects. - ``preserve_all_thinking`` and ``preserve_thinking_between_tool_calls`` - are renderer-internal behaviour flags — they don't map to any Jinja - chat-template kwarg. They OR-compose with template-level toggles on - renderers that expose one (GLM-5 ``clear_thinking``, Nemotron-3 - ``truncate_history_thinking``): either flag saying "keep this - thinking" wins. preserve_* can only ever extend retention; setting - ``preserve_all_thinking=True`` always keeps past thinking, regardless - of the template kwarg. See ``renderers.base.should_preserve_past_thinking``. + ``thinking_retention`` is an optional renderer-level retention override. + Leave it ``None`` to derive the effective policy from the renderer's own + chat-template knobs. Set it explicitly to request retention beyond the + template default; renderers fail loudly when an explicit template knob says + the opposite thing. """ model_config = ConfigDict(frozen=True) - preserve_all_thinking: bool = False - """Restore ``reasoning_content`` on every past assistant turn, even - when the chat template would drop it. Strict superset of - ``preserve_thinking_between_tool_calls``.""" + thinking_retention: ThinkingRetention | None = None + """Explicit retention override, or ``None`` to derive from template knobs: + + - ``None`` — derive the effective bridge policy from this renderer's + chat-template knobs while keeping full renders template-faithful. + - ``"tool_cycle"`` — bridge within the current tool cycle; re-render when + a new user query arrives. + - ``"all"`` — allow bridges across user-query boundaries. - preserve_thinking_between_tool_calls: bool = False - """Restore ``reasoning_content`` only inside the in-flight tool cycle: - the contiguous A-T-...-A block after the most recent ``user`` turn, - and only if it contains at least one ``tool`` response. A new user - turn closes the block and drops its thinking (template default).""" + This does not change full ``render()`` output; full renders stay faithful + to the Python chat-template implementation and its explicit template + kwargs.""" # Fields that are renderer-internal — not forwarded to (or mirrored # by) ``apply_chat_template``. Override in subclasses that hold @@ -87,9 +116,10 @@ def template_field_names(cls) -> frozenset[str]: class AutoRendererConfig(BaseRendererConfig): """Resolve the renderer from ``tokenizer.name_or_path`` at construction - time via ``MODEL_RENDERER_MAP``. Carries only the shared ``preserve_*`` - fields; template kwargs require an explicit renderer choice so that - template-dependent behaviour stays visible at the call site.""" + time via ``MODEL_RENDERER_MAP``. Carries only the shared + ``thinking_retention`` field when explicitly set; template kwargs require + an explicit renderer choice so template-dependent behaviour stays visible + at the call site.""" name: Literal["auto"] = "auto" @@ -119,6 +149,26 @@ class DefaultRendererConfig(BaseRendererConfig): # template. Jinja kwargs live in ``model_extra`` (extra="allow"). _internal_fields = frozenset({"tool_parser", "reasoning_parser"}) + @model_validator(mode="after") + def _reject_legacy_preserve_flags(self): + # ``extra="allow"`` would otherwise swallow the removed ``preserve_*`` + # bools into ``model_extra`` and forward them to apply_chat_template, + # silently dropping the user's intent (DefaultRenderer can't + # selectively re-emit reasoning_content). Reject them like every other + # config's ``extra="forbid"`` does, pointing at the replacement. + legacy = { + "preserve_all_thinking", + "preserve_thinking_between_tool_calls", + } & set(self.model_extra or {}) + if legacy: + raise ValueError( + f"{sorted(legacy)} were replaced by thinking_retention. " + "DefaultRenderer falls back to apply_chat_template and can't " + "selectively re-emit reasoning_content — use thinking_retention " + "on a model-specific renderer." + ) + return self + class Qwen3RendererConfig(BaseRendererConfig): """Qwen3 (text-only) renderer config.""" @@ -166,11 +216,26 @@ class Qwen36RendererConfig(BaseRendererConfig): add_vision_id: bool = False """See :class:`Qwen35RendererConfig.add_vision_id`.""" + preserve_thinking: bool = False + """When ``True``, keep historical ```` blocks even before the + last real user query. Mirrors the Qwen3.6 chat template's native + ``preserve_thinking`` kwarg.""" + image_cache_max: int = 256 """See :class:`Qwen35RendererConfig.image_cache_max`.""" _internal_fields = frozenset({"image_cache_max"}) + @model_validator(mode="after") + def _check_thinking_retention(self): + _reject_thinking_retention_conflict( + self, + "preserve_thinking", + true_implies="all", + false_implies="tool_cycle", + ) + return self + class Qwen3VLRendererConfig(BaseRendererConfig): """Qwen3-VL renderer config.""" @@ -198,9 +263,18 @@ class GLM5RendererConfig(BaseRendererConfig): clear_thinking: bool = True """When ``False``, the renderer keeps ``{reasoning}`` on past-cycle assistant turns instead of dropping them. Mirrors the - chat template's ``clear_thinking`` toggle. OR-composes with - ``preserve_all_thinking`` / ``preserve_thinking_between_tool_calls`` - — see :class:`BaseRendererConfig` for the contract.""" + chat template's ``clear_thinking`` toggle and resolves bridge policy + to ``"all"``.""" + + @model_validator(mode="after") + def _check_thinking_retention(self): + _reject_thinking_retention_conflict( + self, + "clear_thinking", + true_implies="tool_cycle", + false_implies="all", + ) + return self class GLM51RendererConfig(BaseRendererConfig): @@ -215,6 +289,16 @@ class GLM51RendererConfig(BaseRendererConfig): clear_thinking: bool = True """See :class:`GLM5RendererConfig.clear_thinking`.""" + @model_validator(mode="after") + def _check_thinking_retention(self): + _reject_thinking_retention_conflict( + self, + "clear_thinking", + true_implies="tool_cycle", + false_implies="all", + ) + return self + class GLM45RendererConfig(BaseRendererConfig): """GLM-4.5 Air renderer config.""" @@ -260,10 +344,30 @@ class GptOssRendererConfig(BaseRendererConfig): """Override the model-identity line in the preamble. ``None`` uses harmony's built-in default.""" + auto_drop_analysis: bool = True + """Harmony ``RenderConversationConfig.auto_drop_analysis`` behaviour. + ``True`` keeps live tool-cycle analysis but drops stale analysis from + history; ``False`` keeps analysis in all history.""" + _internal_fields = frozenset( - {"use_system_prompt", "knowledge_cutoff", "model_identity"} + { + "use_system_prompt", + "knowledge_cutoff", + "model_identity", + "auto_drop_analysis", + } ) + @model_validator(mode="after") + def _check_thinking_retention(self): + _reject_thinking_retention_conflict( + self, + "auto_drop_analysis", + true_implies="tool_cycle", + false_implies="all", + ) + return self + class Gemma4RendererConfig(BaseRendererConfig): """Gemma 4 renderer config.""" @@ -332,10 +436,11 @@ class LagunaXS2RendererConfig(BaseRendererConfig): class Llama3RendererConfig(BaseRendererConfig): """Llama-3.x Instruct renderer config. - Llama-3 ships no reasoning channel, so the base ``preserve_*_thinking`` - flags don't apply: ``Llama3Renderer`` raises ``NotImplementedError`` - if either is set (matching ``DefaultRenderer``'s contract for the - same case). Both fields below mirror real ``apply_chat_template`` + Llama-3 ships no reasoning channel, so the base ``thinking_retention`` + flag is a no-op: there's never any past-assistant thinking to retain + or drop, so any level leaves the token stream unchanged (same contract + as Kimi-K2 / Qwen3-VL). Both fields below mirror real + ``apply_chat_template`` kwargs. """ @@ -383,9 +488,18 @@ class Nemotron3RendererConfig(BaseRendererConfig): truncate_history_thinking: bool = True """When ``False``, keep ``{reasoning}`` on past-cycle assistant turns instead of dropping them. Mirrors the chat - template's ``truncate_history_thinking`` toggle. OR-composes with - ``preserve_all_thinking`` / ``preserve_thinking_between_tool_calls`` - — see :class:`BaseRendererConfig` for the contract.""" + template's ``truncate_history_thinking`` toggle and resolves bridge + policy to ``"all"``.""" + + @model_validator(mode="after") + def _check_thinking_retention(self): + _reject_thinking_retention_conflict( + self, + "truncate_history_thinking", + true_implies="tool_cycle", + false_implies="all", + ) + return self low_effort: bool = False """When ``True``, append ``\\n\\n{reasoning effort: low}`` to the last user @@ -415,6 +529,16 @@ class Nemotron3UltraRendererConfig(BaseRendererConfig): truncate_history_thinking: bool = True """See :class:`Nemotron3RendererConfig.truncate_history_thinking`.""" + @model_validator(mode="after") + def _check_thinking_retention(self): + _reject_thinking_retention_conflict( + self, + "truncate_history_thinking", + true_implies="tool_cycle", + false_implies="all", + ) + return self + medium_effort: bool = False """When ``True``, append ``\\n\\n{reasoning effort: efficient}`` to the last user message. Mirrors the Ultra chat template's ``medium_effort`` kwarg.""" @@ -437,9 +561,10 @@ class DeepSeekR1RendererConfig(BaseRendererConfig): R1 always reasons — its chat template unconditionally prefills ``\\n`` at the generation prompt and strips ```` from historical assistant turns. There is therefore no ``enable_thinking`` - knob (thinking is not optional), and ``preserve_*`` flags are no-ops - (history reasoning is always dropped); both stored for protocol - uniformity. Applies to full ``deepseek-ai/DeepSeek-R1`` / ``-R1-0528`` + knob (thinking is not optional). With ``thinking_retention=None`` the + resolved bridge policy is ``"template"``; explicit ``"tool_cycle"`` / + ``"all"`` are bridge-policy overrides. Applies to full + ``deepseek-ai/DeepSeek-R1`` / ``-R1-0528`` — NOT the R1-Distill-Qwen/Llama models, which use those base tokenizers and route to the Qwen3 / Llama-3 renderers. """ @@ -485,7 +610,7 @@ class DeepSeekR1RendererConfig(BaseRendererConfig): # Map discriminator → config class. Used by ``create_renderer`` when # resolving ``AutoRendererConfig`` against ``MODEL_RENDERER_MAP``: the # resolved renderer name picks the corresponding typed config, and the -# auto config's ``preserve_*`` fields are carried over. +# auto config's ``thinking_retention`` field is carried over. _CONFIG_BY_NAME: dict[str, type[BaseRendererConfig]] = { "auto": AutoRendererConfig, "default": DefaultRendererConfig, @@ -557,5 +682,7 @@ def config_from_name(name: str) -> BaseRendererConfig | None: "Qwen3RendererConfig", "Qwen3VLRendererConfig", "RendererConfig", + "ResolvedThinkingRetention", + "ThinkingRetention", "config_from_name", ] diff --git a/renderers/deepseek_r1.py b/renderers/deepseek_r1.py index 6612c22a..23d46e44 100644 --- a/renderers/deepseek_r1.py +++ b/renderers/deepseek_r1.py @@ -31,6 +31,7 @@ class DeepSeekR1Renderer(DeepSeekV3Renderer): """Deterministic message → token renderer for DeepSeek-R1 models.""" _config_cls: type = DeepSeekR1RendererConfig + _implied_thinking_retention = "template" _GEN_THINK_PREFILL: str = "\n" def _prepare_assistant_content(self, msg: Message) -> str: diff --git a/renderers/deepseek_v3.py b/renderers/deepseek_v3.py index 5f4840a1..a00f1f20 100644 --- a/renderers/deepseek_v3.py +++ b/renderers/deepseek_v3.py @@ -24,6 +24,8 @@ attribute_text_segments, extract_message_tool_names, reject_assistant_in_extension, + resolve_thinking_retention, + should_rerender_for_thinking_retention, trim_to_turn_close, ) from renderers.configs import DeepSeekV3RendererConfig @@ -48,12 +50,13 @@ class DeepSeekV3Renderer: assistant content is emitted verbatim. The reasoning variant (````-prefilled prompt, history reasoning stripped) lives in :class:`renderers.deepseek_r1.DeepSeekR1Renderer`, which subclasses - this one. ``preserve_*`` flags are no-ops here (no reasoning channel), + this one. ``thinking_retention`` is a no-op here (no reasoning channel), stored for protocol uniformity. """ #: Default typed config; the R1 subclass overrides this. _config_cls: type = DeepSeekV3RendererConfig + _implied_thinking_retention = "all" #: Generation-prompt reasoning prefill. Empty for V3 (bare #: ``<|Assistant|>``); the R1 subclass overrides to ``"\n"``. _GEN_THINK_PREFILL: str = "" @@ -65,6 +68,10 @@ def __init__( ): self._tokenizer = tokenizer self.config = config or type(self)._config_cls() + self.effective_thinking_retention = resolve_thinking_retention( + self.config, + self._implied_thinking_retention, + ) # ── BOS / EOS ──────────────────────────────────────────────── self._bos = self._get_special_token(f"begin{_US}of{_US}sentence") @@ -305,6 +312,11 @@ def bridge_to_next_turn( or reject_assistant_in_extension(new_messages) ): return None + if should_rerender_for_thinking_retention( + self.effective_thinking_retention, + new_messages, + ): + return None previous_ids = trim_to_turn_close( previous_prompt_ids, diff --git a/renderers/default.py b/renderers/default.py index a6620976..785a5375 100644 --- a/renderers/default.py +++ b/renderers/default.py @@ -19,6 +19,7 @@ RenderedTokens, ToolSpec, extract_message_tool_names, + resolve_thinking_retention, ) from renderers.configs import DefaultRendererConfig from renderers.parsers import ( @@ -95,12 +96,16 @@ def __init__( config: DefaultRendererConfig | None = None, ): cfg = config or DefaultRendererConfig() - if cfg.preserve_all_thinking or cfg.preserve_thinking_between_tool_calls: - raise NotImplementedError( - "DefaultRenderer falls back to apply_chat_template and can't " - "selectively re-emit dropped reasoning_content. Configure a " - "model-specific renderer if you need preserve_*_thinking." + if cfg.thinking_retention is not None: + raise ValueError( + "DefaultRenderer cannot implement explicit thinking_retention " + "bridge policy because its template close/turn structure is " + "opaque. Use a typed renderer for this model." ) + self.effective_thinking_retention = resolve_thinking_retention( + cfg, + "template", + ) self._tokenizer = tokenizer self.config = cfg self._tool_parser = _resolve_parser(cfg.tool_parser, tokenizer, get_tool_parser) diff --git a/renderers/gemma4.py b/renderers/gemma4.py index 42392fee..9bbaf21c 100644 --- a/renderers/gemma4.py +++ b/renderers/gemma4.py @@ -20,7 +20,8 @@ ToolSpec, _get_offset_tokenizer, extract_message_tool_names, - should_preserve_past_thinking, + resolve_thinking_retention, + should_rerender_for_thinking_retention, trim_to_turn_close, ) from renderers.configs import Gemma4RendererConfig @@ -67,6 +68,13 @@ def __init__( ): self._tokenizer = tokenizer self.config = config or Gemma4RendererConfig() + # Default to retaining thinking across turns: the bridge reuses the prior + # prefix verbatim (token-faithful prefix continuation / stable KV cache). + # Configurable via thinking_retention (e.g. "tool_cycle" to drop at a new + # user-query boundary). + self.effective_thinking_retention = resolve_thinking_retention( + self.config, "all" + ) self._turn_end = self._token_id("") self._tool_call = self._token_id("<|tool_call>") self._tool_call_end = self._token_id("") @@ -583,23 +591,17 @@ def emit( is_assistant = role == "assistant" tool_calls = msg.get("tool_calls") or [] thinking_text = msg.get("reasoning") or msg.get("reasoning_content") - preserve_thinking = is_assistant and should_preserve_past_thinking( - messages, - msg_idx, - preserve_all_thinking=self.config.preserve_all_thinking, - preserve_thinking_between_tool_calls=self.config.preserve_thinking_between_tool_calls, - ) + # render() is retention-agnostic (canonical thinking_retention model, + # matching upstream renderers): only the current tool cycle's reasoning + # is emitted inline; cross-turn retention is handled by + # bridge_to_next_turn via should_rerender_for_thinking_retention. native_thinking = ( is_assistant and isinstance(thinking_text, str) and local_idx > last_user and bool(tool_calls) ) - if ( - (native_thinking or preserve_thinking) - and isinstance(thinking_text, str) - and thinking_text - ): + if native_thinking and thinking_text: emit("<|channel>thought\n", msg_idx, is_sampled=True, is_content=True) emit(thinking_text, msg_idx, is_sampled=True, is_content=True) emit("\n", msg_idx, is_sampled=True, is_content=True) @@ -765,6 +767,9 @@ def bridge_to_next_turn( or not new_messages or self._uses_tooling(new_messages, tools) or any(msg.get("role") == "assistant" for msg in new_messages) + or should_rerender_for_thinking_retention( + self.effective_thinking_retention, new_messages + ) ): return None diff --git a/renderers/glm45.py b/renderers/glm45.py index 7af92598..bfc5f09c 100644 --- a/renderers/glm45.py +++ b/renderers/glm45.py @@ -23,7 +23,8 @@ attribute_text_segments, extract_message_tool_names, reject_assistant_in_extension, - should_preserve_past_thinking, + resolve_thinking_retention, + should_rerender_for_thinking_retention, ) from renderers.configs import GLM45RendererConfig from renderers.parsing import parse_glm @@ -59,6 +60,10 @@ def __init__( ): self._tokenizer = tokenizer self.config = config or GLM45RendererConfig() + self.effective_thinking_retention = resolve_thinking_retention( + self.config, + "all" if not self.config.enable_thinking else "tool_cycle", + ) self._gmask = self._token_id("[gMASK]") self._sop = self._token_id("") @@ -225,18 +230,11 @@ def emit_text_segments( emit_text_segments(user_segments, i, is_sampled=False) elif role == "assistant": - preserve_thinking = should_preserve_past_thinking( - messages, - i, - preserve_all_thinking=self.config.preserve_all_thinking, - preserve_thinking_between_tool_calls=self.config.preserve_thinking_between_tool_calls, - ) self._render_assistant( msg, i, content, last_ui, - preserve_thinking=preserve_thinking, emit_special=emit_special, emit_text=emit_text, emit_text_segments=emit_text_segments, @@ -321,6 +319,12 @@ def bridge_to_next_turn( ): return None + if should_rerender_for_thinking_retention( + self.effective_thinking_retention, + new_messages, + ): + return None + # Same next-turn-marker scheme as GLM-5, but role markers are # followed by a literal ``\n`` in the prompt text. previous_ids = list(previous_prompt_ids) + list(previous_completion_ids) @@ -457,7 +461,6 @@ def _render_assistant( content, last_user_index, *, - preserve_thinking: bool = False, emit_special, emit_text, emit_text_segments, @@ -495,7 +498,7 @@ def _render_assistant( emit_special(self._assistant, msg_idx, is_sampled=False, is_content=False) emit_text("\n", msg_idx, is_sampled=False, is_content=False) - if (msg_idx > last_user_index or preserve_thinking) and reasoning_content: + if msg_idx > last_user_index and reasoning_content: emit_special(self._think, msg_idx, is_sampled=True, is_content=True) emit_text( reasoning_content.strip(), msg_idx, is_sampled=True, is_content=True diff --git a/renderers/glm5.py b/renderers/glm5.py index 924d754c..4f34d98f 100644 --- a/renderers/glm5.py +++ b/renderers/glm5.py @@ -24,7 +24,8 @@ attribute_text_segments, extract_message_tool_names, reject_assistant_in_extension, - should_preserve_past_thinking, + resolve_thinking_retention, + should_rerender_for_thinking_retention, ) from renderers.configs import GLM5RendererConfig, GLM51RendererConfig from renderers.parsing import parse_glm @@ -68,6 +69,16 @@ def __init__( ): self._tokenizer = tokenizer self.config = config or type(self)._config_cls() + if not self.config.clear_thinking: + implied_thinking_retention = "all" + elif not self.config.enable_thinking: + implied_thinking_retention = "all" + else: + implied_thinking_retention = "tool_cycle" + self.effective_thinking_retention = resolve_thinking_retention( + self.config, + implied_thinking_retention, + ) self._gmask = self._token_id("[gMASK]") self._sop = self._token_id("") @@ -238,18 +249,11 @@ def emit_text_segments( emit_text(content, i, is_sampled=False, is_content=True) elif role == "assistant": - preserve_thinking = should_preserve_past_thinking( - messages, - i, - preserve_all_thinking=self.config.preserve_all_thinking, - preserve_thinking_between_tool_calls=self.config.preserve_thinking_between_tool_calls, - ) self._render_assistant( msg, i, content, last_ui, - preserve_thinking=preserve_thinking, emit_special=emit_special, emit_text=emit_text, emit_text_segments=emit_text_segments, @@ -337,6 +341,12 @@ def bridge_to_next_turn( ): return None + if should_rerender_for_thinking_retention( + self.effective_thinking_retention, + new_messages, + ): + return None + # GLM has no per-turn close token. An assistant turn ends when the # next turn's role marker appears, OR the model emits <|endoftext|>. # vLLM includes these in ``stop_token_ids`` so a clean stop leaves @@ -468,7 +478,6 @@ def _render_assistant( content, last_user_index, *, - preserve_thinking: bool = False, emit_special, emit_text, emit_text_segments, @@ -498,17 +507,12 @@ def _render_assistant( # Chat-template default: keep ```` only on the in-flight cycle # (post-last-user). Past-cycle assistants drop their reasoning. - # ``preserve_thinking`` is the override output of - # ``should_preserve_past_thinking`` — it adds historical assistants - # back when the renderer was constructed with - # ``preserve_all_thinking=True``. ``clear_thinking=False`` mirrors + # ``clear_thinking=False`` mirrors # the template's per-call ``clear_thinking is defined and not # clear_thinking`` gate: a chat_template_kwarg surface for the # same behaviour, gated explicitly by the caller per render. include_thinking = ( - msg_idx > last_user_index - or preserve_thinking - or not self.config.clear_thinking + msg_idx > last_user_index or not self.config.clear_thinking ) and reasoning_content if include_thinking: diff --git a/renderers/gpt_oss.py b/renderers/gpt_oss.py index 2a9c5caa..6165ed09 100644 --- a/renderers/gpt_oss.py +++ b/renderers/gpt_oss.py @@ -58,7 +58,8 @@ ToolSpec, extract_message_tool_names, reject_assistant_in_extension, - should_preserve_past_thinking, + resolve_thinking_retention, + should_rerender_for_thinking_retention, trim_to_turn_close, ) from renderers.configs import GptOssRendererConfig @@ -134,6 +135,10 @@ def __init__( """ self._tokenizer = tokenizer self.config = config or GptOssRendererConfig() + self.effective_thinking_retention = resolve_thinking_retention( + self.config, + "tool_cycle" if self.config.auto_drop_analysis else "all", + ) self._enc: HarmonyEncoding = load_harmony_encoding( HarmonyEncodingName.HARMONY_GPT_OSS ) @@ -421,14 +426,7 @@ def emit_harmony_message( if i == first_system_idx: continue # already emitted as developer is_assistant = msg.get("role") == "assistant" - preserve_thinking = is_assistant and ( - should_preserve_past_thinking( - messages, - i, - preserve_all_thinking=self.config.preserve_all_thinking, - preserve_thinking_between_tool_calls=self.config.preserve_thinking_between_tool_calls, - ) - ) + preserve_thinking = is_assistant and self._should_emit_analysis(messages, i) for hm in self._to_harmony_messages( msg, preserve_thinking=preserve_thinking ): @@ -524,6 +522,12 @@ def bridge_to_next_turn( ): return None + if should_rerender_for_thinking_retention( + self.effective_thinking_retention, + new_messages, + ): + return None + previous_ids = trim_to_turn_close( previous_prompt_ids, previous_completion_ids, @@ -601,6 +605,26 @@ def bridge_to_next_turn( # ── message conversion ─────────────────────────────────────────────────── + def _should_emit_analysis(self, messages: list[Message], msg_idx: int) -> bool: + """Whether to render ``reasoning_content`` as a harmony analysis message.""" + if not self.config.auto_drop_analysis: + return True + + msg = messages[msg_idx] + if not msg.get("tool_calls"): + return False + + # Harmony keeps analysis for an unfinished tool-call cycle, but once a + # later final assistant answer is present it drops the stale analysis. + for later in messages[msg_idx + 1 :]: + if later.get("role") != "assistant": + continue + if later.get("tool_calls"): + continue + if _content_text(later.get("content")): + return False + return True + def _to_harmony_messages( self, msg: Message, *, preserve_thinking: bool = False ) -> list[HarmonyMessage]: @@ -670,8 +694,9 @@ def _assistant_to_harmony( its analysis block is dropped from context. ``preserve_thinking=True``: prepend an analysis-channel message - carrying ``reasoning_content`` so callers that want the trace in - history (e.g. tool-call-chain training) see it surface. + carrying ``reasoning_content``. The render path sets this from + harmony's own ``auto_drop_analysis`` behaviour, not from generic + ``thinking_retention``. """ out: list[HarmonyMessage] = [] diff --git a/renderers/kimi_k2.py b/renderers/kimi_k2.py index e99dfa7a..73376003 100644 --- a/renderers/kimi_k2.py +++ b/renderers/kimi_k2.py @@ -25,6 +25,8 @@ ToolSpec, extract_message_tool_names, reject_assistant_in_extension, + resolve_thinking_retention, + should_rerender_for_thinking_retention, trim_to_turn_close, ) from renderers.configs import KimiK2RendererConfig @@ -38,9 +40,9 @@ class KimiK2Renderer: Kimi K2's chat template doesn't read any thinking-related variable — ``content`` renders verbatim with no reasoning branch. The - ``enable_thinking`` / ``preserve_*`` fields on the config are stored - for protocol uniformity with the rest of the renderer family but - have no effect on the byte-level output. + ``enable_thinking`` / ``thinking_retention`` fields on the config are + stored for protocol uniformity with the rest of the renderer family + but have no effect on the byte-level output. """ def __init__( @@ -50,6 +52,10 @@ def __init__( ): self._tokenizer = tokenizer self.config = config or KimiK2RendererConfig() + self.effective_thinking_retention = resolve_thinking_retention( + self.config, + "all", + ) self._im_user = self._token_id("<|im_user|>") self._im_assistant = self._token_id("<|im_assistant|>") @@ -356,6 +362,11 @@ def bridge_to_next_turn( or reject_assistant_in_extension(new_messages) ): return None + if should_rerender_for_thinking_retention( + self.effective_thinking_retention, + new_messages, + ): + return None previous_ids = trim_to_turn_close( previous_prompt_ids, diff --git a/renderers/kimi_k25.py b/renderers/kimi_k25.py index bca44648..82359339 100644 --- a/renderers/kimi_k25.py +++ b/renderers/kimi_k25.py @@ -38,7 +38,8 @@ ToolSpec, extract_message_tool_names, reject_assistant_in_extension, - should_preserve_past_thinking, + resolve_thinking_retention, + should_rerender_for_thinking_retention, trim_to_turn_close, ) from renderers.configs import KimiK25RendererConfig @@ -599,6 +600,10 @@ def __init__( self._tokenizer = tokenizer self._processor = processor self.config = config or KimiK25RendererConfig() + self.effective_thinking_retention = resolve_thinking_retention( + self.config, + "tool_cycle" if self.config.thinking else "all", + ) # Core structural tokens — all must be single special tokens in the vocab self._im_user = self._token_id("<|im_user|>") @@ -885,17 +890,10 @@ def emit_image( # Body if role == "assistant": is_suffix = i > last_non_tc_assistant - preserve_thinking = should_preserve_past_thinking( - messages, - i, - preserve_all_thinking=self.config.preserve_all_thinking, - preserve_thinking_between_tool_calls=self.config.preserve_thinking_between_tool_calls, - ) self._render_assistant_body( msg, i, is_suffix=is_suffix, - preserve_thinking=preserve_thinking, emit_special=emit_special, emit_text=emit_text, ) @@ -1036,6 +1034,12 @@ def bridge_to_next_turn( ): return None + if should_rerender_for_thinking_retention( + self.effective_thinking_retention, + new_messages, + ): + return None + close_ids: set[int] = {self._im_end} if self._endoftext is not None: close_ids.add(self._endoftext) @@ -1311,7 +1315,6 @@ def _render_assistant_body( msg_idx: int, *, is_suffix: bool, - preserve_thinking: bool = False, emit_special, emit_text, ) -> None: @@ -1366,7 +1369,7 @@ def _render_assistant_body( # ``render`` (also is_sampled=True; it's the model's stop # signal). On assistant tokens ``is_content == sampled_mask`` by # construction. - if is_suffix or (preserve_thinking and reasoning_content): + if is_suffix: emit_text( f"{reasoning_content}", msg_idx, diff --git a/renderers/laguna_xs2.py b/renderers/laguna_xs2.py index bd6b64f2..fb71f6d9 100644 --- a/renderers/laguna_xs2.py +++ b/renderers/laguna_xs2.py @@ -17,10 +17,8 @@ a ``### Tools`` header with an ```` listing and prose format instructions that vary on ``enable_thinking``). - Reasoning is rendered for every assistant message — no last-user-index - gating. ``preserve_all_thinking`` and - ``preserve_thinking_between_tool_calls`` are accepted for protocol - uniformity but are effectively no-ops since past reasoning is preserved - by default. + gating. ``thinking_retention`` is accepted for protocol uniformity but + is effectively a no-op since past reasoning is preserved by default. """ from __future__ import annotations @@ -38,6 +36,8 @@ attribute_text_segments, extract_message_tool_names, reject_assistant_in_extension, + resolve_thinking_retention, + should_rerender_for_thinking_retention, ) from renderers.configs import LagunaXS2RendererConfig from renderers.parsing import parse_laguna_xs2 @@ -85,6 +85,10 @@ def __init__( ): self._tokenizer = tokenizer self.config = config or LagunaXS2RendererConfig() + self.effective_thinking_retention = resolve_thinking_retention( + self.config, + "all", + ) self._eos = self._token_id("〈|EOS|〉") self._think = self._token_id("") @@ -326,6 +330,11 @@ def bridge_to_next_turn( or reject_assistant_in_extension(new_messages) ): return None + if should_rerender_for_thinking_retention( + self.effective_thinking_retention, + new_messages, + ): + return None # The canonical assistant-turn close is ````. ``〈|EOS|〉`` # also stops generation; either being the final token means the turn diff --git a/renderers/llama_3.py b/renderers/llama_3.py index d15792a6..d18d8c87 100644 --- a/renderers/llama_3.py +++ b/renderers/llama_3.py @@ -9,7 +9,7 @@ Notable differences from the Qwen / GLM family renderers: * No ```` / reasoning channel — Llama-3 doesn't ship a - reasoning-content concept, so ``preserve_*_thinking`` flags don't + reasoning-content concept, so the ``thinking_retention`` flag doesn't apply. * ``<|begin_of_text|>`` (BOS) is emitted at the very start of every render. The chat template never omits it. @@ -51,6 +51,8 @@ attribute_text_segments, extract_message_tool_names, reject_assistant_in_extension, + resolve_thinking_retention, + should_rerender_for_thinking_retention, trim_to_turn_close, ) from renderers.configs import Llama3RendererConfig @@ -94,14 +96,18 @@ def __init__( tokenizer: PreTrainedTokenizer, config: Llama3RendererConfig | None = None, ): - # ``preserve_*_thinking`` are accepted but no-ops: Llama-3 ships no + # ``thinking_retention`` is accepted but a no-op: Llama-3 ships no # reasoning_content channel, so there's never any past-assistant - # thinking to retain or drop. The flags are stored on ``self.config`` - # for cross-renderer uniformity but never change the token stream — + # thinking to retain or drop. The level is stored on ``self.config`` + # for cross-renderer uniformity but never changes the token stream — # the same contract as Kimi-K2 / Qwen3-VL (see the never-preserves # renderers in tests/test_preserve_thinking.py). self._tokenizer = tokenizer self.config = config or Llama3RendererConfig() + self.effective_thinking_retention = resolve_thinking_retention( + self.config, + "all", + ) self._bos = self._token_id("<|begin_of_text|>") self._start_header = self._token_id("<|start_header_id|>") @@ -434,6 +440,11 @@ def bridge_to_next_turn( or reject_assistant_in_extension(new_messages) ): return None + if should_rerender_for_thinking_retention( + self.effective_thinking_retention, + new_messages, + ): + return None previous_ids = trim_to_turn_close( previous_prompt_ids, diff --git a/renderers/minimax_m2.py b/renderers/minimax_m2.py index f990274d..a7f0bc70 100644 --- a/renderers/minimax_m2.py +++ b/renderers/minimax_m2.py @@ -24,7 +24,8 @@ attribute_text_segments, extract_message_tool_names, reject_assistant_in_extension, - should_preserve_past_thinking, + resolve_thinking_retention, + should_rerender_for_thinking_retention, trim_to_turn_close, ) from renderers.configs import MiniMaxM2RendererConfig @@ -61,6 +62,10 @@ def __init__( ): self._tokenizer = tokenizer self.config = config or MiniMaxM2RendererConfig() + self.effective_thinking_retention = resolve_thinking_retention( + self.config, + "tool_cycle", + ) self._bos = self._token_id("]~!b[") self._role = self._token_id("]~b]") @@ -237,18 +242,11 @@ def emit_token_overlap_body( emit_text("\n", orig_idx, is_sampled=False, is_content=False) elif role == "assistant": - preserve_thinking = should_preserve_past_thinking( - messages, - orig_idx, - preserve_all_thinking=self.config.preserve_all_thinking, - preserve_thinking_between_tool_calls=self.config.preserve_thinking_between_tool_calls, - ) self._render_assistant( msg, orig_idx, ci, last_ui, - preserve_thinking=preserve_thinking, emit_special=emit_special, emit_text=emit_text, emit_text_segments=emit_text_segments, @@ -330,6 +328,12 @@ def bridge_to_next_turn( ): return None + if should_rerender_for_thinking_retention( + self.effective_thinking_retention, + new_messages, + ): + return None + previous_ids = trim_to_turn_close( previous_prompt_ids, previous_completion_ids, @@ -471,7 +475,6 @@ def _render_assistant( conv_idx, last_user_index, *, - preserve_thinking: bool = False, emit_special, emit_text, emit_text_segments, @@ -502,9 +505,7 @@ def _render_assistant( # ``is_content == sampled_mask`` holds — every sampled token is # body, every scaffold token isn't. tool_calls = msg.get("tool_calls") or [] - emit_thinking = reasoning_content and ( - conv_idx > last_user_index or preserve_thinking - ) + emit_thinking = reasoning_content and conv_idx > last_user_index if emit_thinking: # The thinking branch has the ```` special token diff --git a/renderers/nemotron3.py b/renderers/nemotron3.py index c29129c0..5cc76c91 100644 --- a/renderers/nemotron3.py +++ b/renderers/nemotron3.py @@ -27,7 +27,8 @@ attribute_text_segments, extract_message_tool_names, reject_assistant_in_extension, - should_preserve_past_thinking, + resolve_thinking_retention, + should_rerender_for_thinking_retention, trim_to_turn_close, ) from renderers.configs import Nemotron3RendererConfig, Nemotron3UltraRendererConfig @@ -117,6 +118,16 @@ def __init__( self._tokenizer = tokenizer cfg = config or type(self)._config_cls() self.config = cfg + if not cfg.truncate_history_thinking: + implied_thinking_retention = "all" + elif not cfg.enable_thinking: + implied_thinking_retention = "all" + else: + implied_thinking_retention = "tool_cycle" + self.effective_thinking_retention = resolve_thinking_retention( + cfg, + implied_thinking_retention, + ) # Resolve the per-variant reasoning-effort hint appended to the last # user message. Ultra honours ``medium_effort``; Super honours @@ -423,20 +434,9 @@ def emit_text_segments( elif role == "assistant": # Template: ``include_content = not (truncate_history_thinking - # and loop.index0 < last_user_idx)``. The renderer-internal - # preserve_* overrides only ever *extend* retention, so OR them - # in (a preserved turn keeps its thinking even when the - # template default would drop it). - preserve_thinking = msg_orig_idx >= 0 and should_preserve_past_thinking( - original_messages, - msg_orig_idx, - preserve_all_thinking=self.config.preserve_all_thinking, - preserve_thinking_between_tool_calls=self.config.preserve_thinking_between_tool_calls, - ) + # and loop.index0 < last_user_idx)``. include_content = ( - not self.config.truncate_history_thinking - or i >= last_user_idx_norm - or preserve_thinking + not self.config.truncate_history_thinking or i >= last_user_idx_norm ) self._render_assistant( msg, @@ -542,6 +542,12 @@ def bridge_to_next_turn( ): return None + if should_rerender_for_thinking_retention( + self.effective_thinking_retention, + new_messages, + ): + return None + close_ids: set[int] = {self._im_end} if self._endoftext is not None: close_ids.add(self._endoftext) @@ -704,8 +710,9 @@ def _assistant_body( """Assemble the assistant body string exactly as the chat template. ``include_content`` is the template's ``not (truncate_history_thinking - and loop.index0 < last_user_idx)`` (already OR-ed with the preserve_* - overrides by the caller): ``True`` keeps the full think+content block, + and loop.index0 < last_user_idx)`` (already OR-ed with the + ``thinking_retention`` override by the caller): ``True`` keeps the + full think+content block, ``False`` collapses historical thinking to an empty ````. """ ultra = self._ultra diff --git a/renderers/qwen3.py b/renderers/qwen3.py index 9253b3d3..baa6af4e 100644 --- a/renderers/qwen3.py +++ b/renderers/qwen3.py @@ -21,7 +21,8 @@ attribute_text_segments, extract_message_tool_names, reject_assistant_in_extension, - should_preserve_past_thinking, + resolve_thinking_retention, + should_rerender_for_thinking_retention, trim_to_turn_close, ) from renderers.configs import Qwen3RendererConfig @@ -54,6 +55,10 @@ def __init__( ): self._tokenizer = tokenizer self.config = config or Qwen3RendererConfig() + self.effective_thinking_retention = resolve_thinking_retention( + self.config, + "all" if not self.config.enable_thinking else "tool_cycle", + ) self._im_start = self._token_id("<|im_start|>") self._im_end = self._token_id("<|im_end|>") @@ -76,19 +81,34 @@ def _encode(self, text: str) -> list[int]: return [] return self._tokenizer.encode(text, add_special_tokens=False) + @staticmethod + def _query_boundary_text(content) -> str: + if isinstance(content, str): + return content.strip() + if isinstance(content, list): + parts: list[str] = [] + for item in content: + if isinstance(item, str): + parts.append(item) + elif isinstance(item, dict) and isinstance(item.get("text"), str): + parts.append(item["text"]) + return "".join(parts).strip() + return "" + + @staticmethod + def _is_user_query_message(msg: Message) -> bool: + if msg.get("role") != "user": + return False + content = Qwen3Renderer._query_boundary_text(msg.get("content")) + return not ( + content.startswith("") + and content.endswith("") + ) + @staticmethod def _last_query_index(messages: list[Message]) -> int: for i in range(len(messages) - 1, -1, -1): - msg = messages[i] - if msg.get("role") != "user": - continue - content = msg.get("content") - if not isinstance(content, str): - continue - if not ( - content.startswith("") - and content.endswith("") - ): + if Qwen3Renderer._is_user_query_message(messages[i]): return i return len(messages) - 1 @@ -206,19 +226,12 @@ def emit_text_segments( emit_text("\n", i, is_sampled=False, is_content=False) elif role == "assistant": - preserve_thinking = should_preserve_past_thinking( - messages, - i, - preserve_all_thinking=self.config.preserve_all_thinking, - preserve_thinking_between_tool_calls=self.config.preserve_thinking_between_tool_calls, - ) self._render_assistant( msg, i, content, last_qi, i == num_messages - 1, - preserve_thinking=preserve_thinking, emit_special=emit_special, emit_text=emit_text, emit_text_segments=emit_text_segments, @@ -298,6 +311,13 @@ def bridge_to_next_turn( ): return None + if should_rerender_for_thinking_retention( + self.effective_thinking_retention, + new_messages, + is_user_query=self._is_user_query_message, + ): + return None + previous_ids = trim_to_turn_close( previous_prompt_ids, previous_completion_ids, @@ -418,7 +438,6 @@ def _render_assistant( last_query_index, is_last, *, - preserve_thinking: bool = False, emit_special, emit_text, emit_text_segments, @@ -458,8 +477,7 @@ def _render_assistant( emit_in_template_window = msg_idx > last_query_index and ( is_last or reasoning_content ) - emit_via_override = preserve_thinking and bool(reasoning_content) - if emit_in_template_window or emit_via_override: + if emit_in_template_window: body = ( "\n" + reasoning_content.strip("\n") diff --git a/renderers/qwen35.py b/renderers/qwen35.py index cdb8ee1c..3ebbbc63 100644 --- a/renderers/qwen35.py +++ b/renderers/qwen35.py @@ -29,7 +29,8 @@ attribute_text_segments, extract_message_tool_names, reject_assistant_in_extension, - should_preserve_past_thinking, + resolve_thinking_retention, + should_rerender_for_thinking_retention, trim_to_turn_close, ) from renderers.configs import Qwen35RendererConfig @@ -131,6 +132,16 @@ def __init__( update={"enable_thinking": _default_enable_thinking(tokenizer)} ) self.config = cfg + if getattr(cfg, "preserve_thinking", False): + implied_thinking_retention = "all" + elif not cfg.enable_thinking: + implied_thinking_retention = "all" + else: + implied_thinking_retention = "tool_cycle" + self.effective_thinking_retention = resolve_thinking_retention( + cfg, + implied_thinking_retention, + ) # Look up special token IDs from the tokenizer (not hardcoded) self._im_start = self._token_id("<|im_start|>") @@ -260,6 +271,16 @@ def _render_content(content: Any) -> str: # last_query_index computation # ------------------------------------------------------------------ + @staticmethod + def _is_user_query_message(msg: Message) -> bool: + if msg.get("role") != "user": + return False + content = Qwen35Renderer._render_content(msg.get("content")).strip() + return not ( + content.startswith("") + and content.endswith("") + ) + @staticmethod def _last_query_index(messages: list[Message]) -> int: """Find the index of the last 'real' user query (not a tool_response wrapper). @@ -272,14 +293,7 @@ def _last_query_index(messages: list[Message]) -> int: assistant-only inputs (e.g. the bridge's dummy-assistant render). """ for i in range(len(messages) - 1, -1, -1): - msg = messages[i] - if msg.get("role") != "user": - continue - content = Qwen35Renderer._render_content(msg.get("content")).strip() - if not ( - content.startswith("") - and content.endswith("") - ): + if Qwen35Renderer._is_user_query_message(messages[i]): return i return len(messages) @@ -508,18 +522,11 @@ def flush_buf() -> None: emit_text("\n", i, is_sampled=False, is_content=False) elif role == "assistant": - preserve_thinking = should_preserve_past_thinking( - messages, - i, - preserve_all_thinking=self.config.preserve_all_thinking, - preserve_thinking_between_tool_calls=self.config.preserve_thinking_between_tool_calls, - ) self._render_assistant( msg, i, content, last_qi, - preserve_thinking=preserve_thinking, emit_special=emit_special, emit_text=emit_text, emit_ids=emit_ids, @@ -619,6 +626,13 @@ def bridge_to_next_turn( ): return None + if should_rerender_for_thinking_retention( + self.effective_thinking_retention, + new_messages, + is_user_query=self._is_user_query_message, + ): + return None + previous_ids = trim_to_turn_close( previous_prompt_ids, previous_completion_ids, @@ -903,7 +917,6 @@ def _render_assistant( content: str, last_query_index: int, *, - preserve_thinking: bool = False, emit_special, emit_text, emit_ids, @@ -944,9 +957,9 @@ def _render_assistant( # call tags (````, ````, etc.) are # part of the model's emitted output too — keep them # ``is_content=True`` per the assistant rule. - emit_thinking = self._should_render_thinking(msg_idx, last_query_index) or ( - preserve_thinking and bool(reasoning_content) - ) + emit_thinking = self._should_render_thinking( + msg_idx, last_query_index + ) or getattr(self.config, "preserve_thinking", False) if emit_thinking: # Include thinking block emit_special(self._think, msg_idx, is_sampled=True, is_content=True) diff --git a/renderers/qwen36.py b/renderers/qwen36.py index 6adf8676..56abf9c4 100644 --- a/renderers/qwen36.py +++ b/renderers/qwen36.py @@ -7,12 +7,9 @@ ``None`` as ``null`` (not ``None``), fixing the single-turn extension-break mode where a boolean parameter's case drifted across a re-render. -Historical-thinking retention follows Qwen3.5's default (drop past -```` blocks). The upstream template carries a ``preserve_thinking`` -Jinja toggle for the opposite polarity; on the renderer side that intent -maps to the renderer-agnostic ``preserve_all_thinking`` / -``preserve_thinking_between_tool_calls`` flags on -:class:`renderers.Qwen36RendererConfig`. +Historical-thinking retention follows Qwen3.5's default unless +``Qwen36RendererConfig.preserve_thinking`` is set, matching the upstream +Qwen3.6 Jinja toggle. Everything else — tool system prompt, tool-call XML structure, thinking markers, bridge logic, parser — is identical to Qwen3.5. diff --git a/renderers/qwen3_vl.py b/renderers/qwen3_vl.py index 7b82d7e7..4823e78d 100644 --- a/renderers/qwen3_vl.py +++ b/renderers/qwen3_vl.py @@ -45,6 +45,8 @@ attribute_text_segments, extract_message_tool_names, reject_assistant_in_extension, + resolve_thinking_retention, + should_rerender_for_thinking_retention, trim_to_turn_close, ) from renderers.configs import Qwen3VLRendererConfig @@ -302,9 +304,9 @@ class Qwen3VLRenderer: keyed off ``tokenizer.name_or_path`` the first time a multimodal part is seen. - ``preserve_all_thinking`` / ``preserve_thinking_between_tool_calls`` - on the config are no-ops here — the chat template drops past - ```` blocks unconditionally. Stored for Protocol parity. + Qwen3-VL has no historical reasoning channel in this renderer. The + default bridge policy therefore resolves to ``"all"``; explicit + ``thinking_retention`` still controls whether the bridge is attempted. """ def __init__( @@ -317,6 +319,10 @@ def __init__( self._tokenizer = tokenizer self._processor = processor self.config = config or Qwen3VLRendererConfig() + self.effective_thinking_retention = resolve_thinking_retention( + self.config, + "all", + ) self._im_start = self._token_id("<|im_start|>") self._im_end = self._token_id("<|im_end|>") @@ -410,6 +416,16 @@ def _render_text_content(content: Any) -> str: return "".join(parts) raise TypeError(f"Unexpected content type: {type(content)}") + @staticmethod + def _is_user_query_message(msg: Message) -> bool: + if msg.get("role") != "user": + return False + content = Qwen3VLRenderer._render_text_content(msg.get("content")).strip() + return not ( + content.startswith("") + and content.endswith("") + ) + def _process_image(self, part: dict[str, Any]): """Resolve, process, and characterize a single image part. @@ -665,6 +681,12 @@ def bridge_to_next_turn( or reject_assistant_in_extension(new_messages) ): return None + if should_rerender_for_thinking_retention( + self.effective_thinking_retention, + new_messages, + is_user_query=self._is_user_query_message, + ): + return None previous_ids = trim_to_turn_close( previous_prompt_ids, diff --git a/tests/test_bridge.py b/tests/test_bridge.py index 8b7bf77e..a72ef970 100644 --- a/tests/test_bridge.py +++ b/tests/test_bridge.py @@ -67,7 +67,25 @@ def br_renderer(br_model, br_renderer_name): return _load(br_model, br_renderer_name)[1] -def _simulate_prior_turn(renderer): +@pytest.fixture +def br_renderer_all(br_model, br_renderer_name): + """Renderer forced to ``thinking_retention="all"``. + + The verbatim-extension mechanic tests below cross a user-query + boundary. For thinking models the template drops a past block's + thinking there, so the faithful bridge declines (covered by + ``test_bridge_declines_across_user_query_when_template_drops_thinking``). + ``"all"`` keeps thinking on every path, isolating the pure extension + mechanic from the retention policy across all renderers. + """ + from renderers import create_renderer + + tok, base = _load(br_model, br_renderer_name) + cfg = base.config.model_copy(update={"thinking_retention": "all"}) + return create_renderer(tok, cfg) + + +def _simulate_prior_turn(renderer, assistant=None): """Build a (prev_prompt, prev_completion) pair that a real rollout would produce for a one-turn prior with a clean stop. @@ -76,13 +94,15 @@ def _simulate_prior_turn(renderer): gen_prompt, and take the diff as prev_completion. We then trim prev_completion to the last close token so it matches what vLLM actually hands back (vLLM stops at the close token and excludes the - trailing template scaffolding). + trailing template scaffolding). Pass ``assistant`` to override the + default no-thinking turn (e.g. one carrying ``reasoning_content``). """ prior = [ {"role": "system", "content": "You are helpful."}, {"role": "user", "content": "Hi."}, ] - assistant = [{"role": "assistant", "content": "Hello!"}] + if assistant is None: + assistant = [{"role": "assistant", "content": "Hello!"}] prev_prompt = renderer.render_ids(prior, add_generation_prompt=True) full_with_assistant = renderer.render_ids( @@ -105,11 +125,11 @@ def _simulate_prior_turn(renderer): return prev_prompt, prev_completion -def test_bridge_extends_prev_verbatim_on_clean_stop(br_renderer, br_model): - prev_prompt, prev_completion = _simulate_prior_turn(br_renderer) +def test_bridge_extends_prev_verbatim_on_clean_stop(br_renderer_all, br_model): + prev_prompt, prev_completion = _simulate_prior_turn(br_renderer_all) new_messages = [{"role": "user", "content": "What's 2+2?"}] - bridged = br_renderer.bridge_to_next_turn( + bridged = br_renderer_all.bridge_to_next_turn( prev_prompt, prev_completion, new_messages ) assert bridged is not None, f"{br_model}: bridge returned None on clean stop" @@ -148,8 +168,8 @@ def test_bridge_rejects_empty_prev_or_new(br_renderer): assert br_renderer.bridge_to_next_turn(prev_prompt, prev_completion, []) is None -def test_bridge_synthesises_close_on_truncation(br_renderer, br_model): - prev_prompt, prev_completion = _simulate_prior_turn(br_renderer) +def test_bridge_synthesises_close_on_truncation(br_renderer_all, br_model): + prev_prompt, prev_completion = _simulate_prior_turn(br_renderer_all) # Drop the final close token to simulate a max_tokens truncation. prev_completion_trunc = prev_completion[:-1] if prev_completion else prev_completion if len(prev_completion_trunc) == 0: @@ -157,7 +177,7 @@ def test_bridge_synthesises_close_on_truncation(br_renderer, br_model): f"{br_model}: simulated prior had no completion tokens — can't truncate" ) - bridged = br_renderer.bridge_to_next_turn( + bridged = br_renderer_all.bridge_to_next_turn( prev_prompt, prev_completion_trunc, [{"role": "user", "content": "What's 2+2?"}], @@ -176,12 +196,12 @@ def test_bridge_synthesises_close_on_truncation(br_renderer, br_model): def test_bridge_extension_includes_new_message_text( - br_renderer, br_tokenizer, br_model + br_renderer_all, br_tokenizer, br_model ): - prev_prompt, prev_completion = _simulate_prior_turn(br_renderer) + prev_prompt, prev_completion = _simulate_prior_turn(br_renderer_all) new_messages = [{"role": "user", "content": "HELLO_SENTINEL_XYZ"}] - bridged = br_renderer.bridge_to_next_turn( + bridged = br_renderer_all.bridge_to_next_turn( prev_prompt, prev_completion, new_messages ) assert bridged is not None @@ -190,3 +210,135 @@ def test_bridge_extension_includes_new_message_text( assert "HELLO_SENTINEL_XYZ" in decoded, ( f"{br_model}: new-message content missing from extension; got {decoded!r}" ) + + +def test_bridge_declines_across_user_query_when_template_drops_thinking(): + """Qwen3's template drops a past block's thinking once a new user turn + arrives. The resolved ``tool_cycle`` bridge policy therefore treats a + new user query as a hard re-render boundary, independent of whether the + prior token stream happens to contain sampled thinking: + + - new user query + retention="tool_cycle" -> decline, + and the fallback re-render equals ``apply_chat_template``. + - thinking_retention="all" keeps thinking on every path -> extend. + - a tool response (in-flight cycle, no new query) keeps thinking in + the template too -> extend. + - no prior thinking + new user query -> decline; no marker lookback. + """ + from renderers import create_renderer + from renderers.base import load_tokenizer + from renderers.configs import Qwen3RendererConfig + + tok = load_tokenizer("Qwen/Qwen3-8B") + im_end = tok.convert_tokens_to_ids("<|im_end|>") + + u1 = {"role": "user", "content": "What is 2+2?"} + u2 = {"role": "user", "content": "Multiply that by 3."} + tool = {"role": "tool", "content": "ok"} + think = "\n2 plus 2 is 4.\n\n\n4" + + def prior(r, asst_text): + p = r.render_ids([u1], add_generation_prompt=True) + completion = tok.encode(asst_text, add_special_tokens=False) + [im_end] + return p, completion + + # new user query + prior thinking + default retention -> decline + r = create_renderer(tok, Qwen3RendererConfig()) + p, comp = prior(r, think) + assert r.bridge_to_next_turn(p, comp, [u2]) is None + # ...and the caller's faithful re-render matches the chat template + hist = [u1, {"role": "assistant", "content": think}, u2] + rendered = tok.decode(r.render_ids(hist, add_generation_prompt=True)) + assert rendered == tok.apply_chat_template( + hist, tokenize=False, add_generation_prompt=True + ) + + # thinking_retention="all" keeps thinking everywhere -> extend + r_all = create_renderer(tok, Qwen3RendererConfig(thinking_retention="all")) + p, comp = prior(r_all, think) + assert r_all.bridge_to_next_turn(p, comp, [u2]) is not None + + # tool response continues the in-flight cycle (no new query) -> extend + p, comp = prior(r, think) + assert r.bridge_to_next_turn(p, comp, [tool]) is not None + + # tool_cycle is a user-query-boundary policy; it does not scan prior tokens. + p, comp = prior(r, "4") + assert r.bridge_to_next_turn(p, comp, [u2]) is None + + +# Renderers whose default/effective bridge policy declines at a new user-query +# boundary. The exact query-boundary predicate can still be renderer-specific +# (for example Qwen's folded ```` user messages). +# Non-thinking models (llama, deepseek-v3) are out of scope for this check. +_GUARDED_THINKING_MODELS = { + "Qwen/Qwen3-8B", + "Qwen/Qwen3.5-9B", + "Qwen/Qwen3.6-35B-A3B", + "zai-org/GLM-5", + "zai-org/GLM-5.1", + "THUDM/GLM-4.5-Air", + "nvidia/NVIDIA-Nemotron-3-Nano-30B-A3B-BF16", + "MiniMaxAI/MiniMax-M2.5", + "moonshotai/Kimi-K2.5", + "openai/gpt-oss-20b", +} + + +def test_bridge_declines_across_user_turn_when_thinking_present(br_renderer, br_model): + """Across every guarded thinking renderer: a prior turn carrying + ``reasoning_content`` makes the bridge decline a new *user* query (the + template would strip that thinking) but still extend a *tool* response + (in-flight cycle keeps it). Non-guarded models are skipped.""" + if br_model not in _GUARDED_THINKING_MODELS: + pytest.skip(f"{br_model}: no bridge thinking-guard") + + asst = [ + { + "role": "assistant", + "reasoning_content": "Let me think.", + "content": "", + "tool_calls": [{"function": {"name": "lookup", "arguments": {"q": "x"}}}], + } + ] + prev_prompt, prev_completion = _simulate_prior_turn(br_renderer, asst) + + declined = br_renderer.bridge_to_next_turn( + prev_prompt, prev_completion, [{"role": "user", "content": "next"}] + ) + assert declined is None, f"{br_model}: expected faithful decline across a user turn" + + extended = br_renderer.bridge_to_next_turn( + prev_prompt, prev_completion, [{"role": "tool", "content": "result"}] + ) + assert extended is not None, f"{br_model}: should still bridge within a tool cycle" + + +def test_bridge_keeps_thinking_when_history_kwarg_disables_truncation(): + """GLM ``clear_thinking=False`` / Nemotron ``truncate_history_thinking= + False`` keep all past thinking (the template doesn't strip it), so the + bridge must NOT decline across a user turn — declining would re-render and + re-tokenize model-sampled thinking bytes.""" + from renderers import create_renderer + from renderers.base import load_tokenizer + from renderers.configs import GLM5RendererConfig, Nemotron3RendererConfig + + asst = [ + {"role": "assistant", "reasoning_content": "Let me think.", "content": "Hi"} + ] + cases = [ + ("zai-org/GLM-5", GLM5RendererConfig(clear_thinking=False)), + ( + "nvidia/NVIDIA-Nemotron-3-Nano-30B-A3B-BF16", + Nemotron3RendererConfig(truncate_history_thinking=False), + ), + ] + for model, cfg in cases: + r = create_renderer(load_tokenizer(model), cfg) + prev_prompt, prev_completion = _simulate_prior_turn(r, asst) + bridged = r.bridge_to_next_turn( + prev_prompt, prev_completion, [{"role": "user", "content": "next"}] + ) + assert bridged is not None, ( + f"{model}: bridge must keep verbatim when the template keeps all thinking" + ) diff --git a/tests/test_gemma4.py b/tests/test_gemma4.py index d7e9a87f..0bbfb759 100644 --- a/tests/test_gemma4.py +++ b/tests/test_gemma4.py @@ -90,7 +90,7 @@ @lru_cache(maxsize=1) def _gemma4(): - tokenizer = load_tokenizer("google/gemma-4-E2B-it", use_fastokens=False) + tokenizer = load_tokenizer("google/gemma-4-E2B-it") renderer = create_renderer(tokenizer) return tokenizer, renderer @@ -170,7 +170,7 @@ def test_gemma4_generation_prompt_parity_across_it_sizes( gemma4_model_name, enable_thinking, ): - tokenizer = load_tokenizer(gemma4_model_name, use_fastokens=False) + tokenizer = load_tokenizer(gemma4_model_name) renderer = create_renderer( tokenizer, Gemma4RendererConfig(enable_thinking=enable_thinking), @@ -187,7 +187,7 @@ def test_gemma4_generation_prompt_parity_across_it_sizes( @pytest.mark.parametrize("enable_thinking", [True, False]) def test_gemma4_text_and_tool_chat_parity_with_hf_template(enable_thinking): - tokenizer = load_tokenizer("google/gemma-4-E2B-it", use_fastokens=False) + tokenizer = load_tokenizer("google/gemma-4-E2B-it") renderer = create_renderer( tokenizer, Gemma4RendererConfig(enable_thinking=enable_thinking) ) @@ -383,7 +383,11 @@ def test_gemma4_render_accepts_openai_json_string_arguments(): @pytest.mark.parametrize("enable_thinking", [True, False]) def test_gemma4_bridge_matches_full_render(enable_thinking): - tokenizer = load_tokenizer("google/gemma-4-E2B-it", use_fastokens=False) + tokenizer = load_tokenizer("google/gemma-4-E2B-it") + # Default retention retains thinking across turns (prefix continuation): the + # bridge reuses the prior prefix verbatim and must equal a full render. + # Opting out (thinking_retention="tool_cycle") drops it at a new user-query + # boundary — asserted at the end. renderer = create_renderer( tokenizer, Gemma4RendererConfig(enable_thinking=enable_thinking) ) @@ -407,6 +411,21 @@ def test_gemma4_bridge_matches_full_render(enable_thinking): ) assert bridged.message_tool_names == [None] + # Opting out via thinking_retention="tool_cycle" drops thinking at a new + # user-query boundary -> full re-render required (None). + drop_renderer = create_renderer( + tokenizer, + Gemma4RendererConfig( + enable_thinking=enable_thinking, thinking_retention="tool_cycle" + ), + ) + assert ( + drop_renderer.bridge_to_next_turn( + previous_prompt_ids, previous_completion_ids, next_turn + ) + is None + ) + def test_gemma4_rejects_non_text_multimodal_parts_until_sidecar_exists(): _, renderer = _gemma4() diff --git a/tests/test_llama_3.py b/tests/test_llama_3.py index c6e4c755..8c162329 100644 --- a/tests/test_llama_3.py +++ b/tests/test_llama_3.py @@ -80,8 +80,8 @@ def test_default_date_matches_chat_template_strftime_fallback(llama_pair): def test_preserve_thinking_flags_are_noops(llama_pair): - """Llama-3 has no reasoning channel, so the ``preserve_*_thinking`` - flags are accepted but never change the token stream — the same + """Llama-3 has no reasoning channel, so any ``thinking_retention`` + level is accepted but never changes the token stream — the same never-preserves contract as Kimi-K2 / Qwen3-VL. (Cross-renderer coverage lives in tests/test_preserve_thinking.py.)""" _, _, tok, _ = llama_pair @@ -94,10 +94,12 @@ def test_preserve_thinking_flags_are_noops(llama_pair): }, ] base = Llama3Renderer(tok).render_ids(msgs) - for flag in ("preserve_all_thinking", "preserve_thinking_between_tool_calls"): - r = Llama3Renderer(tok, Llama3RendererConfig(**{flag: True})) - assert r.config.__getattribute__(flag) is True - assert r.render_ids(msgs) == base, f"{flag} must be a no-op for Llama-3" + for level in ("tool_cycle", "all"): + r = Llama3Renderer(tok, Llama3RendererConfig(thinking_retention=level)) + assert r.config.thinking_retention == level + assert r.render_ids(msgs) == base, ( + f"thinking_retention={level!r} must be a no-op for Llama-3" + ) # --------------------------------------------------------------------------- diff --git a/tests/test_load_tokenizer.py b/tests/test_load_tokenizer.py index b2e49c84..ea15d6a5 100644 --- a/tests/test_load_tokenizer.py +++ b/tests/test_load_tokenizer.py @@ -12,6 +12,8 @@ from types import SimpleNamespace from unittest.mock import patch +import pytest + from renderers import base from renderers.base import TOKENIZER_SOURCE_OVERRIDES, TRUSTED_REVISIONS, load_tokenizer @@ -81,7 +83,7 @@ def test_meta_llama_loads_tokenizer_from_unsloth_mirror(mock_from_pretrained): mirror = "unsloth/Llama-3.2-1B-Instruct" mock_from_pretrained.return_value = SimpleNamespace(name_or_path=mirror) - tok = load_tokenizer(canonical, use_fastokens=False) + tok = load_tokenizer(canonical) args, kwargs = mock_from_pretrained.call_args assert args == (mirror,) @@ -120,42 +122,22 @@ def test_tokenizer_source_overrides_are_exact_llama_mirrors(): } -def test_offset_tokenizer_uses_unsloth_mirror_for_meta_llama(monkeypatch): - """Offset-tokenizer reloads must use the same unrestricted source - override, otherwise Llama rendering can hit the gated Meta repo after - the initial tokenizer load succeeds.""" +def test_get_offset_tokenizer_rejects_offsetless_byo(): + """BYO tokenizers without ``return_offsets_mapping`` support raise a + clear error. Hand-coded renderers concatenate scaffold + body in one + BPE pass and attribute tokens via the fast tokenizer's offset map; + no transparent reload-from-name_or_path fallback exists. The + contract is: pass a fast tokenizer or get a loud error at construct + time, not silent BPE drift at the wrap/body boundary.""" class _NoOffsets: - name_or_path = "meta-llama/Llama-3.2-1B-Instruct" - - def __call__(self, *args, **kwargs): - raise NotImplementedError("fastokens shim has no offsets") - - class _OffsetTokenizer: - is_fast = True - - def __init__(self, name_or_path: str): - self.name_or_path = name_or_path + name_or_path = "anywhere/anything" def __call__(self, *args, **kwargs): - return {"offset_mapping": [(0, 1)]} - - calls = [] - - def _fake_load(name_or_path, **kwargs): - calls.append((name_or_path, kwargs)) - return _OffsetTokenizer(name_or_path) - - base._offset_tokenizers.clear() - monkeypatch.setattr(base, "_load_tokenizer_via_auto", _fake_load) - - try: - tok = base._get_offset_tokenizer(_NoOffsets()) - finally: - base._offset_tokenizers.clear() + raise NotImplementedError("BYO tokenizer has no offsets") - assert calls == [("unsloth/Llama-3.2-1B-Instruct", {"trust_remote_code": False})] - assert tok.name_or_path == "meta-llama/Llama-3.2-1B-Instruct" + with pytest.raises(RuntimeError, match="fast tokenizer.*offsets"): + base._get_offset_tokenizer(_NoOffsets()) # --------------------------------------------------------------------------- diff --git a/tests/test_load_tokenizer_fastokens.py b/tests/test_load_tokenizer_fastokens.py deleted file mode 100644 index 28c42a01..00000000 --- a/tests/test_load_tokenizer_fastokens.py +++ /dev/null @@ -1,213 +0,0 @@ -"""Coverage for the fastokens fast-path in ``renderers.base.load_tokenizer``. - -``load_tokenizer`` defaults to routing every supported model through -``fastokens.patch_transformers()`` for ~10x faster encode. Models in -``FASTOKENS_INCOMPATIBLE`` skip the patch (DeepSeek's Metaspace -pretokenizer isn't supported). Callers can opt out per-call with -``use_fastokens=False``. - -These tests pin the policy: - -1. The denylist contains the empirically-verified incompat models — - adding to it should be a deliberate review action. -2. With ``use_fastokens=True`` (the default) on a compatible model, the - resulting tokenizer's backend is the fastokens shim. Encode output - stays byte-identical to vanilla. -3. With ``use_fastokens=False``, the resulting tokenizer is vanilla. -4. For incompat models, the fast path is silently skipped and the - tokenizer still loads + encodes correctly. -5. The fastokens patch is removed immediately after the load so it - doesn't leak into the caller's process — subsequent - ``AutoTokenizer.from_pretrained`` calls outside ``load_tokenizer`` - use vanilla. -""" - -from __future__ import annotations - -import pytest -from transformers import AutoTokenizer - -from renderers.base import ( - FASTOKENS_INCOMPATIBLE, - load_tokenizer, -) - - -# --------------------------------------------------------------------------- -# Denylist shape -# --------------------------------------------------------------------------- - - -def test_fastokens_incompatible_is_explicit_set(): - """The denylist is small and audited — pinning the exact contents - catches accidental drift. Adding/removing entries should be a - deliberate action with a parity probe.""" - assert FASTOKENS_INCOMPATIBLE == frozenset( - { - "deepseek-ai/DeepSeek-V3", - "deepseek-ai/DeepSeek-V3-Base", - "deepseek-ai/DeepSeek-R1", - "deepseek-ai/DeepSeek-R1-0528", - } - ) - - -# --------------------------------------------------------------------------- -# Fast path (compatible model — Qwen3.5-9B as representative) -# --------------------------------------------------------------------------- - - -_FAST_MODEL = "Qwen/Qwen3.5-9B" - - -def _backend_class_name(tok) -> str: - """Return the class name of the underlying backend object so tests - can tell vanilla from fastokens-shimmed tokenizers.""" - backend = getattr(tok, "_tokenizer", None) - return type(backend).__name__ if backend is not None else type(tok).__name__ - - -def test_default_uses_fastokens_on_compatible_model(): - tok = load_tokenizer(_FAST_MODEL) - # The shim type is named ``_TokenizerShim`` (see fastokens._compat); - # match by name so we don't import private fastokens internals. - assert "Shim" in _backend_class_name(tok), ( - f"Expected fastokens shim backend, got {_backend_class_name(tok)!r}" - ) - - -def test_explicit_off_returns_vanilla_backend(): - tok = load_tokenizer(_FAST_MODEL, use_fastokens=False) - assert "Shim" not in _backend_class_name(tok), ( - f"Expected vanilla backend, got {_backend_class_name(tok)!r}" - ) - - -def test_fast_and_vanilla_encode_identically_on_compatible_model(): - fast = load_tokenizer(_FAST_MODEL) - vanilla = load_tokenizer(_FAST_MODEL, use_fastokens=False) - samples = [ - "Hello, world!", - "Lorem ipsum dolor sit amet, consectetur adipiscing elit.", - "🌍 emoji + 中文 + tabs\there", - " ".join([f"word_{i}" for i in range(50)]), - ] - for s in samples: - assert fast.encode(s, add_special_tokens=False) == vanilla.encode( - s, add_special_tokens=False - ), f"encode diverged on {s!r}" - - -# --------------------------------------------------------------------------- -# Denylist: incompat models silently skip the patch and still load. -# --------------------------------------------------------------------------- - - -@pytest.mark.parametrize("model", sorted(FASTOKENS_INCOMPATIBLE)) -def test_incompat_model_loads_via_vanilla_backend(model): - """For models we know diverge / fail under fastokens, the fast path - must be skipped so the load still succeeds with a vanilla backend.""" - if "DeepSeek" in model: - # Skip if upstream gating / size makes the load impractical here. - # We only care that the path doesn't try fastokens. Probe the - # tokenizer_config to make sure the repo is reachable; if not, - # skip rather than fail (CI without HF auth, network issues). - from huggingface_hub import HfApi - - try: - HfApi().repo_info(model) - except Exception as e: - pytest.skip(f"{model}: repo unreachable in this env ({e})") - tok = load_tokenizer(model) - assert "Shim" not in _backend_class_name(tok), ( - f"{model}: should NOT have been patched; got {_backend_class_name(tok)!r}" - ) - # And it still encodes. - ids = tok.encode("hello", add_special_tokens=False) - assert len(ids) > 0 - - -# --------------------------------------------------------------------------- -# Patch must not leak: AutoTokenizer.from_pretrained calls OUTSIDE -# load_tokenizer should still produce a vanilla tokenizer. -# --------------------------------------------------------------------------- - - -def test_patch_is_unloaded_after_call(): - """``load_tokenizer`` brackets the fastokens patch. After it returns - a fastokens-shimmed tokenizer, a fresh ``AutoTokenizer.from_pretrained`` - call must NOT pick up the patch — the user's process stays clean.""" - fast = load_tokenizer(_FAST_MODEL) - assert "Shim" in _backend_class_name(fast), "preconditions: fast path active" - - # Now call AutoTokenizer.from_pretrained directly. It MUST be vanilla. - direct = AutoTokenizer.from_pretrained(_FAST_MODEL, trust_remote_code=False) - assert "Shim" not in _backend_class_name(direct), ( - f"fastokens patch leaked into user-side AutoTokenizer call: " - f"got {_backend_class_name(direct)!r}" - ) - - -# --------------------------------------------------------------------------- -# Failure-mode fallback: if fastokens raises during the patched load, -# load_tokenizer falls back to vanilla without surfacing the error. -# --------------------------------------------------------------------------- - - -def test_fallback_on_fastokens_load_error(monkeypatch): - """Simulate fastokens raising during patched load — load_tokenizer - should fall back to vanilla and return a working tokenizer.""" - import renderers.base as rb - - def _boom(*args, **kwargs): - raise ValueError("simulated fastokens failure: unsupported pre-tokenizer") - - monkeypatch.setattr(rb, "_patched_load", _boom) - - tok = load_tokenizer(_FAST_MODEL) # default use_fastokens=True - # The vanilla fallback ran — backend is not a fastokens shim. - assert "Shim" not in _backend_class_name(tok) - # Still works. - assert len(tok.encode("hi", add_special_tokens=False)) > 0 - - -# --------------------------------------------------------------------------- -# Print suppression: fastokens itself prints "[fastokens] -# patch_transformers: ..." on every patch/unpatch call. Building a -# RendererPool of size N would emit ~N lines (the pool factory calls -# load_tokenizer once per slot). load_tokenizer swallows that stdout -# chatter and emits a single INFO log on the first patch instead. -# --------------------------------------------------------------------------- - - -def test_no_fastokens_stdout_chatter(capsys, caplog): - """``load_tokenizer`` must not leak ``[fastokens]`` prints onto - stdout, and must emit exactly one INFO log per process announcing - the fast path (not once per call).""" - import logging - - import renderers.base as rb - - # Reset the process-wide "announced" flag so this test sees the - # first-call log even if another test loaded a tokenizer earlier. - rb._FASTOKENS_ANNOUNCED = False - - with caplog.at_level(logging.INFO, logger="renderers.base"): - load_tokenizer(_FAST_MODEL) - load_tokenizer(_FAST_MODEL) - - captured = capsys.readouterr() - assert "[fastokens]" not in captured.out, ( - f"fastokens print leaked to stdout: {captured.out!r}" - ) - assert "[fastokens]" not in captured.err, ( - f"fastokens print leaked to stderr: {captured.err!r}" - ) - - fastokens_info = [ - r for r in caplog.records if "fastokens enabled" in r.getMessage() - ] - assert len(fastokens_info) == 1, ( - f"expected exactly one fastokens INFO log across two loads, " - f"got {len(fastokens_info)}" - ) diff --git a/tests/test_multimodal.py b/tests/test_multimodal.py index 28984e4f..6b06add9 100644 --- a/tests/test_multimodal.py +++ b/tests/test_multimodal.py @@ -37,12 +37,16 @@ from renderers.configs import _config_class_for +def _config_for_model(model_name: str, **kwargs): + renderer_name = MODEL_RENDERER_MAP[model_name] + return _config_class_for(renderer_name)(**kwargs) + + def _config_with_add_vision_id(model_name: str, add_vision_id: bool): """Build the typed config for ``model_name`` (resolved via ``MODEL_RENDERER_MAP``) with ``add_vision_id`` set. The qwen_vl family — Qwen3.5 and Qwen3-VL — both expose this field.""" - renderer_name = MODEL_RENDERER_MAP[model_name] - return _config_class_for(renderer_name)(add_vision_id=add_vision_id) + return _config_for_model(model_name, add_vision_id=add_vision_id) pytest.importorskip("PIL", reason="Pillow required for multimodal tests") @@ -529,10 +533,9 @@ def test_multimodal_bridge_extends_and_carries_mm_data( ): """Bridge-to-next-turn invariants for the multimodal case. - Asserts three properties that should hold for every renderer - regardless of thinking-mode quirks (the prior bridge-vs-full - invariant was too strong — see commit log for the divergence - rationale on thinking renderers): + The renderer is forced to ``thinking_retention="all"`` so this test + isolates multimodal bridge mechanics from thinking-retention policy. + Asserts three properties: 1. **Verbatim prefix**: ``bridged.token_ids`` begins with ``previous_prompt_ids + previous_completion_ids``. Whatever the @@ -552,7 +555,13 @@ def test_multimodal_bridge_extends_and_carries_mm_data( pytest.skip(f"{mm_model_name}: HF snapshot not cached locally") kit = _modality_kit(modality, mm_model_name) - tokenizer, _, renderer = _load_processor_and_renderer(mm_model_name) + tokenizer, processor, _ = _load_processor_and_renderer(mm_model_name) + renderer = create_renderer( + tokenizer, + _config_for_model(mm_model_name, thinking_retention="all"), + ) + if hasattr(renderer, "_processor") and renderer._processor is None: + renderer._processor = processor initial = [ { @@ -806,9 +815,15 @@ def test_bridge_refuses_when_add_vision_id_loses_prior_count( kit = _modality_kit(modality, mm_model_name) tokenizer, processor, _ = _load_processor_and_renderer(mm_model_name) + # Force bridge-allowed retention so this test isolates add_vision_id + # counter state from the user-turn retention gate. renderer = create_renderer( tokenizer, - _config_with_add_vision_id(mm_model_name, True), + _config_for_model( + mm_model_name, + add_vision_id=True, + thinking_retention="all", + ), ) if hasattr(renderer, "_processor") and renderer._processor is None: renderer._processor = processor diff --git a/tests/test_preserve_thinking.py b/tests/test_preserve_thinking.py index daa48360..917c4a27 100644 --- a/tests/test_preserve_thinking.py +++ b/tests/test_preserve_thinking.py @@ -1,64 +1,20 @@ -"""Smoke coverage for the ``preserve_*_thinking`` override flags. +"""Targeted coverage for the ``thinking_retention`` bridge-policy flag. -Flags live on the typed renderer config (e.g. -``Qwen3RendererConfig(preserve_all_thinking=True)``) and are stored on -the renderer as ``self.config.preserve_*``. Each test that wants a -non-default flag builds a fresh renderer for that configuration via -``_make`` below. - -Two invariants per renderer: - -1. Default render (no flags) is byte-identical to the existing - ``apply_chat_template`` parity baseline — covered exhaustively elsewhere. -2. Setting either flag never *removes* tokens compared to the default and, - for renderers whose template would drop past-asst thinking, actually - adds tokens for a conversation containing past-asst ``reasoning_content``. - -Renderers whose template either always preserves thinking (DeepSeek-V3) or -never references ``reasoning_content`` for past-asst (Kimi-K2, Qwen3-VL) -are no-ops by design — they're listed below and the test asserts the -default==override equality instead of strict growth. +Generic ``thinking_retention`` controls whether a renderer may append via +``bridge_to_next_turn`` or should fall back to a full re-render. It is not a +chat-template kwarg, so the only full-render contract here is that explicit +generic values do not change render output. """ from __future__ import annotations import pytest -from pydantic import ValidationError from renderers import create_renderer -from renderers.base import MODEL_RENDERER_MAP, should_preserve_past_thinking +from renderers.base import MODEL_RENDERER_MAP from renderers.configs import _config_class_for -def _make(tokenizer, renderer_name, **flags): - """Build a fresh renderer with the given preserve_*_thinking flags - bound at construction. Reuses the cached tokenizer fixture.""" - if renderer_name == "auto": - renderer_name = MODEL_RENDERER_MAP.get( - getattr(tokenizer, "name_or_path", ""), "default" - ) - config = _config_class_for(renderer_name)(**flags) - return create_renderer(tokenizer, config) - - -# Renderers whose template doesn't drop past-asst thinking or has no -# place to re-emit it. For these, override flags MUST be no-ops. -NO_OP_MODELS = { - "deepseek-ai/DeepSeek-V3", - "deepseek-ai/DeepSeek-V3-Base", - "moonshotai/Kimi-K2-Instruct", - "Qwen/Qwen3-VL-4B-Instruct", - "Qwen/Qwen3-VL-8B-Instruct", - "Qwen/Qwen3-VL-30B-A3B-Instruct", - "poolside/Laguna-XS.2", - # Llama-3 has no reasoning channel at all — preserve flags can't add - # or drop anything, so they're pure no-ops. - "meta-llama/Llama-3.2-1B-Instruct", - "meta-llama/Llama-3.2-3B-Instruct", - "unsloth/Llama-3.2-1B-Instruct", -} - - CONVERSATION = [ {"role": "user", "content": "Weather in Paris?"}, { @@ -79,399 +35,63 @@ def _make(tokenizer, renderer_name, **flags): ] -def test_should_preserve_past_thinking_classification(): - # CURRENT-block-only behaviour. between_tool_calls preserves thinking - # ONLY for asst messages that sit AFTER the last user turn AND are in - # a segment that contains a tool. Anything before the last user turn - # falls back to template default (typically dropped). - - # Live tool cycle: U-A_tc-T-A_final, no trailing user. The whole - # post-user segment contains a tool, so both A's are preserved. - live_cycle = [ - {"role": "user", "content": "q"}, - { - "role": "assistant", - "reasoning_content": "r1", - "tool_calls": [{"function": {"name": "f", "arguments": {}}}], - }, - {"role": "tool", "name": "f", "content": "data"}, - {"role": "assistant", "reasoning_content": "r2", "content": "answer"}, - ] - assert should_preserve_past_thinking( - live_cycle, - 1, - preserve_all_thinking=False, - preserve_thinking_between_tool_calls=True, - ) - assert should_preserve_past_thinking( - live_cycle, - 3, - preserve_all_thinking=False, - preserve_thinking_between_tool_calls=True, - ) - - # Same shape with a NEW user appended → now the prior tool block is - # "older" and between_tool_calls must drop its thinking (template - # default). Only preserve_all_thinking would keep them. - closed_cycle = live_cycle + [{"role": "user", "content": "next"}] - assert not should_preserve_past_thinking( - closed_cycle, - 1, - preserve_all_thinking=False, - preserve_thinking_between_tool_calls=True, - ) - assert not should_preserve_past_thinking( - closed_cycle, - 3, - preserve_all_thinking=False, - preserve_thinking_between_tool_calls=True, - ) - # preserve_all_thinking still keeps them. - assert should_preserve_past_thinking( - closed_cycle, - 1, - preserve_all_thinking=True, - preserve_thinking_between_tool_calls=False, - ) - assert should_preserve_past_thinking( - closed_cycle, - 3, - preserve_all_thinking=True, - preserve_thinking_between_tool_calls=False, - ) - - # Current segment without a tool → not a tool cycle → not preserved. - no_tool_yet = [ - {"role": "user", "content": "q"}, - {"role": "assistant", "reasoning_content": "r", "content": "a"}, - ] - assert not should_preserve_past_thinking( - no_tool_yet, - 1, - preserve_all_thinking=False, - preserve_thinking_between_tool_calls=True, - ) - - # Both flags False → always False. - assert not should_preserve_past_thinking( - live_cycle, - 1, - preserve_all_thinking=False, - preserve_thinking_between_tool_calls=False, - ) - - -def test_preserve_flags_default_unchanged( - model_name, tokenizer, renderer_name, renderer -): - # A renderer constructed with both flags explicitly ``False`` must - # produce byte-identical output to one constructed with the defaults. - bare = renderer.render_ids(CONVERSATION) - explicit_off = _make( - tokenizer, - renderer_name, - preserve_all_thinking=False, - preserve_thinking_between_tool_calls=False, - ).render_ids(CONVERSATION) - assert bare == explicit_off, ( - f"{model_name}: explicit flags=False must equal bare default render" - ) - - -def test_preserve_all_thinking_grows_or_no_op( - model_name, tokenizer, renderer_name, renderer -): - from renderers.default import DefaultRenderer - - if isinstance(renderer, DefaultRenderer): - pytest.skip("DefaultRenderer raises on these flags — covered separately") - default = renderer.render_ids(CONVERSATION) - preserved = _make(tokenizer, renderer_name, preserve_all_thinking=True).render_ids( - CONVERSATION - ) - - if model_name in NO_OP_MODELS: - assert preserved == default, ( - f"{model_name} is a no-op renderer; preserve_all_thinking must " - f"not change output (got {len(default)} → {len(preserved)})" - ) - else: - assert len(preserved) > len(default), ( - f"{model_name}: preserve_all_thinking should add tokens for a " - f"conversation with past-asst reasoning_content " - f"(default={len(default)}, preserved={len(preserved)})" +def _make(tokenizer, renderer_name, **flags): + """Build a fresh renderer with the given construction-time flags.""" + if renderer_name == "auto": + renderer_name = MODEL_RENDERER_MAP.get( + getattr(tokenizer, "name_or_path", ""), "default" ) + config = _config_class_for(renderer_name)(**flags) + return create_renderer(tokenizer, config) -def test_preserve_between_tool_calls_strict_subset( - model_name, tokenizer, renderer_name, renderer -): - """``preserve_thinking_between_tool_calls`` is strictly weaker than - ``preserve_all_thinking``: token count satisfies default <= between <= all.""" - from renderers.default import DefaultRenderer - - if isinstance(renderer, DefaultRenderer): - pytest.skip("DefaultRenderer raises on these flags — covered separately") - default = renderer.render_ids(CONVERSATION) - between = _make( - tokenizer, renderer_name, preserve_thinking_between_tool_calls=True - ).render_ids(CONVERSATION) - all_ = _make(tokenizer, renderer_name, preserve_all_thinking=True).render_ids( - CONVERSATION - ) - assert len(default) <= len(between) <= len(all_), ( - f"{model_name}: expected default <= between <= all, " - f"got {len(default)} <= {len(between)} <= {len(all_)}" - ) - - -LIVE_TOOL_CYCLE = [ - {"role": "user", "content": "Weather in Paris?"}, - { - "role": "assistant", - "reasoning_content": "Let me call the tool.", - "content": "Calling.", - "tool_calls": [ - {"function": {"name": "get_weather", "arguments": {"city": "Paris"}}} - ], - }, - {"role": "tool", "name": "get_weather", "content": "Sunny, 22C"}, - { - "role": "assistant", - "reasoning_content": "Tool returned weather.", - "content": "Sunny.", - }, -] - - -def test_preserve_btc_on_live_cycle_matches_all( - model_name, tokenizer, renderer_name, renderer -): - """In a live tool cycle (no trailing user), every past-asst sits in - the current tool-bearing segment. ``preserve_thinking_between_tool_calls`` - should preserve all of their thinking — same set of asst messages as - ``preserve_all_thinking``, so the resulting token sequences must be - identical (independent of which template-default condition each - renderer uses internally).""" - from renderers.default import DefaultRenderer - - if isinstance(renderer, DefaultRenderer): - pytest.skip("DefaultRenderer raises on these flags — covered separately") - btc = _make( - tokenizer, renderer_name, preserve_thinking_between_tool_calls=True - ).render_ids(LIVE_TOOL_CYCLE) - all_ = _make(tokenizer, renderer_name, preserve_all_thinking=True).render_ids( - LIVE_TOOL_CYCLE - ) - assert btc == all_, ( - f"{model_name}: in a live tool cycle btc must match preserve_all " - f"(got len(btc)={len(btc)}, len(all)={len(all_)})" - ) - - -# --------------------------------------------------------------------------- -# End-to-end visibility matrix -# --------------------------------------------------------------------------- - -# Conversation shape: S-U-A-T-A-U-A-T-A. Each assistant carries a unique -# sentinel string in ``reasoning_content`` so we can grep the decoded -# output to see whose thinking was kept. -TWO_BLOCK_TOOLS = [ - { - "type": "function", - "function": { - "name": "lookup", - "description": "look up a value", - "parameters": { - "type": "object", - "properties": {"key": {"type": "string"}}, - "required": ["key"], - }, - }, - } -] - -TWO_BLOCK_CONV = [ - {"role": "system", "content": "be brief"}, - {"role": "user", "content": "first"}, - { - "role": "assistant", - "reasoning_content": "REASON-A2", - "content": "calling.", - "tool_calls": [{"function": {"name": "lookup", "arguments": {"key": "a"}}}], - }, - {"role": "tool", "name": "lookup", "content": "result-a"}, - {"role": "assistant", "reasoning_content": "REASON-A4", "content": "answer-1"}, - {"role": "user", "content": "second"}, - { - "role": "assistant", - "reasoning_content": "REASON-A6", - "content": "calling.", - "tool_calls": [{"function": {"name": "lookup", "arguments": {"key": "b"}}}], - }, - {"role": "tool", "name": "lookup", "content": "result-b"}, - {"role": "assistant", "reasoning_content": "REASON-A8", "content": "answer-2"}, -] - -ALL_SENTINELS = ("REASON-A2", "REASON-A4", "REASON-A6", "REASON-A8") -CURRENT_BLOCK_SENTINELS = ("REASON-A6", "REASON-A8") -OLDER_BLOCK_SENTINELS = ("REASON-A2", "REASON-A4") - -# Renderers whose template renders ``reasoning_content`` for past-asst -# under no condition. Flags accepted as no-ops; sentinels never appear. -NEVER_PRESERVES_MODELS = { - "moonshotai/Kimi-K2-Instruct", - "Qwen/Qwen3-VL-4B-Instruct", - "Qwen/Qwen3-VL-8B-Instruct", - "Qwen/Qwen3-VL-30B-A3B-Instruct", - # Llama-3 ships no rendering path, so reasoning_content never - # surfaces in the output regardless of the preserve flags. - "meta-llama/Llama-3.2-1B-Instruct", - "meta-llama/Llama-3.2-3B-Instruct", - "unsloth/Llama-3.2-1B-Instruct", -} - - -def test_preserve_all_thinking_emits_every_asst_reasoning( - model_name, tokenizer, renderer_name, renderer -): - """``preserve_all_thinking=True`` must surface every past-asst's - ``reasoning_content`` in the decoded output — for renderers that - have any pathway to render reasoning at all.""" - from renderers.default import DefaultRenderer - - if isinstance(renderer, DefaultRenderer): - pytest.skip("DefaultRenderer raises on these flags — covered separately") - - ids = _make(tokenizer, renderer_name, preserve_all_thinking=True).render_ids( - TWO_BLOCK_CONV, tools=TWO_BLOCK_TOOLS - ) - text = tokenizer.decode(ids) - - if model_name in NEVER_PRESERVES_MODELS: - for sentinel in ALL_SENTINELS: - assert sentinel not in text, ( - f"{model_name}: never-preserves renderer leaked {sentinel} " - f"under preserve_all_thinking" - ) - else: - for sentinel in ALL_SENTINELS: - assert sentinel in text, ( - f"{model_name}: preserve_all_thinking did not emit {sentinel} " - f"in decoded output" - ) - - -def test_preserve_btc_emits_current_block_reasoning( +def test_generic_thinking_retention_does_not_change_full_render( model_name, tokenizer, renderer_name, renderer ): - """``preserve_thinking_between_tool_calls=True`` must surface the - current (post-last-user) tool block's reasoning. Older blocks fall - back to template default, which varies per renderer — no universal - assertion there.""" + """Generic retention is bridge policy only, not a render override.""" from renderers.default import DefaultRenderer if isinstance(renderer, DefaultRenderer): - pytest.skip("DefaultRenderer raises on these flags — covered separately") - - ids = _make( - tokenizer, renderer_name, preserve_thinking_between_tool_calls=True - ).render_ids(TWO_BLOCK_CONV, tools=TWO_BLOCK_TOOLS) - text = tokenizer.decode(ids) - - if model_name in NEVER_PRESERVES_MODELS: - for sentinel in ALL_SENTINELS: - assert sentinel not in text, ( - f"{model_name}: never-preserves renderer leaked {sentinel} " - f"under preserve_thinking_between_tool_calls" - ) - else: - for sentinel in CURRENT_BLOCK_SENTINELS: - assert sentinel in text, ( - f"{model_name}: btc did not emit current-block {sentinel} " - f"in decoded output" - ) - + pytest.skip("DefaultRenderer raises on explicit retention — covered separately") -def test_default_renderer_raises_on_flags(): - """``DefaultRenderer`` falls back to apply_chat_template with no - selective re-emit pathway, so constructing one with either flag set - must raise — fail fast, before any render is attempted.""" - from renderers import DefaultRendererConfig - from renderers.base import load_tokenizer - - tok = load_tokenizer("Qwen/Qwen2.5-0.5B-Instruct") - # No flags → constructs cleanly. - create_renderer(tok, DefaultRendererConfig()) - # Either flag set → raises at construction. - with pytest.raises(NotImplementedError): - create_renderer(tok, DefaultRendererConfig(preserve_all_thinking=True)) - with pytest.raises(NotImplementedError): - create_renderer( - tok, - DefaultRendererConfig(preserve_thinking_between_tool_calls=True), + default = renderer.render_ids(CONVERSATION) + for retention in ("tool_cycle", "all"): + explicit = _make( + tokenizer, + renderer_name, + thinking_retention=retention, + ).render_ids(CONVERSATION) + assert explicit == default, ( + f"{model_name}: thinking_retention={retention!r} changed full render" ) -# --------------------------------------------------------------------------- -# Construction-time configuration is discoverable via instance attributes -# --------------------------------------------------------------------------- - - -def test_create_renderer_records_flag_state(model_name, renderer_name, tokenizer): - """Each renderer exposes the bound flag state via ``self.config`` — - useful for downstream code (pool cache keys, logging, test - assertions) that needs to confirm what was constructed.""" +def test_no_thinking_knob_implies_all_bridge_policy( + model_name, renderer_name, tokenizer +): + """No-thinking generation config means there is no thinking to evict.""" from renderers.default import DefaultRenderer bare = _make(tokenizer, renderer_name) - assert bare.config.preserve_all_thinking is False - assert bare.config.preserve_thinking_between_tool_calls is False - - if not isinstance(bare, DefaultRenderer): - # DefaultRenderer raises at construction with either flag set — - # covered by ``test_default_renderer_raises_on_flags``. - all_on = _make(tokenizer, renderer_name, preserve_all_thinking=True) - assert all_on.config.preserve_all_thinking is True - assert all_on.config.preserve_thinking_between_tool_calls is False - - btc_on = _make( - tokenizer, renderer_name, preserve_thinking_between_tool_calls=True - ) - assert btc_on.config.preserve_all_thinking is False - assert btc_on.config.preserve_thinking_between_tool_calls is True - - -# --------------------------------------------------------------------------- -# Regression: legacy chat-template-kwarg pass-throughs are gone -# --------------------------------------------------------------------------- - - -def test_glm5_config_accepts_clear_thinking(): - """``clear_thinking`` is a chat-template field on GLM-5's typed - config. The GLM-5 / GLM-5.1 Jinja templates gate historical - reasoning on ``clear_thinking is defined and not clear_thinking``, - so passing ``clear_thinking=False`` here must reach the renderer's - historical-reasoning gate. Parity vs ``apply_chat_template`` is - asserted in ``test_renderer_config_parity``.""" - from renderers import GLM5RendererConfig - from renderers.base import load_tokenizer - from renderers.glm5 import GLM5Renderer - - tok = load_tokenizer("zai-org/GLM-5") - # Both values must be accepted without raising. - GLM5Renderer(tok, GLM5RendererConfig(clear_thinking=True)) - GLM5Renderer(tok, GLM5RendererConfig(clear_thinking=False)) - + if isinstance(bare, DefaultRenderer): + pytest.skip("DefaultRenderer has no typed no-thinking bridge policy") + + cfg_cls = _config_class_for(renderer_name) + template_fields = cfg_cls.template_field_names() + if "enable_thinking" in template_fields: + no_thinking = {"enable_thinking": False} + elif "thinking" in template_fields: + no_thinking = {"thinking": False} + else: + pytest.skip(f"{model_name}: no no-thinking generation knob") -def test_qwen36_config_rejects_unknown_field(): - """``preserve_thinking`` on Qwen3.6 was a chat-template-kwarg - pass-through in an earlier revision. It is superseded by the - renderer-agnostic ``preserve_all_thinking`` override and must not - appear on the typed config — its default-False semantics are now - inherited from Qwen3.5's render gate. ``extra="forbid"`` on the - pydantic model enforces this at construction.""" - from renderers import Qwen36RendererConfig + all_on = _make(tokenizer, renderer_name, **no_thinking) + assert all_on.effective_thinking_retention == "all" - with pytest.raises(ValidationError, match="preserve_thinking"): - Qwen36RendererConfig(preserve_thinking=True) # type: ignore[call-arg] + conservative = _make( + tokenizer, + renderer_name, + **no_thinking, + thinking_retention="tool_cycle", + ) + assert conservative.effective_thinking_retention == "tool_cycle" diff --git a/tests/test_renderer_config.py b/tests/test_renderer_config.py index 4bc0a31a..a35f2709 100644 --- a/tests/test_renderer_config.py +++ b/tests/test_renderer_config.py @@ -11,11 +11,15 @@ AutoRendererConfig, DefaultRendererConfig, GLM5RendererConfig, + GptOssRendererConfig, + Nemotron3RendererConfig, Qwen3RendererConfig, Qwen35RendererConfig, + Qwen36RendererConfig, RendererConfig, base, create_renderer, + create_renderer_pool, ) @@ -85,7 +89,7 @@ def __init__(self, tokenizer, config): def test_create_renderer_auto_resolves_via_model_map(monkeypatch): """``AutoRendererConfig`` (or ``config=None``) routes through ``MODEL_RENDERER_MAP`` to pick the matching renderer + typed config, - carrying the shared ``preserve_*`` flags over from the auto config.""" + carrying the shared ``thinking_retention`` field over from the auto config.""" class _FakeQwen35: def __init__(self, tokenizer, config): @@ -97,16 +101,109 @@ def __init__(self, tokenizer, config): renderer = create_renderer( SimpleNamespace(name_or_path="fake/qwen35"), - AutoRendererConfig(preserve_all_thinking=True), + AutoRendererConfig(thinking_retention="all"), ) assert isinstance(renderer.config, Qwen35RendererConfig) - assert renderer.config.preserve_all_thinking is True + assert renderer.config.thinking_retention == "all" # Template-level kwargs stay at their per-renderer defaults — auto - # carries only the preserve_* flags. + # carries only the thinking_retention flag. assert renderer.config.add_vision_id is False +def test_create_renderer_auto_applies_chat_template_kwargs(monkeypatch): + """Auto resolution happens before chat-template kwargs are validated.""" + + class _FakeQwen3: + def __init__(self, tokenizer, config): + self.tokenizer = tokenizer + self.config = config + + monkeypatch.setitem(base.RENDERER_REGISTRY, "qwen3", _FakeQwen3) + monkeypatch.setitem(base.MODEL_RENDERER_MAP, "fake/qwen3", "qwen3") + + renderer = create_renderer( + SimpleNamespace(name_or_path="fake/qwen3"), + chat_template_kwargs={"enable_thinking": False}, + ) + + assert isinstance(renderer.config, Qwen3RendererConfig) + assert renderer.config.enable_thinking is False + + +def test_create_renderer_pool_forwards_chat_template_kwargs(monkeypatch): + """Pool construction uses the same renderer-owned config resolution.""" + + class _FakeQwen3: + def __init__(self, tokenizer, config): + self.config = config + + monkeypatch.setitem(base.RENDERER_REGISTRY, "qwen3", _FakeQwen3) + monkeypatch.setitem(base.MODEL_RENDERER_MAP, "fake/qwen3", "qwen3") + monkeypatch.setattr( + base, + "load_tokenizer", + lambda name: SimpleNamespace(name_or_path=name), + ) + + pool = create_renderer_pool( + "fake/qwen3", + size=1, + chat_template_kwargs={"enable_thinking": False}, + ) + + assert isinstance(pool._sole.config, Qwen3RendererConfig) + assert pool._sole.config.enable_thinking is False + + +def test_auto_unknown_model_rejects_chat_template_kwargs(): + tok = SimpleNamespace(name_or_path="unknown/text-model") + + with pytest.raises(ValueError, match="chat_template_kwargs"): + create_renderer(tok, chat_template_kwargs={"enable_thinking": False}) + + +def test_chat_template_kwargs_validate_against_resolved_config(monkeypatch): + class _FakeQwen3: + def __init__(self, tokenizer, config): + self.config = config + + monkeypatch.setitem(base.RENDERER_REGISTRY, "qwen3", _FakeQwen3) + monkeypatch.setitem(base.MODEL_RENDERER_MAP, "fake/qwen3", "qwen3") + + with pytest.raises(ValidationError, match="enable_thinkng"): + create_renderer( + SimpleNamespace(name_or_path="fake/qwen3"), + chat_template_kwargs={"enable_thinkng": False}, + ) + + +def test_chat_template_kwargs_conflict_with_explicit_config(): + with pytest.raises(ValidationError, match="thinking_retention"): + create_renderer( + SimpleNamespace(name_or_path="fake/glm"), + GLM5RendererConfig(thinking_retention="tool_cycle"), + chat_template_kwargs={"clear_thinking": False}, + ) + + +def test_chat_template_kwargs_preserve_default_field_unset_state(monkeypatch): + class _FakeGlm: + def __init__(self, tokenizer, config): + self.config = config + + monkeypatch.setitem(base.RENDERER_REGISTRY, "glm-5", _FakeGlm) + + renderer = create_renderer( + SimpleNamespace(name_or_path="fake/glm"), + GLM5RendererConfig(thinking_retention="all"), + chat_template_kwargs={"enable_thinking": False}, + ) + + assert renderer.config.thinking_retention == "all" + assert renderer.config.enable_thinking is False + + def test_create_renderer_default_argument_is_auto(): """Passing no config is equivalent to passing ``AutoRendererConfig()`` — short form for the common case.""" @@ -114,3 +211,114 @@ def test_create_renderer_default_argument_is_auto(): renderer = create_renderer(tok) # Falls through to DefaultRenderer when no match and no vision config. assert renderer.__class__.__name__ == "DefaultRenderer" + + +@pytest.mark.parametrize( + "config_cls,kwargs", + [ + (GLM5RendererConfig, {"thinking_retention": "template"}), + ( + GLM5RendererConfig, + {"clear_thinking": False, "thinking_retention": "tool_cycle"}, + ), + (GLM5RendererConfig, {"clear_thinking": True, "thinking_retention": "all"}), + ( + Nemotron3RendererConfig, + { + "truncate_history_thinking": False, + "thinking_retention": "tool_cycle", + }, + ), + ( + Nemotron3RendererConfig, + {"truncate_history_thinking": True, "thinking_retention": "all"}, + ), + ( + Qwen36RendererConfig, + {"preserve_thinking": True, "thinking_retention": "tool_cycle"}, + ), + ( + Qwen36RendererConfig, + {"preserve_thinking": False, "thinking_retention": "all"}, + ), + ( + GptOssRendererConfig, + {"auto_drop_analysis": False, "thinking_retention": "tool_cycle"}, + ), + ( + GptOssRendererConfig, + {"auto_drop_analysis": True, "thinking_retention": "all"}, + ), + ], +) +def test_thinking_retention_conflict_raises(config_cls, kwargs): + """Explicit template and generic retention knobs must agree.""" + with pytest.raises(ValidationError, match="thinking_retention"): + config_cls(**kwargs) + + +@pytest.mark.parametrize( + "config_cls,kwargs", + [ + (GLM5RendererConfig, {"clear_thinking": False, "thinking_retention": "all"}), + ( + GLM5RendererConfig, + {"clear_thinking": True, "thinking_retention": "tool_cycle"}, + ), + (GLM5RendererConfig, {"clear_thinking": False}), + (GLM5RendererConfig, {"thinking_retention": "tool_cycle"}), + ( + Nemotron3RendererConfig, + {"truncate_history_thinking": False, "thinking_retention": "all"}, + ), + ( + Nemotron3RendererConfig, + { + "truncate_history_thinking": True, + "thinking_retention": "tool_cycle", + }, + ), + ( + Qwen36RendererConfig, + {"preserve_thinking": True, "thinking_retention": "all"}, + ), + ( + Qwen36RendererConfig, + {"preserve_thinking": False, "thinking_retention": "tool_cycle"}, + ), + ( + GptOssRendererConfig, + {"auto_drop_analysis": False, "thinking_retention": "all"}, + ), + ( + GptOssRendererConfig, + {"auto_drop_analysis": True, "thinking_retention": "tool_cycle"}, + ), + ], +) +def test_thinking_retention_consistent_pairs_are_accepted(config_cls, kwargs): + config_cls(**kwargs) + + +def test_default_renderer_rejects_explicit_retention(): + """Opaque apply_chat_template fallback cannot implement bridge policy.""" + tok = SimpleNamespace(name_or_path="") + create_renderer(tok, DefaultRendererConfig()) + + for retention in ("tool_cycle", "all"): + with pytest.raises(ValueError, match="DefaultRenderer"): + create_renderer(tok, DefaultRendererConfig(thinking_retention=retention)) + + +def test_default_renderer_config_rejects_legacy_preserve_flags(): + """``DefaultRendererConfig`` is ``extra="allow"``, so the removed + ``preserve_*`` bools would otherwise slip into ``model_extra`` and be + forwarded to ``apply_chat_template`` silently. A validator rejects them + with a migration message; genuine Jinja kwargs still pass through.""" + with pytest.raises(ValidationError, match="thinking_retention"): + DefaultRendererConfig(preserve_all_thinking=True) + with pytest.raises(ValidationError, match="thinking_retention"): + DefaultRendererConfig(preserve_thinking_between_tool_calls=True) + + cfg = DefaultRendererConfig(some_jinja_kwarg=True) + assert cfg.model_extra["some_jinja_kwarg"] is True diff --git a/tests/test_renderer_config_parity.py b/tests/test_renderer_config_parity.py index 0ba89dd9..063394b2 100644 --- a/tests/test_renderer_config_parity.py +++ b/tests/test_renderer_config_parity.py @@ -110,6 +110,8 @@ # Qwen3.5 / Qwen3.6 / Qwen3-VL — when True, prefix each image / # video placeholder with ``Picture N: `` / ``Video N: ``. "add_vision_id": [True, False], + # Qwen3.6 — keep historical think blocks before the last real user query. + "preserve_thinking": [True, False], # gpt-oss — pin to a fixed date so the renderer's preamble matches # the harmony oracle built with the same date. The default # ``today's date`` is intentionally avoided here so the assertion diff --git a/uv.lock b/uv.lock index 9294c32d..2c6f5e6e 100644 --- a/uv.lock +++ b/uv.lock @@ -9,12 +9,11 @@ resolution-markers = [ ] [options] -exclude-newer = "2026-05-18T21:42:54.18041997Z" +exclude-newer = "2026-06-19T02:36:32.208558271Z" exclude-newer-span = "P7D" [options.exclude-newer-package] prime-pydantic-config = false -fastokens = false [[package]] name = "annotated-doc" @@ -268,35 +267,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/8a/0e/97c33bf5009bdbac74fd2beace167cab3f978feb69cc36f1ef79360d6c4e/exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598", size = 16740, upload-time = "2025-11-21T23:01:53.443Z" }, ] -[[package]] -name = "fastokens" -version = "0.2.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/14/8e/7e88ec1d48db5a6e8d8d44318ce285e38c04b81508bdc2a60e17045a116f/fastokens-0.2.0.tar.gz", hash = "sha256:ef0e175de5c8cb1b616b3210d75dce1fab78e35fc02f77f03f7847d4678be686", size = 675822, upload-time = "2026-05-17T10:32:55.642Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/e8/14/3d640cbe3c866ee6c113ea4ca37c16c5aa44be6412918928bbd3f3b739ef/fastokens-0.2.0-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:e7db86c2785a502e3cd993c7d3a91c46e2751f94d1f446caa9600e9cf3dafbd1", size = 3078350, upload-time = "2026-05-17T10:32:41.313Z" }, - { url = "https://files.pythonhosted.org/packages/bc/bf/33ca3798842fb8bdacf03a97c874454d50fe328d44e0d3c3ba7a633fd3ab/fastokens-0.2.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:fa8dcbc6ad3f7a7e9f5bf1ef3cfc9f0c04f0c8779be9e38864815dc567b27de4", size = 2983397, upload-time = "2026-05-17T10:32:38.823Z" }, - { url = "https://files.pythonhosted.org/packages/c4/0a/1bfd13fb855bce3ce50faa644d6e0e19343035706c2628e17da1247bbf50/fastokens-0.2.0-cp313-cp313t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:09b399600ae5beaa34e45afd05c6835b8569861955dfdc3e05afba25fe414e5a", size = 3309835, upload-time = "2026-05-17T10:32:33.427Z" }, - { url = "https://files.pythonhosted.org/packages/32/ce/33292977a81011ffc59cee1c20b6f8d0dbd9cb39209c327dc74dc542178d/fastokens-0.2.0-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f363b1e89fe4ed979224c326b25b33b28e1172134f3e7959238276170f12328e", size = 3615173, upload-time = "2026-05-17T10:32:30.898Z" }, - { url = "https://files.pythonhosted.org/packages/07/1c/1bef8b4831bf9220ae182fcee3e250273b7f537c81aec96041f6eb4bc990/fastokens-0.2.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5d79358faa11658d8908fbdfab321b6ae6a0ff52ae3cdce7a0fda5c30629a276", size = 3295485, upload-time = "2026-05-17T10:32:36.109Z" }, - { url = "https://files.pythonhosted.org/packages/92/11/49afb739800b6a82af04fa1e991b68669c789ebeaa3bcbc96c2544c8ff9c/fastokens-0.2.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:cf6ae284b70d65989548a8143e1f463f31126f5650ad45ce6af05b83e4afb3e7", size = 3329524, upload-time = "2026-05-17T10:32:44.607Z" }, - { url = "https://files.pythonhosted.org/packages/f0/d2/f9b1c01535a0ce994d30e86cf49616023246134d2a406b74c5dd49df7825/fastokens-0.2.0-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:40fa091ff12aa8ac00fa8e2133c7256b96209827b88de5120eb1361600d7f68f", size = 3149194, upload-time = "2026-05-17T10:32:47.596Z" }, - { url = "https://files.pythonhosted.org/packages/bd/0b/95b2e5d25efc684988918658c90445e0e5f1dc4fee7dae5edd586b4feaba/fastokens-0.2.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:f643f4739b20c86ed21e2c46e7fe203e6a621e21124074f138819423882e3aba", size = 3401011, upload-time = "2026-05-17T10:32:50.13Z" }, - { url = "https://files.pythonhosted.org/packages/9b/31/80b74196524d7a9576fa96685a39ee1f333ffd12eb29dbeb5e65c6e3dcb2/fastokens-0.2.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:3cd4effdc6610cbee0be717032652a1997976f1a2f21949435d925c7982cf6f3", size = 3593240, upload-time = "2026-05-17T10:32:52.661Z" }, - { url = "https://files.pythonhosted.org/packages/59/96/12814aa955b7277adb07a8b36176181f247f3b9cf973d05b34294df6a72c/fastokens-0.2.0-cp39-abi3-macosx_10_12_x86_64.whl", hash = "sha256:d29eb1d608977d63fc4679d6ce2b360fdb8b0ea8d66a0eaf804f7ac31ba52a3a", size = 3089011, upload-time = "2026-05-17T10:32:43.16Z" }, - { url = "https://files.pythonhosted.org/packages/c0/05/553b59c6a8542ad7d443306fc1fd7bb012e4aae459ccf9ab422ea4c681b7/fastokens-0.2.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:f8ec7a20322aafe245201727c7a507b87333c51dc580940b9ee5456dbfe3963c", size = 2992964, upload-time = "2026-05-17T10:32:39.992Z" }, - { url = "https://files.pythonhosted.org/packages/13/66/a53c2309003510de7c189ba8a9d2ea5a1833e9b447d9f69b95449c3dfe34/fastokens-0.2.0-cp39-abi3-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:dc4c0a25726620b1cc4cfb7e9dc1a53b17bbe3f2f9adbaa57b8375b5f36ac5d4", size = 3316289, upload-time = "2026-05-17T10:32:34.557Z" }, - { url = "https://files.pythonhosted.org/packages/b4/54/e0e4318ee1ad0b5196df72cf93615bba0b81f7869d659a44ccc475969151/fastokens-0.2.0-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:160253f8d30747cf66e7ed895c513e16f7b173dd9e644fa641e2eecbd43a616a", size = 3303534, upload-time = "2026-05-17T10:32:37.462Z" }, - { url = "https://files.pythonhosted.org/packages/64/44/bfff90e4b1a43c17edf7305dafbd56dc992bbe832cc08da78f1f50104c2d/fastokens-0.2.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:b61b9fe5b41e0bb36ad86e7551dc53293c9833909ef07b1cdbaa2055b06c3b3e", size = 3254096, upload-time = "2026-05-17T10:32:28.489Z" }, - { url = "https://files.pythonhosted.org/packages/df/89/bec1a0368100c5f1134ab1ce588d71f88697fadbbad5f26bb130eda00fe8/fastokens-0.2.0-cp39-abi3-manylinux_2_28_armv7l.whl", hash = "sha256:8f138fc64e355589be43068e6996ac0bfcfd872cd75fb5793d11d06cb9c0ac9b", size = 3000177, upload-time = "2026-05-17T10:32:29.745Z" }, - { url = "https://files.pythonhosted.org/packages/f2/fd/cbe8a033e2ef565ce5aef4e02316054b04adad96ee9805cba74a50a84ed9/fastokens-0.2.0-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:c1a6cd93d16e880d81deb12519cc2eb20149d7423321cf00a34ce25ea9c9ef15", size = 3626561, upload-time = "2026-05-17T10:32:32.087Z" }, - { url = "https://files.pythonhosted.org/packages/05/bf/1cad7f0e8d03f5f5b2b417cda8859e4d968d2eebdca0cd336b23d7dbbdbb/fastokens-0.2.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:01b9bdba818d7b2c67d57d9917faf7a1dad32ece0734440130de94ad768b819f", size = 3336689, upload-time = "2026-05-17T10:32:46.21Z" }, - { url = "https://files.pythonhosted.org/packages/05/56/0030c6e67c60ee88b74336d1bb03eb556b2afa60445268921ad02d9cb3b3/fastokens-0.2.0-cp39-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:9aec70b85a06bf2ac95dd76e07e404040dbcfb06eb165f29bc61ef2ab68dc87a", size = 3154666, upload-time = "2026-05-17T10:32:48.835Z" }, - { url = "https://files.pythonhosted.org/packages/c1/5b/02cc5d501ccc2fbd4b068a7aa34a35347bdb3a4189b96af647ef0407af87/fastokens-0.2.0-cp39-abi3-musllinux_1_2_i686.whl", hash = "sha256:8c41f5278ac53853249c1170d653ee0afaa0906c9bc32c8d25c9443b6d667298", size = 3406657, upload-time = "2026-05-17T10:32:51.392Z" }, - { url = "https://files.pythonhosted.org/packages/97/d7/f5fb2564e16b1f5733e05c41b090f95a3fe767f6b888ba7d864193bc5447/fastokens-0.2.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:d068bc50082ad67d5d542847075f1f7b8d10f703274e56e241312f18b4d9e772", size = 3598064, upload-time = "2026-05-17T10:32:54.109Z" }, - { url = "https://files.pythonhosted.org/packages/0a/39/2098de2aa01c3e2ef62b066926d70015f88845e3ac1a976ba1cc3a363c05/fastokens-0.2.0-cp39-abi3-win_amd64.whl", hash = "sha256:5729c44ce1d60cb03e506731dcb17d8c2d69c267098c3ff35ca8be37d618714d", size = 2767905, upload-time = "2026-05-17T10:32:56.759Z" }, -] - [[package]] name = "filelock" version = "3.29.0" @@ -1380,7 +1350,6 @@ wheels = [ name = "renderers" source = { editable = "." } dependencies = [ - { name = "fastokens" }, { name = "jinja2" }, { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, { name = "numpy", version = "2.4.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, @@ -1405,7 +1374,6 @@ dev = [ [package.metadata] requires-dist = [ - { name = "fastokens", specifier = ">=0.2.0" }, { name = "jinja2" }, { name = "numpy" }, { name = "openai", specifier = ">=1.108.1" },