@tuned-tensor/local 0.2.9 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +86 -0
- package/README.md +110 -201
- package/dist/active-model.d.ts +16 -0
- package/dist/active-model.js +88 -0
- package/dist/active-model.js.map +1 -0
- package/dist/artifacts.d.ts +5 -4
- package/dist/artifacts.js +57 -33
- package/dist/artifacts.js.map +1 -1
- package/dist/compare.d.ts +1 -8
- package/dist/compare.js +1 -23
- package/dist/compare.js.map +1 -1
- package/dist/contracts.d.ts +234 -365
- package/dist/contracts.js +95 -224
- package/dist/contracts.js.map +1 -1
- package/dist/dataset.d.ts +11 -4
- package/dist/dataset.js +67 -125
- package/dist/dataset.js.map +1 -1
- package/dist/doctor.d.ts +2 -3
- package/dist/doctor.js +23 -161
- package/dist/doctor.js.map +1 -1
- package/dist/evaluation.d.ts +11 -71
- package/dist/evaluation.js +212 -572
- package/dist/evaluation.js.map +1 -1
- package/dist/general-regression.d.ts +10 -0
- package/dist/general-regression.js +16 -0
- package/dist/general-regression.js.map +1 -0
- package/dist/huggingface-cache.d.ts +8 -7
- package/dist/huggingface-cache.js +16 -8
- package/dist/huggingface-cache.js.map +1 -1
- package/dist/index.d.ts +2 -10
- package/dist/index.js +358 -627
- package/dist/index.js.map +1 -1
- package/dist/local-project.d.ts +1 -11
- package/dist/local-project.js +33 -61
- package/dist/local-project.js.map +1 -1
- package/dist/model-registry.d.ts +3 -16
- package/dist/model-registry.js +76 -292
- package/dist/model-registry.js.map +1 -1
- package/dist/model-server.d.ts +7 -3
- package/dist/model-server.js +34 -25
- package/dist/model-server.js.map +1 -1
- package/dist/orchestrator.d.ts +11 -25
- package/dist/orchestrator.js +393 -563
- package/dist/orchestrator.js.map +1 -1
- package/dist/prefetch.d.ts +1 -5
- package/dist/prefetch.js +14 -19
- package/dist/prefetch.js.map +1 -1
- package/dist/process-runner.d.ts +10 -29
- package/dist/process-runner.js +63 -169
- package/dist/process-runner.js.map +1 -1
- package/dist/process-training.d.ts +0 -1
- package/dist/process-training.js +10 -87
- package/dist/process-training.js.map +1 -1
- package/dist/store.d.ts +1 -3
- package/dist/store.js +92 -290
- package/dist/store.js.map +1 -1
- package/docs/architecture.md +87 -152
- package/docs/spark.md +66 -97
- package/examples/dry-runner.json +14 -0
- package/examples/general-regression.jsonl +4 -0
- package/examples/local-runner.json +13 -6
- package/examples/smoke-spec.json +41 -0
- package/package.json +6 -6
- package/training/local-runner/pyproject.toml +0 -5
- package/training/local-runner/src/evaluate.py +89 -234
- package/training/local-runner/src/model_contract.py +47 -0
- package/training/local-runner/src/prefetch.py +18 -4
- package/training/local-runner/src/serve.py +60 -117
- package/training/local-runner/src/sft_data.py +97 -0
- package/training/local-runner/src/train.py +146 -346
- package/training/local-runner/uv.lock +0 -1197
- package/dist/labeling-sanitize.d.ts +0 -31
- package/dist/labeling-sanitize.js +0 -158
- package/dist/labeling-sanitize.js.map +0 -1
- package/dist/labeling.d.ts +0 -155
- package/dist/labeling.js +0 -496
- package/dist/labeling.js.map +0 -1
- package/dist/openrouter.d.ts +0 -29
- package/dist/openrouter.js +0 -66
- package/dist/openrouter.js.map +0 -1
- package/dist/server.d.ts +0 -9
- package/dist/server.js +0 -211
- package/dist/server.js.map +0 -1
- package/docs/local-workflow-remediation-2026-07-13.md +0 -130
- package/docs/local-workflow-ux-review-2026-07-13.md +0 -458
- package/examples/dpo-preferences.jsonl +0 -2
- package/examples/dpo-run-request.json +0 -34
- package/examples/smoke-run-request.json +0 -34
- package/training/local-runner/src/train_dpo.py +0 -286
|
@@ -1,8 +1,7 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
-
import json
|
|
4
|
-
import base64
|
|
5
3
|
import hmac
|
|
4
|
+
import json
|
|
6
5
|
import math
|
|
7
6
|
import os
|
|
8
7
|
import sys
|
|
@@ -14,21 +13,16 @@ from pathlib import Path
|
|
|
14
13
|
from tempfile import TemporaryDirectory
|
|
15
14
|
from typing import Any
|
|
16
15
|
|
|
17
|
-
|
|
18
|
-
# the model stack only after reading process configuration keeps every local
|
|
19
|
-
# workflow on the same Hugging Face cache contract.
|
|
20
|
-
from evaluate import ( # noqa: E402
|
|
16
|
+
from evaluate import (
|
|
21
17
|
configure_hugging_face_cache,
|
|
22
|
-
generate_multimodal_one,
|
|
23
18
|
import_runtime_dependencies,
|
|
24
|
-
load_multimodal_model,
|
|
25
19
|
load_text_model,
|
|
26
20
|
resolve_adapter_path,
|
|
27
21
|
sampling_kwargs,
|
|
28
22
|
)
|
|
29
23
|
|
|
30
24
|
|
|
31
|
-
MODEL_ARTIFACT = os.environ
|
|
25
|
+
MODEL_ARTIFACT = os.environ.get("TT_MODEL_ARTIFACT")
|
|
32
26
|
BASE_MODEL = os.environ["TT_BASE_MODEL"]
|
|
33
27
|
BASE_MODEL_REVISION = os.environ.get("TT_BASE_MODEL_REVISION")
|
|
34
28
|
MODEL_NAME = os.environ.get("TT_MODEL_NAME", "tuned-tensor-local")
|
|
@@ -36,117 +30,99 @@ MODEL_LOADER = os.environ.get("TT_MODEL_LOADER", "causal_lm")
|
|
|
36
30
|
SYSTEM_PROMPT = os.environ.get("TT_SYSTEM_PROMPT", "").strip()
|
|
37
31
|
HOST = os.environ.get("TT_HOST", "127.0.0.1")
|
|
38
32
|
PORT = int(os.environ.get("TT_PORT", "8000"))
|
|
39
|
-
DEVICE_REQUEST = os.environ.get("TT_DEVICE", "
|
|
40
|
-
TRUST_REMOTE_CODE = os.environ.get("TT_TRUST_REMOTE_CODE", "true").lower() in {"1", "true", "yes"}
|
|
33
|
+
DEVICE_REQUEST = os.environ.get("TT_DEVICE", "cuda")
|
|
41
34
|
DEFAULT_MAX_TOKENS = int(os.environ.get("TT_MAX_TOKENS", "512"))
|
|
42
35
|
DEFAULT_TEMPERATURE = float(os.environ.get("TT_TEMPERATURE", "0"))
|
|
43
36
|
DEFAULT_TOP_P = float(os.environ.get("TT_TOP_P", "1"))
|
|
44
|
-
CHAT_TEMPLATE_KWARGS = json.loads(os.environ.get("TT_CHAT_TEMPLATE_KWARGS", "{}"))
|
|
45
37
|
MAX_REQUEST_BYTES = 2 * 1024 * 1024
|
|
46
38
|
MAX_PROMPT_CHARS = 100_000
|
|
47
39
|
MAX_PROMPT_TOKENS = 16_384
|
|
48
40
|
MAX_MESSAGES = 128
|
|
49
|
-
MAX_IMAGE_BYTES = 5 * 1024 * 1024
|
|
50
41
|
API_KEY = os.environ.get("TT_API_KEY", "")
|
|
51
42
|
MAX_CONCURRENT_REQUESTS = int(os.environ.get("TT_MAX_CONCURRENT_REQUESTS", "1"))
|
|
52
43
|
|
|
53
44
|
|
|
45
|
+
if MODEL_LOADER != "causal_lm":
|
|
46
|
+
raise ValueError("The bundled model server is text-only and requires TT_MODEL_LOADER=causal_lm")
|
|
54
47
|
configure_hugging_face_cache(os.environ.get("HF_HOME"))
|
|
55
48
|
import_runtime_dependencies()
|
|
56
49
|
TEMP_DIR = TemporaryDirectory(prefix="tt-local-serve-")
|
|
57
|
-
ADAPTER_PATH =
|
|
50
|
+
ADAPTER_PATH = (
|
|
51
|
+
resolve_adapter_path(MODEL_ARTIFACT, Path(TEMP_DIR.name))
|
|
52
|
+
if MODEL_ARTIFACT
|
|
53
|
+
else None
|
|
54
|
+
)
|
|
58
55
|
MODEL_PAYLOAD = {
|
|
59
56
|
"base_model": BASE_MODEL,
|
|
60
57
|
"base_model_revision": BASE_MODEL_REVISION,
|
|
61
58
|
"device": DEVICE_REQUEST,
|
|
62
|
-
"trust_remote_code": TRUST_REMOTE_CODE,
|
|
63
59
|
"model_loader": MODEL_LOADER,
|
|
64
60
|
}
|
|
65
|
-
|
|
66
|
-
if MODEL_LOADER == "image_text_to_text":
|
|
67
|
-
MODEL, PROCESSOR, DEVICE = load_multimodal_model(MODEL_PAYLOAD, ADAPTER_PATH)
|
|
68
|
-
TOKENIZER = getattr(PROCESSOR, "tokenizer", PROCESSOR)
|
|
69
|
-
else:
|
|
70
|
-
MODEL, TOKENIZER, DEVICE = load_text_model(MODEL_PAYLOAD, ADAPTER_PATH)
|
|
71
|
-
PROCESSOR = None
|
|
72
|
-
|
|
61
|
+
MODEL, TOKENIZER, DEVICE = load_text_model(MODEL_PAYLOAD, ADAPTER_PATH)
|
|
73
62
|
GENERATION_LOCK = threading.Lock()
|
|
74
63
|
REQUEST_SLOTS = threading.BoundedSemaphore(MAX_CONCURRENT_REQUESTS)
|
|
75
64
|
|
|
76
65
|
|
|
77
|
-
def normalize_messages(raw_messages: Any) -> list[dict[str, Any]]:
|
|
78
|
-
if not isinstance(raw_messages, list) or not raw_messages:
|
|
79
|
-
raise ValueError("Request must include a non-empty messages array.")
|
|
80
|
-
if len(raw_messages) > MAX_MESSAGES:
|
|
81
|
-
raise ValueError(f"Request exceeds the {MAX_MESSAGES}-message limit.")
|
|
82
|
-
messages: list[dict[str, Any]] = []
|
|
83
|
-
for raw in raw_messages:
|
|
84
|
-
if not isinstance(raw, dict) or raw.get("role") not in {"system", "user", "assistant", "tool"}:
|
|
85
|
-
raise ValueError("Each message must have a supported role and content.")
|
|
86
|
-
messages.append({"role": raw["role"], "content": raw.get("content", "")})
|
|
87
|
-
# A spec passed by the server owner is invariant behavior. Client-supplied
|
|
88
|
-
# system messages may add context but cannot replace it.
|
|
89
|
-
if SYSTEM_PROMPT:
|
|
90
|
-
messages.insert(0, {"role": "system", "content": SYSTEM_PROMPT})
|
|
91
|
-
if sum(len(text_content(message.get("content"))) for message in messages) > MAX_PROMPT_CHARS:
|
|
92
|
-
raise ValueError(f"Prompt exceeds the {MAX_PROMPT_CHARS}-character limit.")
|
|
93
|
-
return messages
|
|
94
|
-
|
|
95
|
-
|
|
96
66
|
def text_content(content: Any) -> str:
|
|
97
67
|
if isinstance(content, str):
|
|
98
68
|
return content
|
|
69
|
+
if content is None:
|
|
70
|
+
return ""
|
|
99
71
|
if not isinstance(content, list):
|
|
100
|
-
|
|
72
|
+
raise ValueError("Message content must be text or an array of text parts.")
|
|
101
73
|
parts: list[str] = []
|
|
102
74
|
for part in content:
|
|
103
|
-
if isinstance(part, dict)
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
return "\n".join(
|
|
75
|
+
if not isinstance(part, dict) or part.get("type") != "text":
|
|
76
|
+
raise ValueError("The bundled Qwen model server accepts text content only.")
|
|
77
|
+
text = part.get("text")
|
|
78
|
+
if not isinstance(text, str):
|
|
79
|
+
raise ValueError("Every text content part must contain a string text field.")
|
|
80
|
+
parts.append(text)
|
|
81
|
+
return "\n".join(parts)
|
|
110
82
|
|
|
111
83
|
|
|
112
|
-
def
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
continue
|
|
118
|
-
for part in content:
|
|
119
|
-
if not isinstance(part, dict) or part.get("type") not in {"image", "image_url"}:
|
|
120
|
-
continue
|
|
121
|
-
image = part.get("image_url")
|
|
122
|
-
if isinstance(image, dict):
|
|
123
|
-
image = image.get("url")
|
|
124
|
-
image = image or part.get("image") or part.get("data_uri") or part.get("uri") or part.get("path")
|
|
125
|
-
if isinstance(image, str) and image:
|
|
126
|
-
if not image.startswith("data:image/") or "," not in image:
|
|
127
|
-
raise ValueError("Serving accepts image content only as data:image/... URIs.")
|
|
128
|
-
encoded = image.split(",", 1)[1]
|
|
129
|
-
try:
|
|
130
|
-
decoded_size = len(base64.b64decode(encoded, validate=True))
|
|
131
|
-
except Exception as exc:
|
|
132
|
-
raise ValueError("Image data URI contains invalid base64.") from exc
|
|
133
|
-
if decoded_size > MAX_IMAGE_BYTES:
|
|
134
|
-
raise ValueError(f"Image exceeds the {MAX_IMAGE_BYTES}-byte decoded limit.")
|
|
135
|
-
assets.append({"type": "image", "image": image})
|
|
136
|
-
return assets
|
|
84
|
+
def normalize_messages(raw_messages: Any) -> list[dict[str, str]]:
|
|
85
|
+
if not isinstance(raw_messages, list) or not raw_messages:
|
|
86
|
+
raise ValueError("Request must include a non-empty messages array.")
|
|
87
|
+
if len(raw_messages) > MAX_MESSAGES:
|
|
88
|
+
raise ValueError(f"Request exceeds the {MAX_MESSAGES}-message limit.")
|
|
137
89
|
|
|
90
|
+
system_parts = [SYSTEM_PROMPT] if SYSTEM_PROMPT else []
|
|
91
|
+
conversation: list[dict[str, str]] = []
|
|
92
|
+
for raw in raw_messages:
|
|
93
|
+
if not isinstance(raw, dict) or raw.get("role") not in {"system", "user", "assistant", "tool"}:
|
|
94
|
+
raise ValueError("Each message must have a supported role and text content.")
|
|
95
|
+
role = str(raw["role"])
|
|
96
|
+
content = text_content(raw.get("content", ""))
|
|
97
|
+
if role == "system":
|
|
98
|
+
if content.strip():
|
|
99
|
+
system_parts.append(content.strip())
|
|
100
|
+
else:
|
|
101
|
+
conversation.append({"role": role, "content": content})
|
|
102
|
+
|
|
103
|
+
if not conversation:
|
|
104
|
+
raise ValueError("Request must contain at least one non-system message.")
|
|
105
|
+
messages: list[dict[str, str]] = []
|
|
106
|
+
if system_parts:
|
|
107
|
+
# Qwen permits one leading system message. Merge the invariant owner
|
|
108
|
+
# prompt and any client context instead of triggering an implicit
|
|
109
|
+
# template fallback with duplicate system turns.
|
|
110
|
+
messages.append({"role": "system", "content": "\n\n".join(system_parts)})
|
|
111
|
+
messages.extend(conversation)
|
|
112
|
+
if sum(len(message["content"]) for message in messages) > MAX_PROMPT_CHARS:
|
|
113
|
+
raise ValueError(f"Prompt exceeds the {MAX_PROMPT_CHARS}-character limit.")
|
|
114
|
+
return messages
|
|
138
115
|
|
|
139
|
-
|
|
140
|
-
|
|
116
|
+
|
|
117
|
+
def generate_text(messages: list[dict[str, str]], generation: dict[str, Any]) -> tuple[str, int, int]:
|
|
141
118
|
try:
|
|
142
119
|
prompt = TOKENIZER.apply_chat_template(
|
|
143
|
-
|
|
120
|
+
messages,
|
|
144
121
|
tokenize=False,
|
|
145
122
|
add_generation_prompt=True,
|
|
146
|
-
**CHAT_TEMPLATE_KWARGS,
|
|
147
123
|
)
|
|
148
|
-
except Exception:
|
|
149
|
-
|
|
124
|
+
except Exception as exc:
|
|
125
|
+
raise ValueError(f"Qwen chat-template rendering failed: {exc}") from exc
|
|
150
126
|
inputs = TOKENIZER(prompt, return_tensors="pt")
|
|
151
127
|
if int(inputs["input_ids"].shape[-1]) > MAX_PROMPT_TOKENS:
|
|
152
128
|
raise ValueError(f"Prompt exceeds the {MAX_PROMPT_TOKENS}-token limit.")
|
|
@@ -155,7 +131,7 @@ def generate_text(messages: list[dict[str, Any]], generation: dict[str, Any]) ->
|
|
|
155
131
|
with GENERATION_LOCK:
|
|
156
132
|
import torch
|
|
157
133
|
|
|
158
|
-
with torch.
|
|
134
|
+
with torch.inference_mode():
|
|
159
135
|
generated = MODEL.generate(
|
|
160
136
|
**inputs,
|
|
161
137
|
max_new_tokens=int(generation["max_new_tokens"]),
|
|
@@ -168,33 +144,6 @@ def generate_text(messages: list[dict[str, Any]], generation: dict[str, Any]) ->
|
|
|
168
144
|
return content, prompt_tokens, int(completion_tokens.shape[-1])
|
|
169
145
|
|
|
170
146
|
|
|
171
|
-
def generate_multimodal(messages: list[dict[str, Any]], generation: dict[str, Any]) -> tuple[str, int, int]:
|
|
172
|
-
system_parts = [text_content(message.get("content")) for message in messages if message["role"] == "system"]
|
|
173
|
-
conversation = "\n".join(
|
|
174
|
-
f"{message['role']}: {text_content(message.get('content'))}"
|
|
175
|
-
for message in messages
|
|
176
|
-
if message["role"] != "system"
|
|
177
|
-
)
|
|
178
|
-
example = {
|
|
179
|
-
"input": conversation,
|
|
180
|
-
"output": "",
|
|
181
|
-
"input_assets": image_assets(messages),
|
|
182
|
-
}
|
|
183
|
-
with GENERATION_LOCK:
|
|
184
|
-
result = generate_multimodal_one(
|
|
185
|
-
MODEL,
|
|
186
|
-
PROCESSOR,
|
|
187
|
-
"\n\n".join(part for part in system_parts if part),
|
|
188
|
-
example,
|
|
189
|
-
generation,
|
|
190
|
-
Path.cwd(),
|
|
191
|
-
CHAT_TEMPLATE_KWARGS,
|
|
192
|
-
)
|
|
193
|
-
# The evaluator does not currently expose token counts for multimodal
|
|
194
|
-
# requests. Returning zero is preferable to inventing usage data.
|
|
195
|
-
return str(result["actual"]), 0, 0
|
|
196
|
-
|
|
197
|
-
|
|
198
147
|
def bounded_number(value: Any, default: float, minimum: float, maximum: float) -> float:
|
|
199
148
|
number = float(default if value is None else value)
|
|
200
149
|
if not math.isfinite(number) or number < minimum or number > maximum:
|
|
@@ -220,8 +169,7 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
220
169
|
if not API_KEY:
|
|
221
170
|
return True
|
|
222
171
|
supplied = self.headers.get("authorization", "")
|
|
223
|
-
|
|
224
|
-
return hmac.compare_digest(supplied, expected)
|
|
172
|
+
return hmac.compare_digest(supplied, "Bearer " + API_KEY)
|
|
225
173
|
|
|
226
174
|
def send_json(self, status: int, payload: Any) -> None:
|
|
227
175
|
body = json.dumps(payload).encode("utf-8")
|
|
@@ -274,12 +222,7 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
274
222
|
return
|
|
275
223
|
started = time.perf_counter()
|
|
276
224
|
try:
|
|
277
|
-
|
|
278
|
-
content, prompt_tokens, completion_tokens = generate_multimodal(messages, generation)
|
|
279
|
-
else:
|
|
280
|
-
if image_assets(messages):
|
|
281
|
-
raise ValueError("This text model does not accept image content parts.")
|
|
282
|
-
content, prompt_tokens, completion_tokens = generate_text(messages, generation)
|
|
225
|
+
content, prompt_tokens, completion_tokens = generate_text(messages, generation)
|
|
283
226
|
finally:
|
|
284
227
|
REQUEST_SLOTS.release()
|
|
285
228
|
latency_ms = round((time.perf_counter() - started) * 1000)
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections.abc import Mapping, Sequence
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
IGNORE_INDEX = -100
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def _token_ids(value: Any, description: str) -> list[int]:
|
|
11
|
+
if isinstance(value, Mapping):
|
|
12
|
+
value = value.get("input_ids")
|
|
13
|
+
if not isinstance(value, Sequence) or isinstance(value, (str, bytes)):
|
|
14
|
+
raise TypeError(f"{description} did not return a token-id sequence")
|
|
15
|
+
token_ids = list(value)
|
|
16
|
+
if not token_ids or any(not isinstance(token_id, int) for token_id in token_ids):
|
|
17
|
+
raise ValueError(f"{description} returned empty or invalid token ids")
|
|
18
|
+
return token_ids
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def build_assistant_only_example(
|
|
22
|
+
tokenizer: Any,
|
|
23
|
+
messages: Any,
|
|
24
|
+
*,
|
|
25
|
+
max_length: int,
|
|
26
|
+
) -> dict[str, list[int]]:
|
|
27
|
+
"""Tokenize one chat row while computing loss only on the final assistant turn.
|
|
28
|
+
|
|
29
|
+
The entire assistant completion is reserved before the prompt is truncated.
|
|
30
|
+
Prompt truncation therefore removes the oldest prompt tokens and can never
|
|
31
|
+
silently discard the expected answer.
|
|
32
|
+
"""
|
|
33
|
+
if max_length < 2:
|
|
34
|
+
raise ValueError("max_length must be at least 2")
|
|
35
|
+
if not isinstance(messages, list) or len(messages) < 2:
|
|
36
|
+
raise ValueError("messages must contain prompt context and a final assistant turn")
|
|
37
|
+
if any(not isinstance(message, dict) for message in messages):
|
|
38
|
+
raise ValueError("every message must be an object")
|
|
39
|
+
allowed_roles = {"system", "user", "assistant", "tool"}
|
|
40
|
+
for message in messages:
|
|
41
|
+
role = message.get("role")
|
|
42
|
+
if role not in allowed_roles:
|
|
43
|
+
raise ValueError(f"unsupported message role: {role!r}")
|
|
44
|
+
if not isinstance(message.get("content"), str):
|
|
45
|
+
raise ValueError("the bundled trainer accepts text-only message content")
|
|
46
|
+
|
|
47
|
+
assistant = messages[-1]
|
|
48
|
+
if assistant.get("role") != "assistant":
|
|
49
|
+
raise ValueError("the final message must have role=assistant")
|
|
50
|
+
content = assistant.get("content")
|
|
51
|
+
if not isinstance(content, str) or not content.strip():
|
|
52
|
+
raise ValueError("the final assistant message must contain non-empty text")
|
|
53
|
+
|
|
54
|
+
try:
|
|
55
|
+
prompt_ids = _token_ids(
|
|
56
|
+
tokenizer.apply_chat_template(
|
|
57
|
+
messages[:-1],
|
|
58
|
+
tokenize=True,
|
|
59
|
+
add_generation_prompt=True,
|
|
60
|
+
return_dict=False,
|
|
61
|
+
),
|
|
62
|
+
"prompt chat template",
|
|
63
|
+
)
|
|
64
|
+
full_ids = _token_ids(
|
|
65
|
+
tokenizer.apply_chat_template(
|
|
66
|
+
messages,
|
|
67
|
+
tokenize=True,
|
|
68
|
+
add_generation_prompt=False,
|
|
69
|
+
return_dict=False,
|
|
70
|
+
),
|
|
71
|
+
"full chat template",
|
|
72
|
+
)
|
|
73
|
+
except Exception as exc:
|
|
74
|
+
raise ValueError(f"Qwen chat-template rendering failed: {exc}") from exc
|
|
75
|
+
|
|
76
|
+
if full_ids[: len(prompt_ids)] != prompt_ids:
|
|
77
|
+
raise ValueError(
|
|
78
|
+
"Qwen chat-template output is not prefix-aligned; refusing to guess the assistant loss boundary"
|
|
79
|
+
)
|
|
80
|
+
completion_ids = full_ids[len(prompt_ids) :]
|
|
81
|
+
if not completion_ids:
|
|
82
|
+
raise ValueError("Qwen chat template produced no assistant completion tokens")
|
|
83
|
+
if len(completion_ids) >= max_length:
|
|
84
|
+
raise ValueError(
|
|
85
|
+
f"assistant completion requires {len(completion_ids)} tokens, but max_length={max_length}; "
|
|
86
|
+
"increase max_seq_length so the answer is not truncated"
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
prompt_budget = max_length - len(completion_ids)
|
|
90
|
+
retained_prompt_ids = prompt_ids[-prompt_budget:]
|
|
91
|
+
input_ids = [*retained_prompt_ids, *completion_ids]
|
|
92
|
+
labels = [IGNORE_INDEX] * len(retained_prompt_ids) + completion_ids.copy()
|
|
93
|
+
return {
|
|
94
|
+
"input_ids": input_ids,
|
|
95
|
+
"attention_mask": [1] * len(input_ids),
|
|
96
|
+
"labels": labels,
|
|
97
|
+
}
|