|
| 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) |
0 commit comments