@tuned-tensor/cli 0.13.1 → 0.14.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.
@@ -0,0 +1,168 @@
1
+ # Local serving and coding harnesses
2
+
3
+ `tt serve` verifies TT artifacts and launches **vLLM 0.28.0**. vLLM owns model
4
+ execution, batching, KV/prefix caching, streaming, and native tool-call parsing.
5
+ There is no TT inference loop, HTTP server, or proxy between the harness and
6
+ vLLM. The serving dependencies are locked separately from training, in an
7
+ external cache environment; an npm upgrade does not mutate the training runtime.
8
+
9
+ ## Requirements and scope
10
+
11
+ - The packaged serving runtime requires **Linux and NVIDIA CUDA**. CPU/macOS
12
+ serving is not supported by this runtime; CLI/package commands still work
13
+ there. Unsupported hardware or model/adapter combinations fail rather than
14
+ silently falling back to the old server.
15
+ - Install `uv` and prefetch the certified model with `tt models prefetch`.
16
+ First launch installs the locked serving dependencies and may compile kernels;
17
+ it needs substantially more time and disk than a warm request. CUDA JIT may
18
+ require a working CUDA toolkit/C++ toolchain. Model loading remains offline.
19
+ - Foundation checkpoints and image inputs are not supported by this path.
20
+ - Qwen3.5-2B base and a one-step, rank-16 `all-linear` TT-shaped adapter have been
21
+ exercised on GB10. The registry's Nemotron and Muse Glimmer architectures have
22
+ upstream support, but their exact adapters/hardware combinations are **not
23
+ certified by that Qwen smoke test**. No 70B model was tested here.
24
+
25
+ ## Serve once, compare base and tuned
26
+
27
+ ```bash
28
+ tt models prefetch tunedtensor.json
29
+
30
+ tt serve local-<run-id> --config local-runner.json \
31
+ --context-length 8192 --max-tokens 512
32
+ ```
33
+
34
+ This exposes **both** `local-<run-id>` and `base:<base-model-id>` at the same
35
+ endpoint, sharing the base weights. The adapter is registered at startup; TT
36
+ never enables runtime adapter upload/loading. There is no merge/export step
37
+ and no modification of the verified weights. `tt serve base` starts base-only.
38
+ `active` still fails when no adapter is activated.
39
+
40
+ Artifact integrity, certified base revisions, and stored behavior-prompt
41
+ fingerprints retain their verification. A stored adapter's behavior prompt is
42
+ applied to both model IDs for comparison. `tt serve base` without explicit
43
+ `--spec` does not inject the adjacent spec; use `--spec tunedtensor.json` for that.
44
+ `--no-spec-prompt` is the explicit opt-out. A small generated template prefix
45
+ preserves the owner instructions while leaving tool messages and upstream chat
46
+ formatting intact; it never changes the saved tokenizer/template.
47
+
48
+ ### Resource controls
49
+
50
+ - `--context-length`: total context budget, default 16,384 tokens. Keep it large
51
+ enough for the harness's instructions and tool results, not just the question.
52
+ - `--max-tokens`: generation budget, default 512. vLLM receives this through its
53
+ generation configuration; the upstream API owns per-request validation.
54
+ - `--gpu-memory-utilization`: fraction of device memory budgeted to vLLM,
55
+ default 0.8. On shared-memory machines leave room for the OS and other work.
56
+ For the small Qwen GB10 smoke we used **0.15**; this is not suitable for every
57
+ model size. Reduce the context or memory budget when upstream reports a fit
58
+ failure, and ensure the weights themselves still fit.
59
+ - `--max-concurrent-requests`: maximum sequences scheduled in a vLLM batch,
60
+ default one, maximum eight. This is upstream batching, **not** the previous
61
+ HTTP admission/429 contract; upstream may queue additional requests.
62
+
63
+ Health becomes ready after loading/warm-up. Ctrl-C terminates the process group.
64
+ A port conflict is checked before loading; upstream owns the final bind and any
65
+ race. The unreleased custom server's `--merge-adapter` flag is removed.
66
+
67
+ ## Pi
68
+
69
+ Export a new isolated configuration rather than replacing global settings:
70
+
71
+ ```bash
72
+ export PI_CODING_AGENT_DIR="$(mktemp -d)"
73
+ tt serve local-<run-id> --config local-runner.json \
74
+ --context-length 8192 --max-tokens 512 \
75
+ --print-client-config pi > "$PI_CODING_AGENT_DIR/models.json"
76
+ ```
77
+
78
+ Use the same launch options as the running server. Exporting config verifies the
79
+ TT target but does not start a server. Adapter exports include both model IDs.
80
+ For example, in a disposable coding-fixture directory:
81
+
82
+ ```bash
83
+ pi --provider tt-local --model 'base:Qwen/Qwen3.5-2B' \
84
+ --tools read --no-extensions --no-skills --no-prompt-templates \
85
+ --no-context-files --no-session --offline --thinking off \
86
+ -p 'Read fixture.py and explain what its function returns.'
87
+ ```
88
+
89
+ Repeat with `--model local-<run-id>` in a fresh session. `--tools read` is a
90
+ read-only tool allowlist, **not a filesystem sandbox**. Use a disposable account
91
+ or an enforcing harness permission boundary for untrusted tasks. Enable edits
92
+ or shell commands only when you intend to grant those permissions.
93
+
94
+ For question-only comparisons, `--no-tools` remains an optional experiment
95
+ setting. It is no longer a server requirement. `reasoning: false` and
96
+ `supportsReasoningEffort: false` in the Pi export describe this endpoint's
97
+ reasoning-control contract; **neither disables tool calls or asserts that the
98
+ model cannot reason**.
99
+
100
+ ## OpenCode
101
+
102
+ OpenCode can connect directly using its existing custom provider support:
103
+
104
+ ```json
105
+ {
106
+ "$schema": "https://opencode.ai/config.json",
107
+ "provider": {
108
+ "tt-local": {
109
+ "npm": "@ai-sdk/openai-compatible",
110
+ "name": "TT local",
111
+ "options": { "baseURL": "http://127.0.0.1:8000/v1" },
112
+ "models": {
113
+ "base:Qwen/Qwen3.5-2B": {
114
+ "name": "TT base",
115
+ "limit": { "context": 8192, "output": 512 }
116
+ }
117
+ }
118
+ }
119
+ }
120
+ }
121
+ ```
122
+
123
+ Add the exact adapter ID from `/v1/models` to compare it. Set the actual server
124
+ budgets, configure OpenCode's permissions, and select
125
+ `opencode run --model 'tt-local/base:Qwen/Qwen3.5-2B' ...`. TT does not install a
126
+ second agent framework or change global OpenCode configuration.
127
+
128
+ ## Tool support and security
129
+
130
+ TT selects upstream parsers for the registered models:
131
+
132
+ - Qwen3.5-2B: `qwen3_xml`.
133
+ - Nemotron 3.5 Lightning: `qwen3_coder` (ordinary non-thinking template mode).
134
+ - Muse Glimmer: `muse_glimmer`.
135
+
136
+ These are protocol integrations, not guarantees that a model will choose the
137
+ right tool or produce correct code. A complete test must observe a tool call,
138
+ execute an allowed tool, send its result back, and verify the final answer.
139
+ The harness owns tool execution/permissions; vLLM only generates the calls.
140
+ Fine-tuning can change tool reliability, so compare the tuned model too.
141
+
142
+ The default bind is `127.0.0.1:8000`. Non-loopback binds require both
143
+ `--allow-remote` and `--api-key-env NAME`. An authentication-only middleware
144
+ extends the bearer check to **all** upstream HTTP routes, including health,
145
+ tokenization, and administrative routes; vLLM's built-in guard covers only
146
+ selected prefixes. No token is put in argv or exported client configuration.
147
+ Pi uses `${NAME}` interpolation; OpenCode can use `"apiKey": "{env:NAME}"`.
148
+ Provide the variable separately to each process. Without `--api-key-env`, local
149
+ serving is unauthenticated. Use a trusted host or encrypted tunnel, never expose
150
+ plain HTTP directly to the Internet. Request/output logging and vLLM usage
151
+ telemetry are disabled by the TT launcher.
152
+
153
+ ## API and measurement
154
+
155
+ Use `/health`, `/v1/models`, and `/v1/chat/completions`. API bodies, SSE events,
156
+ usage, tool calls, cancellation, and error semantics come from the pinned
157
+ upstream server, not a TT approximation. The old custom server's message/byte
158
+ limits, HTTP errors, and health JSON are not preserved as an API contract.
159
+
160
+ Measure startup and first-request compilation separately from steady-state
161
+ first-text latency and tokens/second. Compare identical prompts, templates,
162
+ budgets, and fresh harness sessions on an otherwise-idle machine. Test generated
163
+ code independently; a tiny one-step adapter proves integration, not better
164
+ coding. Pi's zero provider token prices do not mean zero hardware cost.
165
+
166
+ References: [vLLM LoRA](https://docs.vllm.ai/en/v0.28.0/features/lora/),
167
+ [Nemotron tool configuration](https://docs.nvidia.com/nim/large-language-models/latest/get-started/advanced/get-started-nemotron-3.5-lightning.html),
168
+ [OpenCode custom providers](https://opencode.ai/docs/providers/#custom-provider).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tuned-tensor/cli",
3
- "version": "0.13.1",
3
+ "version": "0.14.0",
4
4
  "description": "Local TT agent and CUDA fine-tuning CLI for Tuned Tensor workflows",
5
5
  "repository": {
6
6
  "type": "git",
@@ -25,6 +25,8 @@
25
25
  "training/adapter/pyproject.toml",
26
26
  "training/adapter/uv.lock",
27
27
  "training/adapter/src/*.py",
28
+ "training/serving/pyproject.toml",
29
+ "training/serving/uv.lock",
28
30
  "training/foundation/pyproject.toml",
29
31
  "training/foundation/uv.lock",
30
32
  "training/foundation/src/*.py",
@@ -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()
@@ -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'"]