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"" -_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}{tool_calls}> +<{dsml}{invoke} name="$TOOL_NAME"> +<{dsml}{parameter} name="$PARAMETER_NAME" string="true|false">$PARAMETER_VALUE ... - -<{dsml}invoke name="$TOOL_NAME2"> + +<{dsml}{invoke} name="$TOOL_NAME2"> ... - - + + 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.DOTALL, -) -_INVOKE_RE = re.compile( - rf'<{re.escape(_DSML)}invoke name="([^"]+)">\n(.*?)\n', - re.DOTALL, -) -_PARAMETER_RE = re.compile( - rf'<{re.escape(_DSML)}parameter name="([^"]+)" string="(true|false)">' - rf"(.*?)" - rf"", - 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"" + f"" 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" + + f"\n" ) 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" -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", re.DOTALL) + invoke_re = re.compile(rf'<{invoke_tag} name="([^"]+)">\n(.*?)\n', re.DOTALL) + parameter_re = re.compile( + rf'<{parameter_tag} name="([^"]+)" string="(true|false)">(.*?)', + 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\n' + "\n" + _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", + "Malformed tool_calls body", + ) + invoke = 'Done.\n\n<|DSML| calls>\n<|DSML| invoke name="search">\n{}\n\n' + assert_unparsed( + invoke.format("<|DSML| parameter broken>"), + "Malformed tool parameter", + ) + assert_unparsed( + invoke.format( + '<|DSML| parameter name="q" string="true">first\n' + '<|DSML| parameter name="q" string="true">second' + ), + "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\n' + f'<{_DSML}parameter name="x" string="false">43\n' f"\n" f"" 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]]