diff --git a/archived/learn/inference/project_llm-as-a-judge-streamlit-dashboard/requirements.txt b/archived/learn/inference/project_llm-as-a-judge-streamlit-dashboard/requirements.txt
index 0389cada6..259e52b55 100644
--- a/archived/learn/inference/project_llm-as-a-judge-streamlit-dashboard/requirements.txt
+++ b/archived/learn/inference/project_llm-as-a-judge-streamlit-dashboard/requirements.txt
@@ -135,17 +135,19 @@ numpy==1.26.4
oauthlib==3.2.2
onnxruntime==1.19.2
openai==1.47.0
-opentelemetry-api==1.27.0
-opentelemetry-exporter-otlp-proto-common==1.27.0
-opentelemetry-exporter-otlp-proto-grpc==1.27.0
-opentelemetry-exporter-otlp-proto-http==1.27.0
-opentelemetry-instrumentation==0.48b0
-opentelemetry-instrumentation-asgi==0.48b0
-opentelemetry-instrumentation-fastapi==0.48b0
-opentelemetry-proto==1.27.0
-opentelemetry-sdk==1.27.0
-opentelemetry-semantic-conventions==0.48b0
-opentelemetry-util-http==0.48b0
+# 1.28 is the first line that admits protobuf 5.x (1.27 requires protobuf<5.0).
+# Keep the 1.28.2 / 0.49b2 set together; mixing minors fails to resolve.
+opentelemetry-api==1.28.2
+opentelemetry-exporter-otlp-proto-common==1.28.2
+opentelemetry-exporter-otlp-proto-grpc==1.28.2
+opentelemetry-exporter-otlp-proto-http==1.28.2
+opentelemetry-instrumentation==0.49b2
+opentelemetry-instrumentation-asgi==0.49b2
+opentelemetry-instrumentation-fastapi==0.49b2
+opentelemetry-proto==1.28.2
+opentelemetry-sdk==1.28.2
+opentelemetry-semantic-conventions==0.49b2
+opentelemetry-util-http==0.49b2
orderly-set==5.2.2
orjson==3.10.7
overrides==7.7.0
@@ -161,7 +163,9 @@ posthog==3.6.6
prettytable==3.11.0
prompt_toolkit==3.0.47
proto-plus==1.24.0
-protobuf==4.25.5
+# GHSA-7GCM-G887-7QV7 (CVE-2026-0994): 4.25.5 -> first patched 5.x release.
+# streamlit==1.38.0 caps protobuf <6, so stay on the 5.x line.
+protobuf==5.29.6
psutil==6.0.0
ptyprocess==0.7.0
pulsar-client==3.5.0
diff --git a/archived/learn/rag/project_simple-rag-with-chroma/requirements.txt b/archived/learn/rag/project_simple-rag-with-chroma/requirements.txt
index 84e185a87..3ed65657e 100644
--- a/archived/learn/rag/project_simple-rag-with-chroma/requirements.txt
+++ b/archived/learn/rag/project_simple-rag-with-chroma/requirements.txt
@@ -135,17 +135,19 @@ numpy==1.26.4
oauthlib==3.2.2
onnxruntime==1.19.2
openai==1.47.0
-opentelemetry-api==1.27.0
-opentelemetry-exporter-otlp-proto-common==1.27.0
-opentelemetry-exporter-otlp-proto-grpc==1.27.0
-opentelemetry-exporter-otlp-proto-http==1.27.0
-opentelemetry-instrumentation==0.48b0
-opentelemetry-instrumentation-asgi==0.48b0
-opentelemetry-instrumentation-fastapi==0.48b0
-opentelemetry-proto==1.27.0
-opentelemetry-sdk==1.27.0
-opentelemetry-semantic-conventions==0.48b0
-opentelemetry-util-http==0.48b0
+# 1.28 is the first line that admits protobuf 5.x (1.27 requires protobuf<5.0).
+# Keep the 1.28.2 / 0.49b2 set together; mixing minors fails to resolve.
+opentelemetry-api==1.28.2
+opentelemetry-exporter-otlp-proto-common==1.28.2
+opentelemetry-exporter-otlp-proto-grpc==1.28.2
+opentelemetry-exporter-otlp-proto-http==1.28.2
+opentelemetry-instrumentation==0.49b2
+opentelemetry-instrumentation-asgi==0.49b2
+opentelemetry-instrumentation-fastapi==0.49b2
+opentelemetry-proto==1.28.2
+opentelemetry-sdk==1.28.2
+opentelemetry-semantic-conventions==0.49b2
+opentelemetry-util-http==0.49b2
orderly-set==5.2.2
orjson==3.10.7
overrides==7.7.0
@@ -161,7 +163,9 @@ posthog==3.6.6
prettytable==3.11.0
prompt_toolkit==3.0.47
proto-plus==1.24.0
-protobuf==4.25.5
+# GHSA-7GCM-G887-7QV7 (CVE-2026-0994): 4.25.5 -> first patched 5.x release.
+# streamlit==1.38.0 caps protobuf <6, so stay on the 5.x line.
+protobuf==5.29.6
psutil==6.0.0
ptyprocess==0.7.0
pulsar-client==3.5.0
diff --git a/skills/fireworks-training/references/rl-async.md b/skills/fireworks-training/references/rl-async.md
index 6e4d7f0ed..5387789f1 100644
--- a/skills/fireworks-training/references/rl-async.md
+++ b/skills/fireworks-training/references/rl-async.md
@@ -94,6 +94,14 @@ run. Each trajectory is then packed as a separate trainer datum. Thus a group
with `R` surviving runs has `R` rewards and advantages, while its trainer datum
count is `sum(len(run.segments) for run in runs)` and may exceed `R`.
+`main(..., advantage_fn=...)` can override the per-group reward-to-advantage
+calculation. The default `compute_advantages` subtracts the group mean and
+divides by the group standard deviation. For mean-only REINFORCE, pass a
+function that subtracts the mean without that division. Reward scaling belongs
+to the caller; the recipe does not automatically convert binary rewards to
+`-1/+1`. This choice is independent of token-level score centering and gradient
+accumulation normalization.
+
`RolloutSetup` contains the recipe-owned sampler, tokenizer, tokenizer ID,
sampling kwargs, inference base URL, API key, deployment model, group size, and
caller-provided `extras`. Treat `setup.sampler` as borrowed: reuse it for every
diff --git a/training/examples/multihop_qa/train_multihop_qa_igpo.py b/training/examples/multihop_qa/train_multihop_qa_igpo.py
index 48485ece3..a5c338a26 100644
--- a/training/examples/multihop_qa/train_multihop_qa_igpo.py
+++ b/training/examples/multihop_qa/train_multihop_qa_igpo.py
@@ -52,6 +52,7 @@
wandb_finish,
log_metrics_json,
build_service_client,
+ make_weight_sync,
read_api_extra_headers_env,
validate_config,
load_jsonl_dataset,
@@ -382,6 +383,9 @@ def _signal_handler(signum, frame):
job_id=service.trainer_job_id,
service=service,
)
+ publish_weights = make_weight_sync(
+ policy, service, cfg.deployment, extended=True
+ )
policy_job_id = service.trainer_job_id
sampler = service.create_deployment_sampler()
reference = None
@@ -416,8 +420,7 @@ def _signal_handler(signum, frame):
name = (
f"resume-{step_offset}-base" if step_offset > 0 else "step-0-base"
)
- saved = policy.save_weights_for_sampler_ext(name, checkpoint_type="base")
- service.hotload_sampler_snapshot(saved.snapshot_name)
+ publish_weights(name, checkpoint_type="base")
# Rollout processor
rollout_base_url = sampler.base_url.rstrip("/") + (
@@ -717,8 +720,7 @@ def train_step(
and step % WEIGHT_SYNC_INTERVAL == 0
):
with timer("weight_sync"):
- saved = policy.save_weights_for_sampler_ext(f"step-{step}")
- service.hotload_sampler_snapshot(saved.snapshot_name)
+ publish_weights(f"step-{step}")
if (
DCP_SAVE_INTERVAL > 0
diff --git a/training/examples/rl/frozen_lake/train_frozen_lake.py b/training/examples/rl/frozen_lake/train_frozen_lake.py
index f3242d065..a49910a9b 100644
--- a/training/examples/rl/frozen_lake/train_frozen_lake.py
+++ b/training/examples/rl/frozen_lake/train_frozen_lake.py
@@ -53,6 +53,7 @@
wandb_finish,
log_metrics_json,
build_service_client,
+ make_weight_sync,
read_api_extra_headers_env,
build_training_datum_from_token_mask,
validate_config,
@@ -526,6 +527,9 @@ def _signal_handler(signum, frame):
job_id=service.trainer_job_id,
service=service,
)
+ publish_weights = make_weight_sync(
+ policy, service, cfg.deployment, extended=True
+ )
policy_job_id = service.trainer_job_id
sampler = service.create_deployment_sampler()
reference = None
@@ -555,8 +559,7 @@ def _signal_handler(signum, frame):
step_offset = resume_info.step if resume_info else 0
prior_rows_consumed = resume_info.data_consumed if resume_info else 0
name = f"resume-{step_offset}-base" if step_offset > 0 else "step-0-base"
- saved = policy.save_weights_for_sampler_ext(name, checkpoint_type="base")
- service.hotload_sampler_snapshot(saved.snapshot_name)
+ publish_weights(name, checkpoint_type="base")
# -- Build rollout processor ----------------------------------------
rollout_base_url = sampler.base_url.rstrip("/") + (
@@ -751,8 +754,7 @@ def save_intermediate_checkpoint(step: int, data_consumed: int) -> None:
def _weight_sync(step: int) -> float:
logger.info("[step %d] weight_sync: saving + loading...", step)
with wall_timer() as span:
- saved = policy.save_weights_for_sampler_ext(f"step-{step}")
- service.hotload_sampler_snapshot(saved.snapshot_name)
+ publish_weights(f"step-{step}")
logger.info(
"[step %d] weight_sync: done (%.1fs)",
step,
diff --git a/training/examples/rl/harbor/recipes/textworld/train.py b/training/examples/rl/harbor/recipes/textworld/train.py
index 7d7b705fd..b263ee342 100644
--- a/training/examples/rl/harbor/recipes/textworld/train.py
+++ b/training/examples/rl/harbor/recipes/textworld/train.py
@@ -168,7 +168,7 @@ def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
choices=("binary_tv", "binary_kl"),
default="binary_tv",
)
- parser.add_argument("--dppo-threshold", type=float, default=0.15)
+ parser.add_argument("--dppo-threshold", type=float, default=None)
parser.add_argument("--dppo-ratio-log-cap", type=float, default=20.0)
parser.add_argument(
"--score-centering-top-k",
diff --git a/training/pyproject.toml b/training/pyproject.toml
index 398a54679..5131db2e2 100644
--- a/training/pyproject.toml
+++ b/training/pyproject.toml
@@ -7,7 +7,7 @@ name = "fireworks-training-cookbook"
version = "0.1.0"
requires-python = ">=3.11"
dependencies = [
- "fireworks-ai[training]>=1.2.14,<2",
+ "fireworks-ai[training]>=1.2.15,<2",
# Preserve the runtime floors previously supplied by tinker-cookbook.
"aiohttp>=3.9.0",
"pydantic>=2.0.0",
diff --git a/training/recipes/async_rl_loop.py b/training/recipes/async_rl_loop.py
index 83e79eeb5..54cfa5818 100644
--- a/training/recipes/async_rl_loop.py
+++ b/training/recipes/async_rl_loop.py
@@ -55,6 +55,7 @@
WandBConfig,
ReconnectableClient,
build_service_client,
+ make_weight_sync,
flush_phase_trace,
log_metrics,
load_deployment_tokenizer,
@@ -67,6 +68,7 @@
)
from training.utils.checkpoints import TrainingCheckpoints, validate_warm_start_config
from training.utils.dataloader import CursorDataLoader
+from training.utils.data import compute_advantages
from training.utils.logging import ASYNC_RL_WANDB_METRIC_STEPS
from training.utils.rl import PromptGroup
from training.utils.rl.async_rl import (
@@ -584,6 +586,7 @@ def main(
config: Config,
*,
rollout_fn_factory: RolloutFnFactory,
+ advantage_fn: Callable[[list[float]], list[float]] = compute_advantages,
dynamic_filter_fn: DynamicFilterFn | None = None,
evaluation_fn: RolloutEvaluationFn | None = None,
evaluation_interval: int = 1,
@@ -598,6 +601,10 @@ def main(
``completions_per_prompt`` times per dataset row (each invocation is
one trajectory draw against the inference deployment).
+ ``advantage_fn`` maps one prompt group's rewards to per-rollout advantages.
+ The default subtracts the group mean and divides by its standard deviation.
+ Supply a mean-only function for REINFORCE with group-centered rewards.
+
Remote trainer and sampler setup and lifecycle are owned by the SDK-managed
Tinker path.
"""
@@ -809,6 +816,7 @@ def _signal_handler(signum, _):
job_id=service.trainer_job_id,
service=service,
)
+ publish_weights = make_weight_sync(policy, service, cfg.deployment)
reference = None
if cfg.kl_beta > 0:
reference_training_client = service.create_reference_client(
@@ -844,11 +852,7 @@ def _signal_handler(signum, _):
)
with elapsed_timer("weight_sync") as span:
- saved = policy.save_weights_for_sampler(
- f"step-{step_offset}",
- checkpoint_type="base",
- )
- service.hotload_sampler_snapshot(saved.path)
+ publish_weights(f"step-{step_offset}", checkpoint_type="base")
logger.info(
"[step %d] initial weight sync (%.1fs)",
step_offset,
@@ -1096,7 +1100,11 @@ def fwd_bwd_batch(
)
elif cfg.policy_loss == "dppo":
loss_fn = make_dppo_loss_fn(
- **common_loss_kwargs,
+ advantages=adv,
+ ref_logprobs=ref_lp,
+ inf_logprobs=inf_lp,
+ prompt_len=prompt_lens,
+ old_policy_logprobs=old_policy_logprobs,
dppo_config=cfg.dppo,
)
else:
@@ -1202,8 +1210,7 @@ def optimizer_step(step: int) -> dict[str, Any]:
def sync_weights(step: int) -> float:
with wall_timer() as span:
- saved = policy.save_weights_for_sampler(f"step-{step}")
- service.hotload_sampler_snapshot(saved.path)
+ publish_weights(f"step-{step}")
return span.elapsed
async def run_training() -> tuple[int, dict[str, Any]]:
@@ -1222,6 +1229,7 @@ def _step_metrics(metrics: dict[str, Any], step: int) -> None:
)
coordinator = AsyncRLCoordinator(
rows=make_row_requests(),
+ advantage_fn=advantage_fn,
completions_per_prompt=cfg.completions_per_prompt,
prompt_groups_per_step=cfg.prompt_groups_per_step,
training_chunks_per_step=cfg.pipeline_chunks_per_step,
diff --git a/training/recipes/distillation_loop.py b/training/recipes/distillation_loop.py
index 49f6ff50b..ea440d566 100644
--- a/training/recipes/distillation_loop.py
+++ b/training/recipes/distillation_loop.py
@@ -47,6 +47,7 @@
TrainerConfig,
WandBConfig,
build_service_client,
+ make_weight_sync,
load_jsonl_dataset,
load_deployment_tokenizer,
log_metrics_json,
@@ -549,6 +550,9 @@ def _signal_handler(signum, frame):
default_timeout=cfg.step_timeout or 3600,
service=service,
)
+ publish_weights = make_weight_sync(
+ policy, service, cfg.deployment, extended=True
+ )
tokenizer = load_deployment_tokenizer(cfg.deployment)
max_seq_len = service.max_context_length
deployment_id = service.deployment_id
@@ -693,8 +697,7 @@ async def _score_topk_routed(
if cfg.weight_sync_before_training:
name = f"resume-{step_offset}-base" if step_offset > 0 else "step-0-base"
- saved = policy.save_weights_for_sampler_ext(name, checkpoint_type="base")
- service.hotload_sampler_snapshot(saved.snapshot_name)
+ publish_weights(name, checkpoint_type="base")
# -- Prepare sampling and training --------------------------------------
@@ -989,8 +992,7 @@ def train_step(
logger.info("[step %d] weight_sync: saving + loading...", step)
t0 = _time.time()
with timer("weight_sync"):
- saved = policy.save_weights_for_sampler_ext(f"step-{step}")
- service.hotload_sampler_snapshot(saved.snapshot_name)
+ publish_weights(f"step-{step}")
logger.info("[step %d] weight_sync: done (%.1fs)", step, _time.time() - t0)
if (
cfg.step_eval is not None
diff --git a/training/recipes/igpo_loop.py b/training/recipes/igpo_loop.py
index 579887a8a..76b6afce0 100644
--- a/training/recipes/igpo_loop.py
+++ b/training/recipes/igpo_loop.py
@@ -47,6 +47,7 @@
RawRowCursor,
RLPromptDataset,
build_service_client,
+ make_weight_sync,
wandb_log,
setup_wandb,
wandb_finish,
@@ -335,6 +336,9 @@ def _signal_handler(signum, frame):
default_timeout=_timeout,
service=service,
)
+ publish_weights = make_weight_sync(
+ policy, service, cfg.deployment, extended=True
+ )
# KL reference (optional in iGPO): the SDK owns the shared-vs-separate
# decision. LoRA without an explicit reference shape reuses the policy
# session; full-param (or an explicit reference_training_shape_id)
@@ -377,11 +381,7 @@ def _signal_handler(signum, frame):
if cfg.weight_sync_before_training:
with timer("weight_sync"):
- saved = policy.save_weights_for_sampler_ext(
- f"step-{step_offset}",
- checkpoint_type="base",
- )
- service.hotload_sampler_snapshot(saved.snapshot_name)
+ publish_weights(f"step-{step_offset}", checkpoint_type="base")
# Dataset
raw_dataset = load_jsonl_dataset(cfg.dataset, cfg.max_rows)
@@ -688,8 +688,7 @@ def train_step(
# 5. Sync weights
if cfg.weight_sync_interval > 0 and step % cfg.weight_sync_interval == 0:
with timer("weight_sync"):
- saved = policy.save_weights_for_sampler_ext(f"step-{step}")
- service.hotload_sampler_snapshot(saved.snapshot_name)
+ publish_weights(f"step-{step}")
if cfg.dcp_save_interval > 0 and step % cfg.dcp_save_interval == 0:
ckpt.save(
f"step-{step}",
diff --git a/training/recipes/rl_loop.py b/training/recipes/rl_loop.py
index a5c7661ef..9ed56f52d 100644
--- a/training/recipes/rl_loop.py
+++ b/training/recipes/rl_loop.py
@@ -49,6 +49,7 @@
WandBConfig,
build_renderer,
build_service_client,
+ make_weight_sync,
load_deployment_tokenizer,
load_jsonl_dataset,
log_metrics,
@@ -303,6 +304,7 @@ def _signal_handler(signum, _):
job_id=service.trainer_job_id,
service=service,
)
+ publish_weights = make_weight_sync(policy, service, cfg.deployment)
reference = None
if cfg.kl_beta > 0:
reference = ReconnectableClient.from_training_client(
@@ -344,11 +346,7 @@ def _signal_handler(signum, _):
# The synchronous recipe is always strict on-policy: initialize the
# sampler from the trainer, then repeat this sync after every update.
with elapsed_timer("weight_sync") as span:
- saved = policy.save_weights_for_sampler(
- f"step-{step_offset}",
- checkpoint_type="base",
- )
- service.hotload_sampler_snapshot(saved.path)
+ publish_weights(f"step-{step_offset}", checkpoint_type="base")
logger.info("[step %d] initial weight sync (%.1fs)", step_offset, span.elapsed)
flush_timing()
@@ -567,8 +565,7 @@ async def run_training() -> int:
# 4. Publish this policy before the next rollout batch.
with elapsed_timer("weight_sync"):
- saved = policy.save_weights_for_sampler(f"step-{step}")
- service.hotload_sampler_snapshot(saved.path)
+ publish_weights(f"step-{step}")
for index in row_indices:
row_loader.mark_resolved(index)
diff --git a/training/renderer/__init__.py b/training/renderer/__init__.py
index bb30cbb2f..02b72303d 100644
--- a/training/renderer/__init__.py
+++ b/training/renderer/__init__.py
@@ -20,6 +20,7 @@
from training._vendor.tinker_cookbook_0_4_3.renderers import * # noqa: F403
from training.renderer import deepseek_v4 as _deepseek_v4 # noqa: F401 (registers "deepseek_v4")
+from training.renderer import deepseek_v41 as _deepseek_v41 # noqa: F401 (registers "deepseek_v41")
from training.renderer import gemma4 as _gemma4 # noqa: F401 (registers "gemma4")
from training.renderer import glm5 as _glm5 # noqa: F401 (registers "glm5")
from training.renderer import kimi_k26 as _kimi_k26 # noqa: F401 (registers Kimi K2.5/2.6)
diff --git a/training/renderer/_disaggregate_mixin.py b/training/renderer/_disaggregate_mixin.py
index ae49e56d8..d6b0778ae 100644
--- a/training/renderer/_disaggregate_mixin.py
+++ b/training/renderer/_disaggregate_mixin.py
@@ -115,30 +115,6 @@ def _terminal_assistant(prefix: Sequence[Any]) -> Mapping[str, Any] | None:
return None
-def _terminal_turn_start(prefix: Sequence[Any]) -> int:
- """Index of the first message after the prefix's last user message.
-
- That message onward is the terminal turn: the one target this datum
- exists to train.
- """
- for idx in range(len(prefix) - 1, -1, -1):
- msg = prefix[idx]
- if isinstance(msg, Mapping) and msg.get("role") == "user":
- return idx + 1
- return 0
-
-
-def _history_demoted_to_context(prefix: Sequence[Any]) -> list[Any]:
- """Keep per-message flags on the terminal turn, mark history as context."""
- terminal_start = _terminal_turn_start(prefix)
- return [
- msg
- if idx >= terminal_start or not isinstance(msg, Mapping)
- else {**msg, "trainable": False}
- for idx, msg in enumerate(prefix)
- ]
-
-
class DisaggregateMultiTurnMixin:
"""Provide a multi-turn-safe ``build_supervised_examples`` for renderers
that don't satisfy the sequence extension property.
@@ -150,6 +126,47 @@ class Qwen3SplitRenderer(DisaggregateMultiTurnMixin, Qwen3Renderer):
pass
"""
+ def _is_disaggregation_turn_boundary(
+ self,
+ message: Mapping[str, Any],
+ index: int,
+ ) -> bool:
+ del index
+ return message.get("role") == "user"
+
+ def _demote_history_to_context(self, prefix: Sequence[Any]) -> list[Any]:
+ terminal_start = 0
+ for idx in range(len(prefix) - 1, -1, -1):
+ message = prefix[idx]
+ if isinstance(
+ message, Mapping
+ ) and self._is_disaggregation_turn_boundary(message, idx):
+ terminal_start = idx + 1
+ break
+ return [
+ message
+ if idx >= terminal_start or not isinstance(message, Mapping)
+ else {**message, "trainable": False}
+ for idx, message in enumerate(prefix)
+ ]
+
+ def _disaggregation_end_indices(self, messages: Sequence[Mapping[str, Any]]) -> list[int]:
+ """Exclusive ends for turns that contain at least one assistant."""
+ boundaries = [
+ index
+ for index, message in enumerate(messages)
+ if self._is_disaggregation_turn_boundary(message, index)
+ ]
+ ends: list[int] = []
+ turn_start = 0
+ for boundary in boundaries:
+ if any(message.get("role") == "assistant" for message in messages[turn_start:boundary]):
+ ends.append(boundary)
+ turn_start = boundary
+ if any(message.get("role") == "assistant" for message in messages[turn_start:]):
+ ends.append(len(messages))
+ return ends
+
def build_supervised_examples(
self,
messages: list[Any],
@@ -175,10 +192,6 @@ def build_supervised_examples(
self.build_supervised_example(messages, train_on_what=train_on_what)
]
- user_message_idxs = [
- idx for idx, message in enumerate(messages) if message["role"] == "user"
- ]
-
if train_on_what not in (
TrainOnWhat.ALL_ASSISTANT_MESSAGES,
TrainOnWhat.CUSTOMIZED,
@@ -194,7 +207,7 @@ def build_supervised_examples(
)
examples = []
- for next_user_idx in [*user_message_idxs[1:], len(messages)]:
+ for next_user_idx in self._disaggregation_end_indices(messages):
prefix = messages[:next_user_idx]
terminal = _terminal_assistant(prefix)
# Skip rounds whose terminal assistant the user marked
@@ -210,7 +223,7 @@ def build_supervised_examples(
elif train_on_what == TrainOnWhat.CUSTOMIZED and uses_per_message_weights(
prefix
):
- prefix = _history_demoted_to_context(prefix)
+ prefix = self._demote_history_to_context(prefix)
# A fully trainable terminal turn restates LAST_ASSISTANT_TURN
# and a terminal turn masked down to its final answer restates
# LAST_ASSISTANT_MESSAGE; either way the weighted row renders
diff --git a/training/renderer/deepseek_v4.py b/training/renderer/deepseek_v4.py
index a8c17d144..d875d25c3 100644
--- a/training/renderer/deepseek_v4.py
+++ b/training/renderer/deepseek_v4.py
@@ -69,7 +69,7 @@
import json
import re
from collections.abc import Mapping
-from typing import Any, Literal
+from typing import Any, Callable, Literal
import tinker
import torch
@@ -103,25 +103,21 @@
_THINK_CLOSE = ""
_DSML = "|DSML|"
-_TOOL_CALLS_OPEN = f"<{_DSML}tool_calls>"
-_TOOL_CALLS_CLOSE = f"{_DSML}tool_calls>"
-_TOOL_CALLS_BOUNDARY = f"\n\n<{_DSML}tool_calls"
-
# ── Tools / response_format prompt sections ─────────────────────────────────
_TOOLS_TEMPLATE = """## Tools
-You have access to a set of tools to help answer the user's question. You can invoke tools by writing a "<{dsml}tool_calls>" block like the following:
+You have access to a set of tools to help answer the user's question. You can invoke tools by writing a "<{dsml}{tool_calls}>" block like the following:
-<{dsml}tool_calls>
-<{dsml}invoke name="$TOOL_NAME">
-<{dsml}parameter name="$PARAMETER_NAME" string="true|false">$PARAMETER_VALUE{dsml}parameter>
+<{dsml}{tool_calls}>
+<{dsml}{invoke} name="$TOOL_NAME">
+<{dsml}{parameter} name="$PARAMETER_NAME" string="true|false">$PARAMETER_VALUE{dsml}{parameter}>
...
-{dsml}invoke>
-<{dsml}invoke name="$TOOL_NAME2">
+{dsml}{invoke}>
+<{dsml}{invoke} name="$TOOL_NAME2">
...
-{dsml}invoke>
-{dsml}tool_calls>
+{dsml}{invoke}>
+{dsml}{tool_calls}>
String parameters should be specified as is and set `string="true"`. For all other types (numbers, booleans, arrays, objects), pass the value in JSON format and set `string="false"`.
@@ -141,24 +137,6 @@
"You MUST strictly adhere to the following schema to reply:\n{schema}"
)
-# ── Parsing regexes (mirror encoding_dsv4.parse_tool_calls) ─────────────────
-
-_TOOL_CALLS_BLOCK_RE = re.compile(
- rf"\n\n<{re.escape(_DSML)}tool_calls>\n(.*?)\n{re.escape(_DSML)}tool_calls>",
- re.DOTALL,
-)
-_INVOKE_RE = re.compile(
- rf'<{re.escape(_DSML)}invoke name="([^"]+)">\n(.*?)\n{re.escape(_DSML)}invoke>',
- re.DOTALL,
-)
-_PARAMETER_RE = re.compile(
- rf'<{re.escape(_DSML)}parameter name="([^"]+)" string="(true|false)">'
- rf"(.*?)"
- rf"{re.escape(_DSML)}parameter>",
- re.DOTALL,
-)
-
-
# ── Helpers ─────────────────────────────────────────────────────────────────
@@ -257,8 +235,8 @@ def _extract_reasoning_and_text(content: Any) -> tuple[str, str]:
)
-def _normalize_tool_arguments(raw: str) -> dict[str, Any]:
- """Tinker stores tool args as a JSON string; decode it once for rendering.
+def _normalize_tool_arguments(raw: str, *, decode_passes: int = 1) -> dict[str, Any]:
+ """Decode Tinker's JSON-string tool arguments for rendering.
Mirrors the encoder's defensive fallback: if ``raw`` doesn't parse as
a JSON object, wrap it as ``{"arguments": raw}`` so we never crash on
@@ -266,37 +244,54 @@ def _normalize_tool_arguments(raw: str) -> dict[str, Any]:
"""
if not raw:
return {}
- try:
- parsed = json.loads(raw)
- except json.JSONDecodeError:
- return {"arguments": raw}
+ parsed: Any = raw
+ for _ in range(decode_passes):
+ if not isinstance(parsed, str):
+ break
+ try:
+ parsed = json.loads(parsed)
+ except json.JSONDecodeError:
+ break
if not isinstance(parsed, Mapping):
return {"arguments": raw}
return dict(parsed)
-def _format_tool_calls(tool_calls: list[ToolCall]) -> str:
+def _format_tool_calls(
+ tool_calls: list[ToolCall],
+ *,
+ calls_name: str = "tool_calls",
+ invoke_name: str = "invoke",
+ parameter_name: str = "parameter",
+ argument_decode_passes: int = 1,
+) -> str:
"""Render a list of tool calls as a single DSML ``<…tool_calls>`` block."""
invokes: list[str] = []
for tc in tool_calls:
- args = _normalize_tool_arguments(tc.function.arguments)
+ args = _normalize_tool_arguments(tc.function.arguments, decode_passes=argument_decode_passes)
param_lines = [
- f'<{_DSML}parameter name="{k}" string="'
+ f'<{_DSML}{parameter_name} name="{k}" string="'
f'{"true" if isinstance(v, str) else "false"}">'
f"{v if isinstance(v, str) else _to_json(v)}"
- f"{_DSML}parameter>"
+ f"{_DSML}{parameter_name}>"
for k, v in args.items()
]
invokes.append(
- f'<{_DSML}invoke name="{tc.function.name}">\n'
+ f'<{_DSML}{invoke_name} name="{tc.function.name}">\n'
+ "\n".join(param_lines)
- + f"\n{_DSML}invoke>"
+ + f"\n{_DSML}{invoke_name}>"
)
body = "\n".join(invokes)
- return f"\n\n{_TOOL_CALLS_OPEN}\n{body}\n{_TOOL_CALLS_CLOSE}"
+ return f"\n\n<{_DSML}{calls_name}>\n{body}\n{_DSML}{calls_name}>"
-def _render_tools_section(tools: list[Mapping[str, Any]]) -> str:
+def _render_tools_section(
+ tools: list[Mapping[str, Any]],
+ *,
+ calls_name: str = "tool_calls",
+ invoke_name: str = "invoke",
+ parameter_name: str = "parameter",
+) -> str:
"""Render OpenAI-format tool schemas into the ``## Tools`` block.
Mirrors ``encoding_dsv4.render_tools`` exactly: each ``tool`` is
@@ -307,6 +302,9 @@ def _render_tools_section(tools: list[Mapping[str, Any]]) -> str:
function_dicts = [tool["function"] for tool in tools]
return _TOOLS_TEMPLATE.format(
dsml=_DSML,
+ tool_calls=calls_name,
+ invoke=invoke_name,
+ parameter=parameter_name,
think_open=_THINK_OPEN,
think_close=_THINK_CLOSE,
tool_schemas="\n".join(_to_json(t) for t in function_dicts),
@@ -334,7 +332,11 @@ def _has_any_tools(messages: list[Message]) -> bool:
# ── Preprocessing (mirror encoder's merge + sort + drop pipeline) ───────────
-def _merge_tool_messages(messages: list[Message]) -> list[Message]:
+def _merge_tool_messages(
+ messages: list[Message],
+ *,
+ user_blocks: Callable[[Any], list[dict[str, Any]]] | None = None,
+) -> list[Message]:
"""Fold ``role=tool`` messages into the previous user's ``content_blocks``.
Mirrors ``encoding_dsv4.merge_tool_messages``. User messages always get
@@ -363,22 +365,23 @@ def _merge_tool_messages(messages: list[Message]) -> list[Message]:
continue
if role == "user":
- text_block = {
- "type": "text",
- "text": _visible_text(msg.get("content", "")),
- }
+ content_blocks = (
+ user_blocks(msg.get("content", ""))
+ if user_blocks is not None
+ else [{"type": "text", "text": _visible_text(msg.get("content", ""))}]
+ )
if (
merged
and merged[-1].get("role") == "user"
and "content_blocks" in merged[-1]
and merged[-1].get("task") is None
):
- merged[-1]["content_blocks"].append(text_block)
+ merged[-1]["content_blocks"].extend(content_blocks)
else:
new_msg: dict[str, Any] = {
"role": "user",
"content": msg.get("content", ""),
- "content_blocks": [text_block],
+ "content_blocks": content_blocks,
}
for key in ("task", "wo_eos", "mask", "trainable"):
if key in msg:
@@ -484,6 +487,15 @@ class DeepseekV4Renderer(DisaggregateMultiTurnMixin, Renderer):
one full supervised sequence instead.
"""
+ _eos_text = _EOS_TEXT
+ _think_open = _THINK_OPEN
+ _think_close = _THINK_CLOSE
+ _tool_calls_name = "tool_calls"
+ _tool_invoke_name = "invoke"
+ _tool_parameter_name = "parameter"
+ _tool_argument_decode_passes = 1
+ _strict_tool_parsing = False
+
def __init__(
self,
tokenizer: Tokenizer,
@@ -500,6 +512,53 @@ def __init__(
# right assistant header without re-checking the message list.
self._effective_strip = strip_thinking_from_history
+ def _format_tool_calls(self, tool_calls: list[ToolCall]) -> str:
+ return _format_tool_calls(
+ tool_calls,
+ calls_name=self._tool_calls_name,
+ invoke_name=self._tool_invoke_name,
+ parameter_name=self._tool_parameter_name,
+ argument_decode_passes=self._tool_argument_decode_passes,
+ )
+
+ def _render_tools_section(self, tools: list[Mapping[str, Any]]) -> str:
+ return _render_tools_section(
+ tools,
+ calls_name=self._tool_calls_name,
+ invoke_name=self._tool_invoke_name,
+ parameter_name=self._tool_parameter_name,
+ )
+
+ def _last_assistant_turn_boundary(self, messages: list[Message]) -> int:
+ """Last real user message, excluding synthetic tool-result users."""
+ return max(
+ (
+ idx
+ for idx, message in enumerate(messages)
+ if message.get("role") == "user"
+ and any(
+ block.get("type") == "text"
+ for block in message.get("content_blocks", [])
+ )
+ ),
+ default=-1,
+ )
+
+ def _assistant_starts_thinking(
+ self,
+ message: Message,
+ ctx: RenderContext,
+ ) -> bool:
+ del message
+ is_terminal = ctx.last_user_index < 0 or ctx.idx > ctx.last_user_index
+ return self.thinking_mode == "thinking" and (
+ not self._effective_strip or is_terminal
+ )
+
+ def _assistant_emits_header(self, message: Message, ctx: RenderContext) -> bool:
+ del message, ctx
+ return True
+
# ---- public Renderer API --------------------------------------------------
@property
@@ -554,18 +613,7 @@ def _build_tool_turn_example(
(idx for idx, m in enumerate(merged) if m.get("role") == "user"),
default=-1,
)
- last_real_user_idx = max(
- (
- idx
- for idx, m in enumerate(merged)
- if m.get("role") == "user"
- and any(
- block.get("type") == "text"
- for block in m.get("content_blocks", [])
- )
- ),
- default=-1,
- )
+ last_real_user_idx = self._last_assistant_turn_boundary(merged)
chunks_weights: list[tuple[tinker.types.ModelInputChunk, int]] = []
if self._bos_tokens:
@@ -636,27 +684,68 @@ def parse_response(self, response: list[int]) -> tuple[Message, bool]:
completions: returns ``ok=False`` when the EOS hasn't shown up yet.
"""
text = self.tokenizer.decode(response)
- eos_idx = text.find(_EOS_TEXT)
+ eos_idx = text.find(self._eos_text)
ok = eos_idx >= 0
if ok:
text = text[:eos_idx]
reasoning = ""
- if self.thinking_mode == "thinking" and _THINK_CLOSE in text:
- head, _, text = text.partition(_THINK_CLOSE)
- reasoning = head.removeprefix(_THINK_OPEN).strip("\n")
+ if self.thinking_mode == "thinking" and self._think_close in text:
+ head, _, text = text.partition(self._think_close)
+ reasoning = head.removeprefix(self._think_open).strip("\n")
tool_calls: list[ToolCall] = []
unparsed: list[UnparsedToolCall] = []
- block_match = _TOOL_CALLS_BLOCK_RE.search(text)
+ calls_tag = re.escape(f"{_DSML}{self._tool_calls_name}")
+ invoke_tag = re.escape(f"{_DSML}{self._tool_invoke_name}")
+ parameter_tag = re.escape(f"{_DSML}{self._tool_parameter_name}")
+ block_re = re.compile(rf"\n\n<{calls_tag}>\n(.*?)\n{calls_tag}>", re.DOTALL)
+ invoke_re = re.compile(rf'<{invoke_tag} name="([^"]+)">\n(.*?)\n{invoke_tag}>', re.DOTALL)
+ parameter_re = re.compile(
+ rf'<{parameter_tag} name="([^"]+)" string="(true|false)">(.*?){parameter_tag}>',
+ re.DOTALL,
+ )
+ block_match = block_re.search(text)
if block_match:
content = text[: block_match.start()]
- for invoke in _INVOKE_RE.finditer(block_match.group(1)):
+ block_body = block_match.group(1)
+ invokes = list(invoke_re.finditer(block_body))
+ malformed_body = invoke_re.sub("", block_body).strip()
+ if malformed_body and self._strict_tool_parsing:
+ unparsed.append(
+ UnparsedToolCall(
+ raw_text=block_match.group(0),
+ error="Malformed tool_calls body",
+ )
+ )
+ for invoke in invokes:
name = invoke.group(1)
args: dict[str, Any] = {}
- for param in _PARAMETER_RE.finditer(invoke.group(2)):
+ invoke_body = invoke.group(2)
+ parameters = list(parameter_re.finditer(invoke_body))
+ if parameter_re.sub("", invoke_body).strip() and self._strict_tool_parsing:
+ unparsed.append(
+ UnparsedToolCall(
+ raw_text=invoke.group(0),
+ error="Malformed tool parameter",
+ )
+ )
+ continue
+ duplicate_parameter = False
+ for param in parameters:
key, is_str, value = param.group(1), param.group(2), param.group(3)
+ if key in args and self._strict_tool_parsing:
+ unparsed.append(
+ UnparsedToolCall(
+ raw_text=invoke.group(0),
+ error=f"Duplicate tool parameter: {key}",
+ )
+ )
+ duplicate_parameter = True
+ break
args[key] = value if is_str == "true" else _parse_value(value)
+ if duplicate_parameter:
+ continue
tool_calls.append(
ToolCall(
function=ToolCall.FunctionBody(
@@ -673,6 +762,14 @@ def parse_response(self, response: list[int]) -> tuple[Message, bool]:
error="Unexpected content after tool_calls block",
)
)
+ elif self._strict_tool_parsing and f"\n\n<{_DSML}{self._tool_calls_name}" in text:
+ content, _, malformed = text.partition(f"\n\n<{_DSML}{self._tool_calls_name}")
+ unparsed.append(
+ UnparsedToolCall(
+ raw_text=f"<{_DSML}{self._tool_calls_name}{malformed}",
+ error="Malformed tool_calls block",
+ )
+ )
else:
content = text
@@ -781,7 +878,7 @@ def _render_system(self, message: Message) -> RenderedMessage:
tools = message.get("tools")
response_format = message.get("response_format")
if tools:
- body += "\n\n" + _render_tools_section(list(tools))
+ body += "\n\n" + self._render_tools_section(list(tools))
if response_format:
body += "\n\n" + _RESPONSE_FORMAT_TEMPLATE.format(
schema=_to_json(response_format)
@@ -827,22 +924,24 @@ def _render_user_body(self, message: Message) -> str:
parts.append(f"[Unsupported {block_type}]")
return "\n\n".join(parts)
- def _assistant_header_str(self, ctx: RenderContext) -> str:
+ def _assistant_header_str(self, message: Message, ctx: RenderContext) -> str:
# Boundary suffix from the *previous* user/developer message, attributed
# here as the assistant's own header so masking lines up.
- is_terminal = ctx.last_user_index < 0 or ctx.idx > ctx.last_user_index
- thinking_branch = self.thinking_mode == "thinking" and (
- not self._effective_strip or is_terminal
+ if not self._assistant_emits_header(message, ctx):
+ return ""
+ return _ASSISTANT_SP + (
+ _THINK_OPEN
+ if self._assistant_starts_thinking(message, ctx)
+ else _THINK_CLOSE
)
- return _ASSISTANT_SP + (_THINK_OPEN if thinking_branch else _THINK_CLOSE)
def _render_assistant(
self,
message: Message,
ctx: RenderContext,
) -> RenderedMessage:
- header_str = self._assistant_header_str(ctx)
- emit_thinking_body = header_str.endswith(_THINK_OPEN)
+ header_str = self._assistant_header_str(message, ctx)
+ emit_thinking_body = self._assistant_starts_thinking(message, ctx)
# The encoder reads ``reasoning_content`` directly from the message;
# we mirror that, but also accept the cookbook conventions of
@@ -864,7 +963,7 @@ def _render_assistant(
tool_calls = message.get("tool_calls") or []
if tool_calls:
- body += _format_tool_calls(list(tool_calls))
+ body += self._format_tool_calls(list(tool_calls))
body += _EOS_TEXT
diff --git a/training/renderer/deepseek_v41.py b/training/renderer/deepseek_v41.py
new file mode 100644
index 000000000..00f8bf1ff
--- /dev/null
+++ b/training/renderer/deepseek_v41.py
@@ -0,0 +1,385 @@
+"""Compact DeepSeek-V4.1 delta over the V4 renderer."""
+
+from __future__ import annotations
+
+import copy
+import io
+import json
+import math
+from collections.abc import Mapping
+from pathlib import Path
+from typing import Any
+
+import tinker
+import torch
+from huggingface_hub import hf_hub_download
+from PIL import Image
+from training._vendor.tinker_cookbook_0_4_3.renderers.base import (
+ Message,
+ RenderContext,
+ RenderedMessage,
+ Renderer,
+ TrainOnWhat,
+ image_to_chunk,
+)
+from training.renderer import register_renderer
+from training.renderer.deepseek_v4 import (
+ _RESPONSE_FORMAT_TEMPLATE,
+ DeepseekV4Renderer,
+ _extract_reasoning_and_text,
+ _merge_tool_messages,
+ _sort_tool_results_by_call_order,
+ _to_json,
+)
+from training.renderer.message_weights import untrained_synthesized_context
+from training.renderer.reasoning_fields import (
+ original_reasoning,
+ original_reasoning_content,
+)
+from training.renderer.tokenizer import Tokenizer
+
+_SYSTEM_SP = "<|System|>"
+_REASONING_EFFORT = (
+ "Reasoning Effort: 75 "
+ "(range 1-100, the higher the value, the more thorough the reasoning)\n\n"
+)
+_THINKING_START = "_deepseek_v41_thinking_start"
+_IMAGE_PLACEHOLDER = "<|deepseek_image|>"
+
+
+def _without_image_placeholder(text: str) -> str:
+ if _IMAGE_PLACEHOLDER in text:
+ raise ValueError(
+ "Raw DeepSeek V4.1 image placeholders are unsupported; use an image content part"
+ )
+ return text
+
+
+class DeepseekV41ImageTokenCounter:
+ def __init__(
+ self,
+ *,
+ patch_size: int,
+ downsample_ratio: int,
+ max_image_tokens: int,
+ min_pixels: int,
+ max_wh_ratio: float | None,
+ ) -> None:
+ self.patch_size = int(patch_size)
+ self.downsample_ratio = int(downsample_ratio)
+ self.max_image_tokens = int(max_image_tokens)
+ self.min_pixels = int(min_pixels)
+ self.max_wh_ratio = None if max_wh_ratio is None else float(max_wh_ratio)
+
+ @classmethod
+ def from_pretrained(cls, model_name: str) -> "DeepseekV41ImageTokenCounter":
+ model_path = Path(model_name)
+ config_path = (
+ model_path / "config.json"
+ if model_path.is_dir()
+ else Path(hf_hub_download(model_name, "config.json"))
+ )
+ vision = json.loads(config_path.read_text())["vision_config"]
+ return cls(
+ patch_size=vision["patch_size"],
+ downsample_ratio=vision["downsample_ratio"],
+ max_image_tokens=vision.get("max_image_tokens", 1024),
+ min_pixels=vision.get("min_pixels", 295936),
+ max_wh_ratio=vision.get("max_wh_ratio"),
+ )
+
+ def media_tokens_calculator(self, media: dict[str, Any]) -> int:
+ image = media.get("image")
+ if not isinstance(image, Image.Image):
+ raise TypeError("DeepSeek V4.1 image media must contain a PIL image")
+ raw_width, height = image.size
+ width = float(raw_width)
+ if self.max_wh_ratio is not None and width > height * self.max_wh_ratio:
+ width = height * self.max_wh_ratio
+ if width * height < self.min_pixels:
+ scale = math.sqrt(self.min_pixels / (width * height))
+ width, height = int(width * scale), int(height * scale)
+
+ patch = self.patch_size
+ target_width = math.ceil(width / patch) * patch
+ target_height = math.ceil(height / patch) * patch
+
+ def grid(canvas_height: int, canvas_width: int) -> tuple[int, int]:
+ return (
+ math.ceil((canvas_height // patch) / self.downsample_ratio),
+ math.ceil((canvas_width // patch) / self.downsample_ratio),
+ )
+
+ llm_height, llm_width = grid(target_height, target_width)
+ if llm_height * (llm_width + 1) + 2 > self.max_image_tokens:
+ ratio = height / width
+ max_width = math.sqrt((self.max_image_tokens - 2) / ratio + 0.25) - 0.5
+ max_height = max_width * ratio
+ cell = patch * self.downsample_ratio
+ if max_width < 1:
+ target_height, target_width = (
+ (self.max_image_tokens - 2) // 2 * cell,
+ cell,
+ )
+ elif max_height < 1:
+ target_height, target_width = (
+ cell,
+ (self.max_image_tokens - 3) * cell,
+ )
+ else:
+ scale = min(
+ math.floor(max_width) * cell / width,
+ math.floor(max_height) * cell / height,
+ )
+ target_height = math.floor(height * scale / patch) * patch
+ target_width = math.floor(width * scale / patch) * patch
+ llm_height, llm_width = grid(target_height, target_width)
+ return llm_height * (llm_width + 1) + 2
+
+ def get_resize_config(self, media: dict[str, Any]) -> dict[str, int]:
+ return {"num_tokens": self.media_tokens_calculator(media)}
+
+
+def _assistant_reasoning_and_text(message: Mapping[str, Any]) -> tuple[str, str]:
+ explicit = message.get("reasoning_content")
+ if not isinstance(explicit, str) or not explicit:
+ has_reasoning, reasoning = original_reasoning(message)
+ has_reasoning_content, reasoning_content = original_reasoning_content(message)
+ explicit = (
+ reasoning
+ if has_reasoning and reasoning
+ else reasoning_content
+ if has_reasoning_content and reasoning_content
+ else ""
+ )
+ extracted, visible = _extract_reasoning_and_text(message.get("content"))
+ return _without_image_placeholder(
+ explicit or extracted
+ ), _without_image_placeholder(visible)
+
+
+def _user_blocks(content: Any) -> list[dict[str, Any]]:
+ if isinstance(content, str):
+ return [{"type": "text", "text": _without_image_placeholder(content)}]
+ if not isinstance(content, list):
+ raise TypeError("DeepSeek V4.1 user content must be text or content parts")
+ blocks: list[dict[str, Any]] = []
+ for part in content:
+ if not isinstance(part, Mapping):
+ raise TypeError("DeepSeek V4.1 content parts must be objects")
+ kind = part.get("type")
+ if kind == "text":
+ blocks.append(
+ {
+ "type": "text",
+ "text": _without_image_placeholder(str(part.get("text", ""))),
+ }
+ )
+ elif kind == "image":
+ blocks.append({"type": "image", "image": part.get("image")})
+ else:
+ raise ValueError(f"Unsupported DeepSeek V4.1 user content part: {kind!r}")
+ return blocks
+
+
+class DeepseekV41Renderer(DeepseekV4Renderer):
+ _user_sp = "<|User|>"
+ _tool_calls_name = " calls"
+ _tool_invoke_name = " invoke"
+ _tool_parameter_name = " parameter"
+ _tool_argument_decode_passes = 2
+ _strict_tool_parsing = True
+
+ def __init__(
+ self,
+ tokenizer: Tokenizer,
+ *,
+ image_processor: Any | None = None,
+ ) -> None:
+ super().__init__(tokenizer)
+ self.image_processor = image_processor
+
+ def _last_assistant_turn_boundary(self, messages: list[Message]) -> int:
+ boundary = -1
+ for index, message in enumerate(messages):
+ if message.get("role") == "system" and index > 0:
+ boundary = index
+ elif message.get("role") == "user" and any(
+ block.get("type") in {"text", "image"}
+ for block in message.get("content_blocks", [])
+ ):
+ boundary = index
+ return boundary
+
+ def _is_disaggregation_turn_boundary(
+ self,
+ message: Mapping[str, Any],
+ index: int,
+ ) -> bool:
+ return message.get("role") == "user" or (
+ message.get("role") == "system" and index > 0
+ )
+
+ def _preprocess(self, messages: list[Message]) -> list[Message]:
+ normalized: list[Message] = []
+ for original in messages:
+ message = copy.deepcopy(original)
+ if message.get("role") == "assistant":
+ reasoning, visible = _assistant_reasoning_and_text(message)
+ message["content"] = visible
+ if reasoning:
+ message["reasoning_content"] = reasoning
+ normalized.append(message)
+
+ merged = _sort_tool_results_by_call_order(
+ _merge_tool_messages(normalized, user_blocks=_user_blocks)
+ )
+ self._effective_strip = self.strip_thinking_from_history and not any(
+ message.get("tools") for message in merged
+ )
+ boundary = self._last_assistant_turn_boundary(merged)
+ for index, message in enumerate(merged):
+ if message.get("role") != "assistant":
+ continue
+ starts_thinking = not self._effective_strip or index > boundary
+ message[_THINKING_START] = starts_thinking
+ if not starts_thinking:
+ message.pop("reasoning_content", None)
+ return untrained_synthesized_context(merged)
+
+ def _assistant_starts_thinking(
+ self,
+ message: Message,
+ ctx: RenderContext,
+ ) -> bool:
+ del ctx
+ return bool(message.get(_THINKING_START))
+
+ def _assistant_emits_header(self, message: Message, ctx: RenderContext) -> bool:
+ del message
+ if ctx.prev_message is None:
+ return False
+ role = ctx.prev_message.get("role")
+ return role == "user" or (role == "system" and ctx.idx > 1)
+
+ def _assistant_header_str(self, message: Message, ctx: RenderContext) -> str:
+ prefix = _SYSTEM_SP + _REASONING_EFFORT if ctx.idx == 0 else ""
+ return prefix + super()._assistant_header_str(message, ctx)
+
+ def render_message(
+ self,
+ message: Message,
+ ctx: RenderContext,
+ ) -> RenderedMessage:
+ role = message["role"]
+ if role == "system":
+ prefix = _SYSTEM_SP + (_REASONING_EFFORT if ctx.idx == 0 else "")
+ blocks = _user_blocks(message.get("content"))
+ if any(block["type"] != "text" for block in blocks):
+ raise ValueError("DeepSeek V4.1 system messages support text only")
+ body = "\n\n".join(block["text"] for block in blocks)
+ if message.get("tools"):
+ body += "\n\n" + self._render_tools_section(list(message["tools"]))
+ if message.get("response_format"):
+ body += "\n\n" + _RESPONSE_FORMAT_TEMPLATE.format(
+ schema=_to_json(message["response_format"])
+ )
+ return RenderedMessage(
+ header=tinker.types.EncodedTextChunk(tokens=self._encode(prefix)),
+ output=[tinker.types.EncodedTextChunk(tokens=self._encode(body))],
+ )
+ if role == "user":
+ prefix = (
+ _SYSTEM_SP + _REASONING_EFFORT if ctx.idx == 0 else ""
+ ) + self._user_sp
+ # Encode each run between images as one string: BPE merges across
+ # block separators (".\n\n", ">\n\n") must match the publisher prompt.
+ output: list[tinker.types.ModelInputChunk] = []
+ pending = ""
+ for index, block in enumerate(message.get("content_blocks", [])):
+ if index:
+ pending += "\n\n"
+ kind = block.get("type")
+ if kind == "text":
+ text = str(block.get("text", ""))
+ elif kind == "tool_result":
+ content = block.get("content", "")
+ if isinstance(content, list):
+ if any(part.get("type") != "text" for part in content):
+ raise ValueError(
+ "DeepSeek V4.1 tool-result images are unsupported"
+ )
+ content = "\n\n".join(
+ str(part.get("text", "")) for part in content
+ )
+ text = f"{_without_image_placeholder(str(content))}"
+ elif kind == "image":
+ if self.image_processor is None:
+ raise ValueError(
+ "DeepSeek V4.1 image messages require an image processor"
+ )
+ image = block.get("image")
+ if isinstance(image, bytes):
+ image = Image.open(io.BytesIO(image))
+ image.load()
+ if pending:
+ output.append(
+ tinker.types.EncodedTextChunk(tokens=self._encode(pending))
+ )
+ pending = ""
+ output.append(image_to_chunk(image, self.image_processor))
+ continue
+ else:
+ raise ValueError(
+ f"Unsupported DeepSeek V4.1 content block: {kind!r}"
+ )
+ pending += text
+ if pending:
+ output.append(
+ tinker.types.EncodedTextChunk(tokens=self._encode(pending))
+ )
+ return RenderedMessage(
+ header=tinker.types.EncodedTextChunk(tokens=self._encode(prefix)),
+ output=output,
+ )
+ return super().render_message(message, ctx)
+
+ def build_supervised_example(
+ self,
+ messages: list[Message],
+ train_on_what: TrainOnWhat = TrainOnWhat.LAST_ASSISTANT_MESSAGE,
+ ) -> tuple[tinker.ModelInput, torch.Tensor]:
+ prepared = self._preprocess(messages)
+ has_mid_system = any(
+ message.get("role") == "system" and index > 0
+ for index, message in enumerate(prepared)
+ )
+ has_user = any(message.get("role") == "user" for message in prepared)
+ if train_on_what == TrainOnWhat.LAST_ASSISTANT_TURN and (
+ has_mid_system
+ or not has_user
+ or any(message.get("tools") for message in prepared)
+ ):
+ model_input, weights = self._build_tool_turn_example(prepared)
+ else:
+ model_input, weights = Renderer.build_supervised_example(
+ self,
+ prepared,
+ train_on_what=train_on_what,
+ )
+ offset = 0
+ for chunk in model_input.chunks:
+ if isinstance(chunk, tinker.types.ImageChunk):
+ weights[offset : offset + chunk.length] = 0
+ offset += chunk.length
+ return model_input, weights
+
+
+def _deepseek_v41_factory(
+ tokenizer: Tokenizer,
+ image_processor: Any = None,
+) -> DeepseekV41Renderer:
+ return DeepseekV41Renderer(tokenizer, image_processor=image_processor)
+
+
+register_renderer("deepseek_v41", _deepseek_v41_factory)
diff --git a/training/tests/unit/test_async_rl_loop.py b/training/tests/unit/test_async_rl_loop.py
index 7415c5df2..111df6e6d 100644
--- a/training/tests/unit/test_async_rl_loop.py
+++ b/training/tests/unit/test_async_rl_loop.py
@@ -24,6 +24,10 @@ class _StopAfterRolloutSetup(RuntimeError):
pass
+class _StopAfterCoordinatorSetup(RuntimeError):
+ pass
+
+
def test_evaluation_rollout_context_is_explicit_and_compatible() -> None:
seen: list[tuple[int, bool]] = []
@@ -647,8 +651,10 @@ def test_main_requests_trainer_cleanup_for_empty_job_id(
assert kwargs["cleanup_trainer_on_close"] is True
-def test_main_injects_sampler_and_closes_it_before_service(
+@pytest.mark.parametrize("advantage_mode", ["rollout_setup", "default", "mean_only"])
+def test_main_injects_sampler_and_advantages_and_closes_before_service(
monkeypatch: pytest.MonkeyPatch,
+ advantage_mode: str,
) -> None:
events: list[str] = []
expected_tokenizer = object()
@@ -729,24 +735,55 @@ def rollout_factory(setup: async_rl_loop.RolloutSetup):
assert not hasattr(setup, "max_context_tokens")
assert setup.sample_kwargs["max_seq_len"] == 4096
events.append("rollout_factory")
- raise _StopAfterRolloutSetup
+ if advantage_mode == "rollout_setup":
+ raise _StopAfterRolloutSetup
+
+ async def rollout(_row):
+ return None
+
+ return rollout
+
+ def mean_only(rewards):
+ mean = sum(rewards) / len(rewards)
+ return [2 * (reward - mean) for reward in rewards]
+
+ def coordinator(**kwargs):
+ advantages = kwargs["advantage_fn"]([1.0, 1.0, 0.0, 0.0])
+ if advantage_mode == "mean_only":
+ assert advantages == [1.0, 1.0, -1.0, -1.0]
+ else:
+ assert advantages == pytest.approx(
+ [0.8660254, 0.8660254, -0.8660254, -0.8660254]
+ )
+ events.append("coordinator")
+ raise _StopAfterCoordinatorSetup
+
+ monkeypatch.setattr(async_rl_loop, "AsyncRLCoordinator", coordinator)
cfg = async_rl_loop.Config(
log_path="/tmp/async_rl_test_logs",
kl_beta=0,
deployment=async_rl_loop.DeployConfig(tokenizer_model="Qwen/Qwen3-1.7B"),
)
- with pytest.raises(_StopAfterRolloutSetup):
+ error = (
+ _StopAfterRolloutSetup
+ if advantage_mode == "rollout_setup"
+ else _StopAfterCoordinatorSetup
+ )
+ options = {"advantage_fn": mean_only} if advantage_mode == "mean_only" else {}
+ with pytest.raises(error):
async_rl_loop.main(
cfg,
rows=[],
rollout_fn_factory=rollout_factory,
+ **options,
)
assert events == [
"save",
"hotload",
"rollout_factory",
+ *([] if advantage_mode == "rollout_setup" else ["coordinator"]),
"sampler.close",
"service.close",
]
diff --git a/training/tests/unit/test_client.py b/training/tests/unit/test_client.py
index 77d19b0a7..899318560 100644
--- a/training/tests/unit/test_client.py
+++ b/training/tests/unit/test_client.py
@@ -43,6 +43,10 @@ def optim_step(self, params, **kwargs):
self.calls.append((params, kwargs))
return self.future
+ def weight_sync(self):
+ self.calls.append("weight_sync")
+ return self.future
+
def save_weights_for_sampler_ext(self, name, *, checkpoint_type=None):
self.calls.append(("save_weights_for_sampler_ext", name, checkpoint_type))
return self.saved_sampler
@@ -127,6 +131,14 @@ def run_coroutine_threadsafe(self, coro):
return self.future
+def test_weight_sync_waits_without_checkpoint_options():
+ inner = _FakeInnerClient()
+ client = _make_client(inner)
+
+ assert client.weight_sync() == {"ok": True, "timeout": 123}
+ assert inner.calls == ["weight_sync"]
+
+
def test_optim_step_converts_legacy_string_normalization():
inner = _FakeInnerClient()
client = _make_client(inner)
diff --git a/training/tests/unit/test_deepseek_v41_renderer.py b/training/tests/unit/test_deepseek_v41_renderer.py
new file mode 100644
index 000000000..9004fc477
--- /dev/null
+++ b/training/tests/unit/test_deepseek_v41_renderer.py
@@ -0,0 +1,466 @@
+"""High-value DeepSeek-V4.1 supervised-renderer contracts."""
+
+from __future__ import annotations
+
+import importlib.util
+import json
+from pathlib import Path
+
+import pytest
+import tinker
+from PIL import Image
+from training.renderer.deepseek_v41 import (
+ DeepseekV41ImageTokenCounter,
+ DeepseekV41Renderer,
+)
+from training._vendor.tinker_cookbook_0_4_3.renderers.base import TrainOnWhat
+from training.utils.supervised import (
+ build_tool_prefixed_messages,
+ normalize_messages,
+ render_messages_to_datums,
+ resolve_renderer_name,
+)
+
+_EOS = "<|end▁of▁sentence|>"
+_EOS_ID = 0x110000
+
+
+class _Tokenizer:
+ name_or_path = "deepseek-v41-test"
+
+ def encode(self, text: str, add_special_tokens: bool = False) -> list[int]:
+ del add_special_tokens
+ return [_EOS_ID] if text == _EOS else [ord(character) for character in text]
+
+ def decode(self, tokens: list[int], **_: object) -> str:
+ return "".join(
+ _EOS if int(token) == _EOS_ID else chr(int(token)) for token in tokens
+ )
+
+
+class _MergingTokenizer(_Tokenizer):
+ """Merges separator suffixes into one token, as the real BPE vocabulary does."""
+
+ _MERGES = {".\n\n": 0x110001, ">\n\n": 0x110002}
+
+ def encode(self, text: str, add_special_tokens: bool = False) -> list[int]:
+ del add_special_tokens
+ if text == _EOS:
+ return [_EOS_ID]
+ tokens, index = [], 0
+ while index < len(text):
+ merged = next(
+ (
+ item
+ for item in self._MERGES.items()
+ if text.startswith(item[0], index)
+ ),
+ None,
+ )
+ tokens.append(merged[1] if merged else ord(text[index]))
+ index += len(merged[0]) if merged else 1
+ return tokens
+
+
+def _publisher_encoder():
+ root = Path(__file__).resolve().parents[5]
+ path = root / "py/fireworks/models/deepseek4_next/reference/encoding/encoding.py"
+ spec = importlib.util.spec_from_file_location("deepseek_v41_oracle", path)
+ assert spec is not None and spec.loader is not None
+ module = importlib.util.module_from_spec(spec)
+ spec.loader.exec_module(module)
+ return module
+
+
+def _renderer(*, images: bool = False) -> DeepseekV41Renderer:
+ counter = (
+ DeepseekV41ImageTokenCounter(
+ patch_size=2,
+ downsample_ratio=2,
+ max_image_tokens=64,
+ min_pixels=1,
+ max_wh_ratio=None,
+ )
+ if images
+ else None
+ )
+ return DeepseekV41Renderer(_Tokenizer(), image_processor=counter)
+
+
+def _text(model_input: tinker.ModelInput) -> str:
+ return _Tokenizer().decode(list(model_input.to_ints()))
+
+
+@pytest.mark.parametrize(
+ "model",
+ [
+ "deepseek-ai/DeepSeek-V4.1-Flash",
+ "DeepSeekV4.1",
+ "custom/deepseekv41-finetune",
+ "accounts/fireworks/models/deepseek-v41-flash",
+ ],
+)
+def test_v41_aliases_route_before_v4(model: str) -> None:
+ assert resolve_renderer_name(model) == "deepseek_v41"
+
+
+def test_default_effort_initial_and_mid_system_match_publisher() -> None:
+ messages = [
+ {
+ "role": "system",
+ "content": [
+ {"type": "text", "text": "Be "},
+ {"type": "text", "text": "precise."},
+ ],
+ "response_format": {"type": "json_object"},
+ },
+ {"role": "user", "content": "Two plus two?"},
+ {
+ "role": "assistant",
+ "reasoning_content": "Add.",
+ "content": "Four.",
+ },
+ {"role": "system", "content": "Now answer briefly."},
+ ]
+ expected = _publisher_encoder().encode_messages(
+ messages,
+ thinking_mode="thinking",
+ )
+ actual = _text(_renderer().build_generation_prompt(normalize_messages(messages)))
+ assert actual == expected
+ assert _renderer().get_stop_sequences() == [_EOS_ID]
+
+
+def test_tool_order_and_mid_system_last_turn_mask() -> None:
+ tools = [
+ {
+ "type": "function",
+ "function": {"name": name, "parameters": {"type": "object"}},
+ }
+ for name in ("first", "second")
+ ]
+ messages = [
+ {"role": "system", "content": "Use tools.", "tools": tools},
+ {"role": "user", "content": "Run both."},
+ {
+ "role": "assistant",
+ "reasoning_content": "OLD_REASONING",
+ "content": "",
+ "tool_calls": [
+ {
+ "id": call_id,
+ "type": "function",
+ "function": {
+ "name": name,
+ "arguments": json.dumps(json.dumps({"city": "Paris"}))
+ if name == "first"
+ else "{}",
+ },
+ }
+ for call_id, name in (("a", "first"), ("b", "second"))
+ ],
+ },
+ {"role": "tool", "tool_call_id": "b", "content": "SECOND_RESULT"},
+ {"role": "tool", "tool_call_id": "a", "content": "FIRST_RESULT"},
+ {"role": "system", "content": "Answer now."},
+ {
+ "role": "assistant",
+ "reasoning_content": "NEW_REASONING",
+ "content": "Done.",
+ },
+ ]
+ renderer = _renderer()
+ model_input, weights = renderer.build_supervised_example(
+ normalize_messages(messages),
+ train_on_what=TrainOnWhat.LAST_ASSISTANT_TURN,
+ )
+ text = _text(model_input)
+ assert text == _publisher_encoder().encode_messages(
+ messages, thinking_mode="thinking"
+ )
+ trained = renderer.tokenizer.decode(
+ [
+ token
+ for token, weight in zip(
+ model_input.to_ints(), weights.tolist(), strict=True
+ )
+ if weight
+ ]
+ )
+ assert "OLD_REASONING" not in trained
+ assert "NEW_REASONING" in trained and "Done." in trained
+ assert "FIRST_RESULT" not in trained and "SECOND_RESULT" not in trained
+
+
+@pytest.mark.parametrize(
+ "messages",
+ [
+ [
+ {"role": "user", "content": "Draft."},
+ {"role": "assistant", "reasoning_content": "First.", "content": "A."},
+ {"role": "assistant", "reasoning_content": "Retry.", "content": "B."},
+ ],
+ [
+ {"role": "system", "content": "System only."},
+ {"role": "assistant", "reasoning_content": "Think.", "content": "Answer."},
+ ],
+ [{"role": "assistant", "reasoning_content": "Think.", "content": "Answer."}],
+ ],
+)
+def test_assistant_header_matches_publisher(
+ messages: list[dict[str, object]],
+) -> None:
+ actual = _text(
+ _renderer().build_supervised_example(normalize_messages(messages))[0]
+ )
+ expected = _publisher_encoder().encode_messages(messages, thinking_mode="thinking")
+ assert actual == expected
+
+
+def test_embedded_think_is_not_duplicated() -> None:
+ messages = normalize_messages(
+ [
+ {"role": "user", "content": "Answer."},
+ {"role": "assistant", "content": "ONLY_ONCEVisible."},
+ ]
+ )
+ rendered = _text(_renderer().build_supervised_example(messages)[0])
+ assert rendered.count("ONLY_ONCE") == 1
+ assert "ONLY_ONCEVisible." in rendered
+
+
+@pytest.mark.parametrize(
+ "raw_messages",
+ [
+ [
+ {"role": "system", "content": "Initial instruction."},
+ {"role": "assistant", "content": "One."},
+ {"role": "system", "content": "New instruction."},
+ {"role": "assistant", "content": "Two."},
+ {"role": "system", "content": "Trailing instruction."},
+ ],
+ [
+ {"role": "system", "content": "Initial instruction."},
+ {"role": "assistant", "content": "One."},
+ {"role": "system", "content": "Trailing instruction."},
+ ],
+ ],
+)
+def test_mid_system_is_a_supervised_turn_boundary(
+ raw_messages: list[dict[str, object]],
+) -> None:
+ messages = normalize_messages(raw_messages)
+ rendered = render_messages_to_datums(
+ messages,
+ renderer=_renderer(),
+ train_on_what=TrainOnWhat.ALL_ASSISTANT_MESSAGES,
+ )
+ assert len(rendered) == sum(
+ message["role"] == "assistant" for message in raw_messages
+ )
+ assert all(sum(item.datum.loss_fn_inputs["weights"].data) > 0 for item in rendered)
+
+
+def test_top_level_tools_preserve_structured_system_metadata() -> None:
+ tools = [
+ {
+ "type": "function",
+ "function": {"name": "search", "parameters": {"type": "object"}},
+ }
+ ]
+ raw_messages = [
+ {
+ "role": "system",
+ "content": [
+ {"type": "text", "text": "Use "},
+ {"type": "text", "text": "tools."},
+ ],
+ "response_format": {"type": "json_object"},
+ },
+ {"role": "user", "content": "Find it."},
+ ]
+ prefixed = build_tool_prefixed_messages(
+ raw_messages, renderer=_renderer(), tools=tools
+ )
+ actual = _text(_renderer().build_generation_prompt(prefixed))
+ expected_messages = [dict(raw_messages[0], tools=tools), raw_messages[1]]
+ expected = _publisher_encoder().encode_messages(
+ expected_messages, thinking_mode="thinking"
+ )
+ assert actual == expected
+
+
+@pytest.mark.parametrize(
+ "message",
+ [
+ {"role": "system", "content": "<|deepseek_image|>"},
+ {
+ "role": "assistant",
+ "reasoning_content": "<|deepseek_image|>",
+ "content": "answer",
+ },
+ ],
+)
+def test_raw_image_placeholder_is_rejected(message: dict[str, object]) -> None:
+ with pytest.raises(ValueError, match="image placeholders"):
+ _renderer().build_supervised_example(normalize_messages([message]))
+
+
+def test_images_are_ordered_counted_and_zero_loss() -> None:
+ red = Image.new("RGB", (4, 4), "red")
+ blue = Image.new("RGB", (4, 4), "blue")
+ model_input, weights = _renderer(images=True).build_supervised_example(
+ normalize_messages(
+ [
+ {
+ "role": "user",
+ "content": [
+ {"type": "text", "text": "first"},
+ {"type": "image", "image": red},
+ {"type": "text", "text": "second"},
+ {"type": "image", "image": blue},
+ ],
+ },
+ {"role": "assistant", "content": "Done."},
+ ]
+ )
+ )
+ images = [
+ chunk
+ for chunk in model_input.chunks
+ if isinstance(chunk, tinker.types.ImageChunk)
+ ]
+ assert [chunk.expected_tokens for chunk in images] == [4, 4]
+ offset = 0
+ for chunk in model_input.chunks:
+ if isinstance(chunk, tinker.types.ImageChunk):
+ assert not weights[offset : offset + chunk.length].any()
+ offset += chunk.length
+
+
+@pytest.mark.parametrize(
+ "messages",
+ [
+ [
+ {
+ "role": "user",
+ "content": [
+ {"type": "text", "text": "Look."},
+ {"type": "text", "text": "Here."},
+ {"type": "image", "image": "red"},
+ {"type": "text", "text": "What?"},
+ ],
+ },
+ {"role": "assistant", "content": "Red."},
+ ],
+ [
+ {
+ "role": "system",
+ "content": "Use tools.",
+ "tools": [
+ {
+ "type": "function",
+ "function": {"name": name, "parameters": {"type": "object"}},
+ }
+ for name in ("first", "second")
+ ],
+ },
+ {"role": "user", "content": "Run both."},
+ {
+ "role": "assistant",
+ "content": "",
+ "tool_calls": [
+ {
+ "id": name,
+ "type": "function",
+ "function": {"name": name, "arguments": "{}"},
+ }
+ for name in ("first", "second")
+ ],
+ },
+ {"role": "tool", "tool_call_id": "first", "content": "ONE"},
+ {"role": "tool", "tool_call_id": "second", "content": "TWO"},
+ {"role": "assistant", "content": "Done."},
+ ],
+ ],
+)
+def test_block_separators_tokenize_like_the_publisher_prompt(
+ messages: list[dict[str, object]],
+) -> None:
+ image = Image.new("RGB", (4, 4), "red")
+ rendered = [
+ {
+ **message,
+ "content": [
+ dict(part, image=image) if part["type"] == "image" else part
+ for part in message["content"]
+ ],
+ }
+ if isinstance(message["content"], list)
+ else message
+ for message in messages
+ ]
+ renderer = DeepseekV41Renderer(
+ _MergingTokenizer(),
+ image_processor=_renderer(images=True).image_processor,
+ )
+ model_input, _ = renderer.build_supervised_example(normalize_messages(rendered))
+ segments: list[list[int]] = [[]]
+ for chunk in model_input.chunks:
+ if isinstance(chunk, tinker.types.ImageChunk):
+ segments.append([])
+ else:
+ segments[-1].extend(chunk.tokens)
+ publisher = [
+ {
+ **message,
+ "content": [
+ {"type": "image", "data": "red"} if part["type"] == "image" else part
+ for part in message["content"]
+ ],
+ }
+ if isinstance(message["content"], list)
+ else message
+ for message in messages
+ ]
+ prompt = _publisher_encoder().encode_messages(publisher, thinking_mode="thinking")
+ assert segments == [
+ renderer.tokenizer.encode(part) for part in prompt.split("<|deepseek_image|>")
+ ]
+
+
+def test_valid_and_malformed_tool_parsing() -> None:
+ renderer = _renderer()
+
+ def assert_unparsed(text: str, error: str) -> None:
+ message, _ok = renderer.parse_response(renderer.tokenizer.encode(text + _EOS))
+ [unparsed] = message["unparsed_tool_calls"]
+ assert unparsed.error == error
+
+ valid = (
+ 'Reason.Done.\n\n<|DSML| calls>\n<|DSML| invoke name="search">\n'
+ '<|DSML| parameter name="q" string="true">fireworks|DSML| parameter>\n'
+ "|DSML| invoke>\n|DSML| calls>" + _EOS
+ )
+ message, ok = renderer.parse_response(renderer.tokenizer.encode(valid))
+ assert ok
+ assert message["tool_calls"][0].function.name == "search"
+ assert json.loads(message["tool_calls"][0].function.arguments) == {"q": "fireworks"}
+
+ assert_unparsed("Done.\n\n<|DSML| calls>\nbroken", "Malformed tool_calls block")
+ assert_unparsed(
+ "Done.\n\n<|DSML| calls>\nbroken\n|DSML| calls>",
+ "Malformed tool_calls body",
+ )
+ invoke = 'Done.\n\n<|DSML| calls>\n<|DSML| invoke name="search">\n{}\n|DSML| invoke>\n|DSML| calls>'
+ assert_unparsed(
+ invoke.format("<|DSML| parameter broken>"),
+ "Malformed tool parameter",
+ )
+ assert_unparsed(
+ invoke.format(
+ '<|DSML| parameter name="q" string="true">first|DSML| parameter>\n'
+ '<|DSML| parameter name="q" string="true">second|DSML| parameter>'
+ ),
+ "Duplicate tool parameter: q",
+ )
diff --git a/training/tests/unit/test_deepseek_v4_renderer.py b/training/tests/unit/test_deepseek_v4_renderer.py
index cc18f41fc..98102d528 100644
--- a/training/tests/unit/test_deepseek_v4_renderer.py
+++ b/training/tests/unit/test_deepseek_v4_renderer.py
@@ -1635,6 +1635,7 @@ def test_parse_response_extracts_tool_call(tokenizer, renderer_thinking_keep):
f"\n\n<{_DSML}tool_calls>\n"
f'<{_DSML}invoke name="f">\n'
f'<{_DSML}parameter name="x" string="false">42{_DSML}parameter>\n'
+ f'<{_DSML}parameter name="x" string="false">43{_DSML}parameter>\n'
f"{_DSML}invoke>\n"
f"{_DSML}tool_calls>"
f"{_EOS_TEXT}"
@@ -1646,4 +1647,4 @@ def test_parse_response_extracts_tool_call(tokenizer, renderer_thinking_keep):
assert len(msg["tool_calls"]) == 1
tc = msg["tool_calls"][0]
assert tc.function.name == "f"
- assert json.loads(tc.function.arguments) == {"x": 42}
+ assert json.loads(tc.function.arguments) == {"x": 43}
diff --git a/training/tests/unit/test_dppo_loss.py b/training/tests/unit/test_dppo_loss.py
index 5a4500fc1..579b15722 100644
--- a/training/tests/unit/test_dppo_loss.py
+++ b/training/tests/unit/test_dppo_loss.py
@@ -9,6 +9,7 @@
import torch
from training.utils.rl.dppo import DPPOConfig, make_dppo_loss_fn
+from training.utils.rl.tis import TISConfig
def _datum() -> tinker.Datum:
@@ -31,18 +32,24 @@ def _run(
behavior_prob: float,
advantage: float,
config: DPPOConfig | None = None,
+ rollout_prob: float | None = None,
+ tis_config: TISConfig | None = None,
):
policy_logprob = torch.tensor(
[math.log(policy_prob)], requires_grad=True
)
behavior_logprob = math.log(behavior_prob)
+ rollout_logprob = math.log(
+ behavior_prob if rollout_prob is None else rollout_prob
+ )
loss_fn = make_dppo_loss_fn(
advantages=[advantage],
ref_logprobs=[],
- inf_logprobs=[[behavior_logprob]],
+ inf_logprobs=[[rollout_logprob]],
prompt_len=1,
old_policy_logprobs=[[behavior_logprob]],
dppo_config=config,
+ tis_config=tis_config,
)
loss, metrics = loss_fn([_datum()], [policy_logprob])
loss.backward()
@@ -102,9 +109,52 @@ def test_binary_kl_is_finite_and_can_mask() -> None:
policy_prob=0.8,
behavior_prob=0.2,
advantage=1.0,
- config=DPPOConfig(divergence="binary_kl", threshold=0.05),
+ config=DPPOConfig(divergence="binary_kl"),
)
assert loss.item() == pytest.approx(0.0)
assert grad.item() == pytest.approx(0.0)
assert metrics["dppo_divergence_mean"] > 0.05
+
+
+def test_divergence_specific_default_thresholds() -> None:
+ assert DPPOConfig(divergence="binary_tv").threshold == 0.15
+ assert DPPOConfig(divergence="binary_kl").threshold == 0.05
+
+ tv_loss, _, _ = _run(
+ policy_prob=0.24,
+ behavior_prob=0.1,
+ advantage=1.0,
+ config=DPPOConfig(divergence="binary_tv"),
+ )
+ kl_loss, _, _ = _run(
+ policy_prob=0.24,
+ behavior_prob=0.1,
+ advantage=1.0,
+ config=DPPOConfig(divergence="binary_kl"),
+ )
+
+ assert tv_loss.item() != pytest.approx(0.0)
+ assert kl_loss.item() == pytest.approx(0.0)
+
+
+def test_rollout_tis_weight_is_not_applied() -> None:
+ aligned_loss, aligned_grad, aligned_metrics = _run(
+ policy_prob=0.2,
+ behavior_prob=0.1,
+ rollout_prob=0.1,
+ advantage=1.0,
+ )
+ with pytest.warns(DeprecationWarning, match="ignores tis_config"):
+ mismatched_loss, mismatched_grad, mismatched_metrics = _run(
+ policy_prob=0.2,
+ behavior_prob=0.1,
+ rollout_prob=0.01,
+ advantage=1.0,
+ tis_config=TISConfig(cap=0.1),
+ )
+
+ assert mismatched_loss.item() == pytest.approx(aligned_loss.item())
+ assert mismatched_grad.item() == pytest.approx(aligned_grad.item())
+ assert not any(key.startswith("tis/") for key in aligned_metrics)
+ assert not any(key.startswith("tis/") for key in mismatched_metrics)
diff --git a/training/tests/unit/test_harbor_textworld.py b/training/tests/unit/test_harbor_textworld.py
index 3000555e0..4520caf8d 100644
--- a/training/tests/unit/test_harbor_textworld.py
+++ b/training/tests/unit/test_harbor_textworld.py
@@ -393,6 +393,25 @@ def _sampling_args(tmp_path, *extra):
)
+def test_textworld_uses_binary_kl_default_threshold(tmp_path) -> None:
+ from training.examples.rl.harbor.recipes.textworld import train as train_textworld
+
+ args = _sampling_args(
+ tmp_path,
+ "--policy-loss",
+ "dppo",
+ "--dppo-divergence",
+ "binary_kl",
+ )
+ config = train_textworld._build_config(
+ args,
+ run_dir=tmp_path / "run",
+ row_count=256,
+ )
+
+ assert config.dppo.threshold == 0.05
+
+
def test_textworld_sampling_only_requires_a_deployment(tmp_path, monkeypatch):
from training.examples.rl.harbor.recipes.textworld import train as train_textworld
diff --git a/training/tests/unit/test_renderer_cutover_parity.py b/training/tests/unit/test_renderer_cutover_parity.py
index e65f4ad70..d6bf9c513 100644
--- a/training/tests/unit/test_renderer_cutover_parity.py
+++ b/training/tests/unit/test_renderer_cutover_parity.py
@@ -48,6 +48,7 @@ def decode(self, tokens: list[int], **_: Any) -> str:
_BASELINE_CUSTOM_RENDERERS = (
"deepseek_v4",
"deepseek_v4_disable_thinking",
+ "deepseek_v41",
"gemma4",
"gemma4_thinking",
"glm5",
diff --git a/training/tests/unit/test_rl_algorithm_validation.py b/training/tests/unit/test_rl_algorithm_validation.py
index 00d77b33e..594381449 100644
--- a/training/tests/unit/test_rl_algorithm_validation.py
+++ b/training/tests/unit/test_rl_algorithm_validation.py
@@ -106,7 +106,7 @@ def test_dppo_builder_validates_config(config) -> None:
"config",
[
ScoreCenteringConfig(top_k=0),
- ScoreCenteringConfig(top_k=6),
+ ScoreCenteringConfig(top_k=9),
ScoreCenteringConfig(tail_mass_epsilon=0),
],
)
diff --git a/training/tests/unit/test_rl_loop.py b/training/tests/unit/test_rl_loop.py
index 83d2ff516..f73c30242 100644
--- a/training/tests/unit/test_rl_loop.py
+++ b/training/tests/unit/test_rl_loop.py
@@ -60,7 +60,11 @@ class _Service:
reference_client_job_id = None
def create_training_client(self, *_args, **_kwargs):
- return object()
+ return SimpleNamespace(
+ save_weights_for_sampler=lambda *_args, **_kwargs: pytest.fail(
+ "weight publication must not run before this test stops setup"
+ )
+ )
def close(self):
pass
@@ -575,7 +579,11 @@ class _Service:
max_context_length = 4096
def create_training_client(self, *_args, **_kwargs):
- return object()
+ return SimpleNamespace(
+ save_weights_for_sampler=lambda *_args, **_kwargs: pytest.fail(
+ "weight publication must not run before this test stops setup"
+ )
+ )
def create_deployment_sampler(self, *_args, **_kwargs):
return object()
diff --git a/training/tests/unit/test_score_centering_loss.py b/training/tests/unit/test_score_centering_loss.py
index e01a4f2a9..0f39553fa 100644
--- a/training/tests/unit/test_score_centering_loss.py
+++ b/training/tests/unit/test_score_centering_loss.py
@@ -7,9 +7,11 @@
import torch
from training.utils.rl.score_centering import (
+ MAX_SCORE_CENTERING_TOP_K,
ScoreCenteringConfig,
build_score_centering_datums,
make_score_centering_loss_fn,
+ validate_score_centering_config,
)
@@ -83,3 +85,13 @@ def test_score_centering_requires_topk_on_active_positions() -> None:
sampler_topk_logprobs=[[[], [-0.1]]],
config=ScoreCenteringConfig(top_k=2),
)
+
+
+def test_score_centering_accepts_dedicated_top_k_eight() -> None:
+ validate_score_centering_config(ScoreCenteringConfig(top_k=MAX_SCORE_CENTERING_TOP_K))
+ assert ScoreCenteringConfig().top_k == 5
+
+ with pytest.raises(ValueError, match="top_logprobs cap"):
+ validate_score_centering_config(
+ ScoreCenteringConfig(top_k=MAX_SCORE_CENTERING_TOP_K + 1)
+ )
diff --git a/training/tests/unit/test_service.py b/training/tests/unit/test_service.py
index 12e44433a..3b8821b94 100644
--- a/training/tests/unit/test_service.py
+++ b/training/tests/unit/test_service.py
@@ -123,6 +123,7 @@ class FakeServiceClient:
base_model="accounts/acct/models/base",
tokenizer_model="Qwen/Qwen3-1.7B",
max_lora_rank=16,
+ projection_head_dim=3,
max_context_length=4096,
learning_rate=1e-5,
trainer=_trainer_config(),
@@ -141,6 +142,7 @@ class FakeServiceClient:
"base_model": "accounts/acct/models/base",
"tokenizer_model": "Qwen/Qwen3-1.7B",
"max_lora_rank": 16,
+ "projection_head_dim": 3,
"training_shape_id": "ts-x",
"reference_training_shape_id": "ref-ts-x",
"trainer_job_id": "job-1",
@@ -206,6 +208,59 @@ class FakeServiceClient:
)
assert calls[0]["max_lora_rank"] == 32
+ assert "projection_head_dim" not in calls[0]
+
+
+def test_build_service_client_treats_zero_projection_head_dim_as_unset(monkeypatch):
+ calls: list[dict] = []
+
+ class FakeServiceClient:
+ from_firetitan_config = staticmethod(
+ lambda **kwargs: calls.append(kwargs) or "service-sentinel"
+ )
+
+ monkeypatch.setattr(service, "FiretitanServiceClient", FakeServiceClient)
+
+ build_service_client(
+ api_key="k",
+ base_url="https://api",
+ additional_headers=None,
+ base_model="accounts/acct/models/base",
+ tokenizer_model=None,
+ max_lora_rank=32,
+ projection_head_dim=0,
+ max_context_length=None,
+ learning_rate=1e-5,
+ trainer=TrainerConfig(training_shape_id="ts-x"),
+ )
+
+ assert "projection_head_dim" not in calls[0]
+
+
+def test_build_service_client_forwards_service_projection_head_dim(monkeypatch):
+ calls: list[dict] = []
+
+ class FakeServiceClient:
+ from_firetitan_config = staticmethod(
+ lambda **kwargs: calls.append(kwargs) or "service-sentinel"
+ )
+
+ monkeypatch.setattr(service, "FiretitanServiceClient", FakeServiceClient)
+
+ build_service_client(
+ api_key="k",
+ base_url="https://api",
+ additional_headers=None,
+ base_model="accounts/acct/models/base",
+ tokenizer_model=None,
+ max_lora_rank=32,
+ projection_head_dim=2,
+ max_context_length=None,
+ learning_rate=1e-5,
+ trainer=TrainerConfig(training_shape_id="ts-x"),
+ )
+
+ assert calls[0]["projection_head_dim"] == 2
def test_build_service_client_defaults_use_reservation_true(monkeypatch):
diff --git a/training/tests/unit/test_supervised_rendering.py b/training/tests/unit/test_supervised_rendering.py
index ca06c2b8a..0b61d3b55 100644
--- a/training/tests/unit/test_supervised_rendering.py
+++ b/training/tests/unit/test_supervised_rendering.py
@@ -1632,6 +1632,14 @@ def test_resolve_renderer_name_prefers_deepseek_v4() -> None:
assert resolve_renderer_name("custom/DeepSeekV4-finetune") == "deepseek_v4"
+def test_resolve_renderer_name_prefers_deepseek_v41_over_v4() -> None:
+ """V4.1 contains the V4 substring and must not inherit the V4 encoder."""
+ assert resolve_renderer_name("deepseek-ai/DeepSeek-V4.1-Flash") == "deepseek_v41"
+ assert resolve_renderer_name("accounts/fireworks/models/deepseek-v41-flash") == "deepseek_v41"
+ assert resolve_renderer_name("custom/DeepSeekV41-finetune") == "deepseek_v41"
+ assert resolve_renderer_name("deepseek-ai/DeepSeek-V4-Flash") == "deepseek_v4"
+
+
def test_resolve_renderer_name_prefers_glm5_variants_for_glm_5_family() -> None:
"""GLM-5.x tokenizers should resolve to versioned GLM renderers."""
assert resolve_renderer_name("zai-org/GLM-5.1") == "glm5"
@@ -1667,6 +1675,7 @@ def test_resolve_renderer_name_prefers_glm5_variants_for_glm_5_family() -> None:
("glm53_flash_preserve_thinking", True),
("glm_moe_dsa", False),
("deepseek_v4", False),
+ ("deepseek_v41", True),
],
)
def test_renderer_supports_images_matches_renderer_capability(
@@ -1690,6 +1699,7 @@ def test_renderer_supports_images_matches_renderer_capability(
("glm53_flash_interleaved", True),
("glm53_flash_preserve_thinking", True),
("deepseek_v4", False),
+ ("deepseek_v41", False),
],
)
def test_renderer_supports_tool_images_is_explicit(
diff --git a/training/tests/unit/test_weighted_row_invariants.py b/training/tests/unit/test_weighted_row_invariants.py
index ec8a7cc0e..b2b26730c 100644
--- a/training/tests/unit/test_weighted_row_invariants.py
+++ b/training/tests/unit/test_weighted_row_invariants.py
@@ -101,6 +101,7 @@
"glm53_flash_preserve_thinking": "zai-org/GLM-5.3-Flash",
"kimi_k25": "moonshotai/Kimi-K2.5",
"kimi_k27_code": "moonshotai/Kimi-K2.7-Code",
+ "deepseek_v41": "deepseek-ai/DeepSeek-V4.1-Flash",
}
_TOKENIZER_FOR_RENDERER = {**_MATRIX_TOKENIZERS, **_EXTRA_TOKENIZERS}
diff --git a/training/utils/__init__.py b/training/utils/__init__.py
index ee03a2708..65eb6e013 100644
--- a/training/utils/__init__.py
+++ b/training/utils/__init__.py
@@ -111,6 +111,7 @@
"resolve_renderer_snapshot",
"prepare_sampling_messages",
"build_service_client",
+ "make_weight_sync",
"resolve_router_replay_enabled",
"setup_wandb",
"configure_phase_tracing",
@@ -189,7 +190,11 @@
RunStatus,
UserConfigError,
)
-from training.utils.service import build_service_client, resolve_router_replay_enabled
+from training.utils.service import (
+ build_service_client,
+ make_weight_sync,
+ resolve_router_replay_enabled,
+)
from training.utils.streaming import (
DEFAULT_PREFETCH_FACTOR,
DEFAULT_RENDER_WORKERS,
diff --git a/training/utils/client.py b/training/utils/client.py
index aa449fb0d..5ea3f7bad 100644
--- a/training/utils/client.py
+++ b/training/utils/client.py
@@ -261,6 +261,10 @@ def submit_optim_step(
kwargs["emit_grad_norm_metrics"] = emit_grad_norm_metrics
return self._require_client().optim_step(params, **kwargs)
+ def weight_sync(self):
+ """Wait for one complete RDMA weight sync."""
+ return self._require_client().weight_sync().result(timeout=self._default_timeout)
+
def save_state(self, name: str, timeout: int = DCP_TIMEOUT_S):
return self._client.save_state(name).result(timeout=timeout)
diff --git a/training/utils/config.py b/training/utils/config.py
index b4b9df747..2d82bea07 100644
--- a/training/utils/config.py
+++ b/training/utils/config.py
@@ -6,7 +6,7 @@
import warnings
from dataclasses import dataclass
from enum import Enum
-from typing import Any, Callable, Dict, Mapping
+from typing import Any, Callable, Dict, Literal, Mapping
from fireworks.training.sdk.client import FiretitanTrainingClient
from fireworks.training.sdk.deployment import DeploymentConfig
@@ -305,6 +305,13 @@ class DeployConfig:
preemptible: bool = False
"""Request preemptible deployment scheduling. Requires an admin API key."""
+ weight_sync_transport: Literal["RDMA"] | None = None
+ """Opt in to runtime RDMA publication; unset preserves file-based hotload.
+
+ Requires a compatible SDK and full-parameter trainer/rollout runtime.
+ Use weight_sync() to publish; durable checkpoints are saved separately.
+ """
+
def to_deployment_config(
self,
base_model: str,
diff --git a/training/utils/rl/dppo.py b/training/utils/rl/dppo.py
index 314b955b0..da6f4d7c8 100644
--- a/training/utils/rl/dppo.py
+++ b/training/utils/rl/dppo.py
@@ -8,6 +8,7 @@
from __future__ import annotations
+import warnings
from dataclasses import dataclass
from typing import Dict, List, Literal, Tuple, Union
@@ -21,15 +22,24 @@
)
from training.utils.rl.tis import TISConfig
+_DEFAULT_THRESHOLD = {
+ "binary_tv": 0.15,
+ "binary_kl": 0.05,
+}
+
@dataclass
class DPPOConfig:
"""DPPO binary-divergence trust-region configuration."""
divergence: Literal["binary_tv", "binary_kl"] = "binary_tv"
- threshold: float = 0.15
+ threshold: float | None = None
ratio_log_cap: float = 20.0
+ def __post_init__(self) -> None:
+ if self.threshold is None:
+ self.threshold = _DEFAULT_THRESHOLD.get(self.divergence)
+
def validate_dppo_config(config: DPPOConfig) -> None:
"""Validate DPPO settings before building the loss closure."""
@@ -37,7 +47,7 @@ def validate_dppo_config(config: DPPOConfig) -> None:
raise ValueError(
"DPPO divergence must be 'binary_tv' or 'binary_kl'."
)
- if config.threshold < 0:
+ if config.threshold is not None and config.threshold < 0:
raise ValueError("DPPO threshold must be non-negative.")
if config.ratio_log_cap < 0:
raise ValueError("DPPO ratio_log_cap must be non-negative.")
@@ -74,8 +84,14 @@ def make_dppo_loss_fn(
if dppo_config is None:
dppo_config = DPPOConfig()
validate_dppo_config(dppo_config)
- if tis_config is None:
- tis_config = TISConfig()
+ if tis_config is not None:
+ warnings.warn(
+ "DPPO ignores tis_config because truncated importance sampling "
+ "is not part of its objective.",
+ DeprecationWarning,
+ stacklevel=2,
+ )
+ assert dppo_config.threshold is not None
prompt_lens = _normalize_prompt_lens(prompt_len, len(advantages))
def policy_fn(ctx):
@@ -100,7 +116,6 @@ def policy_fn(ctx):
-ratio
* ctx.adv
* trust_region_mask.detach()
- * ctx.tis_weight
* ctx.resp_mask
)
return per_token_loss, {
@@ -125,13 +140,19 @@ def loss_fn(
inf_logprobs,
prompt_lens,
old_policy_logprobs,
- tis_config,
+ # The shared loop requires a TIS config, but DPPO intentionally
+ # ignores its computed weight; infinity also disables truncation.
+ TISConfig(cap=float("inf")),
data,
logprobs_list,
"dppo",
policy_fn,
)
- metrics = dict(result.base_metrics)
+ metrics = {
+ key: value
+ for key, value in result.base_metrics.items()
+ if not key.startswith("tis/")
+ }
ns = result.n_samples
metrics["dppo_mask_frac"] = (
result.extra_sums.get("mask_frac", 0.0) / ns
diff --git a/training/utils/rl/score_centering.py b/training/utils/rl/score_centering.py
index 16aabb953..3b9e67eb0 100644
--- a/training/utils/rl/score_centering.py
+++ b/training/utils/rl/score_centering.py
@@ -14,14 +14,18 @@
import tinker
import torch
-MAX_SCORE_CENTERING_TOP_K = 5
+# Public inference defaults to 5 (``FIREWORKS_MAX_LOGPROBS``). Dedicated
+# deployments can raise that with ``--max-logprobs``. 8 is the cookbook cap
+# for this approximation; the default stays at the public limit.
+PUBLIC_DEFAULT_TOP_LOGPROBS = 5
+MAX_SCORE_CENTERING_TOP_K = 8
@dataclass(frozen=True)
class ScoreCenteringConfig:
"""Top-k sampler-distribution approximation settings."""
- top_k: int = MAX_SCORE_CENTERING_TOP_K
+ top_k: int = PUBLIC_DEFAULT_TOP_LOGPROBS
tail_mass_epsilon: float = 1e-6
@@ -30,8 +34,10 @@ def validate_score_centering_config(config: ScoreCenteringConfig) -> None:
raise ValueError("score centering top_k must be positive.")
if config.top_k > MAX_SCORE_CENTERING_TOP_K:
raise ValueError(
- "score centering top_k exceeds the public inference "
- f"top_logprobs limit ({MAX_SCORE_CENTERING_TOP_K})."
+ "score centering top_k exceeds the supported sampler "
+ f"top_logprobs cap ({MAX_SCORE_CENTERING_TOP_K}). "
+ "Public inference defaults to 5; set the deployment "
+ "--max-logprobs at least as high as top_k."
)
if config.tail_mass_epsilon <= 0:
raise ValueError("score centering tail_mass_epsilon must be positive.")
diff --git a/training/utils/service.py b/training/utils/service.py
index d60eed505..8b07be2fa 100644
--- a/training/utils/service.py
+++ b/training/utils/service.py
@@ -15,6 +15,27 @@
from training.utils.config import DeployConfig, TrainerConfig, WeightSyncScope
+def make_weight_sync(
+ policy: Any, service: Any, deployment: DeployConfig, *, extended: bool = False
+):
+ """Select publication once, preserving FILE names and initial-base requests."""
+ if deployment.weight_sync_transport == "RDMA":
+ return lambda name, **kwargs: policy.weight_sync()
+ save = (
+ policy.save_weights_for_sampler_ext
+ if extended
+ else policy.save_weights_for_sampler
+ )
+
+ def publish(name, **kwargs):
+ saved = save(name, **kwargs)
+ return service.hotload_sampler_snapshot(
+ saved.snapshot_name if extended else saved.path
+ )
+
+ return publish
+
+
def resolve_router_replay_enabled(
*,
requested: bool,
@@ -39,6 +60,7 @@ def _firetitan_service_kwargs(
base_model: str,
tokenizer_model: str | None,
max_lora_rank: int | None,
+ projection_head_dim: int | None = None,
max_context_length: int | None,
learning_rate: float,
trainer: TrainerConfig,
@@ -85,6 +107,16 @@ def _firetitan_service_kwargs(
"hotload_timeout_s": hotload_timeout_s,
"cleanup_deployment_on_close": cleanup_deployment_on_close,
}
+ # Projection topology is service-scoped. Omit the field entirely for actor
+ # services; the SDK also canonicalizes an explicit zero to ``None``.
+ if isinstance(projection_head_dim, bool) or (
+ projection_head_dim is not None and not isinstance(projection_head_dim, int)
+ ):
+ raise ValueError("projection_head_dim must be a non-negative integer when set")
+ if projection_head_dim is not None and projection_head_dim < 0:
+ raise ValueError("projection_head_dim must be a non-negative integer when set")
+ if projection_head_dim:
+ service_kwargs["projection_head_dim"] = projection_head_dim
# Keep the default path compatible with the cookbook's declared minimum SDK,
# which predates reservation_target. An explicit target requires the newer SDK
# surface and is therefore forwarded only when the caller requests it.
@@ -119,8 +151,14 @@ def _firetitan_service_kwargs(
raise ValueError(
"wait_for_trainer_before_deployment requires PER_TRAINER weight sync"
)
+ if deployment.weight_sync_transport is not None:
+ if deployment.weight_sync_transport != "RDMA":
+ raise ValueError("weight_sync_transport must be 'RDMA' or None")
+ if deployment.weight_sync_scope != WeightSyncScope.PER_TRAINER:
+ raise ValueError("RDMA weight sync requires PER_TRAINER weight sync")
supported = {f.name for f in fields(FiretitanProvisioningConfig)}
optional_deploy = {
+ "weight_sync_transport": deployment.weight_sync_transport,
"wait_for_trainer_before_deployment": deployment.wait_for_trainer_before_deployment,
}
for name, value in optional_deploy.items():
@@ -146,6 +184,7 @@ def build_service_client(
base_model: str,
tokenizer_model: str | None,
max_lora_rank: int | None,
+ projection_head_dim: int | None = None,
max_context_length: int | None,
learning_rate: float,
trainer: TrainerConfig,
@@ -160,6 +199,7 @@ def build_service_client(
base_model=base_model,
tokenizer_model=tokenizer_model,
max_lora_rank=max_lora_rank,
+ projection_head_dim=projection_head_dim,
max_context_length=max_context_length,
learning_rate=learning_rate,
trainer=trainer,
diff --git a/training/utils/supervised.py b/training/utils/supervised.py
index a13eac570..3bd443130 100644
--- a/training/utils/supervised.py
+++ b/training/utils/supervised.py
@@ -42,6 +42,7 @@
import training.renderer.gemma4 as _gemma4_renderer # noqa: F401 — triggers register_renderer
import training.renderer._gemma4_split as _gemma4_split_renderer # noqa: F401 — split override
import training.renderer.deepseek_v4 as _deepseek_v4_renderer # noqa: F401 — triggers register_renderer
+import training.renderer.deepseek_v41 as _deepseek_v41_renderer # noqa: F401 — triggers register_renderer
import training.renderer.mistral as _mistral_renderer # noqa: F401 — triggers register_renderer
import training.renderer.kimi_k27_code as _kimi_k27_code_renderer # noqa: F401 — triggers register_renderer
import training.renderer.kimi_k3 as _kimi_k3_renderer # noqa: F401 — triggers register_renderer
@@ -72,6 +73,11 @@
)
from training.utils.tokenizers import load_tokenizer
+_DEEPSEEK_V41_MODEL_RE = re.compile(
+ r"(?:^|[/_.-])deepseek[-_]?v?4(?:[._-]?1|p1)(?=$|[/_.-])"
+)
+
+
@dataclass(frozen=True)
class RenderedSupervisedDatum:
"""Rendered sequence ids, token-level weights, and the final training datum."""
@@ -174,19 +180,26 @@ def build_tool_prefixed_messages(
if not tool_specs:
return normalized_messages
system_prompt = ""
+ response_format: dict[str, Any] | None = None
has_explicit_empty_system_message = False
if normalized_messages and normalized_messages[0].get("role") == "system":
- sys_content = normalized_messages.pop(0).get("content")
+ system_message = normalized_messages.pop(0)
+ sys_content = system_message.get("content")
+ raw_response_format = system_message.get("response_format")
+ if isinstance(raw_response_format, Mapping):
+ response_format = copy.deepcopy(dict(raw_response_format))
if isinstance(sys_content, str):
system_prompt = sys_content
has_explicit_empty_system_message = sys_content == ""
elif isinstance(sys_content, list):
- system_prompt = "\n".join(
+ system_prompt = "\n\n".join(
part.get("text", "")
for part in sys_content
if isinstance(part, Mapping) and part.get("type") == "text"
)
prefix_messages = list(prefix_builder(tool_specs, system_prompt=system_prompt))
+ if response_format is not None and prefix_messages and prefix_messages[0].get("role") == "system":
+ prefix_messages[0]["response_format"] = response_format # type: ignore[typeddict-unknown-key]
if has_explicit_empty_system_message and getattr(
renderer,
"_preserves_explicit_empty_system_with_tools",
@@ -302,6 +315,9 @@ def resolve_renderer_name(
return "qwen3_5"
if "gemma-4" in normalized_model_name or "gemma4" in normalized_model_name:
return "gemma4"
+ # V4.1 must be matched before V4 because dotted names contain "deepseek-v4".
+ if _DEEPSEEK_V41_MODEL_RE.search(normalized_model_name):
+ return "deepseek_v41"
# DeepSeek-V4 ships a custom non-Jinja encoder (see encoding_dsv4.py upstream)
# with thinking blocks and DSML tool calls. Match the V4 family explicitly so
# we don't accidentally claim V3 (which routes through tinker_cookbook's
@@ -613,6 +629,11 @@ def _get_image_processor_with_remote_code_default(
tokenizer_model
)
+ if renderer_name == "deepseek_v41":
+ return _deepseek_v41_renderer.DeepseekV41ImageTokenCounter.from_pretrained(
+ tokenizer_model
+ )
+
if renderer_name in {
"glm53_flash",
"glm53_flash_interleaved",
@@ -658,6 +679,7 @@ def renderer_supports_images(renderer_name: str) -> bool:
"kimi_k3",
"muse_glimmer",
"glm53_flash",
+ "deepseek_v41",
)
)
@@ -900,6 +922,14 @@ def normalize_messages(
normalized_tools.append(copy.deepcopy(dict(tool)))
normalized_message["tools"] = normalized_tools # type: ignore[typeddict-unknown-key]
+ response_format = message.get("response_format")
+ if response_format is not None:
+ if not isinstance(response_format, Mapping):
+ raise TypeError("Message response_format must be an object")
+ normalized_message["response_format"] = copy.deepcopy( # type: ignore[typeddict-unknown-key]
+ dict(response_format)
+ )
+
tool_calls = message.get("tool_calls")
if tool_calls is not None:
normalized_message["tool_calls"] = _normalize_tool_calls(tool_calls)
@@ -1552,7 +1582,10 @@ def _requires_renderer_supervised_examples(
return False
if train_on_what not in _SPLIT_REQUIRED_TRAINING_MODES:
return False
- return sum(1 for message in messages if message["role"] == "user") > 1
+ turn_ends = getattr(renderer, "_disaggregation_end_indices", None)
+ if callable(turn_ends):
+ return bool(ends := turn_ends(messages)) and (len(ends) > 1 or ends[0] != len(messages))
+ return sum(message["role"] == "user" for message in messages) > 1
def _equivalent_single_example_train_on_what(
diff --git a/training/uv.lock b/training/uv.lock
index adf6cc7ef..3a7742f42 100644
--- a/training/uv.lock
+++ b/training/uv.lock
@@ -914,7 +914,7 @@ wheels = [
[[package]]
name = "fireworks-ai"
-version = "1.2.14"
+version = "1.2.15"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "aiohttp" },
@@ -926,9 +926,9 @@ dependencies = [
{ name = "sniffio" },
{ name = "typing-extensions" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/40/83/a40e83bd0517f864b799590024f89b8f40612ce100f0fd055af60ed47ec2/fireworks_ai-1.2.14.tar.gz", hash = "sha256:6645840552455fc85ea94fb2326ed9cacd3ea776533ed0e2a9dd6b66952591ed", size = 594264, upload-time = "2026-09-18T23:31:49.914Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/bb/80/7b86977ddc6f25510f36e1afcdb0c4759867b4947f13bd24e06e374408a3/fireworks_ai-1.2.15.tar.gz", hash = "sha256:dcf40ad63aaebcf85c5924785b086e69dcca385a338b7e14b15bfaa7c38d47cb", size = 597894, upload-time = "2026-09-22T22:33:48.03Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/88/67/b681e2fdc60f31080452e5a553eb3d535104b765141427cd496ebf3b9e87/fireworks_ai-1.2.14-py3-none-any.whl", hash = "sha256:28c3691ee27264ff08416ccb2d2285a996ef4ced720ac9efc9c1e167f456be09", size = 557127, upload-time = "2026-09-18T23:31:48.602Z" },
+ { url = "https://files.pythonhosted.org/packages/6e/74/ae29164589f510a4328c2737cc596b232680b55e0dbc4551ca923a9e071b/fireworks_ai-1.2.15-py3-none-any.whl", hash = "sha256:5194614882f43fb4bc5d3fd0301b51d89f24ea6c9baf6dc47ddab00bdc46c2ba", size = 559560, upload-time = "2026-09-22T22:33:46.256Z" },
]
[package.optional-dependencies]
@@ -987,7 +987,7 @@ requires-dist = [
{ name = "aiohttp", specifier = ">=3.9.0" },
{ name = "datasets", specifier = ">=2.14.0" },
{ name = "eval-protocol", marker = "extra == 'eval'", specifier = ">=0.3.32" },
- { name = "fireworks-ai", extras = ["training"], specifier = ">=1.2.14,<2" },
+ { name = "fireworks-ai", extras = ["training"], specifier = ">=1.2.15,<2" },
{ name = "fireworks-training-cookbook", extras = ["eval"], marker = "extra == 'dev'" },
{ name = "huggingface-hub", specifier = ">=1.5.0,<2.0" },
{ name = "hydra-core", specifier = ">=1.3,<2" },
@@ -2711,49 +2711,55 @@ crypto = [
[[package]]
name = "pyqwest"
-version = "0.6.2"
+version = "0.10.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "opentelemetry-api" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/b1/52/90938b0620768dd97a0d82929afc62870037d904e608cac4e5ef6278e4d3/pyqwest-0.6.2.tar.gz", hash = "sha256:17c41121b9dc400133aae282ce8b00bc8d59e75e095b18ca1d9fb5bd1a3ee33a", size = 452447, upload-time = "2026-06-15T07:53:02.495Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/06/86/1818ecd6b0d5e0339a6927ef9d06fc14bb18153887df71a76d7e0452e005/pyqwest-0.6.2-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:18e0ef18fd106cb2a2560a78961f26785b4db45477fa42d160987e823207184a", size = 5100370, upload-time = "2026-06-15T07:51:52.367Z" },
- { url = "https://files.pythonhosted.org/packages/10/11/5295652f43a998dda9bf572c7489ddcdd99e0c9f5ddbd1039d73446d4cb2/pyqwest-0.6.2-cp310-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:31885a69499a23f85ed6970c7e8b227f284ea8427ec695026aa69d4ad17ca94a", size = 5497128, upload-time = "2026-06-15T07:51:54.307Z" },
- { url = "https://files.pythonhosted.org/packages/dd/52/c3e35be12fd5503376a64ec91711e1ed0d614a09a119b2d938d01d6a7973/pyqwest-0.6.2-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bccae7bc1fa195d81d439dfdcba6d213b4c234ab645c902b9da209001facb70d", size = 5528596, upload-time = "2026-06-15T07:51:56.423Z" },
- { url = "https://files.pythonhosted.org/packages/0f/ac/32e05ac720cbc64014295b01f82229c012379393701ebb73c5a0274086d6/pyqwest-0.6.2-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:674841db9d441aa6ab693f52f73df62baa21648888ebf9a8d576dc9f61e4d904", size = 5663292, upload-time = "2026-06-15T07:51:58.417Z" },
- { url = "https://files.pythonhosted.org/packages/e7/8d/57d760ac9fbf826f6576d66ee53f2419dabd97437148f6ed0f9eb3c38177/pyqwest-0.6.2-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:c72e470599e37db8e5b9d58f1d944d9d2f3e60280a62dba0453019f3c856b34d", size = 5842408, upload-time = "2026-06-15T07:52:00.484Z" },
- { url = "https://files.pythonhosted.org/packages/32/2e/f5aade7f6c84d6e37351b0e8810628ec7007f35a986a5d63047e7f7ea483/pyqwest-0.6.2-cp310-abi3-win_amd64.whl", hash = "sha256:1fc6a2ec7448cc6cc8861496dc05e8c14035c7da8da16dde8aa656656f5a8523", size = 4696348, upload-time = "2026-06-15T07:52:02.329Z" },
- { url = "https://files.pythonhosted.org/packages/35/75/5591c4087c03fd28f93b0f88061c0b670f5c29fdb3f1dfe5a1cd80f66e32/pyqwest-0.6.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2a0e873a2810464dcc41872ecf8d1b0119d96f86994a0ae82a8f982da3e9a100", size = 5086955, upload-time = "2026-06-15T07:52:04.137Z" },
- { url = "https://files.pythonhosted.org/packages/5c/7b/078011e618214e2d980669eb1ca3ba1c69b30fabcb2102915a92b6e4c777/pyqwest-0.6.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:34e423746911a21945f069ef5900a38a17eebd2f07f3c3071870e44034a85402", size = 5490823, upload-time = "2026-06-15T07:52:06.075Z" },
- { url = "https://files.pythonhosted.org/packages/7b/1a/f80f5417b4b4d1057dc46edff23c753171ba095743ec510ca130b4c0e197/pyqwest-0.6.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:526f5f2724ec7148fd0d7bdc53a25fcc961c8288fb271ace12af5db7e77d1644", size = 5520681, upload-time = "2026-06-15T07:52:07.963Z" },
- { url = "https://files.pythonhosted.org/packages/7b/e4/3fb6a3cb5e75855e11c70133fda27fb0f71d816d17c6847b4cff5334636b/pyqwest-0.6.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:f47bffb117d42ce835c8f1a84d2b2e92e698907e1fbe796a17c038a8e72ea225", size = 5657487, upload-time = "2026-06-15T07:52:09.875Z" },
- { url = "https://files.pythonhosted.org/packages/c5/0c/cbc68a6dbdf06d479afb07edcc33a201f55030817edbe05d16db34635e1a/pyqwest-0.6.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3628ad3d5a851b69b64b2c510bb8f233be829eb0c653fbe2b14dd6edbca2aeab", size = 5833432, upload-time = "2026-06-15T07:52:11.681Z" },
- { url = "https://files.pythonhosted.org/packages/0b/56/83c23b1f6d3eae4793b827a7959497db9c517900ce47b24eb5b9f04fda21/pyqwest-0.6.2-cp312-cp312-win_amd64.whl", hash = "sha256:dd7fc9e73c99a55f2793b055c04753729764f726c9ac909db78dd28f35dd48b6", size = 4689771, upload-time = "2026-06-15T07:52:13.496Z" },
- { url = "https://files.pythonhosted.org/packages/5a/c8/7460ac69ca76a6e9b382f9b3e048c43d1d58cfdcb427a85e62d4374e1325/pyqwest-0.6.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c863bf92d719f76a5564edb27de072ebf7be4bc243002cafbde48fb1ea7c177c", size = 5086087, upload-time = "2026-06-15T07:52:15.229Z" },
- { url = "https://files.pythonhosted.org/packages/cf/7b/ad94597a15dc168417da75702a5e9aa6b59eb66adbbf668aa1a64c9246d7/pyqwest-0.6.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ed8e436415e9b2fa901505f9dfc0838e727b46e5fd82330ac9cef3e4837b89e6", size = 5491566, upload-time = "2026-06-15T07:52:16.991Z" },
- { url = "https://files.pythonhosted.org/packages/c7/17/bd5babca1d66aeb87df87246dfe7ef4da23dfe73f8809bcf7c15f7d393d3/pyqwest-0.6.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:279400267f5ca802af8da86c1dac57c71d64a808e306433085cc10a8c7fef5be", size = 5520820, upload-time = "2026-06-15T07:52:18.726Z" },
- { url = "https://files.pythonhosted.org/packages/a0/80/68bbd131557de18381e69f6168129ee1ca1bc3ccc8954dada2a7bfff8aec/pyqwest-0.6.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:931f343dbbdc6ed2748cb9ca4a27125e24818b2b11e373a61bc314bc8a99f152", size = 5657456, upload-time = "2026-06-15T07:52:20.513Z" },
- { url = "https://files.pythonhosted.org/packages/d3/a7/c8ed8c179eccb62b7bf39b4d328af225a5081b21da5f4332ab5071acb6a1/pyqwest-0.6.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:5502b12f68528b7cf6f96f945432715f69e01f29a6e171471e55dfba6598fb7a", size = 5832552, upload-time = "2026-06-15T07:52:22.296Z" },
- { url = "https://files.pythonhosted.org/packages/19/98/d169a06e09d1780d2ba3a00d395832c47a770d0ac4caea6195623d84a8ea/pyqwest-0.6.2-cp313-cp313-win_amd64.whl", hash = "sha256:6e72751cc703232178cdc3373e369e6709c6825b6c2dee1532febd97338e5d81", size = 4689388, upload-time = "2026-06-15T07:52:24.164Z" },
- { url = "https://files.pythonhosted.org/packages/27/7e/26da32e3e16af88401dbe9ab781ac1ac7fd5e6ba8c70c03126997cc742c6/pyqwest-0.6.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:06f467cce2eaaa024fbcf39eefb634bc373ccec40f70376529b4f1bc87cc78c3", size = 5089084, upload-time = "2026-06-15T07:52:26.378Z" },
- { url = "https://files.pythonhosted.org/packages/f5/98/1c4c4cd2a3a890314e1e2af9d02409a0ed83b21845b44024506a6bcec264/pyqwest-0.6.2-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f8619aee2b028bd220d527c8682206b5c74e74417d0c83c16e1e58d6ab4e6758", size = 5496927, upload-time = "2026-06-15T07:52:28.462Z" },
- { url = "https://files.pythonhosted.org/packages/67/8f/01aec37fc3e7c9ac61f831935ef680e1698aef3dcb931fd566445aab01a2/pyqwest-0.6.2-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ebbef2147b1b1b76b2cd3a576cdd5378a62dd2f65f2389bb00a291b867f7c70a", size = 5525409, upload-time = "2026-06-15T07:52:30.528Z" },
- { url = "https://files.pythonhosted.org/packages/d6/aa/59d8866b421acac8331ed48dbc9ed616d2386690f98d22891dbb443ba48f/pyqwest-0.6.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:9aa73cadfbf6eb96af80ec3d77bf9c212a170b5c19386f754889b7640b65b91d", size = 5663185, upload-time = "2026-06-15T07:52:32.38Z" },
- { url = "https://files.pythonhosted.org/packages/df/dd/50c472f24ae6ffd028395a811b043a42d2d3cba0cdc0ae06a2735987770a/pyqwest-0.6.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:393753384ac20d94f3241cbcf022699e7672ed1eaa64262ce5550e2602fe744d", size = 5839754, upload-time = "2026-06-15T07:52:34.284Z" },
- { url = "https://files.pythonhosted.org/packages/66/d7/056b16c56541fa17fdf64c832a1c7979cf9fd9b1f16fdb7179042758e2c6/pyqwest-0.6.2-cp314-cp314-win_amd64.whl", hash = "sha256:de349a0b198dfd32aef63065ac1360fa8ce0ef6bba389970e54d682fa3a21e62", size = 4704432, upload-time = "2026-06-15T07:52:36.369Z" },
- { url = "https://files.pythonhosted.org/packages/bc/d7/92a51c7251edb072342fdcec19aa8fff2a744e55844402f53ec8de9e7919/pyqwest-0.6.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:244a327ef4f5bed2675a5bc30455bc05053f89d2afa61673a564b9393be599f3", size = 5083571, upload-time = "2026-06-15T07:52:38.263Z" },
- { url = "https://files.pythonhosted.org/packages/33/07/b30b2b89a96e6ecdb74a528e6203eddffdeb8bc18d56d9c34908addf1011/pyqwest-0.6.2-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:26b508b2d9442828d878faa60d82f6e9a71e107e8ed52c2e6df5e92acbb3cf68", size = 5488925, upload-time = "2026-06-15T07:52:40.386Z" },
- { url = "https://files.pythonhosted.org/packages/cc/9b/f88816ebc3643f6598c43f7ed6e38a75264db15321b11b1d2d40cbcb002d/pyqwest-0.6.2-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:57c9b73d427a774b67bdb2b8cec2b2425d4460d5bd892be586bbcc54a37a8488", size = 5522569, upload-time = "2026-06-15T07:52:42.364Z" },
- { url = "https://files.pythonhosted.org/packages/5a/29/286b56183d9a70e06fca4e083d765f8c2022bfce81787d9bc9218bb818dd/pyqwest-0.6.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:35317d7023f45d202bc32d931af37f385cc3161ff33e696e2d11e5362ae6621e", size = 5651779, upload-time = "2026-06-15T07:52:44.548Z" },
- { url = "https://files.pythonhosted.org/packages/e4/f0/6615b09f0a061a20c4e53baa28bf1d6328e16caf8fbc2c94f98cec46ece5/pyqwest-0.6.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:09c42938376657e348ec1778daeb84400b399e95a44e85feedbdbdc396d720d9", size = 5831037, upload-time = "2026-06-15T07:52:46.568Z" },
- { url = "https://files.pythonhosted.org/packages/7f/38/d85140144d28ea8963ae81d60393d91bafc08087cce2c1a807f8e7870f8c/pyqwest-0.6.2-cp314-cp314t-win_amd64.whl", hash = "sha256:3d842368f4322ebad4ca5f1edf6955230207f05f06e43ba7ac4c8d8a57cf022f", size = 4693774, upload-time = "2026-06-15T07:52:48.81Z" },
- { url = "https://files.pythonhosted.org/packages/cc/ad/985bce113517df2f22f33010b422316f18147611e4f1a6367ad17961d83d/pyqwest-0.6.2-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:dfd110da1ebf8c78bafac7ad853e45b42f7324c699a8bbb712cd8d870c9b5f86", size = 5105913, upload-time = "2026-06-15T07:52:50.984Z" },
- { url = "https://files.pythonhosted.org/packages/27/5a/b57517f4bb58809c6172dbb50adbf4e3ffbe507bd6f8ae5702de7ee2b407/pyqwest-0.6.2-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9dda5b2aa9ba1d0ce9f0472f79c82b1b73be8b98009d9024013bd2805ab6b6a4", size = 5514329, upload-time = "2026-06-15T07:52:53.018Z" },
- { url = "https://files.pythonhosted.org/packages/f2/78/77556be672170d158fd9f4d61454d95b78f40a7d1be081113660eb869b9e/pyqwest-0.6.2-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:21c9f716159dbcec1ebd74d1a338b2a8ca9ec3f4364a0a0816f668e4147f4975", size = 5531376, upload-time = "2026-06-15T07:52:55.007Z" },
- { url = "https://files.pythonhosted.org/packages/dd/4c/7f8a24f66640ab481188ebd1dd0f29ddeb905ef05112548d07813e65c8b6/pyqwest-0.6.2-pp311-pypy311_pp73-musllinux_1_2_aarch64.whl", hash = "sha256:34b73a96721673cfe8948e05299a22b91f6bd4f98a3414037e1a2970059658e0", size = 5677824, upload-time = "2026-06-15T07:52:56.936Z" },
- { url = "https://files.pythonhosted.org/packages/a1/3f/6b116c1220e2f857106edc8ddf84e32da59800a194b38283f74ac2ec34b8/pyqwest-0.6.2-pp311-pypy311_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:dcba03d13c98cea676a3ff523a81610807b315c78f8d557b4f09874571cb4ec9", size = 5846895, upload-time = "2026-06-15T07:52:59.094Z" },
- { url = "https://files.pythonhosted.org/packages/2e/d7/5bfd3659d78d349ff7de5efeb5451242b12be7bca303a0b1b302c9d233ce/pyqwest-0.6.2-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:5499c229cc6627341f179d2be43f8c4fad878146a0c47d47b9fb3ff11ba8cbd9", size = 4705345, upload-time = "2026-06-15T07:53:00.872Z" },
+sdist = { url = "https://files.pythonhosted.org/packages/07/ee/0ff9facfa9e7a4f6df2a770d4eaf1ad0f74165da7e8c28e888461f07604c/pyqwest-0.10.0.tar.gz", hash = "sha256:6c1a693be17d57d2c2eca4085e32c2809c53090c16719a907c90ebcf1f40dc01", size = 482248, upload-time = "2026-08-21T06:09:20.656Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/43/ee/b1a28f57c689606cfd065d8a553841150f7daaa91d20e58dcc2c5ea191f8/pyqwest-0.10.0-cp310-abi3-macosx_10_12_x86_64.whl", hash = "sha256:aa492d5777dd145a60795ed95d9d4707a3cd1091fdcdfc93a82ac7fdc43ebacd", size = 5261059, upload-time = "2026-08-21T06:08:04.999Z" },
+ { url = "https://files.pythonhosted.org/packages/dc/13/9c5046cfd6ef705bde0b620ba8a794335bcabc0839342a2a647f2427b27e/pyqwest-0.10.0-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:59f3f16628e518c674102e7b5fcff2101bba6abb4f6737ec5fade9b9278e6a53", size = 5134207, upload-time = "2026-08-21T06:08:06.955Z" },
+ { url = "https://files.pythonhosted.org/packages/93/7d/50021dd88d82d6966ab1c27593ceaee9d1ed62fbe597c40e8dc187cfa5fd/pyqwest-0.10.0-cp310-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d6e7db305a8318b1f3218053e87501f8f245ca8bd63e948e0282d04bf0883470", size = 5640730, upload-time = "2026-08-21T06:08:09.135Z" },
+ { url = "https://files.pythonhosted.org/packages/ff/3f/5bf6c32e9e701837a8c47ce6e3ad38978cfec8eb7bc6596181e5f9e1eaeb/pyqwest-0.10.0-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a5c757cfac5f53c8671dcb4850d5fc4c4339ea3e90636331c9318f8e3ddabc06", size = 5561462, upload-time = "2026-08-21T06:08:10.836Z" },
+ { url = "https://files.pythonhosted.org/packages/5f/61/ca9ba5721461b7ce5cfaac373ab3a1723ddcc434af7430f8bd628da6e623/pyqwest-0.10.0-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:234b3f71e3f314d997c203d8cf829b7117edd041153f9c277d0060ab90134148", size = 5801847, upload-time = "2026-08-21T06:08:12.502Z" },
+ { url = "https://files.pythonhosted.org/packages/d2/44/95593919b996a417093f598d887822b9b899e8d025588c9bfaf8c60dd812/pyqwest-0.10.0-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:5637256a0dac0ef57e0eaa02b032014965e4a4c995e1deca1b1b97e6d1765f78", size = 5978692, upload-time = "2026-08-21T06:08:14.383Z" },
+ { url = "https://files.pythonhosted.org/packages/a5/b9/b2821ce5188457168ebb25d5ff65b1ca1bf27bc6b4a33df4bcc2357e625c/pyqwest-0.10.0-cp310-abi3-win_amd64.whl", hash = "sha256:7ea761937acf3a00d1a7e70e982949d18946e5471d1419266ab3a78bbfa19759", size = 4876627, upload-time = "2026-08-21T06:08:16.084Z" },
+ { url = "https://files.pythonhosted.org/packages/86/b4/16ccef1c203fa258ce46a86aefc1a79c13b5f0b8d49627347d90eef25efd/pyqwest-0.10.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:a21f1f15252a8303623b4f17b9c6de595ace11b3ade07f2adb6d07121e8191aa", size = 5274815, upload-time = "2026-08-21T06:08:17.777Z" },
+ { url = "https://files.pythonhosted.org/packages/5f/61/6a87f84f571441ea43279587d4bfcad4543505918ae2b83a1ebdcfa98be5/pyqwest-0.10.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:eb472c6e5d6833ebfec79db310e426eb17b01ac64c0e2c251bd9192c0d2ee0c5", size = 5123656, upload-time = "2026-08-21T06:08:19.501Z" },
+ { url = "https://files.pythonhosted.org/packages/8b/f2/ab69e581cf9b798b0e169f7b27fd3f8b6f9f1631bd4d3b6e22e5abaf8d8b/pyqwest-0.10.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:83578e24cccd5e0dc04d60a0af7bfb43325b5f22d03ff74ff79ed0ecf553b50d", size = 5641253, upload-time = "2026-08-21T06:08:21.627Z" },
+ { url = "https://files.pythonhosted.org/packages/9f/dd/f1a62eebf8321ace506bd94551a01431f6a45b882225455eae3ea6e8c6d1/pyqwest-0.10.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6bb511c434f79c641efb5573e5795e56dc972252f4b96e52a9636d4ece5231a4", size = 5567341, upload-time = "2026-08-21T06:08:23.346Z" },
+ { url = "https://files.pythonhosted.org/packages/4c/48/51d767691973e046887f5e6d96e32142fe296823b163ccba732233a6ef72/pyqwest-0.10.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:1aaccd8a9db9430b2aedb5bad8ead80742cbc056b85c229516c70dc80539f906", size = 5803874, upload-time = "2026-08-21T06:08:25.096Z" },
+ { url = "https://files.pythonhosted.org/packages/58/0a/d2834ccc6e59ad110718895cc65ff2a68aa6e010f0ba8fbe42a57ea33c21/pyqwest-0.10.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:73d9eb438ab4a957a1ce0619d3af8c1c1126bfb9181033b123d792fcf4224531", size = 5981518, upload-time = "2026-08-21T06:08:27.594Z" },
+ { url = "https://files.pythonhosted.org/packages/75/f3/274b4c268e9a55fbdb1b3637ac50b5bf42cd3a85d1cfbdc15c602a7b0d9c/pyqwest-0.10.0-cp312-cp312-win_amd64.whl", hash = "sha256:317a74d633abe3bc5bccabf479e069c515dab9e6a755274b0ccb1d8a5bbfede3", size = 4870638, upload-time = "2026-08-21T06:08:29.421Z" },
+ { url = "https://files.pythonhosted.org/packages/8d/3d/8b1092f25159bf61a9470ebd35438c669b91ef553a7ee205bdec8006107b/pyqwest-0.10.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:3978e794b9cfd8eaa500fb5d7aee63bc6172c605efa0abc1f62d85485bc049e1", size = 5273599, upload-time = "2026-08-21T06:08:31.107Z" },
+ { url = "https://files.pythonhosted.org/packages/75/10/54a9786123942b124c2afb9562b74e158afec7be40ef0caa0d37f615d379/pyqwest-0.10.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:715991fd4f04862cd7a9d7452daabcdbd74dff4dff55eb20c22d60382dc2a4ed", size = 5122920, upload-time = "2026-08-21T06:08:33.025Z" },
+ { url = "https://files.pythonhosted.org/packages/a2/5b/6a6bd76f91e068b9a619f62aef9fe5ef201f859ddbb6b0a11ad3875ecdda/pyqwest-0.10.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c04798bed79c1dfa0e5b0e30fb137124311083490d44d6dfbe068d3dd254349e", size = 5639577, upload-time = "2026-08-21T06:08:34.896Z" },
+ { url = "https://files.pythonhosted.org/packages/68/28/12277a24a8dd74b0a7f124c624d9ed58eccb41e2087ff1087a14d348c778/pyqwest-0.10.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:35b472877e73dd63fed089c2bc8fa198407f005c8c19e0a93f025ebefde01a81", size = 5565835, upload-time = "2026-08-21T06:08:36.567Z" },
+ { url = "https://files.pythonhosted.org/packages/9e/b6/b7da4a3f1fe43600154ae75e91ba7969024d886d60077dd3a1ba8e66d170/pyqwest-0.10.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:564ec360b7848b35e009038ffbca00466305a9708ab21829477f64aa8cad4c64", size = 5803078, upload-time = "2026-08-21T06:08:38.362Z" },
+ { url = "https://files.pythonhosted.org/packages/0c/6f/0c9ba210f49f232289afaa8f06369c5f135ac786e1ca0cb22243b7f1fe2c/pyqwest-0.10.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:b5c80e88a5967c1cadb3237c450f91a84a3683f8838c8dca96f09fee3612e762", size = 5980431, upload-time = "2026-08-21T06:08:39.967Z" },
+ { url = "https://files.pythonhosted.org/packages/9b/f5/306eeed41a3cd3100247e6e442f4345277b70f1d24efe1641181b14839cd/pyqwest-0.10.0-cp313-cp313-win_amd64.whl", hash = "sha256:dc3d80b402fb59dbe015e25993ac8147456fb231a4c949f92a89f31315ad50f9", size = 4870198, upload-time = "2026-08-21T06:08:41.687Z" },
+ { url = "https://files.pythonhosted.org/packages/64/18/0086a408e7cbf39dab18fa5b7e42c969a98382da5a4e6debe40f05acc6a1/pyqwest-0.10.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:23a28beb55fa6d975949bffae4adfb69378f3229bb5cbd71231e95bf66f5b26c", size = 5274542, upload-time = "2026-08-21T06:08:43.323Z" },
+ { url = "https://files.pythonhosted.org/packages/0c/7b/e1b9aaaf7596e4faaa53cefc2efaca4e3cde721e308e6385e366361cfdcc/pyqwest-0.10.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:e4415ae40b8eedb1713dab14d7f9fecc3f79d26f3206c561087b88b99d5ce24b", size = 5127922, upload-time = "2026-08-21T06:08:45.116Z" },
+ { url = "https://files.pythonhosted.org/packages/75/cd/e6d68bb1de5dd26100fcfc878cbd67c402a928774edf1e8ae304c5a84f5b/pyqwest-0.10.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:14b875d2273212d7fa8e4b755d8d736ffd226b1c707a9c0017dfdc8393a96eca", size = 5645856, upload-time = "2026-08-21T06:08:47.055Z" },
+ { url = "https://files.pythonhosted.org/packages/f7/48/8c9f9f0467c41f6a563146d57a52f8f6d60c0cb09d0fd3ef88ad5a1c442f/pyqwest-0.10.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b5071491e416ea54e3b95bf9ffbed0bd065b093cb96e10a75c3d8f2cbe3c9823", size = 5569831, upload-time = "2026-08-21T06:08:48.78Z" },
+ { url = "https://files.pythonhosted.org/packages/16/ac/c85ab70c6c72078d49a82da76b820e46aaf95a3f6fe271dac955ac195d21/pyqwest-0.10.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b68b5e68d513a4c63a072f8f40e38015160cf90bfbf7e8ef7c3935ca87e9e022", size = 5806735, upload-time = "2026-08-21T06:08:50.652Z" },
+ { url = "https://files.pythonhosted.org/packages/66/db/ad7375b22fb2d0807431dcc9bc2aaf840e374c298cd15071024d5f6dd6d1/pyqwest-0.10.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:c48910d27820b9c46fcd001b0fe514a3cf47d4784f59512dcdb8c91c395f82e4", size = 5984786, upload-time = "2026-08-21T06:08:52.377Z" },
+ { url = "https://files.pythonhosted.org/packages/17/88/c449a772afe129683fd7acc657cbc7c69bec085dc75b6e8710a50fbb44e7/pyqwest-0.10.0-cp314-cp314-win_amd64.whl", hash = "sha256:d03ba2cd17948b623a6210981d342eb122546d8a8e910ec77511aff4b1acdd00", size = 4872288, upload-time = "2026-08-21T06:08:54.101Z" },
+ { url = "https://files.pythonhosted.org/packages/3d/f8/439ffc0ee12cd7d9b57ac07ccea78ad3ba66b0d6817d429dd661d73308c4/pyqwest-0.10.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:07a0eb595f4096232c2d22549b6e4612c1ecada7934e46462c2c37ce14a89cfb", size = 5256823, upload-time = "2026-08-21T06:08:55.681Z" },
+ { url = "https://files.pythonhosted.org/packages/df/2b/72ecd27d104d2b4284710194cce796d607674966c3ea66436768e812a66a/pyqwest-0.10.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:26401baf7dafc71c8d12d2e8389519d141e6f7c14094d0dd4cf9ec1d3b5555bd", size = 5112624, upload-time = "2026-08-21T06:08:57.366Z" },
+ { url = "https://files.pythonhosted.org/packages/a4/24/0fb89c3f7d5a0410fcb7560588b4c741ef24b19195c307d4263f04e75c2b/pyqwest-0.10.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e5e3c436e041d8873ce5bb0fdcf9f9e86f5604e8f0ef9e03149efebd8cb474f6", size = 5631844, upload-time = "2026-08-21T06:08:59.165Z" },
+ { url = "https://files.pythonhosted.org/packages/4c/4f/921d14754a186f0143ad62b50108dd808328e347e98e9dafab3897eeb405/pyqwest-0.10.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:09364115761579eabfc79d1e954cdb3ded508dac1903fac7285d4c6f058c683f", size = 5555869, upload-time = "2026-08-21T06:09:00.783Z" },
+ { url = "https://files.pythonhosted.org/packages/b8/70/504780417319a626fe9549a7e6f9020a3d448eddf8a09617238a3426e90c/pyqwest-0.10.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:559674a98a8b1217e1830ecd41c9905bf2b60983c6b8017063dfac199f00727c", size = 5793738, upload-time = "2026-08-21T06:09:03.324Z" },
+ { url = "https://files.pythonhosted.org/packages/45/f7/8d0a5b8a3289f4300dc9005ebde75d316f2371a2671617f942036337731a/pyqwest-0.10.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:f399a696392fff3db3eef0a18ef65b8a3b8396d129193487d966b8eb11006376", size = 5972889, upload-time = "2026-08-21T06:09:05.2Z" },
+ { url = "https://files.pythonhosted.org/packages/89/c4/f4c781e475c451cb5f4762a2f814a1750ef375b8db4db09bb2dcea03c4e5/pyqwest-0.10.0-cp314-cp314t-win_amd64.whl", hash = "sha256:0f9163d6dd991bf1bf27308ba38ba021af660b15fffa47ebca98e41cf6f00309", size = 4858036, upload-time = "2026-08-21T06:09:06.926Z" },
+ { url = "https://files.pythonhosted.org/packages/7a/6f/29f605665f33aab894db8daf11b3b64bdca015fb11135815c53a150191a6/pyqwest-0.10.0-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:cfcc7ba0229baa17831582befb046ace167b368140dae022d0b89b8d586ba12c", size = 5264903, upload-time = "2026-08-21T06:09:09.183Z" },
+ { url = "https://files.pythonhosted.org/packages/c8/c8/4ce4b40f21397a6482da910fae18b32c28dcdaddf3498aef2fe38597a9e0/pyqwest-0.10.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:eff9ccf427604d34c635954def07b6113d4754f968073eef2df40bdb80b05bf5", size = 5142291, upload-time = "2026-08-21T06:09:10.943Z" },
+ { url = "https://files.pythonhosted.org/packages/41/34/7205db9cddc5a2286da459ca013af629e20f09498751c60093b952379add/pyqwest-0.10.0-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:78662158093f9d5c742368f4dd9956aa595f44c6ac0860777c98853ddd5e1610", size = 5648118, upload-time = "2026-08-21T06:09:12.616Z" },
+ { url = "https://files.pythonhosted.org/packages/1e/94/75c6cd5d01cf68c99c0cfd66b165dce3fd9d9fce97cd4973117cab68355d/pyqwest-0.10.0-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0879a3f0b37876372aa328b9fee956165db174c1ab72464146fe515f49399ddb", size = 5565620, upload-time = "2026-08-21T06:09:14.223Z" },
+ { url = "https://files.pythonhosted.org/packages/7a/a6/20a910d2f096908cf25ec47fa84b35b1bbb1af63dc9713f53f38ed97802e/pyqwest-0.10.0-pp311-pypy311_pp73-musllinux_1_2_aarch64.whl", hash = "sha256:850b8de6ade09a60bdb2f969871a177c2c304b594b2034ba3f5962c7bea75551", size = 5809262, upload-time = "2026-08-21T06:09:15.897Z" },
+ { url = "https://files.pythonhosted.org/packages/e4/f7/f92fe4004d93f08c5118e750feefdbc72a73438b7fd7083300728e999911/pyqwest-0.10.0-pp311-pypy311_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:399802647ea646c6ac9b5460e541b7c209b7a13563c667b2690ded2060185f2e", size = 5985550, upload-time = "2026-08-21T06:09:17.579Z" },
+ { url = "https://files.pythonhosted.org/packages/47/3e/2c896e54dbe3f1ba6e3bd10e9d412f422a2e33827ff591d59f67b13e0fa5/pyqwest-0.10.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:c26f3de1feb5d066d7a66802a47407a93ba043696064ad80beda4a0a4bf10056", size = 4873529, upload-time = "2026-08-21T06:09:19.161Z" },
]
[[package]]