diff --git a/README.md b/README.md
index ec7ae7f81..3d2b40eca 100644
--- a/README.md
+++ b/README.md
@@ -106,6 +106,7 @@ sh INSTALL_MEGATRON.sh
| Server startup scripts | transformers/megatron | [Script](cookbook/client/server) |
## Changelog
+- 🎉2026-08-12 The ModelScope training service has been deployed to [Qwen/Qwen3.8-27B](https://www.modelscope.cn/models/Qwen/Qwen3.8-27B).
- 🎉2026-08-04 Sandboxed multi-turn RL is now supported: run model-generated code in isolated [AgentENV](https://github.com/kvcache-ai/AgentENV) Firecracker microVMs, or in an OpenEnv server, with the same `train.py`. See the [cookbook](cookbook/rl/envs) and the [deployment guide](docs/source_en/Usage%20Guide/Agentic-RL-Deployment-and-Training.md).
- 🎉2026-05-20 Support DeepSeek-V4-Flash and DeepSeek-V4-Pro models.
- 🎉2026-05-20 Multi-turn rollout and tool calling in RL are now supported. The Cookbook is currently being written. You can use `from twinkle_agentic.rollout import MultiTurnRollout/APIMultiTurnRollout` directly for multi-turn rollout.
@@ -142,13 +143,16 @@ supported on Twinkle✨ framework.
> For serverless training service accessed via `base_url=https://www.modelscope.cn/twinkle`, it
> is currently provided via the Tinker-compatible APIs. We will be rolling out services that support
> both Tinker APIs, as well as the full-fledged Twinkle✨ native APIs. The serverless endpoint is backed
-> by one training base at a time, and currently it is [Qwen3.6-27B](https://modelscope.cn/models/Qwen/Qwen3.6-27B).
+> by one training base at a time, and currently it is [Qwen3.8-27B](https://modelscope.cn/models/Qwen/Qwen3.8-27B).
| Model Type | Model ID on [ModelScope](https://modelscope.cn) | Model Size | Requires | Support Megatron | HF Model ID |
|---------------------|-----------------------------------------------------------------------------------------------------------------|:---------------------------------------:|----------------------|:----------------:|:---------------------------------------------------------------------------------------------------------:|
| qwen3 series | [Qwen/Qwen3-14B-Base](https://modelscope.cn/models/Qwen/Qwen3-14B-Base) | 0.6B/1.7B/4B/8B/14B | transformers>=4.51 | ✔ | [Qwen/Qwen3-14B-Base](https://huggingface.co/Qwen/Qwen3-14B-Base) |
| | [Qwen/Qwen3-32B](https://modelscope.cn/models/Qwen/Qwen3-32B) | 0.6B/1.7B/4B/8B/14B/32B | transformers>=4.51 | ✔ | [Qwen/Qwen3-32B](https://huggingface.co/Qwen/Qwen3-32B) |
| qwen3_moe series | [Qwen/Qwen3-30B-A3B-Base](https://modelscope.cn/models/Qwen/Qwen3-30B-A3B-Base) | 30B-A3B/A3B-Base,235B-A22B | transformers>=4.51 | ✔ | [Qwen/Qwen3-30B-A3B-Base](https://huggingface.co/Qwen/Qwen3-30B-A3B-Base) |
+| qwen3.8 series | [Qwen/Qwen3.8-27B](https://www.modelscope.cn/models/Qwen/Qwen3.8-27B) | 27B | transformers>=5.2.0 | ✔ | [Qwen/Qwen3.8-27B](https://huggingface.co/Qwen/Qwen3.8-27B) |
+| qwen3.6 moe series | [Qwen/Qwen3.6-35B-A3B](https://www.modelscope.cn/models/Qwen/Qwen3.6-35B-A3B) | 35B-A3B, etc. | transformers>=5.2.0 | ✔ | [Qwen/Qwen3.6-35B-A3B](https://huggingface.co/Qwen/Qwen3.6-35B-A3B) |
+| qwen3.6 series | [Qwen/Qwen3.6-27B](https://www.modelscope.cn/models/Qwen/Qwen3.6-27B) | 4B ~ 27B | transformers>=5.2.0 | ✔ | [Qwen/Qwen3.6-27B](https://huggingface.co/Qwen/Qwen3.6-27B) |
| qwen3.5 moe series | [Qwen/Qwen3.5-35B-A3B](https://www.modelscope.cn/models/Qwen/Qwen3.5-35B-A3B) | 35B-A3B,122B-A10B, etc. | transformers>=5.2.0 | ✔ | [Qwen/Qwen3.5-35B-A3B](https://huggingface.co/Qwen/Qwen3.5-35B-A3B) |
| qwen3.5 series | [Qwen/Qwen3.5-9B](https://www.modelscope.cn/models/Qwen/Qwen3.5-9B) | 2B ~ 27B | transformers>=5.2.0 | ✔ | [Qwen/Qwen3.5-9B](https://huggingface.co/Qwen/Qwen3.5-9B) |
| qwen2 series | [Qwen/Qwen2-0.5B-Instruct](https://modelscope.cn/models/Qwen/Qwen2-0.5B-Instruct) | 0.5B/1.5B/7B/72B | transformers>=4.37 | ✔ | [Qwen/Qwen2-0.5B-Instruct](https://huggingface.co/Qwen/Qwen2-0.5B-Instruct) |
@@ -254,7 +258,7 @@ from twinkle.dataset import Dataset, DatasetMeta
from twinkle.preprocessor import SelfCognitionProcessor
from twinkle.server.common import input_feature_to_datum
-base_model = 'ms://Qwen/Qwen3.6-27B'
+base_model = 'ms://Qwen/Qwen3.8-27B'
base_url='your-base-url'
api_key='your-api-key'
diff --git a/README_ZH.md b/README_ZH.md
index 51de62dd1..f2f214f48 100644
--- a/README_ZH.md
+++ b/README_ZH.md
@@ -102,6 +102,7 @@ sh INSTALL_MEGATRON.sh
Twinkle✨支持相同的算法接口运行在单GPU、torchrun多机、Ray、Client等各场景下。其算法过程是外露的,非常便于修改和调试。完整的框架介绍请查看[快速开始](https://modelscope.github.io/twinkle-web/zh/docs/usage-guide/quick-start/)
## 更新日志
+- 🎉2026-08-12 ModelScope的训练服务部署为[Qwen/Qwen3.8-27B](https://www.modelscope.cn/models/Qwen/Qwen3.8-27B)。
- 🎉2026-08-04 支持沙箱环境下的多轮RL训练:模型生成的代码可在隔离的 [AgentENV](https://github.com/kvcache-ai/AgentENV) Firecracker microVM 或 OpenEnv 服务中执行,两个后端共用同一份 `train.py`。参考 [cookbook](cookbook/rl/envs) 和[部署文档](docs/source_zh/使用指引/Agentic%20RL部署与训练.md)。
- 🎉2026-05-20 支持DeepSeek-V4-Flash and DeepSeek-V4-Pro系列模型。
- 🎉2026-05-20 支持多轮rollout和RL中的工具调用,Cookbook正在编写中,可以直接使用`from twinkle_agentic.rollout import MultiTurnRollout/APIMultiTurnRollout`进行多轮rollout。
@@ -132,13 +133,16 @@ Twinkle✨支持相同的算法接口运行在单GPU、torchrun多机、Ray、Cl
随着新模型的发布,我们将添加对更多模型的支持。下表列出了 Twinkle✨ 框架当前支持的模型。
>[!Note]
-> 通过 `base_url=https://www.modelscope.cn/twinkle` 访问的无服务器训练服务,目前是通过兼容Tinker的API提供的。我们将陆续推出同时支持Tinker API和完整Twinkle✨原生 API的服务。无服务器端点每次由一个训练基座支持,目前使用的是[Qwen3.6-27B](https://modelscope.cn/models/Qwen/Qwen3.6-27B)。
+> 通过 `base_url=https://www.modelscope.cn/twinkle` 访问的无服务器训练服务,目前是通过兼容Tinker的API提供的。我们将陆续推出同时支持Tinker API和完整Twinkle✨原生 API的服务。无服务器端点每次由一个训练基座支持,目前使用的是[Qwen3.8-27B](https://modelscope.cn/models/Qwen/Qwen3.8-27B)。
| Model Type | Model ID 举例 | Model Size | Requires | Support Megatron | HF Model ID |
|---------------------|-----------------------------------------------------------------------------------------------------------------|:---------------------------------------:|----------------------|:----------------:|:---------------------------------------------------------------------------------------------------------:|
| qwen3 全系列 | [Qwen/Qwen3-14B-Base](https://modelscope.cn/models/Qwen/Qwen3-14B-Base) | 0.6B/1.7B/4B/8B/14B | transformers>=4.51 | ✔ | [Qwen/Qwen3-14B-Base](https://huggingface.co/Qwen/Qwen3-14B-Base) |
| | [Qwen/Qwen3-32B](https://modelscope.cn/models/Qwen/Qwen3-32B) | 0.6B/1.7B/4B/8B/14B/32B | transformers>=4.51 | ✔ | [Qwen/Qwen3-32B](https://huggingface.co/Qwen/Qwen3-32B) |
| qwen3_moe 全系列 | [Qwen/Qwen3-30B-A3B-Base](https://modelscope.cn/models/Qwen/Qwen3-30B-A3B-Base) | 30B-A3B/A3B-Base,235B-A22B | transformers>=4.51 | ✔ | [Qwen/Qwen3-30B-A3B-Base](https://huggingface.co/Qwen/Qwen3-30B-A3B-Base) |
+| qwen3.8 全系列 | [Qwen/Qwen3.8-27B](https://www.modelscope.cn/models/Qwen/Qwen3.8-27B) | 27B | transformers>=5.2.0 | ✔ | [Qwen/Qwen3.8-27B](https://huggingface.co/Qwen/Qwen3.8-27B) |
+| qwen3.6 moe 全系列 | [Qwen/Qwen3.6-35B-A3B](https://www.modelscope.cn/models/Qwen/Qwen3.6-35B-A3B) | 35B-A3B, etc. | transformers>=5.2.0 | ✔ | [Qwen/Qwen3.6-35B-A3B](https://huggingface.co/Qwen/Qwen3.6-35B-A3B) |
+| qwen3.6 全系列 | [Qwen/Qwen3.6-27B](https://www.modelscope.cn/models/Qwen/Qwen3.6-27B) | 4B ~ 27B | transformers>=5.2.0 | ✔ | [Qwen/Qwen3.6-27B](https://huggingface.co/Qwen/Qwen3.6-27B) |
| qwen3.5 moe 全系列 | [Qwen/Qwen3.5-35B-A3B](https://www.modelscope.cn/models/Qwen/Qwen3.5-35B-A3B) | 35B-A3B,122B-A10B, etc. | transformers>=5.2.0 | ✔ | [Qwen/Qwen3.5-35B-A3B](https://huggingface.co/Qwen/Qwen3.5-35B-A3B) |
| qwen3.5 全系列 | [Qwen/Qwen3.5-9B](https://www.modelscope.cn/models/Qwen/Qwen3.5-9B) | 2B ~ 27B | transformers>=5.2.0 | ✔ | [Qwen/Qwen3.5-9B](https://huggingface.co/Qwen/Qwen3.5-9B) |
| qwen2 全系列 | [Qwen/Qwen2-0.5B-Instruct](https://modelscope.cn/models/Qwen/Qwen2-0.5B-Instruct) | 0.5B/1.5B/7B/72B | transformers>=4.37 | ✔ | [Qwen/Qwen2-0.5B-Instruct](https://huggingface.co/Qwen/Qwen2-0.5B-Instruct) |
@@ -243,7 +247,7 @@ from twinkle.dataset import Dataset, DatasetMeta
from twinkle.preprocessor import SelfCognitionProcessor
from twinkle.server.common import input_feature_to_datum
-base_model = 'ms://Qwen/Qwen3.6-27B'
+base_model = 'ms://Qwen/Qwen3.8-27B'
base_url='your-base-url'
api_key='your-api-key'
diff --git a/cookbook/client/server/megatron/server_config.yaml b/cookbook/client/server/megatron/server_config.yaml
index 67ef5fdaa..759601985 100644
--- a/cookbook/client/server/megatron/server_config.yaml
+++ b/cookbook/client/server/megatron/server_config.yaml
@@ -39,7 +39,7 @@ applications:
server_config:
per_token_model_limit: 3 # Maximum number of models (adapters) per token (server-globally enforced)
supported_models:
- - Qwen/Qwen3.6-27B
+ - Qwen/Qwen3.8-27B
deployments:
- name: TinkerCompatServer
@@ -59,11 +59,11 @@ applications:
# 3. Sampler Service - Runs inference / sampling using vLLM engine
# Used for generating text from the model (e.g., evaluating LoRA results).
# Config: TP=2 x DP=2 on 4 GPUs, ~27GB weights/GPU, ~37GB for KV cache + LoRA
- - name: sampler-Qwen3.6-27B
- route_prefix: /api/v1/sampler/Qwen/Qwen3.6-27B
+ - name: sampler-Qwen3.8-27B
+ route_prefix: /api/v1/sampler/Qwen/Qwen3.8-27B
import_path: sampler
args:
- model_id: "ms://Qwen/Qwen3.6-27B" # ModelScope model identifier
+ model_id: "ms://Qwen/Qwen3.8-27B" # ModelScope model identifier
nproc_per_node: 4 # Number of GPU processes per node
sampler_type: vllm # Inference engine: 'vllm' (fast) or 'torch' (TorchSampler)
engine_args: # vLLM engine-specific settings
@@ -102,12 +102,12 @@ applications:
# 2. Model Service - Hosts the base model for training.
# Config: PP=2 x DP=2 on 4 GPUs, ~27GB weights/GPU, comfortable for LoRA training
- - name: models-Qwen3.6-27B
- route_prefix: /api/v1/model/Qwen/Qwen3.6-27B
+ - name: models-Qwen3.8-27B
+ route_prefix: /api/v1/model/Qwen/Qwen3.8-27B
import_path: model
args:
backend: megatron # Use Megatron-LM backend
- model_id: "ms://Qwen/Qwen3.6-27B" # ModelScope model identifier
+ model_id: "ms://Qwen/Qwen3.8-27B" # ModelScope model identifier
max_length: 32768 # model max length
max_loras: 3 # model max loras
nproc_per_node: 4 # Number of GPU processes per node
diff --git a/docs/source_en/Components/Model/SupportedModels.md b/docs/source_en/Components/Model/SupportedModels.md
index 7cd9e8b4d..3e868816a 100644
--- a/docs/source_en/Components/Model/SupportedModels.md
+++ b/docs/source_en/Components/Model/SupportedModels.md
@@ -6,6 +6,8 @@ Twinkle supports any model compatible with HuggingFace Transformers or Megatron-
| Model Family | Model IDs | Parameters | Features |
|:-------------|:----------|:-----------|:---------|
+| Qwen 3.8 | `Qwen/Qwen3.8-27B` | 27B | Thinking mode |
+| Qwen 3.6 | `Qwen/Qwen3.6-27B`, `Qwen/Qwen3.6-35B-A3B` | 27B, 35B-A3B | MoE, Thinking mode |
| Qwen 3.5 | `Qwen/Qwen3.5-0.6B` ~ `Qwen/Qwen3.5-235B-A22B` | 0.6B–235B | MoE, Thinking mode |
| Qwen 2.5 | `Qwen/Qwen2.5-0.5B` ~ `Qwen/Qwen2.5-72B` | 0.5B–72B | Dense |
| DeepSeek V4 | `deepseek-ai/DeepSeek-V4` | 685B MoE | Custom DSML encoding |
diff --git a/docs/source_en/Usage Guide/Introduction-with-Qwen3.5.md b/docs/source_en/Usage Guide/Introduction-with-Qwen3.5.md
index 6f402823d..22dc07dfc 100644
--- a/docs/source_en/Usage Guide/Introduction-with-Qwen3.5.md
+++ b/docs/source_en/Usage Guide/Introduction-with-Qwen3.5.md
@@ -465,9 +465,9 @@ init_tinker_client()
from tinker import ServiceClient
-# Base model
-base_model = 'Qwen/Qwen3.5-4B'
-base_url = 'http://www.modelscope.cn/twinkle'
+# Base model (currently deployed in the ModelScope official environment)
+base_model = 'Qwen/Qwen3.8-27B'
+base_url = 'https://www.modelscope.cn/twinkle'
def train():
@@ -530,7 +530,7 @@ Alongside the open-source release of Twinkle, ModelScope provides a hosted model
```python
base_url = 'https://www.modelscope.cn/twinkle'
-base_model = 'Qwen/Qwen3.5-4B' # Model currently deployed in the official environment
+base_model = 'Qwen/Qwen3.8-27B' # Model currently deployed in the official environment
```
---
diff --git a/docs/source_en/Usage Guide/Server and Client/Overview.md b/docs/source_en/Usage Guide/Server and Client/Overview.md
index 3c45673f0..b09d86145 100644
--- a/docs/source_en/Usage Guide/Server and Client/Overview.md
+++ b/docs/source_en/Usage Guide/Server and Client/Overview.md
@@ -106,5 +106,5 @@ The same examples work with the ModelScope managed service by changing only the
```bash
export TWINKLE_SERVER_URL=https://www.modelscope.cn/twinkle
export TWINKLE_SERVER_TOKEN="$MODELSCOPE_TOKEN"
-export TWINKLE_MODEL_ID=Qwen/Qwen3.6-27B
+export TWINKLE_MODEL_ID=Qwen/Qwen3.8-27B
```
diff --git a/docs/source_en/Usage Guide/Train-as-a-Service.md b/docs/source_en/Usage Guide/Train-as-a-Service.md
index 57ba26fbc..286b52001 100644
--- a/docs/source_en/Usage Guide/Train-as-a-Service.md
+++ b/docs/source_en/Usage Guide/Train-as-a-Service.md
@@ -2,7 +2,7 @@
Alongside the open-source release of the Twinkle framework, we also provide a hosted model training service (Training as a Service) powered by ModelScope's backend infrastructure. Developers can use this service to experience Twinkle's training API for free.
-The model currently running on the cluster is [Qwen/Qwen3.6-27B](https://www.modelscope.cn/models/Qwen/Qwen3.6-27B). Below are the detailed usage instructions:
+The model currently running on the cluster is [Qwen/Qwen3.8-27B](https://www.modelscope.cn/models/Qwen/Qwen3.8-27B). Below are the detailed usage instructions:
## Step 1. Register a ModelScope Account and Obtain Your API Key
@@ -30,7 +30,7 @@ from twinkle.dataset import Dataset, DatasetMeta
from twinkle.preprocessor import SelfCognitionProcessor
from twinkle.server.common import input_feature_to_datum
-base_model = 'ms://Qwen/Qwen3.6-27B'
+base_model = 'ms://Qwen/Qwen3.8-27B'
base_url='https://www.modelscope.cn/twinkle'
api_key=os.environ.get('MODELSCOPE_TOKEN')
@@ -64,7 +64,7 @@ for epoch in range(2):
print(f'Saved checkpoint for epoch {epoch} to {result.path}')
```
-With the code above, you can train a self-cognition LoRA based on `Qwen/Qwen3.6-27B`. This LoRA will change the model's name and creator to the names specified during training. To perform inference using this LoRA:
+With the code above, you can train a self-cognition LoRA based on `Qwen/Qwen3.8-27B`. This LoRA will change the model's name and creator to the names specified during training. To perform inference using this LoRA:
```python
import os
@@ -79,7 +79,7 @@ init_tinker_client()
from tinker import ServiceClient
-base_model = 'Qwen/Qwen3.6-27B'
+base_model = 'Qwen/Qwen3.8-27B'
base_url = 'https://www.modelscope.cn/twinkle'
# Step 2: Define the base model and connect to the server
@@ -92,7 +92,7 @@ service_client = ServiceClient(
# The model_path is a twinkle:// URI pointing to a previously saved LoRA checkpoint.
# The server will load the base model and apply the LoRA adapter weights.
sampling_client = service_client.create_sampling_client(
- model_path='twinkle://xxx-Qwen_Qwen3.6-35B-A3B-xxx/weights/twinkle-lora-1',
+ model_path='twinkle://xxx-Qwen_Qwen3.8-27B-xxx/weights/twinkle-lora-1',
base_model=base_model
)
diff --git "a/docs/source_zh/\344\275\277\347\224\250\346\214\207\345\274\225/Qwen3.5\346\234\200\344\275\263\345\256\236\350\267\265.md" "b/docs/source_zh/\344\275\277\347\224\250\346\214\207\345\274\225/Qwen3.5\346\234\200\344\275\263\345\256\236\350\267\265.md"
index fd5561085..c80b1e15d 100644
--- "a/docs/source_zh/\344\275\277\347\224\250\346\214\207\345\274\225/Qwen3.5\346\234\200\344\275\263\345\256\236\350\267\265.md"
+++ "b/docs/source_zh/\344\275\277\347\224\250\346\214\207\345\274\225/Qwen3.5\346\234\200\344\275\263\345\256\236\350\267\265.md"
@@ -465,9 +465,9 @@ init_tinker_client()
from tinker import ServiceClient
-# 基座模型
-base_model = 'Qwen/Qwen3.5-4B'
-base_url = 'http://www.modelscope.cn/twinkle'
+# 基座模型(魔搭官方环境当前部署的模型)
+base_model = 'Qwen/Qwen3.8-27B'
+base_url = 'https://www.modelscope.cn/twinkle'
def train():
@@ -530,7 +530,7 @@ Twinkle 框架开源的同时,魔搭社区依托自身算力基础设施,提
```python
base_url = 'https://www.modelscope.cn/twinkle'
-base_model = 'Qwen/Qwen3.5-4B' # 官方环境当前部署的模型
+base_model = 'Qwen/Qwen3.8-27B' # 官方环境当前部署的模型
```
---
diff --git "a/docs/source_zh/\344\275\277\347\224\250\346\214\207\345\274\225/\346\234\215\345\212\241\347\253\257\345\222\214\345\256\242\346\210\267\347\253\257/\346\246\202\350\277\260.md" "b/docs/source_zh/\344\275\277\347\224\250\346\214\207\345\274\225/\346\234\215\345\212\241\347\253\257\345\222\214\345\256\242\346\210\267\347\253\257/\346\246\202\350\277\260.md"
index 5d3044e0c..eace748c1 100644
--- "a/docs/source_zh/\344\275\277\347\224\250\346\214\207\345\274\225/\346\234\215\345\212\241\347\253\257\345\222\214\345\256\242\346\210\267\347\253\257/\346\246\202\350\277\260.md"
+++ "b/docs/source_zh/\344\275\277\347\224\250\346\214\207\345\274\225/\346\234\215\345\212\241\347\253\257\345\222\214\345\256\242\346\210\267\347\253\257/\346\246\202\350\277\260.md"
@@ -106,5 +106,5 @@ python cookbook/client/twinkle/self_cognition.py
```bash
export TWINKLE_SERVER_URL=https://www.modelscope.cn/twinkle
export TWINKLE_SERVER_TOKEN="$MODELSCOPE_TOKEN"
-export TWINKLE_MODEL_ID=Qwen/Qwen3.6-27B
+export TWINKLE_MODEL_ID=Qwen/Qwen3.8-27B
```
diff --git "a/docs/source_zh/\344\275\277\347\224\250\346\214\207\345\274\225/\350\256\255\347\273\203\346\234\215\345\212\241.md" "b/docs/source_zh/\344\275\277\347\224\250\346\214\207\345\274\225/\350\256\255\347\273\203\346\234\215\345\212\241.md"
index c5db28d3f..c57cf066b 100644
--- "a/docs/source_zh/\344\275\277\347\224\250\346\214\207\345\274\225/\350\256\255\347\273\203\346\234\215\345\212\241.md"
+++ "b/docs/source_zh/\344\275\277\347\224\250\346\214\207\345\274\225/\350\256\255\347\273\203\346\234\215\345\212\241.md"
@@ -3,7 +3,7 @@
在 Twinkle 框架开源的同时,我们依托ModelScope的后台服务,也提供了托管的模型训练服务(Training as a Service),开发者可以通过这一服务,
免费体验Twinkle的训练API。
-目前在集群中运行的模型是[Qwen/Qwen3.6-27B](https://www.modelscope.cn/models/Qwen/Qwen3.6-27B)。下面介绍具体的使用方法:
+目前在集群中运行的模型是[Qwen/Qwen3.8-27B](https://www.modelscope.cn/models/Qwen/Qwen3.8-27B)。下面介绍具体的使用方法:
## Step 1. 注册ModelScope用户并获取 API Key
@@ -31,7 +31,7 @@ from twinkle.dataset import Dataset, DatasetMeta
from twinkle.preprocessor import SelfCognitionProcessor
from twinkle.server.common import input_feature_to_datum
-base_model = 'ms://Qwen/Qwen3.6-27B'
+base_model = 'ms://Qwen/Qwen3.8-27B'
base_url='https://www.modelscope.cn/twinkle'
api_key=os.environ.get('MODELSCOPE_TOKEN')
@@ -65,7 +65,7 @@ for epoch in range(2):
print(f'Saved checkpoint for epoch {epoch} to {result.path}')
```
-通过上述代码,你可以训练一个原模型为`Qwen/Qwen3.6-27B`的自我认知lora。这个lora会改变模型的名称和制造者为训练时指定的名称。使用这个lora进行推理:
+通过上述代码,你可以训练一个原模型为`Qwen/Qwen3.8-27B`的自我认知lora。这个lora会改变模型的名称和制造者为训练时指定的名称。使用这个lora进行推理:
```python
import os
@@ -80,7 +80,7 @@ init_tinker_client()
from tinker import ServiceClient
-base_model = 'Qwen/Qwen3.6-27B'
+base_model = 'Qwen/Qwen3.8-27B'
base_url = 'https://www.modelscope.cn/twinkle'
# Step 2: Define the base model and connect to the server
@@ -93,7 +93,7 @@ service_client = ServiceClient(
# The model_path is a twinkle:// URI pointing to a previously saved LoRA checkpoint.
# The server will load the base model and apply the LoRA adapter weights.
sampling_client = service_client.create_sampling_client(
- model_path='twinkle://xxx-Qwen_Qwen3.6-35B-A3B-xxx/weights/twinkle-lora-1',
+ model_path='twinkle://xxx-Qwen_Qwen3.8-27B-xxx/weights/twinkle-lora-1',
base_model=base_model
)
diff --git "a/docs/source_zh/\347\273\204\344\273\266/\346\250\241\345\236\213/SupportedModels.md" "b/docs/source_zh/\347\273\204\344\273\266/\346\250\241\345\236\213/SupportedModels.md"
index bfbb03ea0..f932ff8a1 100644
--- "a/docs/source_zh/\347\273\204\344\273\266/\346\250\241\345\236\213/SupportedModels.md"
+++ "b/docs/source_zh/\347\273\204\344\273\266/\346\250\241\345\236\213/SupportedModels.md"
@@ -6,6 +6,8 @@ Twinkle 支持任何兼容 HuggingFace Transformers 或 Megatron-LM 的模型。
| 模型系列 | 模型 ID | 参数量 | 特性 |
|:---------|:--------|:-------|:-----|
+| Qwen 3.8 | `Qwen/Qwen3.8-27B` | 27B | 思考模式 |
+| Qwen 3.6 | `Qwen/Qwen3.6-27B`, `Qwen/Qwen3.6-35B-A3B` | 27B、35B-A3B | MoE、思考模式 |
| Qwen 3.5 | `Qwen/Qwen3.5-0.6B` ~ `Qwen/Qwen3.5-235B-A22B` | 0.6B–235B | MoE、思考模式 |
| Qwen 2.5 | `Qwen/Qwen2.5-0.5B` ~ `Qwen/Qwen2.5-72B` | 0.5B–72B | Dense |
| DeepSeek V4 | `deepseek-ai/DeepSeek-V4` | 685B MoE | 自定义 DSML 编码 |
diff --git a/notebook/dpo.ipynb b/notebook/dpo.ipynb
index caeb65824..d2a13cfa5 100644
--- a/notebook/dpo.ipynb
+++ b/notebook/dpo.ipynb
@@ -95,7 +95,7 @@
"\n",
"| 配置项 | 默认值 | 说明 |\n",
"|--------|--------|------|\n",
- "| `BASE_MODEL` | Qwen/Qwen3.6-27B | 基座模型 |\n",
+ "| `BASE_MODEL` | Qwen/Qwen3.8-27B | 基座模型 |\n",
"| `BATCH_SIZE` | 4 | 每步处理的 DPO 样本对数 |\n",
"| `LEARNING_RATE` | 1e-4 | 学习率 |\n",
"| `DPO_BETA` | 0.1 | DPO 温度系数,控制偏好强度 |\n",
@@ -149,8 +149,8 @@
"logger = get_logger()\n",
"\n",
"# ========== 全局配置 ==========\n",
- "BASE_MODEL = 'Qwen/Qwen3.6-27B'\n",
- "BASE_URL = 'http://www.modelscope.cn/twinkle'\n",
+ "BASE_MODEL = 'Qwen/Qwen3.8-27B'\n",
+ "BASE_URL = 'https://www.modelscope.cn/twinkle'\n",
"API_KEY = getpass(\"ModelScope Token: \")\n",
"DATASET_ID = 'ms://hjh0119/shareAI-Llama3-DPO-zh-en-emoji'\n",
"\n",
@@ -484,7 +484,7 @@
"\n",
"logger = get_logger()\n",
"\n",
- "BASE_MODEL = 'Qwen/Qwen3.6-27B'\n",
+ "BASE_MODEL = 'Qwen/Qwen3.8-27B'\n",
"\n",
"# TODO: 替换为训练输出的检查点路径\n",
"weight_path = '<替换为你的 twinkle:// 检查点路径>' # 例如: save_result.path\n",
@@ -493,7 +493,7 @@
"from tinker import ServiceClient\n",
"\n",
"service_client = ServiceClient(\n",
- " base_url='http://www.modelscope.cn/twinkle',\n",
+ " base_url='https://www.modelscope.cn/twinkle',\n",
" api_key=API_KEY,\n",
")\n",
"\n",
@@ -548,7 +548,7 @@
"from peft import PeftModel\n",
"from transformers import AutoModelForCausalLM, AutoTokenizer\n",
"\n",
- "base_model_id = 'Qwen/Qwen3.6-27B'\n",
+ "base_model_id = 'Qwen/Qwen3.8-27B'\n",
"lora_path = '<替换为你的 LoRA 检查点路径>'\n",
"output_dir = '<替换为输出目录>'\n",
"\n",
@@ -582,7 +582,7 @@
"vllm serve <输出目录> --tensor-parallel-size 2\n",
"```\n",
"\n",
- "> **提示**:对于 Dense 模型(如 Qwen3.6-27B),LoRA 权重可以直接通过 vLLM 的 `enable_lora` 加载,无需合并。只有在不支持动态 LoRA 的部署场景下才需要合并。\n"
+ "> **提示**:对于 Dense 模型(如 Qwen3.8-27B),LoRA 权重可以直接通过 vLLM 的 `enable_lora` 加载,无需合并。只有在不支持动态 LoRA 的部署场景下才需要合并。\n"
],
"id": "peft_merge_2"
},
diff --git a/notebook/multi_modal.ipynb b/notebook/multi_modal.ipynb
index 4c88d5855..9bb52467f 100644
--- a/notebook/multi_modal.ipynb
+++ b/notebook/multi_modal.ipynb
@@ -31,7 +31,7 @@
"\n",
"| 条件 | 说明 |\n",
"|------|------|\n",
- "| 服务端已启动 | 需支持多模态模型(如 Qwen3.6 系列) |\n",
+ "| 服务端已启动 | 需支持多模态模型(如 Qwen3.8 系列) |\n",
"| 环境变量 | `.env` 文件或环境中设置 `MODELSCOPE_TOKEN` |\n",
"| 依赖安装 | `pip install twinkle-kit[tinker]` |\n",
"\n",
@@ -149,8 +149,8 @@
"metadata": {},
"outputs": [],
"source": [
- "base_model = 'Qwen/Qwen3.6-27B'\n",
- "base_url = 'http://www.modelscope.cn/twinkle'\n",
+ "base_model = 'Qwen/Qwen3.8-27B'\n",
+ "base_url = 'https://www.modelscope.cn/twinkle'\n",
"\n",
"client = init_twinkle_client(base_url=base_url, api_key=api_key)\n",
"\n",
@@ -413,7 +413,7 @@
"\n",
"logger = get_logger()\n",
"\n",
- "BASE_MODEL = 'Qwen/Qwen3.6-27B'\n",
+ "BASE_MODEL = 'Qwen/Qwen3.8-27B'\n",
"\n",
"# TODO: 替换为训练输出的检查点路径\n",
"weight_path = '<替换为你的 twinkle:// 检查点路径>'\n",
@@ -422,7 +422,7 @@
"from tinker import ServiceClient\n",
"\n",
"service_client = ServiceClient(\n",
- " base_url='http://www.modelscope.cn/twinkle',\n",
+ " base_url='https://www.modelscope.cn/twinkle',\n",
" api_key=api_key,\n",
")\n",
"\n",
@@ -476,7 +476,7 @@
"from peft import PeftModel\n",
"from transformers import AutoModelForCausalLM, AutoTokenizer\n",
"\n",
- "base_model_id = 'Qwen/Qwen3.6-27B'\n",
+ "base_model_id = 'Qwen/Qwen3.8-27B'\n",
"lora_path = '<替换为你的 LoRA 检查点路径>'\n",
"output_dir = '<替换为输出目录>'\n",
"\n",
@@ -510,7 +510,7 @@
"vllm serve <输出目录> --tensor-parallel-size 2\n",
"```\n",
"\n",
- "> **提示**:对于 Dense 模型(如 Qwen3.6-27B),LoRA 权重可以直接通过 vLLM 的 `enable_lora` 加载,无需合并。只有在不支持动态 LoRA 的部署场景下才需要合并。\n"
+ "> **提示**:对于 Dense 模型(如 Qwen3.8-27B),LoRA 权重可以直接通过 vLLM 的 `enable_lora` 加载,无需合并。只有在不支持动态 LoRA 的部署场景下才需要合并。\n"
],
"id": "peft_merge_2"
},
diff --git a/notebook/sample.ipynb b/notebook/sample.ipynb
index 34bd2d974..5c3c18b1b 100644
--- a/notebook/sample.ipynb
+++ b/notebook/sample.ipynb
@@ -108,7 +108,7 @@
"\n",
"logger = get_logger()\n",
"\n",
- "BASE_MODEL = 'Qwen/Qwen3.6-27B'\n",
+ "BASE_MODEL = 'Qwen/Qwen3.8-27B'\n",
"\n",
"# TODO: 替换为你的训练检查点路径\n",
"weight_path = '<替换为你的 twinkle:// 检查点路径>' # 例如: 'twinkle://xxx/weights/twinkle-lora-2'\n",
@@ -139,7 +139,7 @@
"from tinker import ServiceClient\n",
"\n",
"service_client = ServiceClient(\n",
- " base_url='http://www.modelscope.cn/twinkle',\n",
+ " base_url='https://www.modelscope.cn/twinkle',\n",
" api_key=api_key,\n",
")\n",
"\n",
@@ -261,7 +261,7 @@
"from peft import PeftModel\n",
"from transformers import AutoModelForCausalLM, AutoTokenizer\n",
"\n",
- "base_model_id = 'Qwen/Qwen3.6-27B'\n",
+ "base_model_id = 'Qwen/Qwen3.8-27B'\n",
"lora_path = '<替换为你的 LoRA 检查点路径>'\n",
"output_dir = '<替换为输出目录>'\n",
"\n",
@@ -295,7 +295,7 @@
"vllm serve <输出目录> --tensor-parallel-size 2\n",
"```\n",
"\n",
- "> **提示**:对于 Dense 模型(如 Qwen3.6-27B),LoRA 权重可以直接通过 vLLM 的 `enable_lora` 加载,无需合并。只有在不支持动态 LoRA 的部署场景下才需要合并。\n"
+ "> **提示**:对于 Dense 模型(如 Qwen3.8-27B),LoRA 权重可以直接通过 vLLM 的 `enable_lora` 加载,无需合并。只有在不支持动态 LoRA 的部署场景下才需要合并。\n"
],
"id": "peft_merge_2"
},
diff --git a/notebook/self_cognition.ipynb b/notebook/self_cognition.ipynb
index 5b1d91a75..5e3383f13 100644
--- a/notebook/self_cognition.ipynb
+++ b/notebook/self_cognition.ipynb
@@ -153,8 +153,8 @@
"\n",
"from tinker import ServiceClient\n",
"\n",
- "base_model = 'Qwen/Qwen3.6-27B'\n",
- "base_url = 'http://www.modelscope.cn/twinkle'\n",
+ "base_model = 'Qwen/Qwen3.8-27B'\n",
+ "base_url = 'https://www.modelscope.cn/twinkle'\n",
"api_key = getpass(\"ModelScope Token: \")"
]
},
@@ -427,7 +427,7 @@
"from peft import PeftModel\n",
"from transformers import AutoModelForCausalLM, AutoTokenizer\n",
"\n",
- "base_model_id = 'Qwen/Qwen3.6-27B'\n",
+ "base_model_id = 'Qwen/Qwen3.8-27B'\n",
"lora_path = '<替换为你的 LoRA 检查点路径>'\n",
"output_dir = '<替换为输出目录>'\n",
"\n",
@@ -461,7 +461,7 @@
"vllm serve <输出目录> --tensor-parallel-size 2\n",
"```\n",
"\n",
- "> **提示**:对于 Dense 模型(如 Qwen3.6-27B),LoRA 权重可以直接通过 vLLM 的 `enable_lora` 加载,无需合并。只有在不支持动态 LoRA 的部署场景下才需要合并。\n"
+ "> **提示**:对于 Dense 模型(如 Qwen3.8-27B),LoRA 权重可以直接通过 vLLM 的 `enable_lora` 加载,无需合并。只有在不支持动态 LoRA 的部署场景下才需要合并。\n"
]
},
{
diff --git a/notebook/short_math_grpo.ipynb b/notebook/short_math_grpo.ipynb
index f7344bcfc..a05766789 100644
--- a/notebook/short_math_grpo.ipynb
+++ b/notebook/short_math_grpo.ipynb
@@ -90,7 +90,7 @@
"\n",
"| 配置项 | 默认值 | 说明 |\n",
"|--------|--------|------|\n",
- "| `MODEL_ID` | ms://Qwen/Qwen3.6-27B | 基座模型(需加 `ms://` 前缀) |\n",
+ "| `MODEL_ID` | ms://Qwen/Qwen3.8-27B | 基座模型(需加 `ms://` 前缀) |\n",
"| `NUM_GENERATIONS` | 4 | 每个 prompt 生成几条回答 |\n",
"| `MAX_NEW_TOKENS` | 1024 | 单条回答最大 token 数 |\n",
"| `LEARNING_RATE` | 2e-5 | 学习率 |\n",
@@ -149,7 +149,7 @@
"logger = get_logger()\n",
"\n",
"# ========== 全局配置 ==========\n",
- "MODEL_ID = 'ms://Qwen/Qwen3.6-27B'\n",
+ "MODEL_ID = 'ms://Qwen/Qwen3.8-27B'\n",
"NUM_GENERATIONS = 4\n",
"MAX_NEW_TOKENS = 1024\n",
"LEARNING_RATE = 2e-5\n",
@@ -339,7 +339,7 @@
"source": [
"# 初始化 Twinkle 客户端\n",
"client = init_twinkle_client(\n",
- " base_url='http://www.modelscope.cn/twinkle',\n",
+ " base_url='https://www.modelscope.cn/twinkle',\n",
" api_key=API_KEY,\n",
")\n",
"\n",
@@ -532,7 +532,7 @@
"from twinkle_client.model import MultiLoraTransformersModel\n",
"# 步骤 1:初始化 Twinkle 客户端。\n",
"# Tinker 检查点(twinkle:// 路径)由同一检查点服务解析\n",
- "init_twinkle_client(base_url='http://www.modelscope.cn/twinkle', api_key=API_KEY)\n",
+ "init_twinkle_client(base_url='https://www.modelscope.cn/twinkle', api_key=API_KEY)\n",
"\n",
"# 步骤 2:创建模型客户端(上传时无需训练状态)\n",
"model = MultiLoraTransformersModel(model_id=MODEL_ID)\n",
@@ -579,7 +579,7 @@
"\n",
"logger = get_logger()\n",
"\n",
- "BASE_MODEL = 'Qwen/Qwen3.6-27B'\n",
+ "BASE_MODEL = 'Qwen/Qwen3.8-27B'\n",
"\n",
"# TODO: 替换为训练输出的检查点路径\n",
"weight_path = '<替换为你的 twinkle:// 检查点路径>' # 例如: save_result.path\n",
@@ -588,7 +588,7 @@
"from tinker import ServiceClient\n",
"\n",
"service_client = ServiceClient(\n",
- " base_url='http://www.modelscope.cn/twinkle',\n",
+ " base_url='https://www.modelscope.cn/twinkle',\n",
" api_key=API_KEY,\n",
")\n",
"\n",
@@ -646,7 +646,7 @@
"from peft import PeftModel\n",
"from transformers import AutoModelForCausalLM, AutoTokenizer\n",
"\n",
- "base_model_id = 'Qwen/Qwen3.6-27B'\n",
+ "base_model_id = 'Qwen/Qwen3.8-27B'\n",
"lora_path = '<替换为你的 LoRA 检查点路径>'\n",
"output_dir = '<替换为输出目录>'\n",
"\n",
@@ -678,7 +678,7 @@
"vllm serve <输出目录> --tensor-parallel-size 2\n",
"```\n",
"\n",
- "> **提示**:对于 Dense 模型(如 Qwen3.6-27B),LoRA 权重可以直接通过 vLLM 的 `enable_lora` 加载,无需合并。只有在不支持动态 LoRA 的部署场景下才需要合并。\n"
+ "> **提示**:对于 Dense 模型(如 Qwen3.8-27B),LoRA 权重可以直接通过 vLLM 的 `enable_lora` 加载,无需合并。只有在不支持动态 LoRA 的部署场景下才需要合并。\n"
]
},
{
diff --git a/src/twinkle/server/utils/template_utils.py b/src/twinkle/server/utils/template_utils.py
index c593a8c07..839242521 100644
--- a/src/twinkle/server/utils/template_utils.py
+++ b/src/twinkle/server/utils/template_utils.py
@@ -8,9 +8,14 @@
# Template mapping for different model families
# Key: model name pattern to match, Value: template name
+# Qwen3.6 / Qwen3.8 share Qwen3.5's architecture (``Qwen3_5ForConditionalGeneration``,
+# ``model_type: qwen3_5``) and therefore its template. Qwen3.8 differs from Qwen3.6
+# only in chat_template semantics (``reasoning_effort``, ``preserve_thinking`` default),
+# which the jinja template itself handles.
MODEL_TEMPLATE_MAPPING = {
'Qwen3.5': 'Qwen3_5Template',
'Qwen3.6': 'Qwen3_5Template',
+ 'Qwen3.8': 'Qwen3_5Template',
# Add more model-template mappings here as needed
# 'ModelName': 'TemplateName',
}
diff --git a/tests/template/test_qwen3_5_template_consistency.py b/tests/template/test_qwen3_5_template_consistency.py
new file mode 100644
index 000000000..d479d53a5
--- /dev/null
+++ b/tests/template/test_qwen3_5_template_consistency.py
@@ -0,0 +1,369 @@
+# Copyright (c) ModelScope Contributors. All rights reserved.
+"""Tests for the Qwen3.5-family template registration and encode consistency.
+
+Two layers:
+
+ - ``TestModelTemplateMapping`` is a pure unit test over
+ ``MODEL_TEMPLATE_MAPPING``; no model files needed, always runs.
+ - ``TestEncodeMatchesApplyChatTemplate`` asserts that
+ ``Template.encode()`` reproduces ``processor.apply_chat_template()``
+ byte-for-byte. It is parametrized over whichever Qwen3.5-family models
+ are present locally (Qwen3.5 / Qwen3.6 / Qwen3.8 all report
+ ``model_type: qwen3_5*``), and skips when none are available.
+
+Why the consistency layer matters: ``encode`` routes through
+``apply_chat_template`` but first applies ``_to_standard_reasoning_content``
+(inline ```` -> ``reasoning_content`` field) and the Qwen3 template
+patches. Any of those can silently desynchronize the rendered text from the
+token stream, which breaks multi-turn rollout bridge alignment. Qwen3.8 makes
+this concrete: it dropped the template's inline ```` parsing and
+flipped the ``preserve_thinking`` default to true, so feeding raw inline
+thinking straight to ``apply_chat_template`` yields a doubly-nested
+```` block. ``test_inline_thinking_is_normalized_not_double_wrapped``
+pins down that twinkle's normalization prevents exactly that.
+
+Point the tests at specific checkouts with
+``TWINKLE_TEST_QWEN35_MODEL_DIRS=/path/a:/path/b``.
+"""
+import json
+import os
+import pytest
+import re
+from pathlib import Path
+from typing import List, Tuple
+
+import twinkle
+from twinkle.data_format import Message, Trajectory
+from twinkle.server.utils.template_utils import DEFAULT_TEMPLATE, get_template_for_model
+
+twinkle.initialize(mode='local')
+
+_QWEN3_5_TEMPLATE = 'Qwen3_5Template'
+
+# ---------------------------------------------------------------------------
+# Part A: model name -> template mapping (no model files required)
+# ---------------------------------------------------------------------------
+
+
+class TestModelTemplateMapping:
+ """Qwen3.5 / 3.6 / 3.8 share one architecture, hence one template."""
+
+ @pytest.mark.parametrize(
+ 'model_name',
+ [
+ 'Qwen3.5-4B',
+ 'Qwen3.5-35B-A3B',
+ 'ms://Qwen/Qwen3.5-9B',
+ 'Qwen3.6-27B',
+ 'Qwen3.6-35B-A3B',
+ 'ms://Qwen/Qwen3.6-27B',
+ # Qwen3.8 ships under a preview org and carries a date suffix;
+ # both must still resolve through the 'Qwen3.8' substring.
+ 'Qwen3.8-27B',
+ 'Qwen3.8-27B-0811',
+ 'QM-preview/Qwen3.8-27B-0811',
+ 'ms://QM-preview/Qwen3.8-27B-0811',
+ ],
+ )
+ def test_qwen3_5_family_maps_to_qwen3_5_template(self, model_name):
+ assert get_template_for_model(model_name) == _QWEN3_5_TEMPLATE
+
+ @pytest.mark.parametrize('model_name', [
+ 'Qwen2.5-7B-Instruct',
+ 'Qwen3-8B',
+ 'llama-3-8b',
+ 'DeepSeek-V4-Flash',
+ '',
+ ])
+ def test_non_family_models_fall_back_to_default(self, model_name):
+ assert get_template_for_model(model_name) == DEFAULT_TEMPLATE
+
+ def test_qwen3_8_is_registered(self):
+ """Regression guard: Qwen3.8 must not silently fall back to Template.
+
+ Without the mapping entry the default template renders a different
+ prompt, which is a near-invisible training bug.
+ """
+ assert get_template_for_model('Qwen3.8-27B-0811') != DEFAULT_TEMPLATE
+
+
+# ---------------------------------------------------------------------------
+# Local model discovery
+# ---------------------------------------------------------------------------
+
+
+def _config_dir(root: Path) -> Path:
+ """Resolve a model root to the directory holding config.json."""
+ if (root / 'config.json').is_file():
+ return root
+ for candidate in sorted(root.glob('snapshots/*')):
+ if (candidate / 'config.json').is_file():
+ return candidate
+ return root
+
+
+def _is_usable(cfg_dir: Path) -> bool:
+ """Require a Qwen3.5-family config *and* a chat template to render with."""
+ cfg_path = cfg_dir / 'config.json'
+ if not cfg_path.is_file():
+ return False
+ try:
+ model_type = json.loads(cfg_path.read_text()).get('model_type', '')
+ except (json.JSONDecodeError, OSError):
+ return False
+ if not str(model_type).startswith('qwen3_5'):
+ return False
+ if (cfg_dir / 'chat_template.jinja').is_file():
+ return True
+ tok_cfg = cfg_dir / 'tokenizer_config.json'
+ try:
+ return bool(json.loads(tok_cfg.read_text()).get('chat_template'))
+ except (json.JSONDecodeError, OSError):
+ return False
+
+
+def _cache_roots() -> List[Path]:
+ """Candidate ModelScope cache roots, covering both on-disk layouts.
+
+ ``MODELSCOPE_CACHE`` may or may not already include the ``hub`` segment,
+ and models live under ``/models``, so probe each combination.
+ """
+ bases = []
+ env_cache = os.getenv('MODELSCOPE_CACHE')
+ if env_cache:
+ bases.append(Path(env_cache))
+ bases += [Path.home() / '.cache' / 'modelscope', Path('/root/.cache/modelscope')]
+
+ roots, seen = [], set()
+ for base in bases:
+ for models_dir in (base / 'models', base / 'hub' / 'models'):
+ resolved = str(models_dir)
+ if resolved not in seen and models_dir.is_dir():
+ seen.add(resolved)
+ roots.append(models_dir)
+ return roots
+
+
+# Official releases only: finetuned derivatives share the base template and
+# would just slow the suite down. The 3.8 preview carries a date suffix, so
+# match on the family prefix rather than pinning exact names.
+_OFFICIAL_DIR = re.compile(r'^(Qwen|QM-preview)--Qwen3\.[568](-|$)')
+
+
+def _discover_models() -> List[Tuple[str, str]]:
+ """Return ``(label, path)`` for every local Qwen3.5-family model."""
+ override = os.getenv('TWINKLE_TEST_QWEN35_MODEL_DIRS')
+ if override:
+ roots = [Path(p) for p in override.split(':') if p]
+ else:
+ roots = [p for models_dir in _cache_roots() for p in models_dir.iterdir() if _OFFICIAL_DIR.match(p.name)]
+
+ found, seen = [], set()
+ for root in sorted(roots, key=lambda p: p.name):
+ cfg_dir = _config_dir(root)
+ if root.name not in seen and _is_usable(cfg_dir):
+ seen.add(root.name)
+ found.append((root.name, str(cfg_dir)))
+ return found
+
+
+_MODELS = _discover_models()
+_MODEL_IDS = [label for label, _ in _MODELS]
+
+requires_model = pytest.mark.skipif(
+ not _MODELS,
+ reason='No local Qwen3.5-family model found; set TWINKLE_TEST_QWEN35_MODEL_DIRS to run',
+)
+
+
+@pytest.fixture(scope='module', params=[path for _, path in _MODELS], ids=_MODEL_IDS)
+def template(request):
+ """A Qwen3_5Template bound to a local checkout (tokenizer/processor only)."""
+ from twinkle.template import Qwen3_5Template
+ try:
+ return Qwen3_5Template(model_id=request.param, max_length=8192)
+ except Exception as e: # noqa: BLE001 - environment issue, not a test failure
+ pytest.skip(f'Failed to build template for {request.param}: {e}')
+
+
+# ---------------------------------------------------------------------------
+# Part B: encode() must reproduce apply_chat_template() byte-for-byte
+# ---------------------------------------------------------------------------
+
+_PLAIN_CASES = {
+ 'single_turn': [
+ {
+ 'role': 'user',
+ 'content': 'What is 1+1?'
+ },
+ {
+ 'role': 'assistant',
+ 'content': '2'
+ },
+ ],
+ 'multi_turn': [
+ {
+ 'role': 'user',
+ 'content': 'a'
+ },
+ {
+ 'role': 'assistant',
+ 'content': 'b'
+ },
+ {
+ 'role': 'user',
+ 'content': 'c'
+ },
+ {
+ 'role': 'assistant',
+ 'content': 'd'
+ },
+ ],
+ 'with_system': [
+ {
+ 'role': 'system',
+ 'content': 'You are a helpful assistant.'
+ },
+ {
+ 'role': 'user',
+ 'content': 'hi'
+ },
+ {
+ 'role': 'assistant',
+ 'content': 'ok'
+ },
+ ],
+ 'reasoning_content_field': [
+ {
+ 'role': 'user',
+ 'content': 'hi'
+ },
+ {
+ 'role': 'assistant',
+ 'content': 'ok',
+ 'reasoning_content': 'let me think'
+ },
+ ],
+ 'unicode_and_code': [
+ {
+ 'role': 'user',
+ 'content': '用 Python 写平方函数,你好世界!'
+ },
+ {
+ 'role': 'assistant',
+ 'content': '```python\ndef f(x):\n return x**2\n```'
+ },
+ ],
+}
+
+_TOOLS = [{
+ 'type': 'function',
+ 'function': {
+ 'name': 'get_weather',
+ 'description': 'Get the weather for a city.',
+ 'parameters': {
+ 'type': 'object',
+ 'properties': {
+ 'city': {
+ 'type': 'string'
+ }
+ },
+ 'required': ['city'],
+ },
+ },
+}]
+
+
+@requires_model
+class TestEncodeMatchesApplyChatTemplate:
+ """``decode(encode(traj)) == apply_chat_template(messages)``."""
+
+ @staticmethod
+ def _encoded_text(template, trajectory, **kwargs) -> str:
+ encoded = template.encode(trajectory, **kwargs)
+ return template.tokenizer.decode(encoded['input_ids'])
+
+ @staticmethod
+ def _reference_text(template, messages, tools=None, add_generation_prompt=False) -> str:
+ """Mirror the kwargs ``Template._apply_chat_template`` passes through."""
+ return template.processor.apply_chat_template(
+ messages,
+ tools=list(tools or []),
+ tokenize=False,
+ add_generation_prompt=add_generation_prompt,
+ enable_thinking=template.enable_thinking,
+ )
+
+ @pytest.mark.parametrize('case', sorted(_PLAIN_CASES), ids=sorted(_PLAIN_CASES))
+ def test_encode_matches_reference(self, template, case):
+ messages = _PLAIN_CASES[case]
+ trajectory = Trajectory(messages=[Message(**m) for m in messages])
+ assert self._encoded_text(template, trajectory) == self._reference_text(template, messages)
+
+ def test_encode_matches_reference_with_generation_prompt(self, template):
+ messages = [{'role': 'user', 'content': 'What is 1+1?'}]
+ trajectory = Trajectory(messages=[Message(**m) for m in messages])
+ got = self._encoded_text(template, trajectory, add_generation_prompt=True)
+ expected = self._reference_text(template, messages, add_generation_prompt=True)
+ assert got == expected
+
+ def test_encode_matches_reference_with_tools(self, template):
+ messages = [
+ {
+ 'role': 'user',
+ 'content': 'Weather in Beijing?'
+ },
+ {
+ 'role': 'assistant',
+ 'content': 'Let me check.'
+ },
+ ]
+ trajectory = Trajectory(messages=[Message(**m) for m in messages], tools=_TOOLS)
+ got = self._encoded_text(template, trajectory)
+ expected = self._reference_text(template, messages, tools=_TOOLS)
+ assert got == expected
+
+ def test_inline_thinking_is_normalized_not_double_wrapped(self, template):
+ """Inline ```` must be hoisted into ``reasoning_content``.
+
+ On Qwen3.8 the template no longer parses inline ```` itself, so
+ passing raw inline thinking to ``apply_chat_template`` produces a
+ doubly-nested ```` block. ``encode`` must instead match the
+ explicitly-normalized message list.
+ """
+ inline = [
+ {
+ 'role': 'user',
+ 'content': 'hi'
+ },
+ {
+ 'role': 'assistant',
+ 'content': '\nreason step\n\n\nanswer'
+ },
+ ]
+ normalized = [
+ {
+ 'role': 'user',
+ 'content': 'hi'
+ },
+ {
+ 'role': 'assistant',
+ 'content': 'answer',
+ 'reasoning_content': 'reason step'
+ },
+ ]
+ trajectory = Trajectory(messages=[Message(**m) for m in inline])
+ got = self._encoded_text(template, trajectory)
+
+ assert got == self._reference_text(template, normalized)
+ # The exact corruption this guards against.
+ assert '\n\n\n\n' not in got
+ assert got.count('') == got.count('')
+
+ def test_labels_align_with_input_ids(self, template):
+ """Sanity: the assistant span is supervised, the prompt span is not."""
+ trajectory = Trajectory(messages=[Message(**m) for m in _PLAIN_CASES['single_turn']])
+ encoded = template.encode(trajectory)
+ assert len(encoded['labels']) == len(encoded['input_ids'])
+ assert (encoded['labels'] == -100).sum() > 0
+ assert (encoded['labels'] != -100).sum() > 0