@tuned-tensor/local 0.2.8 → 0.3.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.
Files changed (82) hide show
  1. package/CHANGELOG.md +67 -0
  2. package/README.md +84 -204
  3. package/dist/artifacts.d.ts +3 -4
  4. package/dist/artifacts.js +55 -33
  5. package/dist/artifacts.js.map +1 -1
  6. package/dist/compare.d.ts +1 -8
  7. package/dist/compare.js +1 -23
  8. package/dist/compare.js.map +1 -1
  9. package/dist/contracts.d.ts +59 -366
  10. package/dist/contracts.js +73 -224
  11. package/dist/contracts.js.map +1 -1
  12. package/dist/dataset.d.ts +2 -4
  13. package/dist/dataset.js +31 -125
  14. package/dist/dataset.js.map +1 -1
  15. package/dist/doctor.d.ts +2 -3
  16. package/dist/doctor.js +23 -161
  17. package/dist/doctor.js.map +1 -1
  18. package/dist/evaluation.d.ts +11 -71
  19. package/dist/evaluation.js +212 -572
  20. package/dist/evaluation.js.map +1 -1
  21. package/dist/huggingface-cache.d.ts +8 -7
  22. package/dist/huggingface-cache.js +16 -8
  23. package/dist/huggingface-cache.js.map +1 -1
  24. package/dist/index.d.ts +0 -10
  25. package/dist/index.js +205 -628
  26. package/dist/index.js.map +1 -1
  27. package/dist/local-project.d.ts +1 -11
  28. package/dist/local-project.js +33 -61
  29. package/dist/local-project.js.map +1 -1
  30. package/dist/model-registry.d.ts +3 -16
  31. package/dist/model-registry.js +76 -292
  32. package/dist/model-registry.js.map +1 -1
  33. package/dist/model-server.d.ts +1 -2
  34. package/dist/model-server.js +9 -18
  35. package/dist/model-server.js.map +1 -1
  36. package/dist/orchestrator.d.ts +11 -25
  37. package/dist/orchestrator.js +246 -566
  38. package/dist/orchestrator.js.map +1 -1
  39. package/dist/prefetch.d.ts +1 -5
  40. package/dist/prefetch.js +14 -19
  41. package/dist/prefetch.js.map +1 -1
  42. package/dist/process-runner.d.ts +10 -29
  43. package/dist/process-runner.js +63 -169
  44. package/dist/process-runner.js.map +1 -1
  45. package/dist/process-training.d.ts +0 -1
  46. package/dist/process-training.js +10 -87
  47. package/dist/process-training.js.map +1 -1
  48. package/dist/store.d.ts +1 -3
  49. package/dist/store.js +92 -290
  50. package/dist/store.js.map +1 -1
  51. package/docs/architecture.md +87 -152
  52. package/docs/spark.md +66 -97
  53. package/examples/dry-runner.json +14 -0
  54. package/examples/local-runner.json +8 -6
  55. package/examples/smoke-spec.json +41 -0
  56. package/package.json +6 -6
  57. package/training/local-runner/pyproject.toml +0 -5
  58. package/training/local-runner/src/evaluate.py +89 -234
  59. package/training/local-runner/src/model_contract.py +47 -0
  60. package/training/local-runner/src/prefetch.py +18 -4
  61. package/training/local-runner/src/serve.py +54 -115
  62. package/training/local-runner/src/sft_data.py +97 -0
  63. package/training/local-runner/src/train.py +146 -346
  64. package/training/local-runner/uv.lock +0 -1197
  65. package/dist/labeling-sanitize.d.ts +0 -31
  66. package/dist/labeling-sanitize.js +0 -158
  67. package/dist/labeling-sanitize.js.map +0 -1
  68. package/dist/labeling.d.ts +0 -155
  69. package/dist/labeling.js +0 -496
  70. package/dist/labeling.js.map +0 -1
  71. package/dist/openrouter.d.ts +0 -29
  72. package/dist/openrouter.js +0 -66
  73. package/dist/openrouter.js.map +0 -1
  74. package/dist/server.d.ts +0 -9
  75. package/dist/server.js +0 -211
  76. package/dist/server.js.map +0 -1
  77. package/docs/local-workflow-remediation-2026-07-13.md +0 -130
  78. package/docs/local-workflow-ux-review-2026-07-13.md +0 -458
  79. package/examples/dpo-preferences.jsonl +0 -2
  80. package/examples/dpo-run-request.json +0 -34
  81. package/examples/smoke-run-request.json +0 -34
  82. 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,14 +13,9 @@ from pathlib import Path
14
13
  from tempfile import TemporaryDirectory
15
14
  from typing import Any
16
15
 
17
- # HF_HOME is supplied by the Node launcher before this module starts. Importing
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,
@@ -36,21 +30,20 @@ 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", "auto")
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-")
@@ -59,94 +52,73 @@ MODEL_PAYLOAD = {
59
52
  "base_model": BASE_MODEL,
60
53
  "base_model_revision": BASE_MODEL_REVISION,
61
54
  "device": DEVICE_REQUEST,
62
- "trust_remote_code": TRUST_REMOTE_CODE,
63
55
  "model_loader": MODEL_LOADER,
64
56
  }
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
-
57
+ MODEL, TOKENIZER, DEVICE = load_text_model(MODEL_PAYLOAD, ADAPTER_PATH)
73
58
  GENERATION_LOCK = threading.Lock()
74
59
  REQUEST_SLOTS = threading.BoundedSemaphore(MAX_CONCURRENT_REQUESTS)
75
60
 
76
61
 
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
62
  def text_content(content: Any) -> str:
97
63
  if isinstance(content, str):
98
64
  return content
65
+ if content is None:
66
+ return ""
99
67
  if not isinstance(content, list):
100
- return str(content) if content is not None else ""
68
+ raise ValueError("Message content must be text or an array of text parts.")
101
69
  parts: list[str] = []
102
70
  for part in content:
103
- if isinstance(part, dict) and part.get("type") == "text":
104
- parts.append(str(part.get("text", "")))
105
- elif isinstance(part, dict) and part.get("type") in {"image", "image_url"}:
106
- continue
107
- else:
108
- parts.append(str(part))
109
- return "\n".join(part for part in parts if part)
71
+ if not isinstance(part, dict) or part.get("type") != "text":
72
+ raise ValueError("The bundled Qwen model server accepts text content only.")
73
+ text = part.get("text")
74
+ if not isinstance(text, str):
75
+ raise ValueError("Every text content part must contain a string text field.")
76
+ parts.append(text)
77
+ return "\n".join(parts)
110
78
 
111
79
 
112
- def image_assets(messages: list[dict[str, Any]]) -> list[dict[str, str]]:
113
- assets: list[dict[str, str]] = []
114
- for message in messages:
115
- content = message.get("content")
116
- if not isinstance(content, list):
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
80
+ def normalize_messages(raw_messages: Any) -> list[dict[str, str]]:
81
+ if not isinstance(raw_messages, list) or not raw_messages:
82
+ raise ValueError("Request must include a non-empty messages array.")
83
+ if len(raw_messages) > MAX_MESSAGES:
84
+ raise ValueError(f"Request exceeds the {MAX_MESSAGES}-message limit.")
85
+
86
+ system_parts = [SYSTEM_PROMPT] if SYSTEM_PROMPT else []
87
+ conversation: list[dict[str, str]] = []
88
+ for raw in raw_messages:
89
+ if not isinstance(raw, dict) or raw.get("role") not in {"system", "user", "assistant", "tool"}:
90
+ raise ValueError("Each message must have a supported role and text content.")
91
+ role = str(raw["role"])
92
+ content = text_content(raw.get("content", ""))
93
+ if role == "system":
94
+ if content.strip():
95
+ system_parts.append(content.strip())
96
+ else:
97
+ conversation.append({"role": role, "content": content})
98
+
99
+ if not conversation:
100
+ raise ValueError("Request must contain at least one non-system message.")
101
+ messages: list[dict[str, str]] = []
102
+ if system_parts:
103
+ # Qwen permits one leading system message. Merge the invariant owner
104
+ # prompt and any client context instead of triggering an implicit
105
+ # template fallback with duplicate system turns.
106
+ messages.append({"role": "system", "content": "\n\n".join(system_parts)})
107
+ messages.extend(conversation)
108
+ if sum(len(message["content"]) for message in messages) > MAX_PROMPT_CHARS:
109
+ raise ValueError(f"Prompt exceeds the {MAX_PROMPT_CHARS}-character limit.")
110
+ return messages
137
111
 
138
112
 
139
- def generate_text(messages: list[dict[str, Any]], generation: dict[str, Any]) -> tuple[str, int, int]:
140
- normalized = [{"role": message["role"], "content": text_content(message.get("content"))} for message in messages]
113
+ def generate_text(messages: list[dict[str, str]], generation: dict[str, Any]) -> tuple[str, int, int]:
141
114
  try:
142
115
  prompt = TOKENIZER.apply_chat_template(
143
- normalized,
116
+ messages,
144
117
  tokenize=False,
145
118
  add_generation_prompt=True,
146
- **CHAT_TEMPLATE_KWARGS,
147
119
  )
148
- except Exception:
149
- prompt = "\n".join(f"{message['role']}: {message['content']}" for message in normalized) + "\nassistant:"
120
+ except Exception as exc:
121
+ raise ValueError(f"Qwen chat-template rendering failed: {exc}") from exc
150
122
  inputs = TOKENIZER(prompt, return_tensors="pt")
151
123
  if int(inputs["input_ids"].shape[-1]) > MAX_PROMPT_TOKENS:
152
124
  raise ValueError(f"Prompt exceeds the {MAX_PROMPT_TOKENS}-token limit.")
@@ -155,7 +127,7 @@ def generate_text(messages: list[dict[str, Any]], generation: dict[str, Any]) ->
155
127
  with GENERATION_LOCK:
156
128
  import torch
157
129
 
158
- with torch.no_grad():
130
+ with torch.inference_mode():
159
131
  generated = MODEL.generate(
160
132
  **inputs,
161
133
  max_new_tokens=int(generation["max_new_tokens"]),
@@ -168,33 +140,6 @@ def generate_text(messages: list[dict[str, Any]], generation: dict[str, Any]) ->
168
140
  return content, prompt_tokens, int(completion_tokens.shape[-1])
169
141
 
170
142
 
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
143
  def bounded_number(value: Any, default: float, minimum: float, maximum: float) -> float:
199
144
  number = float(default if value is None else value)
200
145
  if not math.isfinite(number) or number < minimum or number > maximum:
@@ -220,8 +165,7 @@ class Handler(BaseHTTPRequestHandler):
220
165
  if not API_KEY:
221
166
  return True
222
167
  supplied = self.headers.get("authorization", "")
223
- expected = "Bearer " + API_KEY
224
- return hmac.compare_digest(supplied, expected)
168
+ return hmac.compare_digest(supplied, "Bearer " + API_KEY)
225
169
 
226
170
  def send_json(self, status: int, payload: Any) -> None:
227
171
  body = json.dumps(payload).encode("utf-8")
@@ -274,12 +218,7 @@ class Handler(BaseHTTPRequestHandler):
274
218
  return
275
219
  started = time.perf_counter()
276
220
  try:
277
- if MODEL_LOADER == "image_text_to_text":
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)
221
+ content, prompt_tokens, completion_tokens = generate_text(messages, generation)
283
222
  finally:
284
223
  REQUEST_SLOTS.release()
285
224
  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
+ }