@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.
Files changed (89) hide show
  1. package/CHANGELOG.md +86 -0
  2. package/README.md +110 -201
  3. package/dist/active-model.d.ts +16 -0
  4. package/dist/active-model.js +88 -0
  5. package/dist/active-model.js.map +1 -0
  6. package/dist/artifacts.d.ts +5 -4
  7. package/dist/artifacts.js +57 -33
  8. package/dist/artifacts.js.map +1 -1
  9. package/dist/compare.d.ts +1 -8
  10. package/dist/compare.js +1 -23
  11. package/dist/compare.js.map +1 -1
  12. package/dist/contracts.d.ts +234 -365
  13. package/dist/contracts.js +95 -224
  14. package/dist/contracts.js.map +1 -1
  15. package/dist/dataset.d.ts +11 -4
  16. package/dist/dataset.js +67 -125
  17. package/dist/dataset.js.map +1 -1
  18. package/dist/doctor.d.ts +2 -3
  19. package/dist/doctor.js +23 -161
  20. package/dist/doctor.js.map +1 -1
  21. package/dist/evaluation.d.ts +11 -71
  22. package/dist/evaluation.js +212 -572
  23. package/dist/evaluation.js.map +1 -1
  24. package/dist/general-regression.d.ts +10 -0
  25. package/dist/general-regression.js +16 -0
  26. package/dist/general-regression.js.map +1 -0
  27. package/dist/huggingface-cache.d.ts +8 -7
  28. package/dist/huggingface-cache.js +16 -8
  29. package/dist/huggingface-cache.js.map +1 -1
  30. package/dist/index.d.ts +2 -10
  31. package/dist/index.js +358 -627
  32. package/dist/index.js.map +1 -1
  33. package/dist/local-project.d.ts +1 -11
  34. package/dist/local-project.js +33 -61
  35. package/dist/local-project.js.map +1 -1
  36. package/dist/model-registry.d.ts +3 -16
  37. package/dist/model-registry.js +76 -292
  38. package/dist/model-registry.js.map +1 -1
  39. package/dist/model-server.d.ts +7 -3
  40. package/dist/model-server.js +34 -25
  41. package/dist/model-server.js.map +1 -1
  42. package/dist/orchestrator.d.ts +11 -25
  43. package/dist/orchestrator.js +393 -563
  44. package/dist/orchestrator.js.map +1 -1
  45. package/dist/prefetch.d.ts +1 -5
  46. package/dist/prefetch.js +14 -19
  47. package/dist/prefetch.js.map +1 -1
  48. package/dist/process-runner.d.ts +10 -29
  49. package/dist/process-runner.js +63 -169
  50. package/dist/process-runner.js.map +1 -1
  51. package/dist/process-training.d.ts +0 -1
  52. package/dist/process-training.js +10 -87
  53. package/dist/process-training.js.map +1 -1
  54. package/dist/store.d.ts +1 -3
  55. package/dist/store.js +92 -290
  56. package/dist/store.js.map +1 -1
  57. package/docs/architecture.md +87 -152
  58. package/docs/spark.md +66 -97
  59. package/examples/dry-runner.json +14 -0
  60. package/examples/general-regression.jsonl +4 -0
  61. package/examples/local-runner.json +13 -6
  62. package/examples/smoke-spec.json +41 -0
  63. package/package.json +6 -6
  64. package/training/local-runner/pyproject.toml +0 -5
  65. package/training/local-runner/src/evaluate.py +89 -234
  66. package/training/local-runner/src/model_contract.py +47 -0
  67. package/training/local-runner/src/prefetch.py +18 -4
  68. package/training/local-runner/src/serve.py +60 -117
  69. package/training/local-runner/src/sft_data.py +97 -0
  70. package/training/local-runner/src/train.py +146 -346
  71. package/training/local-runner/uv.lock +0 -1197
  72. package/dist/labeling-sanitize.d.ts +0 -31
  73. package/dist/labeling-sanitize.js +0 -158
  74. package/dist/labeling-sanitize.js.map +0 -1
  75. package/dist/labeling.d.ts +0 -155
  76. package/dist/labeling.js +0 -496
  77. package/dist/labeling.js.map +0 -1
  78. package/dist/openrouter.d.ts +0 -29
  79. package/dist/openrouter.js +0 -66
  80. package/dist/openrouter.js.map +0 -1
  81. package/dist/server.d.ts +0 -9
  82. package/dist/server.js +0 -211
  83. package/dist/server.js.map +0 -1
  84. package/docs/local-workflow-remediation-2026-07-13.md +0 -130
  85. package/docs/local-workflow-ux-review-2026-07-13.md +0 -458
  86. package/examples/dpo-preferences.jsonl +0 -2
  87. package/examples/dpo-run-request.json +0 -34
  88. package/examples/smoke-run-request.json +0 -34
  89. 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
- # 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,
28
22
  )
29
23
 
30
24
 
31
- MODEL_ARTIFACT = os.environ["TT_MODEL_ARTIFACT"]
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", "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-")
57
- ADAPTER_PATH = resolve_adapter_path(MODEL_ARTIFACT, Path(TEMP_DIR.name))
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
- return str(content) if content is not None else ""
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) 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)
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 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
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
- 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]
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
- normalized,
120
+ messages,
144
121
  tokenize=False,
145
122
  add_generation_prompt=True,
146
- **CHAT_TEMPLATE_KWARGS,
147
123
  )
148
- except Exception:
149
- prompt = "\n".join(f"{message['role']}: {message['content']}" for message in normalized) + "\nassistant:"
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.no_grad():
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
- expected = "Bearer " + API_KEY
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
- 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)
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
+ }