@tuned-tensor/cli 0.13.1 → 0.15.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.
@@ -1,280 +1,146 @@
1
+ """Artifact preparation only; vLLM owns HTTP, batching, streaming and tools."""
1
2
  from __future__ import annotations
2
3
 
3
- import hmac
4
4
  import json
5
- import math
5
+ import hmac
6
6
  import os
7
+ import signal
8
+ import socket
7
9
  import sys
8
- import threading
9
- import time
10
- import uuid
11
- from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
12
10
  from pathlib import Path
13
11
  from tempfile import TemporaryDirectory
14
- from typing import Any
15
-
16
- from evaluate import (
17
- configure_hugging_face_cache,
18
- import_runtime_dependencies,
19
- load_text_model,
20
- resolve_adapter_path,
21
- sampling_kwargs,
22
- )
23
- from model_contract import assert_certified_base_model_revision, chat_template_kwargs
24
-
25
-
26
- MODEL_ARTIFACT = os.environ.get("TT_MODEL_ARTIFACT")
27
- BASE_MODEL = os.environ["TT_BASE_MODEL"]
28
- MODEL_SOURCE = os.environ.get("TT_MODEL_SOURCE", BASE_MODEL)
29
- BASE_MODEL_REVISION = os.environ.get("TT_BASE_MODEL_REVISION")
30
- MODEL_NAME = os.environ.get("TT_MODEL_NAME", "tuned-tensor-local")
31
- MODEL_LOADER = os.environ.get("TT_MODEL_LOADER", "causal_lm")
32
- SYSTEM_PROMPT = os.environ.get("TT_SYSTEM_PROMPT", "").strip()
33
- HOST = os.environ.get("TT_HOST", "127.0.0.1")
34
- PORT = int(os.environ.get("TT_PORT", "8000"))
35
- DEVICE_REQUEST = os.environ.get("TT_DEVICE", "cuda")
36
- DEFAULT_MAX_TOKENS = int(os.environ.get("TT_MAX_TOKENS", "512"))
37
- DEFAULT_TEMPERATURE = float(os.environ.get("TT_TEMPERATURE", "0"))
38
- DEFAULT_TOP_P = float(os.environ.get("TT_TOP_P", "1"))
39
- MAX_REQUEST_BYTES = 2 * 1024 * 1024
40
- MAX_PROMPT_CHARS = 100_000
41
- MAX_PROMPT_TOKENS = 16_384
42
- MAX_MESSAGES = 128
43
- API_KEY = os.environ.get("TT_API_KEY", "")
44
- MAX_CONCURRENT_REQUESTS = int(os.environ.get("TT_MAX_CONCURRENT_REQUESTS", "1"))
45
-
46
-
47
- if MODEL_LOADER not in ("causal_lm", "image_text_to_text"):
48
- raise ValueError(
49
- f"The bundled model server does not support TT_MODEL_LOADER={MODEL_LOADER!r}; "
50
- "expected causal_lm or image_text_to_text"
51
- )
52
- assert_certified_base_model_revision(BASE_MODEL, BASE_MODEL_REVISION, "Serving base model revision")
53
- configure_hugging_face_cache(os.environ.get("HF_HOME"))
54
- import_runtime_dependencies()
55
- TEMP_DIR = TemporaryDirectory(prefix="tt-local-serve-")
56
- ADAPTER_PATH = (
57
- resolve_adapter_path(MODEL_ARTIFACT, Path(TEMP_DIR.name))
58
- if MODEL_ARTIFACT
59
- else None
60
- )
61
- MODEL_PAYLOAD = {
62
- "base_model": BASE_MODEL,
63
- "model_source": MODEL_SOURCE,
64
- "base_model_revision": BASE_MODEL_REVISION,
65
- "device": DEVICE_REQUEST,
66
- "model_loader": MODEL_LOADER,
67
- }
68
- MODEL, TOKENIZER, DEVICE = load_text_model(MODEL_PAYLOAD, ADAPTER_PATH, MODEL_LOADER)
69
- GENERATION_LOCK = threading.Lock()
70
- REQUEST_SLOTS = threading.BoundedSemaphore(MAX_CONCURRENT_REQUESTS)
71
-
72
12
 
73
- def text_content(content: Any) -> str:
74
- if isinstance(content, str):
75
- return content
76
- if content is None:
77
- return ""
78
- if not isinstance(content, list):
79
- raise ValueError("Message content must be text or an array of text parts.")
80
- parts: list[str] = []
81
- for part in content:
82
- if not isinstance(part, dict) or part.get("type") != "text":
83
- raise ValueError("The bundled model server accepts text content only.")
84
- text = part.get("text")
85
- if not isinstance(text, str):
86
- raise ValueError("Every text content part must contain a string text field.")
87
- parts.append(text)
88
- return "\n".join(parts)
89
-
90
-
91
- def normalize_messages(raw_messages: Any) -> list[dict[str, str]]:
92
- if not isinstance(raw_messages, list) or not raw_messages:
93
- raise ValueError("Request must include a non-empty messages array.")
94
- if len(raw_messages) > MAX_MESSAGES:
95
- raise ValueError(f"Request exceeds the {MAX_MESSAGES}-message limit.")
96
-
97
- system_parts = [SYSTEM_PROMPT] if SYSTEM_PROMPT else []
98
- conversation: list[dict[str, str]] = []
99
- for raw in raw_messages:
100
- if not isinstance(raw, dict) or raw.get("role") not in {"system", "user", "assistant", "tool"}:
101
- raise ValueError("Each message must have a supported role and text content.")
102
- role = str(raw["role"])
103
- content = text_content(raw.get("content", ""))
104
- if role == "system":
105
- if content.strip():
106
- system_parts.append(content.strip())
13
+ from evaluate import configure_hugging_face_cache, resolve_adapter_path
14
+ from model_contract import assert_certified_base_model_revision, assert_certified_model_config
15
+
16
+ class BearerAuthMiddleware:
17
+ """Keep TT's auth boundary on ALL routes, including upstream admin APIs.
18
+
19
+ vLLM's default bearer guard only covers selected API prefixes. This is
20
+ transport admission only; requests/responses and generation stay upstream.
21
+ """
22
+ def __init__(self, app):
23
+ self.app = app
24
+ self.expected = os.environ["TT_API_KEY"].encode()
25
+
26
+ async def __call__(self, scope, receive, send):
27
+ if scope["type"] in {"http", "websocket"}:
28
+ authorization = dict(scope.get("headers", [])).get(b"authorization", b"")
29
+ scheme, _, token = authorization.partition(b" ")
30
+ if scheme.lower() != b"bearer" or not hmac.compare_digest(token, self.expected):
31
+ if scope["type"] == "websocket":
32
+ await send({"type": "websocket.close", "code": 1008})
33
+ else:
34
+ await send({"type": "http.response.start", "status": 401,
35
+ "headers": [(b"content-type", b"application/json")]})
36
+ await send({"type": "http.response.body", "body": b'{"error":"Unauthorized"}'})
37
+ return
38
+ await self.app(scope, receive, send)
39
+
40
+
41
+ def build_vllm_args(model_source: str, adapter_path: str | None, temp_dir: Path) -> list[str]:
42
+ args = [
43
+ "serve", model_source,
44
+ "--served-model-name", os.environ["TT_MODEL_NAME"],
45
+ "--host", os.environ.get("TT_HOST", "127.0.0.1"),
46
+ "--port", os.environ.get("TT_PORT", "8000"),
47
+ "--max-model-len", os.environ.get("TT_CONTEXT_LENGTH", "16384"),
48
+ "--max-num-seqs", os.environ.get("TT_MAX_CONCURRENT_REQUESTS", "1"),
49
+ "--gpu-memory-utilization", os.environ.get("TT_GPU_MEMORY_UTILIZATION", "0.8"),
50
+ "--generation-config", "vllm",
51
+ "--override-generation-config", json.dumps({
52
+ "max_new_tokens": int(os.environ.get("TT_MAX_TOKENS", "512")),
53
+ "temperature": float(os.environ.get("TT_TEMPERATURE", "0")),
54
+ "top_p": float(os.environ.get("TT_TOP_P", "1")),
55
+ }),
56
+ "--language-model-only",
57
+ "--no-enable-log-requests", "--no-enable-log-outputs",
58
+ ]
59
+ if os.environ.get("TT_API_KEY"):
60
+ args += ["--middleware", "serve.BearerAuthMiddleware"]
61
+ base = os.environ["TT_BASE_MODEL"]
62
+ # Upstream model-specific parsers, not a TT parser or model-size heuristic.
63
+ parser = {
64
+ "Qwen/Qwen3.5-2B": "qwen3_xml",
65
+ "nvidia/NVIDIA-Nemotron-3.5-Lightning-30B-A3B-BF16": "qwen3_coder",
66
+ "meta-models/Muse-Glimmer-30B": "muse_glimmer",
67
+ }[base]
68
+ args += ["--enable-auto-tool-choice", "--tool-call-parser", parser]
69
+ if base != "meta-models/Muse-Glimmer-30B":
70
+ args += ["--default-chat-template-kwargs", '{"enable_thinking":false}']
71
+ if adapter_path:
72
+ config = json.loads((Path(adapter_path) / "adapter_config.json").read_text())
73
+ adapter_base = config.get("base_model_name_or_path")
74
+ if adapter_base not in {os.environ["TT_BASE_MODEL"], model_source}:
75
+ raise ValueError("The adapter base does not match the verified serving model.")
76
+ base_name = "base:" + os.environ["TT_BASE_MODEL"]
77
+ args[args.index("--served-model-name") + 1] = base_name
78
+ args += ["--enable-lora", "--max-lora-rank", str(max(8, int(config["r"]))),
79
+ "--lora-modules", json.dumps({"name": os.environ["TT_MODEL_NAME"],
80
+ "path": adapter_path, "base_model_name": base_name})]
81
+ prompt = os.environ.get("TT_SYSTEM_PROMPT", "").strip()
82
+ if prompt:
83
+ source = Path(model_source)
84
+ template_file = source / "chat_template.jinja"
85
+ if template_file.is_file():
86
+ template = template_file.read_text(encoding="utf-8")
107
87
  else:
108
- conversation.append({"role": role, "content": content})
109
-
110
- if not conversation:
111
- raise ValueError("Request must contain at least one non-system message.")
112
- messages: list[dict[str, str]] = []
113
- if system_parts:
114
- # The certified chat templates accept one leading system message. Merge the invariant owner
115
- # prompt and any client context instead of triggering an implicit
116
- # template fallback with duplicate system turns.
117
- messages.append({"role": "system", "content": "\n\n".join(system_parts)})
118
- messages.extend(conversation)
119
- if sum(len(message["content"]) for message in messages) > MAX_PROMPT_CHARS:
120
- raise ValueError(f"Prompt exceeds the {MAX_PROMPT_CHARS}-character limit.")
121
- return messages
122
-
123
-
124
- def generate_text(messages: list[dict[str, str]], generation: dict[str, Any]) -> tuple[str, int, int]:
125
- try:
126
- prompt = TOKENIZER.apply_chat_template(
127
- messages,
128
- tokenize=False,
129
- add_generation_prompt=True,
130
- **chat_template_kwargs(BASE_MODEL),
131
- )
132
- except Exception as exc:
133
- raise ValueError(f"Model chat-template rendering failed: {exc}") from exc
134
- inputs = TOKENIZER(prompt, return_tensors="pt")
135
- if int(inputs["input_ids"].shape[-1]) > MAX_PROMPT_TOKENS:
136
- raise ValueError(f"Prompt exceeds the {MAX_PROMPT_TOKENS}-token limit.")
137
- target_device = next(MODEL.parameters()).device
138
- inputs = {key: value.to(target_device) for key, value in inputs.items()}
139
- with GENERATION_LOCK:
140
- import torch
141
-
142
- with torch.inference_mode():
143
- generated = MODEL.generate(
144
- **inputs,
145
- max_new_tokens=int(generation["max_new_tokens"]),
146
- pad_token_id=TOKENIZER.eos_token_id,
147
- **sampling_kwargs(generation),
148
- )
149
- prompt_tokens = int(inputs["input_ids"].shape[-1])
150
- completion_tokens = generated[0][prompt_tokens:]
151
- content = TOKENIZER.decode(completion_tokens, skip_special_tokens=True).strip()
152
- return content, prompt_tokens, int(completion_tokens.shape[-1])
153
-
154
-
155
- def bounded_number(value: Any, default: float, minimum: float, maximum: float) -> float:
156
- number = float(default if value is None else value)
157
- if not math.isfinite(number) or number < minimum or number > maximum:
158
- raise ValueError(f"Generation value must be between {minimum} and {maximum}.")
159
- return number
160
-
161
-
162
- def bounded_integer(value: Any, default: int, minimum: int, maximum: int) -> int:
163
- number = bounded_number(value, default, minimum, maximum)
164
- if not number.is_integer():
165
- raise ValueError(f"Generation value must be an integer between {minimum} and {maximum}.")
166
- return int(number)
167
-
168
-
169
- class Handler(BaseHTTPRequestHandler):
170
- server_version = "TunedTensorLocalServer/0.1"
171
-
172
- def setup(self) -> None:
173
- super().setup()
174
- self.connection.settimeout(30)
175
-
176
- def authorized(self) -> bool:
177
- if not API_KEY:
178
- return True
179
- supplied = self.headers.get("authorization", "")
180
- return hmac.compare_digest(supplied, "Bearer " + API_KEY)
181
-
182
- def send_json(self, status: int, payload: Any) -> None:
183
- body = json.dumps(payload).encode("utf-8")
184
- self.send_response(status)
185
- self.send_header("Content-Type", "application/json")
186
- self.send_header("Content-Length", str(len(body)))
187
- self.end_headers()
188
- self.wfile.write(body)
189
-
190
- def do_GET(self) -> None: # noqa: N802
191
- if not self.authorized():
192
- self.send_json(401, {"error": {"message": "Unauthorized"}})
193
- return
194
- if self.path in {"/", "/health"}:
195
- self.send_json(200, {"status": "ok", "model": MODEL_NAME, "device": DEVICE})
196
- return
197
- if self.path == "/v1/models":
198
- self.send_json(200, {
199
- "object": "list",
200
- "data": [{"id": MODEL_NAME, "object": "model", "owned_by": "tuned-tensor-local"}],
201
- })
202
- return
203
- self.send_json(404, {"error": {"message": "Not found"}})
204
-
205
- def do_POST(self) -> None: # noqa: N802
206
- if not self.authorized():
207
- self.send_json(401, {"error": {"message": "Unauthorized"}})
208
- return
209
- if self.path not in {"/v1/chat/completions", "/chat/completions"}:
210
- self.send_json(404, {"error": {"message": "Not found"}})
211
- return
88
+ template = json.loads((source / "tokenizer_config.json").read_text())["chat_template"]
89
+ if not isinstance(template, str):
90
+ raise ValueError("Serving requires an unambiguous string chat template.")
91
+ # Literal data, never prompt text interpolated as Jinja source. Preserve
92
+ # tool calls/results verbatim while merging leading system context.
93
+ prefix = "{%- set tt = namespace(system=" + json.dumps(prompt) + ", history=[]) -%}"
94
+ prefix += "{%- for message in messages -%}{%- if message.role == 'system' -%}"
95
+ prefix += "{%- set tt.system = tt.system + '\\n\\n' + message.content -%}"
96
+ prefix += "{%- else -%}{%- set tt.history = tt.history + [message] -%}{%- endif -%}{%- endfor -%}"
97
+ prefix += "{%- set messages = [{'role': 'system', 'content': tt.system}] + tt.history -%}"
98
+ path = temp_dir / "chat-template.jinja"
99
+ path.write_text(prefix + template, encoding="utf-8")
100
+ args += ["--chat-template", str(path)]
101
+ return args
102
+
103
+
104
+ def prepare_model_source() -> str:
105
+ base = os.environ["TT_BASE_MODEL"]
106
+ revision = os.environ.get("TT_BASE_MODEL_REVISION")
107
+ assert_certified_base_model_revision(base, revision, "Serving base model revision")
108
+ configure_hugging_face_cache(os.environ.get("HF_HOME"))
109
+ source = os.environ.get("TT_MODEL_SOURCE", base)
110
+ if not Path(source).is_dir():
111
+ if source != base:
112
+ raise ValueError("Explicit serving snapshot is not an existing directory.")
113
+ from huggingface_hub import snapshot_download
114
+ source = snapshot_download(repo_id=base, revision=revision, local_files_only=True)
115
+ config = json.loads((Path(source) / "config.json").read_text(encoding="utf-8"))
116
+ assert_certified_model_config(config, "Serving model config", base)
117
+ return str(Path(source).resolve())
118
+
119
+
120
+ def run_server() -> None:
121
+ host = os.environ.get("TT_HOST", "127.0.0.1")
122
+ port = int(os.environ.get("TT_PORT", "8000"))
123
+ # Early diagnostic; upstream still owns the real bind and handles races.
124
+ family = socket.AF_INET6 if ":" in host else socket.AF_INET
125
+ with socket.socket(family, socket.SOCK_STREAM) as probe:
126
+ probe.bind((host, port))
127
+ model_source = prepare_model_source()
128
+ if os.environ.get("TT_API_KEY"):
129
+ os.environ["VLLM_API_KEY"] = os.environ["TT_API_KEY"]
130
+ with TemporaryDirectory(prefix="tt-serve-") as temp:
131
+ adapter = resolve_adapter_path(os.environ.get("TT_MODEL_ARTIFACT"), Path(temp))
132
+ args = build_vllm_args(model_source, adapter, Path(temp))
133
+ from vllm.entrypoints.cli.main import main
134
+ previous = sys.argv
212
135
  try:
213
- length = int(self.headers.get("content-length", "0"))
214
- if length <= 0 or length > MAX_REQUEST_BYTES:
215
- raise ValueError("Request body is empty or too large.")
216
- body = json.loads(self.rfile.read(length))
217
- if not isinstance(body, dict):
218
- raise ValueError("Request body must be a JSON object.")
219
- if body.get("stream"):
220
- self.send_json(400, {"error": {"message": "Streaming is not supported yet."}})
221
- return
222
- messages = normalize_messages(body.get("messages"))
223
- generation = {
224
- "max_new_tokens": bounded_integer(body.get("max_tokens"), DEFAULT_MAX_TOKENS, 1, 8192),
225
- "temperature": bounded_number(body.get("temperature"), DEFAULT_TEMPERATURE, 0, 5),
226
- "top_p": bounded_number(body.get("top_p"), DEFAULT_TOP_P, 0, 1),
227
- }
228
- if not REQUEST_SLOTS.acquire(blocking=False):
229
- self.send_json(429, {"error": {"message": "The local model is busy; retry shortly."}})
230
- return
231
- started = time.perf_counter()
232
- try:
233
- content, prompt_tokens, completion_tokens = generate_text(messages, generation)
234
- finally:
235
- REQUEST_SLOTS.release()
236
- latency_ms = round((time.perf_counter() - started) * 1000)
237
- self.send_json(200, {
238
- "id": "chatcmpl-" + uuid.uuid4().hex,
239
- "object": "chat.completion",
240
- "created": int(time.time()),
241
- "model": MODEL_NAME,
242
- "choices": [{
243
- "index": 0,
244
- "message": {"role": "assistant", "content": content},
245
- "finish_reason": "stop",
246
- }],
247
- "usage": {
248
- "prompt_tokens": prompt_tokens,
249
- "completion_tokens": completion_tokens,
250
- "total_tokens": prompt_tokens + completion_tokens,
251
- },
252
- "tt_local": {"latency_ms": latency_ms, "device": DEVICE},
253
- })
254
- except (ValueError, TypeError, json.JSONDecodeError) as exc:
255
- self.send_json(400, {"error": {"message": str(exc)}})
256
- except Exception as exc:
257
- print(f"Model server error: {type(exc).__name__}: {exc}", file=sys.stderr, flush=True)
258
- self.send_json(500, {"error": {"message": "Internal model server error."}})
259
-
260
- def log_message(self, fmt: str, *args: Any) -> None:
261
- print("%s - %s" % (self.address_string(), fmt % args), flush=True)
262
-
263
-
264
- def main() -> None:
265
- print(f"Serving {MODEL_NAME} on http://{HOST}:{PORT}", flush=True)
266
- print(f"OpenAI-compatible endpoint: http://{HOST}:{PORT}/v1/chat/completions", flush=True)
267
- print(f"Device: {DEVICE}", flush=True)
268
- server = ThreadingHTTPServer((HOST, PORT), Handler)
269
- server.daemon_threads = True
270
- server.request_queue_size = max(2, MAX_CONCURRENT_REQUESTS * 2)
271
- try:
272
- server.serve_forever()
273
- except KeyboardInterrupt:
274
- print("Model server stopped.", flush=True)
275
- finally:
276
- server.server_close()
136
+ sys.argv = ["vllm", *args]
137
+ main()
138
+ finally:
139
+ sys.argv = previous
277
140
 
278
141
 
279
142
  if __name__ == "__main__":
280
- main()
143
+ # Ensure temporary artifacts unwind if stopped before upstream installs its
144
+ # own graceful shutdown handlers. Node forwards signals to the whole group.
145
+ signal.signal(signal.SIGTERM, lambda *_: sys.exit(0))
146
+ run_server()
@@ -92,8 +92,8 @@ def assert_certified_request() -> None:
92
92
  def require_cuda() -> dict[str, Any]:
93
93
  if not torch.cuda.is_available():
94
94
  raise RuntimeError(
95
- "TT Local bundled training requires an NVIDIA CUDA GPU. "
96
- "Run this workflow on DGX Spark and verify it first with tt-local doctor."
95
+ "TT local training requires an NVIDIA CUDA GPU. "
96
+ "Run this workflow on a supported CUDA host and verify it first with tt doctor."
97
97
  )
98
98
  device_index = torch.cuda.current_device()
99
99
  properties = torch.cuda.get_device_properties(device_index)
@@ -0,0 +1,9 @@
1
+ [project]
2
+ name = "tuned-tensor-serving"
3
+ version = "0.1.0"
4
+ requires-python = ">=3.12,<3.13"
5
+ dependencies = ["vllm==0.28.0"]
6
+
7
+ [tool.uv]
8
+ package = false
9
+ environments = ["sys_platform == 'linux'"]