Skip to content

Commit dfd6470

Browse files
committed
Add local token-id generation endpoint
1 parent 344fda6 commit dfd6470

6 files changed

Lines changed: 433 additions & 1 deletion

File tree

cppmega_mlx/inference/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@
3232
validate_kv_head_dim,
3333
)
3434
from cppmega_mlx.inference.sampling import sample_next_token
35+
from cppmega_mlx.inference.serve import create_local_generation_app
3536
from cppmega_mlx.inference.serving import (
3637
ContinuousBatchScheduler,
3738
PAGED_ATTENTION_NOT_INTEGRATED_MESSAGE,
@@ -97,6 +98,7 @@
9798
"build_paged_block_table",
9899
"builtin_code_metadata_adapters",
99100
"clone_contiguous_kv_cache",
101+
"create_local_generation_app",
100102
"generate_tokens",
101103
"generate_tokens_mtp_self_speculative",
102104
"generate_tokens_speculative",

cppmega_mlx/inference/serve.py

Lines changed: 255 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,255 @@
1+
"""Local FastAPI adapter for token-id generation."""
2+
3+
from __future__ import annotations
4+
5+
from collections.abc import Callable, Mapping
6+
from importlib import import_module
7+
from typing import Any, NoReturn
8+
9+
import mlx.core as mx
10+
11+
from cppmega_mlx.inference.generation import generate_tokens
12+
13+
ModelKwargsBuilder = Callable[[list[list[int]]], Mapping[str, mx.array]]
14+
TokenDecoder = Callable[[int], str]
15+
16+
17+
def create_local_generation_app(
18+
model: Any,
19+
*,
20+
model_id: str = "local",
21+
eos_token_id: int | None = None,
22+
decode_token: TokenDecoder | None = None,
23+
model_kwargs_builder: ModelKwargsBuilder | None = None,
24+
) -> Any:
25+
"""Create the Mac-local token-id serving app.
26+
27+
This is intentionally not an OpenAI-compatible chat/completions API. It is
28+
the local Stream I adapter around the eager token-id generation loop, so
29+
callers keep ownership of tokenization and optional side-channel metadata.
30+
"""
31+
32+
FastAPI, HTTPException = _load_fastapi()
33+
app = FastAPI(title="cppmega.mlx local generation", version="0.1.0")
34+
35+
@app.get("/health")
36+
def health() -> dict[str, str]:
37+
return {"status": "ok", "model_id": model_id}
38+
39+
@app.post("/generate")
40+
async def generate(payload: dict[str, Any]) -> dict[str, Any]:
41+
try:
42+
input_rows = _parse_input_ids(payload.get("input_ids"))
43+
max_new_tokens = _parse_int_option(
44+
payload,
45+
"max_new_tokens",
46+
default=16,
47+
minimum=0,
48+
)
49+
temperature = _parse_float_option(
50+
payload,
51+
"temperature",
52+
default=1.0,
53+
minimum=0.0,
54+
)
55+
top_k = _parse_optional_int_option(
56+
payload,
57+
"top_k",
58+
minimum=1,
59+
)
60+
top_p = _parse_optional_float_option(
61+
payload,
62+
"top_p",
63+
default=1.0,
64+
minimum_exclusive=0.0,
65+
maximum=1.0,
66+
)
67+
request_eos_token_id = _parse_optional_int_option(
68+
payload,
69+
"eos_token_id",
70+
default=eos_token_id,
71+
minimum=0,
72+
)
73+
model_kwargs = _build_model_kwargs(model_kwargs_builder, input_rows)
74+
prompt = mx.array(input_rows, dtype=mx.int32)
75+
output = generate_tokens(
76+
model,
77+
prompt,
78+
max_new_tokens=max_new_tokens,
79+
model_kwargs=model_kwargs,
80+
eos_token_id=request_eos_token_id,
81+
temperature=temperature,
82+
top_k=top_k,
83+
top_p=top_p,
84+
)
85+
except _BadGenerationRequest as exc:
86+
raise HTTPException(status_code=400, detail=str(exc)) from exc
87+
except ValueError as exc:
88+
raise HTTPException(status_code=400, detail=str(exc)) from exc
89+
90+
mx.eval(output)
91+
output_rows = _coerce_nested_int_rows(output.tolist())
92+
prompt_width = len(input_rows[0])
93+
generated_rows = [row[prompt_width:] for row in output_rows]
94+
response: dict[str, Any] = {
95+
"model": model_id,
96+
"input_ids": input_rows,
97+
"output_ids": output_rows,
98+
"generated_ids": generated_rows,
99+
"finish_reason": _finish_reason(generated_rows, request_eos_token_id),
100+
}
101+
if decode_token is not None:
102+
response["generated_text"] = [
103+
"".join(decode_token(token_id) for token_id in row)
104+
for row in generated_rows
105+
]
106+
return response
107+
108+
return app
109+
110+
111+
class _BadGenerationRequest(ValueError):
112+
pass
113+
114+
115+
def _load_fastapi() -> tuple[Any, Any]:
116+
try:
117+
fastapi = import_module("fastapi")
118+
except ModuleNotFoundError as exc:
119+
raise RuntimeError(
120+
"fastapi is required for create_local_generation_app; install the "
121+
"cppmega-mlx[gui] extra or provide FastAPI in the serving environment"
122+
) from exc
123+
return fastapi.FastAPI, fastapi.HTTPException
124+
125+
126+
def _parse_input_ids(value: Any) -> list[list[int]]:
127+
if not isinstance(value, list):
128+
_raise_bad_request("input_ids must be list[int] or list[list[int]]")
129+
if not value:
130+
_raise_bad_request("input_ids must not be empty")
131+
132+
if all(_is_token_id(token_id) for token_id in value):
133+
rows = [list(value)]
134+
elif all(isinstance(row, list) for row in value):
135+
rows = [list(row) for row in value]
136+
else:
137+
_raise_bad_request("input_ids must be list[int] or list[list[int]]")
138+
139+
if not rows or not rows[0]:
140+
_raise_bad_request("input_ids rows must not be empty")
141+
row_width = len(rows[0])
142+
for row in rows:
143+
if len(row) != row_width:
144+
_raise_bad_request("input_ids rows must have the same length")
145+
if not all(_is_token_id(token_id) for token_id in row):
146+
_raise_bad_request("input_ids must contain non-negative integer token ids")
147+
return rows
148+
149+
150+
def _build_model_kwargs(
151+
builder: ModelKwargsBuilder | None,
152+
input_rows: list[list[int]],
153+
) -> Mapping[str, mx.array] | None:
154+
if builder is None:
155+
return None
156+
built = builder([row.copy() for row in input_rows])
157+
if not isinstance(built, Mapping):
158+
raise ValueError("model_kwargs_builder must return a mapping")
159+
for key, value in built.items():
160+
if not isinstance(key, str):
161+
raise ValueError("model_kwargs_builder keys must be strings")
162+
if not isinstance(value, mx.array):
163+
raise ValueError("model_kwargs_builder values must be mlx arrays")
164+
return built
165+
166+
167+
def _parse_int_option(
168+
payload: Mapping[str, Any],
169+
name: str,
170+
*,
171+
default: int,
172+
minimum: int,
173+
) -> int:
174+
value = payload.get(name, default)
175+
if not isinstance(value, int) or isinstance(value, bool):
176+
_raise_bad_request(f"{name} must be an integer")
177+
if value < minimum:
178+
_raise_bad_request(f"{name} must be >= {minimum}")
179+
return value
180+
181+
182+
def _parse_optional_int_option(
183+
payload: Mapping[str, Any],
184+
name: str,
185+
*,
186+
default: int | None = None,
187+
minimum: int,
188+
) -> int | None:
189+
value = payload.get(name, default)
190+
if value is None:
191+
return None
192+
if not isinstance(value, int) or isinstance(value, bool):
193+
_raise_bad_request(f"{name} must be an integer or null")
194+
if value < minimum:
195+
_raise_bad_request(f"{name} must be >= {minimum}")
196+
return value
197+
198+
199+
def _parse_float_option(
200+
payload: Mapping[str, Any],
201+
name: str,
202+
*,
203+
default: float,
204+
minimum: float,
205+
) -> float:
206+
value = payload.get(name, default)
207+
if not isinstance(value, int | float) or isinstance(value, bool):
208+
_raise_bad_request(f"{name} must be numeric")
209+
result = float(value)
210+
if result < minimum:
211+
_raise_bad_request(f"{name} must be >= {minimum}")
212+
return result
213+
214+
215+
def _parse_optional_float_option(
216+
payload: Mapping[str, Any],
217+
name: str,
218+
*,
219+
default: float | None = None,
220+
minimum_exclusive: float,
221+
maximum: float,
222+
) -> float | None:
223+
value = payload.get(name, default)
224+
if value is None:
225+
return None
226+
if not isinstance(value, int | float) or isinstance(value, bool):
227+
_raise_bad_request(f"{name} must be numeric or null")
228+
result = float(value)
229+
if not minimum_exclusive < result <= maximum:
230+
_raise_bad_request(f"{name} must be in ({minimum_exclusive}, {maximum}]")
231+
return result
232+
233+
234+
def _finish_reason(
235+
generated_rows: list[list[int]],
236+
eos_token_id: int | None,
237+
) -> str:
238+
if eos_token_id is not None and generated_rows:
239+
if all(row and row[-1] == eos_token_id for row in generated_rows):
240+
return "eos"
241+
return "length"
242+
243+
244+
def _coerce_nested_int_rows(rows: Any) -> list[list[int]]:
245+
if not isinstance(rows, list):
246+
raise TypeError("generated tokens must serialize to nested rows")
247+
return [[int(token_id) for token_id in row] for row in rows]
248+
249+
250+
def _is_token_id(value: Any) -> bool:
251+
return isinstance(value, int) and not isinstance(value, bool) and value >= 0
252+
253+
254+
def _raise_bad_request(detail: str) -> NoReturn:
255+
raise _BadGenerationRequest(detail)

docs/mlx_port_master_plan.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -888,7 +888,7 @@ Excluded as Hopper-only dead-end on Metal: cppmega/megatron/cute_dsl_mimo/ (sm_9
888888
170. **SCOPED DONE (local streaming slice)**: add `GenerationChunk` and `stream_generate_tokens(...)` in `cppmega_mlx/inference/generation.py`, exported from `cppmega_mlx.inference`. Covered eager full-prefix and contiguous-KV token streaming, batched rows, all-rows EOS stopping, optional per-token text decode, zero-new-token no-op, package export boundary, quantized-KV fail-closed behavior, and paged-attention non-integration guard. This is not full mlx-lm registry export, model-integrated paged attention, prompt cache, q4/KV-q4, speculative/self-spec decoding, OpenAI serving, throughput/quality/long-context benching, or GB10/CUDA parity.
889889
171. **SCOPED DONE (contiguous prompt-cache reuse)**: add PromptCacheEntry, clone_contiguous_kv_cache(...), build_prompt_cache(...), and generate_tokens_with_prompt_cache(...) for repeated-prefix reuse on the Mac-local contiguous KV path. Covered build/reuse, suffix decode, prefix mismatch fail-closed behavior, zero-new-token no-op, package exports, independent cache cloning, and real HybridTinyLM prompt-cache-vs-full-KV parity. This is not paged attention, OpenAI serving, sliding-window/SSM safety, q4/KV-q4, speculative decode, or CUDA/H200 parity.
890890
172. **SCOPED DONE**: validate the local contiguous prompt-cache path as attention/stateless-route only. `build_prompt_cache(...)` and `generate_tokens_with_prompt_cache(...)` now fail closed for stateful route roles such as Mamba3/M2RNN/engram, because the contiguous KV entry does not carry SSM/recurrent/ngram state. Attention-only prompt-cache parity remains covered against full-prefix KV generation. This is not model-integrated sliding-window cache support, SSM-state prompt caching, paged prompt caching, or mlx-lm API parity for every upstream route.
891-
173. Add OpenAI-compatible serving endpoint (vllm-mlx pattern); cppmega_mlx/inference/serve.py.
891+
173. **SCOPED DONE (local token-id serving only)**: add `create_local_generation_app(...)` in `cppmega_mlx/inference/serve.py`, exported from `cppmega_mlx.inference`. The FastAPI adapter exposes `/health` and `/generate` for already-tokenized prompts, threads eager generation options, supports an explicit `model_kwargs_builder` hook for caller-owned side-channel tensors, validates invalid token-id/options requests as HTTP 400, and can return optional generated text via a caller-supplied token decoder. This follows the resolved local research topology; it is not an OpenAI-compatible chat/completions API, tokenizer service, cloud fleet endpoint, paged-attention serving stack, or vllm-mlx parity claim.
892892
174. Add throughput benchmark: prefill / decode tok/s on Qwen3-4B-class and NAM56R-class models.
893893
175. Add quality benchmark: ARC / MMLU / HumanEval on q4-quantized model.
894894
176. Add long-context benchmark (NIAH, RULER) on KV-q4 path.

docs/porting_plan.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -211,6 +211,7 @@ The current collected test files are:
211211
- tests/test_inference_infilling.py
212212
- tests/test_inference_quantization.py
213213
- tests/test_inference_sampling.py
214+
- tests/test_inference_serve.py
214215
- tests/test_inference_serving.py
215216
- tests/test_inference_side_channels.py
216217
- tests/test_inference_speculative_decode.py

0 commit comments

Comments
 (0)