Spaces:
Sleeping
Sleeping
Download src/cism/server.py from spitfire4794/test1111111: direct link, hf CLI and curl.
- Browser
- Download file 230 kB
-
https://huggingface.co/spaces/spitfire4794/test1111111/resolve/main/src/cism/server.py
- Command line
-
hf download hf://spaces/spitfire4794/test1111111/src/cism/server.py
-
curl -L -o server.py https://huggingface.co/spaces/spitfire4794/test1111111/resolve/main/src/cism/server.py
230 kB
| """Single-process, text-only OpenAI HTTP adapter for an already loaded engine. | |
| Run the returned app with Uvicorn in one process (no multiple workers). Admission | |
| is bounded by ``queue_size`` INCLUDING the active job. Tokenization, chat | |
| formatting, prefill, and generation all run on one dedicated thread. | |
| Serving decode (Stream S1 -- iteration-level continuous batching): | |
| - Lone single-stream jobs run the dense ``engine.stream`` (``Session``) path, | |
| bitwise-identical to before, so single-stream tokens/s are unregressed by | |
| construction. The native paged-KV path (``create_paged_session``/block-16, | |
| bitwise-identical math) is the default single-stream path | |
| (``CISM_SERVE_PAGED=0`` opts back out to dense): cooled A/B measured | |
| 0.999x of dense single-stream (shared BlockPool, zero per-session | |
| alloc; Stream S2) with ~26x prefill savings on shared prefixes via | |
| copy-on-write fork. ``spec_k > 0``, Surjo, | |
| and FWKV jobs always keep the dense ``engine.stream`` path. | |
| - Stream S3 automatic prefix reuse: the paged single path above matches | |
| each arrival against a per-engine radix prefix index (``paging.PrefixCache``) | |
| and fork-shares the longest cached token prefix (suffix-only prefill). | |
| Shared-prefix decode is token-identical to full prefill (native fork | |
| re-checks token equality); stale anchors (S2 eviction / pool reset) | |
| invalidate on first failed fork with exact full-prefill fallback. | |
| Hit/miss counters via ``prefix_cache_stats``; ``CISM_SERVE_PREFIXCACHE=0`` | |
| disables. The dense ``BatchSession`` epoch path has no fork entry point, | |
| so it keeps full-prefill rebuilds (documented v0 limit). | |
| - Eligible co-queued greedy jobs (non-streaming, ``temperature == 0``, | |
| ``spec_k == 0``, no A2 opt-ins) on a dense engine with a steppable native | |
| ``BatchSession`` run in an iteration-level scheduler epoch: per-step ADD | |
| (new arrivals join mid-decode via session rebuild over survivors' | |
| ``prompt + generated`` prefixes -- exact for greedy decoding), per-step | |
| EVICT (finished/errored/cancelled leave immediately with one terminal | |
| publish), oldest-first waiting queue with a starvation guard, and chunked | |
| prefill (at most ``prefill_chunk_tokens`` new prompt tokens per rebuild; | |
| env ``CISM_SERVE_PREFILL_CHUNK``). Sampled jobs (same exclusions, | |
| ``temperature > 0``) form same-parameter epochs: identical | |
| (``temperature``, ``top_p``, ``top_k``) jobs share one native session | |
| with each job's own serial seed per sequence (token-identical to | |
| serial); sampled epochs form only on a drain (no mid-decode joins) and | |
| never rotate (a rebuild would restart RNG streams), so they always run | |
| to drain. Streaming, speculative, constrained/tool/logprob, Surjo/FWKV, | |
| pool/batch, and test-double jobs always run serially (remaining v0 | |
| limits below). | |
| - Kill switches: ``CISM_SERVE_PAGED=0`` (back to dense single), | |
| ``CISM_SERVE_MICROBATCH=0`` (disable batching entirely), | |
| ``CISM_SERVE_CONTINUOUS=0`` (fall back to the A1 FIFO micro-batch path), | |
| ``CISM_SERVE_PREFIXCACHE=0`` (disable S3 automatic prefix reuse; | |
| ``CISM_SERVE_PREFIXCACHE_ANCHORS`` bounds retained anchors, default 8). | |
| Rotation tunables: ``CISM_SERVE_EPOCH_STEPS`` (default 1024), | |
| ``CISM_SERVE_MAX_WAIT_S`` (default 30), ``CISM_SERVE_JOIN_DEFER_TOKENS`` | |
| (default 2: skip a join rebuild when the live batch is that close to | |
| draining). | |
| Stream S1 v0 limits (documented, not faked): | |
| - Batches are heterogeneous in prompt/limit/stop; greedy epochs are | |
| seed-independent (mid-decode joins allowed), sampled epochs are | |
| parameter-homogeneous form-on-drain groups (no joins, no rotation, so | |
| per-seq RNG streams stay serial-identical). Per-job truncation keeps | |
| ``"length"``/``"stop"`` exact. | |
| - No preemption with KV preservation: epoch rotation recomputes survivors' | |
| prefixes (one prefill per rebuild); no swap-out, no priorities. | |
| - No intra-prompt prefill chunking: one long prompt prefills atomically | |
| inside a single native call (it is deferred while an epoch is live so it | |
| never stalls decodes, then runs alone). | |
| - No cross-engine fairness beyond FIFO drain order on a shared worker. | |
| Stream A2 API surface (exact-math opt-in paths; never faked): | |
| - Token logprobs (``logprobs`` on ``/v1/completions``, ``logprobs`` + | |
| ``top_logprobs`` on ``/v1/chat/completions``, non-streaming only): after | |
| generation, each emitted token is rescored with the real engine | |
| ``logits()`` over its true prefix (the same native logits the decoder | |
| used -- bitwise vs the reference in ``test_logits_and_cached_generation``), | |
| with an exact float64 log-softmax. Cross-check: the mean negative | |
| logprob equals ``engine.nll()`` over the same IDs within fp32 noise. | |
| - JSON structured output (``response_format`` ``json_object``/``json_schema`` | |
| on chat, greedy only): a Python-side greedy loop calls the real engine | |
| ``logits()`` per step and takes the argmax over tokens whose full-sequence | |
| decode keeps the output a viable JSON (resp. schema) prefix. Only a | |
| documented schema subset is supported (object/array/string/integer/number/ | |
| boolean/null, properties, required, additionalProperties, items, enum, | |
| const); anything else is a 400, never silently ignored. | |
| - Tool calling (``tools`` + ``tool_choice`` on chat, non-streaming only): | |
| the assistant's text is parsed for a real ``{"name", "arguments"}`` call | |
| matching a declared tool, arguments are validated against the tool's | |
| schema, the call is executed by a real local registry function (currently | |
| ``calc``: exact arithmetic, no imports/calls), and the result is fed back | |
| as a follow-up turn. Bounded (at most ``_TOOL_MAX_CALLS`` executions), so | |
| the loop always terminates. Tool names outside the registry are a 400: | |
| the server cannot execute arbitrary client code and will not pretend to. | |
| - Everything without an exact path stays 400/404 (embeddings, audio/image, | |
| ``n > 1``, penalties, ``logit_bias``, streaming+logprobs/tools/JSON, | |
| batch+JSON/tools, pool engines+logprobs, unknown tools). Constrained, tool, | |
| and logprob jobs always run serially -- they never join a micro-batch -- | |
| so the single-stream gate is unregressed by construction. | |
| """ | |
| from __future__ import annotations | |
| import asyncio | |
| from contextlib import asynccontextmanager, suppress | |
| from dataclasses import dataclass, field | |
| import ast | |
| import hmac | |
| import json | |
| import math | |
| import os | |
| import queue | |
| import statistics | |
| import threading | |
| import time | |
| from typing import Annotated, Any, Iterator, Literal, Protocol | |
| import uuid | |
| from fastapi import APIRouter, Depends, FastAPI, Request | |
| from fastapi.exceptions import RequestValidationError | |
| from fastapi.responses import HTMLResponse, JSONResponse, StreamingResponse | |
| from pydantic import BaseModel, ConfigDict, Field, model_validator | |
| from starlette.exceptions import HTTPException | |
| class _Engine(Protocol): | |
| model_id: str | |
| context_length: int | |
| def encode(self, prompt: str | list[int]) -> list[int]: ... | |
| def format_chat(self, messages: list[dict]) -> list[int]: ... | |
| def stream(self, prompt: str | list[int], **kwargs: Any) -> Iterator[Any]: ... | |
| class _StrictModel(BaseModel): | |
| model_config = ConfigDict(extra="forbid", strict=True, allow_inf_nan=False) | |
| class _StreamOptions(_StrictModel): | |
| include_usage: bool = False | |
| class _TextFormat(_StrictModel): | |
| type: Literal["text"] | |
| class _JsonObjectFormat(_StrictModel): | |
| type: Literal["json_object"] | |
| class _JsonSchemaBody(_StrictModel): | |
| model_config = ConfigDict(extra="forbid", strict=True, allow_inf_nan=False, | |
| populate_by_name=True) | |
| name: _NonemptyString | |
| schema_: dict[str, Any] = Field(alias="schema") | |
| strict: bool | None = None | |
| def schema(self) -> dict[str, Any]: | |
| return self.schema_ | |
| class _JsonSchemaFormat(_StrictModel): | |
| type: Literal["json_schema"] | |
| json_schema: _JsonSchemaBody | |
| _ResponseFormat = _TextFormat | _JsonObjectFormat | _JsonSchemaFormat | |
| class _FunctionDef(_StrictModel): | |
| name: _NonemptyString | |
| description: str | None = None | |
| parameters: dict[str, Any] | None = None | |
| strict: bool | None = None | |
| class _ToolDef(_StrictModel): | |
| type: Literal["function"] | |
| function: _FunctionDef | |
| class _NamedToolFunction(_StrictModel): | |
| name: _NonemptyString | |
| class _NamedToolChoice(_StrictModel): | |
| type: Literal["function"] | |
| function: _NamedToolFunction | |
| _ToolChoice = Literal["auto", "none", "required"] | _NamedToolChoice | |
| _NonemptyString = Annotated[str, Field(min_length=1)] | |
| _Token = Annotated[int, Field(ge=0)] | |
| class _GenerationRequest(_StrictModel): | |
| model: _NonemptyString | |
| max_tokens: int | None = Field(default=None, ge=1) | |
| max_completion_tokens: int | None = Field(default=None, ge=1) | |
| temperature: float = Field(default=0.0, ge=0.0, le=2.0) | |
| top_p: float = Field(default=1.0, gt=0.0, le=1.0) | |
| top_k: int = Field(default=0, ge=0) | |
| seed: int = Field(default=0, ge=0) | |
| stop: _NonemptyString | Annotated[list[_NonemptyString], Field(max_length=4)] | None = None | |
| stream: bool = False | |
| n: int = Field(default=1, ge=1, le=1) | |
| stream_options: _StreamOptions | None = None | |
| # CISM extension: greedy prompt-lookup speculation (0 = off). | |
| spec_k: int = Field(default=0, ge=0, le=16) | |
| def check_options(self) -> _GenerationRequest: | |
| if self.max_tokens is not None and self.max_completion_tokens is not None: | |
| raise ValueError("Specify only one token limit.") | |
| if self.stream_options is not None and not self.stream: | |
| raise ValueError("stream_options requires stream=true.") | |
| return self | |
| def token_limit(self) -> int: | |
| if self.max_completion_tokens is not None: | |
| return self.max_completion_tokens | |
| return self.max_tokens if self.max_tokens is not None else 128 | |
| class _CompletionRequest(_GenerationRequest): | |
| prompt: str | Annotated[list[_Token], Field(min_length=1)] | |
| # Exact-math token logprobs (top-N per emitted token, non-streaming only). | |
| logprobs: int | None = Field(default=None, ge=0, le=20) | |
| class _Message(_StrictModel): | |
| role: Literal["system", "developer", "user", "assistant"] | |
| content: str | |
| class _ChatRequest(_GenerationRequest): | |
| messages: Annotated[list[_Message], Field(min_length=1)] | |
| response_format: _ResponseFormat | None = None | |
| # Exact-math token logprobs (OpenAI chat shape, non-streaming only). | |
| logprobs: bool = False | |
| top_logprobs: int | None = Field(default=None, ge=0, le=20) | |
| # Server-side tool loop (non-streaming only; registry tools only). | |
| tools: Annotated[list[_ToolDef], Field(max_length=8)] | None = None | |
| tool_choice: _ToolChoice | None = None | |
| def check_a2_options(self) -> _ChatRequest: | |
| if self.top_logprobs is not None and not self.logprobs: | |
| raise ValueError("top_logprobs requires logprobs=true.") | |
| if self.tool_choice is not None and not self.tools: | |
| raise ValueError("tool_choice requires tools.") | |
| return self | |
| class _BatchRequest(_StrictModel): | |
| """Phase2 batched completions: list of prompts -> order-preserving results. | |
| Single ``model`` for the whole batch (same-model same-quant fast path via | |
| native ``BatchSession`` when available). ``prompts`` items mirror | |
| ``_CompletionRequest.prompt`` (text or token IDs). Sampling fields mirror | |
| ``_GenerationRequest`` except streaming (batch is non-streaming only); | |
| ``spec_k`` must be 0 because ``BatchSession`` has no speculation path. | |
| """ | |
| model: _NonemptyString | |
| prompts: Annotated[list[str | Annotated[list[_Token], Field(min_length=1)]], | |
| Field(min_length=1, max_length=64)] | |
| max_tokens: int | None = Field(default=None, ge=1) | |
| max_completion_tokens: int | None = Field(default=None, ge=1) | |
| temperature: float = Field(default=0.0, ge=0.0, le=2.0) | |
| top_p: float = Field(default=1.0, gt=0.0, le=1.0) | |
| top_k: int = Field(default=0, ge=0) | |
| seed: int = Field(default=0, ge=0) | |
| stop: _NonemptyString | Annotated[list[_NonemptyString], Field(max_length=4)] | None = None | |
| spec_k: int = Field(default=0, ge=0, le=16) | |
| def check_options(self) -> _BatchRequest: | |
| if self.max_tokens is not None and self.max_completion_tokens is not None: | |
| raise ValueError("Specify only one token limit.") | |
| return self | |
| def token_limit(self) -> int: | |
| if self.max_completion_tokens is not None: | |
| return self.max_completion_tokens | |
| return self.max_tokens if self.max_tokens is not None else 128 | |
| class _LoadRequest(_StrictModel): | |
| model: _NonemptyString | |
| precision: Literal["fp32", "int8", "hybrid-int4", "hybrid-fp4"] = "int8" | |
| act_precision: Literal["fp32", "int8"] | None = None | |
| revision: str | None = None | |
| threads: int = Field(default=2, ge=1, le=64) | |
| trust_remote_code: bool = False | |
| class _UnloadRequest(_StrictModel): | |
| model: _NonemptyString | |
| class _CacheRequest(_StrictModel): | |
| model: _NonemptyString | |
| decode_tokens: int = Field(default=64, ge=1, le=2048) | |
| scrub_mb: int = Field(default=96, ge=1, le=1024) | |
| class _TokenizeRequest(_StrictModel): | |
| model: _NonemptyString | |
| text: _NonemptyString | |
| class _NllRequest(_StrictModel): | |
| model: _NonemptyString | |
| text: _NonemptyString | |
| window: int = Field(default=128, ge=2, le=2048) | |
| class _RuntimeRequest(_StrictModel): | |
| model: _NonemptyString | |
| lock_pages: bool | None = None | |
| keep_warm: float | None = None | |
| class _AutoBenchRequest(_StrictModel): | |
| model: _NonemptyString | |
| precision: Literal["fp32", "int8", "hybrid-int4", "hybrid-fp4"] = "int8" | |
| threads: int = Field(default=2, ge=1, le=64) | |
| max_tokens: int = Field(default=512, ge=1, le=2048) | |
| thread_sweep: bool = False | |
| spec_sweep: bool = False | |
| bench_act_int8: bool = False | |
| include_torch: bool = True | |
| compile: bool = False | |
| class _APIError(Exception): | |
| def __init__( | |
| self, status: int, message: str, code: str, param: str | None = None | |
| ) -> None: | |
| super().__init__(message) | |
| self.status = status | |
| error_type = { | |
| 401: "authentication_error", | |
| 429: "rate_limit_error", | |
| 503: "server_error", | |
| }.get(status, "invalid_request_error") | |
| self.body = {"error": { | |
| "message": message, "type": error_type, "param": param, "code": code, | |
| }} | |
| class _Chunk: | |
| text: str | |
| finish_reason: str | None | |
| prompt_tokens: int | |
| completion_tokens: int | |
| spec_proposed: int = 0 | |
| spec_accepted: int = 0 | |
| # Full decoded token IDs behind this chunk (empty when the backend does | |
| # not report them). Used only by opt-in exact-math paths (logprobs). | |
| token_ids: list[int] = field(default_factory=list) | |
| # Accumulated OpenAI-style tool calls for tool-loop jobs. | |
| tool_calls: list[dict] = field(default_factory=list) | |
| # Stream S3 automatic prefix-cache outcome for this job's prefill. | |
| prefix_hit: bool = False | |
| prefix_len: int = 0 | |
| class _Job: | |
| body: _CompletionRequest | _ChatRequest | |
| engine: _Engine | None = None | |
| output: queue.Queue = field(default_factory=lambda: queue.Queue(maxsize=8)) | |
| cancel: threading.Event = field(default_factory=threading.Event) | |
| finished: bool = False | |
| consumed: bool = False | |
| output_ready: asyncio.Event = field(default_factory=asyncio.Event) | |
| notification_lock: threading.Lock = field(default_factory=threading.Lock) | |
| notification_pending: bool = False | |
| notified: bool = False | |
| # Worker-side arrival time (monotonic) for the S1 starvation guard. | |
| enqueued_at: float = 0.0 | |
| def notify(self, loop: asyncio.AbstractEventLoop) -> None: | |
| with self.notification_lock: | |
| if self.notification_pending or self.notified: | |
| return | |
| self.notification_pending = True | |
| try: | |
| loop.call_soon_threadsafe(self._wake) | |
| except RuntimeError: | |
| # Shutdown can close the loop before a native call returns. | |
| self.notification_pending = False | |
| self.cancel.set() | |
| def _wake(self) -> None: | |
| with self.notification_lock: | |
| self.notification_pending = False | |
| self.notified = True | |
| self.output_ready.set() | |
| class _ContState: | |
| """Per-job mutable state for one S1 continuous epoch (worker thread only).""" | |
| job: _Job | |
| engine: Any | |
| prompt_ids: list[int] | |
| stop: list[str] | None | |
| eff: int # effective generation budget: min(token_limit, ctx - plen) | |
| context: int | |
| arrival: float | |
| admittable: bool | |
| # S1 epoch kind: "greedy" (mid-decode joins allowed -- seed-independent), | |
| # "sampled" (form-only epochs -- per-seq RNG state cannot survive a | |
| # rebuild), or "serial" (never batched). | |
| kind: str = "serial" | |
| gen: list[int] = field(default_factory=list) | |
| def plen(self) -> int: | |
| return len(self.prompt_ids) | |
| def remaining(self) -> int: | |
| return self.eff - len(self.gen) | |
| class _Worker: | |
| def __init__(self, engine: _Engine, capacity: int) -> None: | |
| self.engine = engine | |
| self.loop = asyncio.get_running_loop() | |
| self.capacity = capacity | |
| self.pending: queue.Queue[_Job] = queue.Queue(maxsize=capacity) | |
| self.lock = threading.Lock() | |
| self.jobs: set[_Job] = set() | |
| self.stopping = threading.Event() | |
| # A non-cooperative native call cannot be killed safely by Python. | |
| self.thread = threading.Thread(target=self._run, name="cism-inference", daemon=True) | |
| def ready(self) -> bool: | |
| return self.thread.is_alive() and not self.stopping.is_set() | |
| def submit(self, body: _CompletionRequest | _ChatRequest, | |
| engine: _Engine | None = None) -> _Job: | |
| with self.lock: | |
| if not self.ready: | |
| raise _APIError(503, "Server is not ready.", "server_unavailable") | |
| if len(self.jobs) >= self.capacity: | |
| raise _APIError(429, "Generation queue is full.", "queue_full") | |
| job = _Job(body, engine=engine) | |
| job.enqueued_at = time.monotonic() | |
| self.jobs.add(job) | |
| self.pending.put_nowait(job) | |
| return job | |
| def release(self, job: _Job) -> None: | |
| job.cancel.set() | |
| job.notify(self.loop) | |
| with self.lock: | |
| job.consumed = True | |
| if job.finished: | |
| self.jobs.discard(job) | |
| def stop(self) -> None: | |
| with self.lock: | |
| self.stopping.set() | |
| for job in self.jobs: | |
| job.cancel.set() | |
| job.notify(self.loop) | |
| self.jobs.clear() | |
| def _put(self, job: _Job, value: _Chunk | _APIError) -> bool: | |
| while not job.cancel.is_set() and not self.stopping.is_set(): | |
| try: | |
| job.output.put(value, timeout=0.05) | |
| job.notify(self.loop) | |
| return True | |
| except queue.Full: | |
| pass | |
| return False | |
| def _run(self) -> None: | |
| while True: | |
| try: | |
| first = self.pending.get(timeout=0.05) | |
| except queue.Empty: | |
| if self.stopping.is_set(): | |
| return | |
| continue | |
| takes = 1 | |
| try: | |
| if first.cancel.is_set() or self.stopping.is_set(): | |
| self._finish(first) | |
| elif self._use_continuous(first): | |
| takes = self._run_continuous(first) | |
| else: | |
| batch = self._collect_batch(first) | |
| takes = len(batch) | |
| if len(batch) > 1: | |
| self._run_micro_batch(batch) | |
| else: | |
| self._run_single(first) | |
| finally: | |
| for _ in range(takes): | |
| self.pending.task_done() | |
| def _use_continuous(self, job: _Job) -> bool: | |
| """True when ``job`` heads an S1 iteration-level epoch. | |
| Requires both kill switches on (``CISM_SERVE_CONTINUOUS``, | |
| ``CISM_SERVE_MICROBATCH``), a batch-eligible body (non-streaming | |
| greedy, or non-streaming sampled with valid params -- same | |
| exclusions otherwise: no spec/A2 opt-ins), and a dense engine with | |
| a steppable native ``BatchSession``. Everything else keeps the | |
| exact A1 FIFO path, so streaming/speculative/Surjo/test-double | |
| jobs are bit-for-bit unchanged. Non-admittable heads inside the | |
| loop still run on the serial ``_run_single`` path. | |
| """ | |
| if not _serve_continuous_enabled() or not _serve_microbatch_enabled(): | |
| return False | |
| if not (_micro_batch_eligible(job.body) | |
| or _sampled_batch_eligible(job.body)): | |
| return False | |
| engine = job.engine or self.engine | |
| if engine is None or _is_pool_or_batch_engine(engine): | |
| return False | |
| return _can_native_batch(engine) | |
| def _collect_batch(self, first: _Job) -> list[_Job]: | |
| """Drain co-queued jobs without waiting (zero added latency). | |
| A lone job returns alone and runs immediately on the single path, so | |
| single-stream latency/tokens-s are unregressed by construction. Only | |
| when jobs are already co-queued under concurrent load is a micro-batch | |
| formed, capped at ``_MICRO_BATCH_MAX``. | |
| """ | |
| batch = [first] | |
| if not _serve_microbatch_enabled(): | |
| return batch | |
| while len(batch) < _MICRO_BATCH_MAX: | |
| try: | |
| batch.append(self.pending.get_nowait()) | |
| except queue.Empty: | |
| break | |
| return batch | |
| def _finish(self, job: _Job) -> None: | |
| with self.lock: | |
| job.finished = True | |
| if job.consumed: | |
| self.jobs.discard(job) | |
| def _run_single(self, job: _Job) -> None: | |
| iterator = None | |
| engine = job.engine or self.engine | |
| try: | |
| if job.cancel.is_set() or self.stopping.is_set(): | |
| return | |
| body = job.body | |
| try: | |
| if isinstance(body, _ChatRequest): | |
| prompt = engine.format_chat( | |
| [message.model_dump() for message in body.messages] | |
| ) | |
| tokens = engine.encode(prompt) | |
| else: | |
| tokens = engine.encode(body.prompt) | |
| except (ValueError, TypeError): | |
| raise _APIError(400, "Invalid prompt or chat messages.", "invalid_prompt") from None | |
| if len(tokens) > engine.context_length: | |
| raise _APIError(400, "Prompt exceeds the model context length.", | |
| "context_length_exceeded") | |
| if job.cancel.is_set() or self.stopping.is_set(): | |
| return | |
| stop = [body.stop] if isinstance(body.stop, str) else body.stop | |
| # Stream A2 opt-in jobs run serially on exact-math paths (never | |
| # micro-batched, never paged): constrained JSON over engine.logits | |
| # and multi-turn tool loops over engine.stream. | |
| if isinstance(body, _ChatRequest) and _needs_tools(body): | |
| self._run_tool_loop(job, engine, tokens, body, stop) | |
| return | |
| if _needs_constrained(body): | |
| assert isinstance(body, _ChatRequest) | |
| self._run_constrained_job(job, engine, tokens, body) | |
| return | |
| if body.spec_k == 0 and _serve_paged_enabled() and _can_paged(engine): | |
| # Paged-KV single path: bitwise-identical to dense Session. | |
| iterator = iter(_stream_paged( | |
| engine, tokens, max_new_tokens=body.token_limit, | |
| temperature=body.temperature, top_p=body.top_p, | |
| top_k=body.top_k, seed=body.seed, stop=stop, | |
| cancel=job.cancel, chunk_size=16, | |
| )) | |
| else: | |
| iterator = iter(engine.stream( | |
| tokens, max_new_tokens=body.token_limit, | |
| temperature=body.temperature, top_p=body.top_p, | |
| top_k=body.top_k, seed=body.seed, stop=stop, | |
| cancel=job.cancel, chunk_size=16, | |
| spec_k=body.spec_k, | |
| )) | |
| terminal = False | |
| for chunk in iterator: | |
| if not self._put(job, _Chunk( | |
| chunk.text, chunk.finish_reason, | |
| chunk.prompt_tokens, chunk.completion_tokens, | |
| getattr(chunk, "spec_proposed", 0) or 0, | |
| getattr(chunk, "spec_accepted", 0) or 0, | |
| token_ids=list(getattr(chunk, "token_ids", None) or []), | |
| prefix_hit=bool(getattr(chunk, "prefix_hit", False)), | |
| prefix_len=int(getattr(chunk, "prefix_len", 0) or 0), | |
| )): | |
| break | |
| if chunk.finish_reason is not None: | |
| terminal = True | |
| break | |
| if not terminal and not job.cancel.is_set(): | |
| raise _APIError(503, "Generation did not complete.", "generation_failed") | |
| except _APIError as error: | |
| self._put(job, error) | |
| except Exception: | |
| self._put(job, _APIError(503, "Generation failed.", "generation_failed")) | |
| finally: | |
| if iterator is not None: | |
| with suppress(Exception): | |
| close = getattr(iterator, "close", None) | |
| if close is not None: | |
| close() | |
| self._finish(job) | |
| def _run_constrained_job(self, job: _Job, engine: Any, | |
| tokens: list[int], body: _ChatRequest) -> None: | |
| """One greedy constrained-JSON turn over real engine logits.""" | |
| try: | |
| if job.cancel.is_set() or self.stopping.is_set(): | |
| return | |
| target = _constrained_target(body) | |
| text, ids, finish, prompt_n, gen_n = _run_constrained_sync( | |
| engine, tokens, token_limit=body.token_limit, | |
| target=target, cancel=job.cancel) | |
| self._put(job, _Chunk(text, finish, prompt_n, gen_n, | |
| token_ids=list(ids))) | |
| except _APIError as error: | |
| self._put(job, error) | |
| except Exception: | |
| self._put(job, _APIError(503, "Generation failed.", "generation_failed")) | |
| finally: | |
| self._finish(job) | |
| def _collect_turn(self, job: _Job, engine: Any, tokens: list[int], | |
| body: _ChatRequest, stop: list[str] | None) -> tuple[str, list[int], str | None, int, int] | None: | |
| """Collect one dense ``engine.stream`` turn fully in memory. | |
| Tool-loop turns (intermediate and final) never publish incrementally: | |
| the caller publishes exactly one synthetic terminal chunk, so | |
| ``generate()`` observes the normal single-terminal protocol with the | |
| accumulated ``tool_calls`` attached. Returns | |
| (text, ids, finish, prompt_n, gen_n), or None when cancelled. | |
| """ | |
| iterator = None | |
| try: | |
| iterator = iter(engine.stream( | |
| tokens, max_new_tokens=body.token_limit, | |
| temperature=body.temperature, top_p=body.top_p, | |
| top_k=body.top_k, seed=body.seed, stop=stop, | |
| cancel=job.cancel, chunk_size=16, spec_k=body.spec_k, | |
| )) | |
| parts: list[str] = [] | |
| ids: list[int] = [] | |
| last: Any = None | |
| terminal = False | |
| for chunk in iterator: | |
| if job.cancel.is_set() or self.stopping.is_set(): | |
| return None | |
| ids.extend(list(getattr(chunk, "token_ids", None) or [])) | |
| parts.append(chunk.text) | |
| last = chunk | |
| if chunk.finish_reason is not None: | |
| terminal = True | |
| break | |
| if not terminal or last is None: | |
| raise _APIError(503, "Generation did not complete.", "generation_failed") | |
| return "".join(parts), ids, last.finish_reason, last.prompt_tokens, last.completion_tokens | |
| finally: | |
| if iterator is not None: | |
| with suppress(Exception): | |
| close = getattr(iterator, "close", None) | |
| if close is not None: | |
| close() | |
| def _run_tool_loop(self, job: _Job, engine: Any, tokens: list[int], | |
| body: _ChatRequest, stop: list[str] | None) -> None: | |
| """Bounded server-side tool loop with the real engine. | |
| Each turn generates assistant text with ``engine.stream``; text that | |
| parses as a ``{"name", "arguments"}`` call for a declared registry | |
| tool is validated and executed for real, and the JSON result is fed | |
| back as a follow-up user turn. At most ``_TOOL_MAX_CALLS`` executions, | |
| so the loop always terminates. Every turn is collected in memory and | |
| exactly one synthetic terminal chunk is published (with the | |
| accumulated ``tool_calls`` attached), so ``generate()`` observes the | |
| normal single-terminal protocol. | |
| """ | |
| try: | |
| assert body.tools is not None | |
| declared = {tool.function.name: tool.function for tool in body.tools} | |
| choice = body.tool_choice | |
| required_name = (choice.function.name | |
| if isinstance(choice, _NamedToolChoice) else None) | |
| require_call = choice == "required" or required_name is not None | |
| messages = [message.model_dump() for message in body.messages] | |
| tool_calls: list[dict] = [] | |
| final: tuple[str, list[int], str | None, int, int] | None = None | |
| pending_call: tuple[str, dict] | None = None | |
| current_tokens = list(tokens) | |
| for turn in range(_TOOL_MAX_CALLS + 1): | |
| if job.cancel.is_set() or self.stopping.is_set(): | |
| return | |
| last_turn = turn == _TOOL_MAX_CALLS | |
| turn_result = self._collect_turn( | |
| job, engine, current_tokens, body, stop) | |
| if turn_result is None: | |
| return | |
| text, _ids, finish, _pn, _gn = turn_result | |
| parsed = _parse_tool_call(text) | |
| if parsed is not None: | |
| name, arguments = parsed | |
| if name not in declared or (required_name is not None and name != required_name): | |
| parsed = None | |
| if parsed is None or last_turn: | |
| if parsed is not None: | |
| pending_call = parsed | |
| final = turn_result | |
| break | |
| name, arguments = parsed | |
| function = declared[name] | |
| params_schema = (function.parameters if function.parameters is not None | |
| else _TOOL_REGISTRY[name][1]) | |
| if _schema_support_error(params_schema, "tools.parameters") is not None: | |
| result: dict[str, Any] = { | |
| "error": "unsupported parameters schema for this tool"} | |
| elif not _mini_validate(arguments, params_schema): | |
| result = {"error": "arguments do not match the tool parameters schema"} | |
| else: | |
| try: | |
| result = dict(_TOOL_REGISTRY[name][0](arguments)) | |
| except ValueError as exc: | |
| result = {"error": str(exc)} | |
| except Exception: | |
| result = {"error": "tool execution failed"} | |
| tool_calls.append({ | |
| "id": f"call_{len(tool_calls)}", | |
| "type": "function", | |
| "function": {"name": name, "arguments": json.dumps( | |
| arguments, ensure_ascii=True, allow_nan=False, | |
| separators=(",", ":"))}, | |
| }) | |
| messages = [*messages, | |
| {"role": "assistant", "content": text}, | |
| {"role": "user", "content": ( | |
| f"Tool {name} returned: {json.dumps(result, ensure_ascii=True, allow_nan=False)}")}] | |
| try: | |
| current_tokens = engine.encode( | |
| engine.format_chat(messages)) | |
| except (ValueError, TypeError): | |
| raise _APIError(400, "Invalid prompt or chat messages.", | |
| "invalid_prompt") from None | |
| if len(current_tokens) > engine.context_length: | |
| raise _APIError(400, "Prompt exceeds the model context length.", | |
| "context_length_exceeded") | |
| assert final is not None | |
| text, ids, finish, prompt_n, gen_n = final | |
| if pending_call is not None and require_call: | |
| # Budget spent while the model still wants tools: report the | |
| # pending call honestly instead of looping forever. | |
| name, arguments = pending_call | |
| tool_calls.append({ | |
| "id": f"call_{len(tool_calls)}", | |
| "type": "function", | |
| "function": {"name": name, "arguments": json.dumps( | |
| arguments, ensure_ascii=True, allow_nan=False, | |
| separators=(",", ":"))}, | |
| }) | |
| finish = "tool_calls" | |
| self._put(job, _Chunk(text, finish, prompt_n, gen_n, | |
| token_ids=list(ids), | |
| tool_calls=tool_calls)) | |
| except _APIError as error: | |
| self._put(job, error) | |
| except Exception: | |
| self._put(job, _APIError(503, "Generation failed.", "generation_failed")) | |
| finally: | |
| self._finish(job) | |
| def _run_micro_batch(self, batch: list[_Job]) -> None: | |
| """FIFO continuous-batching step for co-queued greedy jobs (v0). | |
| Policy: strict FIFO; the head job must be micro-batch eligible | |
| (non-streaming, greedy, ``spec_k == 0``) on a dense engine with a | |
| native single-weight-stream batch path. The eligible FIFO prefix with | |
| the head's ``(stop, token_limit)`` key runs as one ``BatchSession`` | |
| micro-batch (chunked further by ``ContinuousSchedulerV0`` budgets when | |
| configured on the worker); every other job runs serially in order. | |
| Greedy decode is seed-independent, so coalescing is bitwise-identical | |
| to serial execution. Per-job validation errors never poison the batch. | |
| """ | |
| try: | |
| head = batch[0] | |
| engine = head.engine or self.engine | |
| key = _micro_batch_key(head.body) if _micro_batch_eligible(head.body) else None | |
| group: list[_Job] = [] | |
| rest: list[_Job] = [] | |
| if (key is not None and not _is_pool_or_batch_engine(engine) | |
| and _can_native_batch(engine)): | |
| for job in batch: | |
| if ((job.engine or self.engine) is engine | |
| and _micro_batch_eligible(job.body) | |
| and _micro_batch_key(job.body) == key): | |
| group.append(job) | |
| else: | |
| rest.append(job) | |
| else: | |
| rest = list(batch) | |
| if len(group) < 2: | |
| rest = batch | |
| group = [] | |
| for job in group: | |
| if job.cancel.is_set() or self.stopping.is_set(): | |
| self._finish(job) | |
| group = [job for job in group | |
| if not job.cancel.is_set() and not self.stopping.is_set()] | |
| if group: | |
| self._run_batch_group(engine, group) | |
| for job in rest: | |
| if job.cancel.is_set() or self.stopping.is_set(): | |
| self._finish(job) | |
| else: | |
| self._run_single(job) | |
| finally: | |
| # Jobs finished inside _run_single/_run_batch_group are already | |
| # marked; mark any leftovers (cancelled heads) here. | |
| for job in batch: | |
| with self.lock: | |
| if not job.finished: | |
| job.finished = True | |
| if job.consumed: | |
| self.jobs.discard(job) | |
| def _run_batch_group(self, engine: Any, group: list[_Job]) -> None: | |
| """Run one compatible FIFO group as a single-weight-stream batch.""" | |
| body0 = group[0].body | |
| stop = [body0.stop] if isinstance(body0.stop, str) else (list(body0.stop) if body0.stop else None) | |
| token_limit = body0.token_limit | |
| cfg = getattr(self, "scheduler_config", None) or {} | |
| # Per-job encode/validation first: one bad prompt must not fail others. | |
| valid: list[tuple[_Job, Any]] = [] | |
| for job in group: | |
| body = job.body | |
| try: | |
| try: | |
| if isinstance(body, _ChatRequest): | |
| prompt = engine.format_chat( | |
| [message.model_dump() for message in body.messages]) | |
| prompt_arg: Any = engine.encode(prompt) | |
| else: | |
| prompt_arg = body.prompt | |
| probe = engine.encode(prompt_arg) | |
| if len(probe) > engine.context_length: | |
| raise _APIError(400, "Prompt exceeds the model context length.", | |
| "context_length_exceeded") | |
| if isinstance(prompt_arg, list) and len(prompt_arg) > engine.context_length: | |
| raise _APIError(400, "Prompt exceeds the model context length.", | |
| "context_length_exceeded") | |
| except _APIError: | |
| raise | |
| except (ValueError, TypeError): | |
| raise _APIError(400, "Invalid prompt or chat messages.", "invalid_prompt") from None | |
| valid.append((job, prompt_arg)) | |
| except _APIError as error: | |
| self._put(job, error) | |
| self._finish(job) | |
| except Exception: | |
| self._put(job, _APIError(503, "Generation failed.", "generation_failed")) | |
| self._finish(job) | |
| if not valid: | |
| return | |
| try: | |
| from .paging import ContinuousSchedulerV0 | |
| sched = ContinuousSchedulerV0( | |
| num_blocks=int(cfg.get("scheduler_blocks", 512)), block_size=16, | |
| max_batch_tokens=int(cfg.get("max_batch_tokens", 4096)), | |
| prefill_chunk_tokens=int(cfg.get("prefill_chunk_tokens", 512))) | |
| lens: list[int] = [] | |
| for job, prompt_arg in valid: | |
| try: | |
| lens.append(len(engine.encode(prompt_arg))) | |
| except (ValueError, TypeError): | |
| lens.append(token_limit) | |
| for idx, length in enumerate(lens): | |
| sched.add(idx, length, token_limit, stop=stop) | |
| chunks = sched.plan_chunks() | |
| except Exception: | |
| chunks = [[{"req_id": i} for i in range(len(valid))]] | |
| sched = None # type: ignore[assignment] | |
| ordered: dict[int, tuple] = {} | |
| failed: dict[int, _APIError] = {} | |
| prompts = [prompt_arg for _, prompt_arg in valid] | |
| try: | |
| for chunk in chunks: | |
| indices = [entry["req_id"] for entry in chunk] | |
| sub = [prompts[i] for i in indices] | |
| try: | |
| rows = _run_batch_chunk_sync( | |
| engine, sub, token_limit, 0.0, 1.0, 0, body0.seed, stop) | |
| except _APIError as error: | |
| for i in indices: | |
| failed[i] = error | |
| continue | |
| except (ValueError, TypeError) as exc: | |
| msg = str(exc) | |
| error = _APIError( | |
| 400, "Prompt exceeds the model context length.", | |
| "context_length_exceeded") if ( | |
| "context" in msg.lower() or "Prompt length" in msg) else _APIError( | |
| 400, "Invalid prompt or chat messages.", "invalid_prompt") | |
| for i in indices: | |
| failed[i] = error | |
| continue | |
| except Exception: | |
| error = _APIError(503, "Generation failed.", "generation_failed") | |
| for i in indices: | |
| failed[i] = error | |
| continue | |
| if len(rows) != len(indices): | |
| error = _APIError(503, "Generation did not complete.", "generation_failed") | |
| for i in indices: | |
| failed[i] = error | |
| continue | |
| for i, row in zip(indices, rows): | |
| ordered[i] = row | |
| if sched is not None: | |
| for entry in chunk: | |
| try: | |
| sched.free(entry["req_id"]) | |
| except Exception: | |
| pass | |
| finally: | |
| for pos, (job, _) in enumerate(valid): | |
| try: | |
| if pos in failed: | |
| self._put(job, failed[pos]) | |
| else: | |
| row = ordered.get(pos) | |
| if row is None: | |
| self._put(job, _APIError(503, "Generation did not complete.", | |
| "generation_failed")) | |
| else: | |
| text, _ids, finish, prompt_n, gen_n = row | |
| if finish is None: | |
| self._put(job, _APIError(503, "Generation did not complete.", | |
| "generation_failed")) | |
| else: | |
| self._put(job, _Chunk(text, finish, prompt_n, gen_n, | |
| token_ids=list(_ids))) | |
| finally: | |
| self._finish(job) | |
| def _run_continuous(self, first: _Job) -> int: | |
| """Iteration-level scheduler loop for S1 (vLLM-style, Python-side). | |
| ADD: each iteration drains new arrivals into an | |
| :class:`paging.IterationScheduler` waiting queue (oldest-first) and | |
| admits group-matching jobs into the active batch -- including | |
| mid-decode joins, which rebuild the native ``BatchSession`` with | |
| survivors' ``prompt + generated`` prefixes (greedy continuation is | |
| an exact function of the prefix, so joined decoding is | |
| bitwise-identical to an uninterrupted run; per-job remaining | |
| budgets truncate with ``"length"`` exactly as a lone run would). | |
| EVICT: one shared-session ``BatchSession`` weight-stream pass per | |
| iteration (``next_tokens(1)`` while the batch is shared or waiters | |
| are present, so finished/errored/cancelled leave immediately; | |
| ``min(16, remaining)`` chunks for a lone idle job, mirroring | |
| ``Engine.stream`` -- this keeps single-stream tokens/s at parity by | |
| avoiding a Python GIL round-trip per token when nobody else needs | |
| the batch; join latency is bounded by one chunk). Per-job | |
| remaining budgets truncate with ``"length"`` exactly as a lone run | |
| would. | |
| CHUNKED PREFILL: admissions per rebuild are capped at | |
| ``prefill_chunk_tokens`` new prompt tokens (env | |
| ``CISM_SERVE_PREFILL_CHUNK``); an oversized single prompt is | |
| deferred while an epoch is live and runs alone once it drains | |
| (intra-prompt chunking still needs native support -- documented | |
| limit). Epochs rotate after ``CISM_SERVE_EPOCH_STEPS`` steps or | |
| when a same-engine waiter exceeds ``CISM_SERVE_MAX_WAIT_S``. | |
| Non-admissible jobs (streaming/sampled/spec/A2, other engines) keep | |
| global FIFO order: when the batch is empty and the head is not | |
| admittable it runs on the serial ``_run_single`` path. Returns the | |
| number of ``pending`` gets consumed (``first`` + drains). | |
| """ | |
| from .paging import ContinuousSchedulerV0, IterationScheduler | |
| cfg = getattr(self, "scheduler_config", None) or {} | |
| chunk = _continuous_prefill_chunk( | |
| int(cfg.get("prefill_chunk_tokens", 512))) | |
| budget = int(cfg.get("max_batch_tokens", 4096)) | |
| sched = IterationScheduler( | |
| max_batch=_MICRO_BATCH_MAX, prefill_chunk_tokens=chunk, | |
| max_wait_s=_continuous_max_wait_s(), max_batch_tokens=budget) | |
| max_wait = sched.max_wait_s | |
| join_defer = _continuous_join_defer_tokens() | |
| states: dict[int, _ContState] = {} | |
| takes = 0 | |
| def arrive(job: _Job) -> None: | |
| if job.cancel.is_set() or self.stopping.is_set(): | |
| self._finish(job) | |
| return | |
| engine = job.engine or self.engine | |
| if engine is None: | |
| self._put(job, _APIError(503, "Server is not ready.", | |
| "server_unavailable")) | |
| self._finish(job) | |
| return | |
| try: | |
| prompt_ids, stop = _cont_encode(engine, job.body) | |
| except _APIError as error: | |
| self._put(job, error) | |
| self._finish(job) | |
| return | |
| except Exception: | |
| self._put(job, _APIError(503, "Generation failed.", | |
| "generation_failed")) | |
| self._finish(job) | |
| return | |
| limit = job.body.token_limit | |
| context = int(engine.context_length) | |
| eff = min(limit, context - len(prompt_ids)) | |
| if eff <= 0: | |
| # Prompt fills the context: zero new tokens, terminal | |
| # "length" with empty text (mirrors native max_new=0). | |
| self._put(job, _Chunk("", "length", len(prompt_ids), 0, | |
| token_ids=[])) | |
| self._finish(job) | |
| return | |
| admittable = _micro_batch_eligible(job.body) | |
| if admittable: | |
| kind = "greedy" | |
| group = (id(engine), "greedy") | |
| elif _sampled_batch_eligible(job.body): | |
| kind = "sampled" | |
| admittable = True | |
| group = (id(engine), "sampled", job.body.temperature, | |
| job.body.top_p, job.body.top_k) | |
| else: | |
| kind = "serial" | |
| group = (id(engine), "serial") | |
| states[id(job)] = _ContState( | |
| job, engine, prompt_ids, stop, eff, context, | |
| arrival=(job.enqueued_at or time.monotonic()), | |
| admittable=admittable, kind=kind) | |
| sched.add(id(job), len(prompt_ids), group, | |
| states[id(job)].arrival, admittable=admittable) | |
| def fits(rid: int, _admitted: list) -> bool: | |
| # Context-window compatibility: with maxR = max remaining over | |
| # the would-be active set, every member's extended prompt must | |
| # still fit (native requires prompt + max_new <= context), and | |
| # the token budget must hold (lone head exempt). | |
| active = [states[r] for r in sched.running_ids] | |
| cand = states[rid] | |
| # Sampled RNG exactness: a sampled job never joins a live epoch | |
| # (a rebuild would restart its RNG stream), and nothing joins a | |
| # live sampled epoch. Sampled epochs form only on a drain. | |
| if active and (cand.kind == "sampled" | |
| or sched.group_of(sched.running_ids[0])[1] == "sampled"): | |
| return False | |
| if cand.plen + cand.eff > cand.context: | |
| return False | |
| pool = active + [cand] | |
| max_r = max(s.remaining for s in pool) | |
| if any(s.plen + len(s.gen) + max_r > s.context for s in pool): | |
| return False | |
| if len(pool) > 1 and sum(s.plen + s.eff for s in pool) > budget: | |
| return False | |
| if active and join_defer > 0: | |
| starved = (time.monotonic() - cand.arrival) > max_wait | |
| if not starved and all(s.remaining <= join_defer | |
| for s in active): | |
| return False # drain is imminent; join would only | |
| # force a pointless recompute-prefill | |
| return True | |
| arrive(first) | |
| takes += 1 | |
| session = None | |
| slot_rids: list[int] = [] | |
| fresh = False # first step after a (re)build stays granular | |
| epoch_steps = 0 | |
| epoch_cap = _continuous_epoch_steps() | |
| try: | |
| while len(sched) > 0: | |
| if self.stopping.is_set(): | |
| if session is not None: | |
| try: | |
| session.cancel() | |
| except Exception: | |
| pass | |
| session = None | |
| break | |
| while True: | |
| try: | |
| job = self.pending.get_nowait() | |
| except queue.Empty: | |
| break | |
| takes += 1 | |
| arrive(job) | |
| now = time.monotonic() | |
| for rid in sched.waiting_ids: | |
| state = states.get(rid) | |
| if state is not None and state.job.cancel.is_set(): | |
| states.pop(rid, None) | |
| try: | |
| sched.drop(rid, now) | |
| except Exception: | |
| pass | |
| self._finish(state.job) | |
| if not sched.running_ids: | |
| session = None | |
| slot_rids = [] | |
| head = sched.head_id() | |
| if head is None: | |
| break | |
| state = states[head] | |
| if not state.admittable: | |
| states.pop(head, None) | |
| try: | |
| sched.drop(head, now) | |
| except Exception: | |
| pass | |
| self._run_single(state.job) | |
| continue | |
| admitted, _ = sched.admit(now, fits=fits) | |
| if not admitted: | |
| # Head admittable but unfittable: fail it alone so | |
| # one bad job never wedges the queue (per-job | |
| # isolation, as in the A1 batch group). | |
| states.pop(head, None) | |
| try: | |
| sched.drop(head, now) | |
| except Exception: | |
| pass | |
| self._put(state.job, _APIError( | |
| 503, "Generation failed.", "generation_failed")) | |
| self._finish(state.job) | |
| continue | |
| session, slot_rids = self._cont_build(states, sched) | |
| fresh = True | |
| epoch_steps = 0 | |
| continue | |
| admitted, rotate = sched.admit(now, fits=fits) | |
| if admitted: | |
| session, slot_rids = self._cont_build( | |
| states, sched, session=session) | |
| fresh = True | |
| epoch_steps = 0 | |
| if rotate or epoch_steps >= epoch_cap: | |
| running_kind = (sched.group_of(sched.running_ids[0])[1] | |
| if sched.running_ids else "greedy") | |
| if running_kind == "sampled": | |
| # Never rotate a live sampled epoch: rotation | |
| # requeues + rebuilds, restarting per-seq RNG | |
| # streams and diverging from serial. Reset the | |
| # counter and fall through to stepping -- sampled | |
| # epochs always run to drain; waiters (even | |
| # starved same-group ones) join the next epoch. | |
| # (A bare `continue` here would livelock: rotation | |
| # changes no state, so the live epoch would never | |
| # step again.) | |
| epoch_steps = 0 | |
| else: | |
| if session is not None: | |
| try: | |
| session.cancel() | |
| except Exception: | |
| pass | |
| session = None | |
| slot_rids = [] | |
| fresh = False | |
| sched.requeue_running() | |
| epoch_steps = 0 | |
| continue | |
| # Step size: granular (1 token) while the batch is shared | |
| # or waiters are present (prompt evict/publish + fast | |
| # joins); chunked (16, mirroring Engine.stream) for a lone | |
| # idle job, so a lone request follows the same native call | |
| # pattern as the dense path (avoids a GIL round-trip per | |
| # token at parity). | |
| if len(sched.running_ids) > 1 or sched.waiting_ids or fresh: | |
| count = 1 | |
| else: | |
| count = min(16, states[sched.running_ids[0]].remaining) | |
| if self._cont_step(states, sched, session, slot_rids, count): | |
| session = None # all drained; loop re-admits | |
| slot_rids = [] | |
| else: | |
| epoch_steps += 1 | |
| fresh = False | |
| finally: | |
| for rid in list(states): | |
| state = states.pop(rid) | |
| try: | |
| self._put(state.job, _APIError( | |
| 503, "Generation failed.", "generation_failed")) | |
| except Exception: | |
| pass | |
| try: | |
| self._finish(state.job) | |
| except Exception: | |
| pass | |
| return takes | |
| def _cont_build(self, states: dict[int, _ContState], sched: Any, | |
| session: Any = None) -> tuple[Any, list[int]]: | |
| """(Re)build the epoch's shared native session for mid-decode joins. | |
| Prompts are survivors' ``prompt + generated`` prefixes plus new | |
| arrivals' prompts; ``max_new`` is the max remaining budget (per-job | |
| truncation keeps ``"length"`` exact). Returns ``(session, | |
| slot_rids)`` where ``slot_rids`` is the build-time member order | |
| (native slot ``i`` forever maps to ``slot_rids[i]`` until the next | |
| rebuild). Postcondition: a ``None`` session implies empty running | |
| state (every member failed, so the caller simply re-admits). | |
| """ | |
| if session is not None: | |
| try: | |
| session.cancel() | |
| except Exception: | |
| pass | |
| engine = states[sched.running_ids[0]].engine | |
| def fail_running(error: _APIError) -> tuple[Any, list[int]]: | |
| for rid in sched.running_ids: | |
| state = states.pop(rid, None) | |
| if state is None: | |
| continue | |
| self._put(state.job, error) | |
| self._finish(state.job) | |
| for rid in list(sched.running_ids): | |
| try: | |
| sched.finish(rid, time.monotonic()) | |
| except Exception: | |
| pass | |
| return None, [] | |
| members = [rid for rid in sched.running_ids | |
| if states[rid].remaining > 0] | |
| if not members: | |
| return fail_running(_APIError(503, "Generation did not complete.", | |
| "generation_failed")) | |
| prompts = [states[r].prompt_ids + states[r].gen for r in members] | |
| max_r = max(states[r].remaining for r in members) | |
| body0 = states[members[0]].job.body | |
| eos = _engine_eos(engine) | |
| # Epoch-homogeneous sampling params (group key): greedy epochs run | |
| # temperature=0 (seed-independent, join-safe); sampled epochs carry | |
| # the group's (temperature, top_p, top_k) with each job's own serial | |
| # seed per sequence, so batched sampled decode is token-identical | |
| # to serial execution. | |
| if states[members[0]].kind == "sampled": | |
| temperature, top_p, top_k = (body0.temperature, body0.top_p, | |
| body0.top_k) | |
| seq_seeds = [states[r].job.body.seed for r in members] | |
| else: | |
| temperature, top_p, top_k = 0.0, 1.0, 0 | |
| seq_seeds = [] | |
| seed = states[members[0]].job.body.seed | |
| lock = getattr(engine, "_generation_lock", None) | |
| if lock is not None: | |
| lock.acquire() | |
| try: | |
| try: | |
| fresh = engine._native.create_batch_session( | |
| prompts, max_new_tokens=max_r, temperature=temperature, | |
| top_p=top_p, top_k=top_k, seed=seed, eos_token_ids=eos, | |
| seq_seeds=seq_seeds) | |
| except (ValueError, TypeError) as exc: | |
| message = str(exc) | |
| if "context" in message.lower() or "KV cache" in message: | |
| error = _APIError(400, "Prompt exceeds the model context length.", | |
| "context_length_exceeded") | |
| else: | |
| error = _APIError(400, "Invalid prompt or chat messages.", | |
| "invalid_prompt") | |
| return fail_running(error) | |
| except Exception: | |
| return fail_running(_APIError(503, "Generation failed.", | |
| "generation_failed")) | |
| return fresh, list(members) | |
| finally: | |
| if lock is not None: | |
| lock.release() | |
| def _cont_step(self, states: dict[int, _ContState], sched: Any, | |
| session: Any, slot_rids: list[int], count: int) -> bool: | |
| """Run one decode pass (``count`` tokens) over the active batch. | |
| Native slot ``i`` maps to ``slot_rids[i]`` (build-time order, stable | |
| across evictions since finished seqs are ``cancel_seq``-masked, not | |
| removed). Finished / errored / cancelled sequences leave | |
| immediately with exactly one terminal publish. Returns True when no | |
| active member remains (epoch drained). | |
| """ | |
| from .paging import ContinuousSchedulerV0 | |
| alive = [states[r] for r in slot_rids if r in states] | |
| if not alive: | |
| return True | |
| engine = alive[0].engine | |
| lock = getattr(engine, "_generation_lock", None) | |
| def drop(rid: int, value: _Chunk | _APIError | None) -> None: | |
| state = states.pop(rid, None) | |
| try: | |
| sched.finish(rid, time.monotonic()) | |
| except Exception: | |
| pass | |
| if state is not None: | |
| if value is not None: | |
| self._put(state.job, value) | |
| self._finish(state.job) | |
| if lock is not None: | |
| lock.acquire() | |
| try: | |
| try: | |
| out = session.next_tokens(max(1, count)) | |
| reasons = list(session.finish_reasons) | |
| except Exception: | |
| for rid in slot_rids: | |
| drop(rid, _APIError(503, "Generation failed.", | |
| "generation_failed")) | |
| return True | |
| finally: | |
| if lock is not None: | |
| lock.release() | |
| if len(out) != len(slot_rids) or len(reasons) != len(slot_rids): | |
| for rid in slot_rids: | |
| drop(rid, _APIError(503, "Generation did not complete.", | |
| "generation_failed")) | |
| return True | |
| for index, rid in enumerate(slot_rids): | |
| state = states.get(rid) | |
| if state is None: | |
| continue # evicted earlier; slot masked via cancel_seq | |
| toks, reason = out[index], reasons[index] | |
| if state.job.cancel.is_set() or self.stopping.is_set(): | |
| try: | |
| session.cancel_seq(index) | |
| except Exception: | |
| pass | |
| drop(rid, None) | |
| continue | |
| if reason == "cancelled": | |
| drop(rid, None) | |
| continue | |
| state.gen.extend(list(toks or [])) | |
| finish = reason if reason in ("stop", "length") else None | |
| if finish is None and state.remaining <= 0: | |
| # Per-job budget spent while the shared session runs on: | |
| # terminal "length", exactly as a lone run would report. | |
| finish = "length" | |
| if finish is None: | |
| continue | |
| try: | |
| text = engine.tokenizer.decode( | |
| list(state.gen), skip_special_tokens=True, | |
| clean_up_tokenization_spaces=False) | |
| except Exception: | |
| try: | |
| session.cancel_seq(index) | |
| except Exception: | |
| pass | |
| drop(rid, _APIError(503, "Generation failed.", | |
| "generation_failed")) | |
| continue | |
| if state.stop: | |
| text, stop_finish = ContinuousSchedulerV0.apply_stop( | |
| text, state.stop) | |
| finish = stop_finish or finish | |
| try: | |
| session.cancel_seq(index) | |
| except Exception: | |
| pass | |
| drop(rid, _Chunk(text, finish, state.plen, len(state.gen), | |
| token_ids=list(state.gen))) | |
| return not sched.running_ids | |
| async def receive( | |
| self, job: _Job, request: Request, *, streaming: bool = False | |
| ) -> _Chunk | _APIError: | |
| # Before ASGI 2.4 StreamingResponse owns the disconnect listener. | |
| spec = request.scope.get("asgi", {}).get("spec_version", "2.0") | |
| poll_disconnect = not streaming or tuple(map(int, spec.split("."))) >= (2, 4) | |
| while True: | |
| if self.stopping.is_set(): | |
| raise _APIError(503, "Server is shutting down.", "server_unavailable") | |
| if job.cancel.is_set(): | |
| raise _APIError(503, "Request disconnected.", "request_cancelled") | |
| try: | |
| return job.output.get_nowait() | |
| except queue.Empty: | |
| if not self.thread.is_alive(): | |
| raise _APIError(503, "Inference worker is unavailable.", "server_unavailable") | |
| # Re-arm only when drained. Keep any scheduled callback marked as | |
| # pending until it runs, even if the consumer drains a new burst. | |
| # Thus neither a fast producer nor a slow client can queue callbacks | |
| # without bound. Rechecking after clear prevents a lost wakeup. | |
| with job.notification_lock: | |
| job.notified = False | |
| job.output_ready.clear() | |
| try: | |
| return job.output.get_nowait() | |
| except queue.Empty: | |
| pass | |
| try: | |
| async with asyncio.timeout(0.05): | |
| await job.output_ready.wait() | |
| except TimeoutError: | |
| if poll_disconnect and await request.is_disconnected(): | |
| job.cancel.set() | |
| raise _APIError(503, "Request disconnected.", "request_cancelled") | |
| class _JobResponse(StreamingResponse): | |
| def __init__(self, content, worker: _Worker, job: _Job) -> None: | |
| super().__init__(content, media_type="text/event-stream", headers={ | |
| "Cache-Control": "no-cache", "X-Accel-Buffering": "no", | |
| }) | |
| self.worker = worker | |
| self.job = job | |
| async def __call__(self, scope, receive, send) -> None: | |
| # A send failure can leave the generator suspended at yield, so its | |
| # finally block alone cannot reliably cancel inference on disconnect. | |
| try: | |
| await super().__call__(scope, receive, send) | |
| finally: | |
| self.worker.release(self.job) | |
| await self.body_iterator.aclose() | |
| def _usage(chunk: _Chunk) -> dict[str, int]: | |
| proposed, accepted = chunk.spec_proposed, chunk.spec_accepted | |
| return { | |
| "prompt_tokens": chunk.prompt_tokens, | |
| "completion_tokens": chunk.completion_tokens, | |
| "total_tokens": chunk.prompt_tokens + chunk.completion_tokens, | |
| "spec_proposed": proposed, | |
| "spec_accepted": accepted, | |
| "spec_accept_rate": round(accepted / proposed, 4) if proposed else None, | |
| } | |
| async def _rescore_logprobs(engine: Any, body: _CompletionRequest | _ChatRequest, | |
| gen_ids: list[int], text: str) -> dict[str, Any]: | |
| """Rescore emitted IDs with real engine logits (thread-offloaded). | |
| The prompt is re-encoded on the tokenizer-only path (identical to the | |
| worker's encoding); scoring itself runs in a worker thread since native | |
| ``logits()`` calls block. Raises _APIError on any failure -- logprobs | |
| are never synthesized. | |
| """ | |
| top_n = _logprobs_top_n(body) | |
| chat = isinstance(body, _ChatRequest) | |
| if text and not gen_ids: | |
| raise _APIError(503, "Engine did not report token IDs; logprobs unavailable.", | |
| "generation_failed") | |
| def _run() -> dict[str, Any]: | |
| try: | |
| if chat: | |
| assert isinstance(body, _ChatRequest) | |
| prompt_ids = engine.encode(engine.format_chat( | |
| [message.model_dump() for message in body.messages])) | |
| else: | |
| assert isinstance(body, _CompletionRequest) | |
| prompt_ids = engine.encode(body.prompt) | |
| except (ValueError, TypeError): | |
| raise _APIError(400, "Invalid prompt or chat messages.", "invalid_prompt") from None | |
| if gen_ids and not all(isinstance(t, int) for t in gen_ids): | |
| raise _APIError(503, "Engine reported invalid token IDs.", "generation_failed") | |
| surfaces, chosen, tops = _score_token_logprobs(engine, prompt_ids, gen_ids, top_n) | |
| if chat: | |
| return {"content": [ | |
| {"token": token, "logprob": logprob, | |
| "top_logprobs": [{"token": t, "logprob": lp} for t, lp in top]} | |
| for token, logprob, top in zip(surfaces, chosen, tops)]} | |
| return {"tokens": surfaces, "token_logprobs": chosen, | |
| "top_logprobs": [{token: logprob for token, logprob in top} | |
| for top in tops]} | |
| return await asyncio.to_thread(_run) | |
| # -- Stream A2: exact-math opt-in paths (logprobs / JSON / tools) ------------- | |
| #: Max tool executions per chat request; the loop always terminates. | |
| _TOOL_MAX_CALLS = 4 | |
| #: Documented JSON-schema subset for constrained decoding and tool args. | |
| _SCHEMA_TYPES = frozenset({"object", "array", "string", "integer", "number", | |
| "boolean", "null"}) | |
| _SCHEMA_VALUE_KEYS = frozenset({"type", "properties", "required", | |
| "additionalProperties", "items", "enum", "const"}) | |
| def _wants_logprobs(body: _CompletionRequest | _ChatRequest) -> bool: | |
| if isinstance(body, _ChatRequest): | |
| return bool(body.logprobs) | |
| return body.logprobs is not None | |
| def _logprobs_top_n(body: _CompletionRequest | _ChatRequest) -> int: | |
| if isinstance(body, _ChatRequest): | |
| return body.top_logprobs or 0 | |
| return body.logprobs or 0 | |
| def _needs_constrained(body: _CompletionRequest | _ChatRequest) -> bool: | |
| fmt = getattr(body, "response_format", None) | |
| return fmt is not None and getattr(fmt, "type", "text") != "text" | |
| def _constrained_target(body: _CompletionRequest | _ChatRequest) -> Any: | |
| """None (plain text), True (any JSON), or a schema dict.""" | |
| fmt = getattr(body, "response_format", None) | |
| if fmt is None or getattr(fmt, "type", "text") == "text": | |
| return None | |
| if getattr(fmt, "type", None) == "json_object": | |
| return True | |
| return fmt.json_schema.schema # type: ignore[union-attr] | |
| def _needs_tools(body: _CompletionRequest | _ChatRequest) -> bool: | |
| tools = getattr(body, "tools", None) | |
| if not tools: | |
| return False | |
| return getattr(body, "tool_choice", None) != "none" | |
| # -- minimal JSON-schema subset (validate + support check) -------------------- | |
| def _schema_support_error(schema: Any, where: str = "schema") -> str | None: | |
| """None when ``schema`` is inside the supported subset, else a reason. | |
| Supported: type (object/array/string/integer/number/boolean/null, or a | |
| list thereof), properties, required, additionalProperties (bool), items, | |
| enum (JSON scalars), const. Everything else ($ref, allOf/anyOf/oneOf, | |
| pattern, format, length/range keywords, ...) is rejected so the server | |
| never silently ignores a constraint. | |
| """ | |
| if not isinstance(schema, dict): | |
| return f"{where} must be an object" | |
| for key in schema: | |
| if key not in _SCHEMA_VALUE_KEYS: | |
| return f"unsupported keyword {key!r} at {where}" | |
| kind = schema.get("type", None) | |
| if kind is not None: | |
| kinds = kind if isinstance(kind, list) else [kind] | |
| if not kinds or any(k not in _SCHEMA_TYPES for k in kinds): | |
| return f"unsupported type at {where}" | |
| if "properties" in schema: | |
| props = schema["properties"] | |
| if not isinstance(props, dict): | |
| return f"properties must be an object at {where}" | |
| for name, sub in props.items(): | |
| reason = _schema_support_error(sub, f"{where}.properties.{name}") | |
| if reason is not None: | |
| return reason | |
| if "required" in schema: | |
| required = schema["required"] | |
| if (not isinstance(required, list) or not required | |
| or any(not isinstance(name, str) for name in required)): | |
| return f"required must be a nonempty string list at {where}" | |
| if "additionalProperties" in schema and not isinstance( | |
| schema["additionalProperties"], bool): | |
| return f"additionalProperties must be a boolean at {where}" | |
| if "items" in schema: | |
| reason = _schema_support_error(schema["items"], f"{where}.items") | |
| if reason is not None: | |
| return reason | |
| if "enum" in schema: | |
| enum = schema["enum"] | |
| if (not isinstance(enum, list) or not enum | |
| or any(isinstance(v, (dict, list)) for v in enum)): | |
| return f"enum must be a nonempty scalar list at {where}" | |
| return None | |
| def _mini_validate(value: Any, schema: Any) -> bool: | |
| """Validate ``value`` against the supported schema subset.""" | |
| if not isinstance(schema, dict): | |
| return False | |
| if "const" in schema and value != schema["const"]: | |
| return False | |
| if "enum" in schema and value not in schema["enum"]: | |
| return False | |
| kind = schema.get("type", None) | |
| if kind is not None: | |
| kinds = set(kind) if isinstance(kind, list) else {kind} | |
| if not any(_mini_type_ok(value, k) for k in kinds): | |
| return False | |
| if len(kinds) == 1: | |
| kind = next(iter(kinds)) | |
| else: | |
| kind = None | |
| if kind == "object" or (kind is None and "properties" in schema): | |
| if not isinstance(value, dict): | |
| return kind is None | |
| props = schema.get("properties", {}) | |
| for name, sub in props.items(): | |
| if name in value and not _mini_validate(value[name], sub): | |
| return False | |
| for name in schema.get("required", []): | |
| if name not in value: | |
| return False | |
| if schema.get("additionalProperties") is False: | |
| if any(name not in props for name in value): | |
| return False | |
| return True | |
| if kind == "array" or (kind is None and "items" in schema): | |
| if not isinstance(value, list): | |
| return kind is None | |
| items = schema.get("items") | |
| return items is None or all(_mini_validate(v, items) for v in value) | |
| return True | |
| def _mini_type_ok(value: Any, kind: str) -> bool: | |
| if kind == "null": | |
| return value is None | |
| if kind == "boolean": | |
| return isinstance(value, bool) | |
| if kind == "integer": | |
| return isinstance(value, int) and not isinstance(value, bool) | |
| if kind == "number": | |
| return isinstance(value, (int, float)) and not isinstance(value, bool) | |
| if kind == "string": | |
| return isinstance(value, str) | |
| if kind == "array": | |
| return isinstance(value, list) | |
| if kind == "object": | |
| return isinstance(value, dict) | |
| return False | |
| # -- JSON prefix viability (structural + top-level key allowlist) ------------- | |
| def _skip_ws(text: str, i: int) -> int: | |
| while i < len(text) and text[i] in " \t\n\r": | |
| i += 1 | |
| return i | |
| def _parse_string(text: str, i: int) -> tuple[str, int]: | |
| """Parse text[i] == '\"'. Returns (ok|partial|bad, next index).""" | |
| n = len(text) | |
| j = i + 1 | |
| while j < n: | |
| c = text[j] | |
| if c == '"': | |
| return "ok", j + 1 | |
| if c == "\\": | |
| j += 1 | |
| if j >= n: | |
| return "partial", j | |
| e = text[j] | |
| if e not in "\"\\/bfnrtu": | |
| return "bad", j | |
| if e == "u": | |
| hex_digits = text[j + 1:j + 5] | |
| if len(hex_digits) < 4: | |
| rest = text[j + 1:] | |
| if all(ch in "0123456789abcdefABCDEF" for ch in rest): | |
| return "partial", n | |
| return "bad", j | |
| if any(ch not in "0123456789abcdefABCDEF" for ch in hex_digits): | |
| return "bad", j | |
| j += 4 | |
| j += 1 | |
| continue | |
| if ord(c) < 0x20: | |
| return "bad", j | |
| j += 1 | |
| return "partial", n | |
| def _parse_literal(text: str, i: int, word: str) -> tuple[str, int]: | |
| rest = text[i:i + len(word)] | |
| if rest == word: | |
| return "ok", i + len(word) | |
| if word.startswith(rest) and i + len(rest) >= len(text): | |
| return "partial", len(text) | |
| return "bad", i | |
| def _parse_number(text: str, i: int) -> tuple[str, int]: | |
| n = len(text) | |
| j = i | |
| if j < n and text[j] == "-": | |
| j += 1 | |
| digits = 0 | |
| while j < n and text[j].isdigit(): | |
| j += 1 | |
| digits += 1 | |
| if digits == 0: | |
| # Lone "-" (or empty) is a partial number, anything else is bad. | |
| return ("partial", j) if j > i else ("bad", i) | |
| if j < n and text[j] == ".": | |
| j += 1 | |
| frac = 0 | |
| while j < n and text[j].isdigit(): | |
| j += 1 | |
| frac += 1 | |
| if frac == 0: | |
| return "partial", j | |
| if j < n and text[j] in "eE": | |
| j += 1 | |
| if j < n and text[j] in "+-": | |
| j += 1 | |
| exp = 0 | |
| while j < n and text[j].isdigit(): | |
| j += 1 | |
| exp += 1 | |
| if exp == 0: | |
| return "partial", j | |
| return "ok", j | |
| def _parse_value(text: str, i: int) -> tuple[str, int]: | |
| """Returns (ok|partial|bad, next index) for one JSON value at i.""" | |
| n = len(text) | |
| i = _skip_ws(text, i) | |
| if i >= n: | |
| return "partial", i | |
| c = text[i] | |
| if c == "{": | |
| i += 1 | |
| i = _skip_ws(text, i) | |
| if i >= n: | |
| return "partial", i | |
| if text[i] == "}": | |
| return "ok", i + 1 | |
| while True: | |
| i = _skip_ws(text, i) | |
| if i >= n: | |
| return "partial", i | |
| if text[i] != '"': | |
| return "bad", i | |
| status, i = _parse_string(text, i) | |
| if status != "ok": | |
| return status, i | |
| i = _skip_ws(text, i) | |
| if i >= n or text[i] != ":": | |
| return ("partial", i) if i >= n else ("bad", i) | |
| i += 1 | |
| status, i = _parse_value(text, i) | |
| if status != "ok": | |
| return status, i | |
| i = _skip_ws(text, i) | |
| if i >= n: | |
| return "partial", i | |
| if text[i] == ",": | |
| i += 1 | |
| continue | |
| if text[i] == "}": | |
| return "ok", i + 1 | |
| return "bad", i | |
| if c == "[": | |
| i += 1 | |
| i = _skip_ws(text, i) | |
| if i >= n: | |
| return "partial", i | |
| if text[i] == "]": | |
| return "ok", i + 1 | |
| while True: | |
| status, i = _parse_value(text, i) | |
| if status != "ok": | |
| return status, i | |
| i = _skip_ws(text, i) | |
| if i >= n: | |
| return "partial", i | |
| if text[i] == ",": | |
| i += 1 | |
| continue | |
| if text[i] == "]": | |
| return "ok", i + 1 | |
| return "bad", i | |
| if c == '"': | |
| return _parse_string(text, i) | |
| if c == "t": | |
| return _parse_literal(text, i, "true") | |
| if c == "f": | |
| return _parse_literal(text, i, "false") | |
| if c == "n": | |
| return _parse_literal(text, i, "null") | |
| if c == "-" or c.isdigit(): | |
| return _parse_number(text, i) | |
| return "bad", i | |
| def _json_prefix_viable(text: str) -> bool: | |
| """True when ``text`` is a strict prefix of (or equal to) some valid JSON.""" | |
| status, i = _parse_value(text, 0) | |
| if status == "bad": | |
| return False | |
| if status == "partial": | |
| return True | |
| return _skip_ws(text, i) >= len(text) | |
| def _top_level_keys(text: str) -> list[str]: | |
| """Complete top-level object keys visible in a (possibly partial) prefix.""" | |
| keys: list[str] = [] | |
| i = _skip_ws(text, 0) | |
| n = len(text) | |
| if i >= n or text[i] != "{": | |
| return keys | |
| depth = 0 | |
| while i < n: | |
| c = text[i] | |
| if c in "{[": | |
| depth += 1 | |
| i += 1 | |
| elif c in "}]": | |
| depth -= 1 | |
| i += 1 | |
| if depth <= 0: | |
| break | |
| elif c == '"': | |
| status, j = _parse_string(text, i) | |
| if status != "ok": | |
| break | |
| if depth == 1: | |
| k = _skip_ws(text, j) | |
| if k < n and text[k] == ":": | |
| try: | |
| keys.append(json.loads(text[i:j])) | |
| except ValueError: | |
| pass | |
| i = j | |
| else: | |
| i += 1 | |
| return keys | |
| def _schema_prefix_ok(text: str, schema: Any) -> bool: | |
| """Top-level container/key guidance for the supported schema subset.""" | |
| if not isinstance(schema, dict): | |
| return True | |
| kind = schema.get("type", None) | |
| expected: str | None = None | |
| if kind == "object" or (kind is None and "properties" in schema): | |
| expected = "object" | |
| elif kind == "array" or (kind is None and "items" in schema): | |
| expected = "array" | |
| stripped = text.lstrip() | |
| if not stripped: | |
| return True | |
| if expected == "object": | |
| if not stripped.startswith("{"): | |
| return False | |
| if schema.get("additionalProperties") is False and isinstance( | |
| schema.get("properties"), dict): | |
| allowed = set(schema["properties"]) | |
| if any(key not in allowed for key in _top_level_keys(text)): | |
| return False | |
| elif expected == "array": | |
| if not stripped.startswith("["): | |
| return False | |
| return True | |
| def _prefix_viable(text: str, schema: Any | None) -> bool: | |
| if not _json_prefix_viable(text): | |
| return False | |
| return schema is None or _schema_prefix_ok(text, schema) | |
| def _complete_and_valid(text: str, target: Any) -> bool: | |
| """True only for a complete document that validates (schema or any JSON).""" | |
| try: | |
| value = json.loads(text) | |
| except ValueError: | |
| return False | |
| if target is True or target is None: | |
| return True | |
| return _mini_validate(value, target) | |
| # -- exact log-softmax rescoring over real engine logits ---------------------- | |
| def _log_softmax_rows(logits: Any) -> list[float]: | |
| flat = list(logits) | |
| if not flat: | |
| raise ValueError("empty logits") | |
| peak = max(float(v) for v in flat) | |
| if not math.isfinite(peak): | |
| raise ValueError("non-finite logits") | |
| total = math.fsum(math.exp(float(v) - peak) for v in flat) | |
| offset = peak + math.log(total) | |
| return [float(v) - offset for v in flat] | |
| def _argsort_desc(values: Any) -> list[int]: | |
| try: | |
| import numpy as _np # hard dependency (pyproject); exact float64 path | |
| except ImportError: # pragma: no cover - portable fallback | |
| _np = None # type: ignore[assignment] | |
| if _np is not None: | |
| arr = _np.asarray(list(values), dtype=_np.float64) | |
| return [int(i) for i in _np.argsort(arr, kind="stable")[::-1]] | |
| order = list(range(len(list(values)))) | |
| order.sort(key=lambda i: float(list(values)[i]), reverse=True) | |
| return order | |
| def _score_token_logprobs(engine: Any, prompt_ids: list[int], | |
| gen_ids: list[int], top_n: int) -> tuple[list[str], list[float], list[list[tuple[str, float]]]]: | |
| """Exact per-token logprobs: real ``engine.logits`` + float64 log-softmax. | |
| ``prompt_ids`` is the exact encoded prompt; ``gen_ids`` the emitted IDs. | |
| Returns (token surfaces, chosen logprobs, top-N lists) in order. | |
| """ | |
| logits_fn = getattr(engine, "logits", None) | |
| if not callable(logits_fn): | |
| raise _APIError(400, "This engine does not expose logits; logprobs are unsupported.", | |
| "unsupported", "logprobs") | |
| decode = engine.tokenizer.decode | |
| surfaces: list[str] = [] | |
| chosen: list[float] = [] | |
| tops: list[list[tuple[str, float]]] = [] | |
| for pos, tid in enumerate(gen_ids): | |
| try: | |
| row = _log_softmax_rows(logits_fn(list(prompt_ids) + gen_ids[:pos])) | |
| except _APIError: | |
| raise | |
| except (ValueError, TypeError, RuntimeError) as exc: | |
| raise _APIError(400, f"Logprob rescoring failed: {exc}.", "scoring_failed") | |
| if tid < 0 or tid >= len(row): | |
| raise _APIError(503, "Engine reported an out-of-vocabulary token.", "generation_failed") | |
| order = _argsort_desc(row) | |
| keep = order[:max(top_n, 1)] if top_n else [tid] | |
| # Decode surfaces lazily but exactly (single-token decode each). | |
| table: dict[int, str] = {} | |
| for cand in dict.fromkeys([tid, *keep]): | |
| try: | |
| table[cand] = decode([cand], skip_special_tokens=True, | |
| clean_up_tokenization_spaces=False) | |
| except Exception: | |
| raise _APIError(503, "Engine tokenizer failed during rescoring.", | |
| "generation_failed") from None | |
| surfaces.append(table[tid]) | |
| chosen.append(row[tid]) | |
| tops.append([(table[c], row[c]) for c in keep[:top_n]] if top_n else []) | |
| return surfaces, chosen, tops | |
| # -- greedy constrained JSON decoding over real engine logits ----------------- | |
| def _run_constrained_sync(engine: Any, prompt_ids: list[int], *, | |
| token_limit: int, target: Any, | |
| cancel: threading.Event) -> tuple[str, list[int], str, int, int]: | |
| """Greedy argmax over schema-viable tokens using real engine logits. | |
| Returns (text, token_ids, finish_reason, prompt_len, gen_len). | |
| ``finish_reason`` is ``"stop"`` only for a complete, validating | |
| document; otherwise ``"length"`` (budget/context spent). | |
| """ | |
| schema = None if target is True else target | |
| config = getattr(engine, "config", None) | |
| vocab = config.get("vocab_size") if isinstance(config, dict) else None | |
| if not isinstance(vocab, int) or vocab <= 0: | |
| raise _APIError(400, "This engine does not expose a vocabulary; structured output is unsupported.", | |
| "unsupported", "response_format") | |
| logits_fn = getattr(engine, "logits", None) | |
| if not callable(logits_fn): | |
| raise _APIError(400, "This engine does not expose logits; structured output is unsupported.", | |
| "unsupported", "response_format") | |
| try: | |
| context = int(engine.context_length) | |
| except (TypeError, ValueError): | |
| raise _APIError(400, "This engine does not expose a context length.", | |
| "unsupported", "response_format") from None | |
| decode = engine.tokenizer.decode | |
| eos = set(_engine_eos(engine)) | |
| budget = min(token_limit, max(context - len(prompt_ids), 0)) | |
| prefix = list(prompt_ids) | |
| generated: list[int] = [] | |
| text = "" | |
| if _complete_and_valid(text, target): | |
| return text, generated, "stop", len(prompt_ids), 0 | |
| finish = "length" | |
| for _ in range(budget): | |
| if cancel.is_set(): | |
| raise _APIError(503, "Request disconnected.", "request_cancelled") | |
| if len(prefix) >= context: | |
| break | |
| try: | |
| row = _log_softmax_rows(logits_fn(list(prefix))) | |
| except _APIError: | |
| raise | |
| except (ValueError, TypeError, RuntimeError) as exc: | |
| raise _APIError(400, f"Constrained decoding failed: {exc}.", "generation_failed") | |
| if len(row) != vocab: | |
| raise _APIError(503, "Engine logits do not match the model vocabulary.", | |
| "generation_failed") | |
| picked: tuple[int, str] | None = None | |
| for tid in _argsort_desc(row): | |
| try: | |
| full = decode(generated + [tid], skip_special_tokens=True, | |
| clean_up_tokenization_spaces=False) | |
| except Exception: | |
| continue | |
| if tid in eos and not _complete_and_valid(full, target): | |
| continue | |
| if _prefix_viable(full, schema): | |
| picked = (tid, full) | |
| break | |
| if picked is None: | |
| raise _APIError(503, "Constrained decoding found no valid continuation.", | |
| "generation_failed") | |
| tid, full = picked | |
| generated.append(tid) | |
| prefix.append(tid) | |
| text = full | |
| if _complete_and_valid(text, target): | |
| finish = "stop" | |
| break | |
| return text, generated, finish, len(prompt_ids), len(generated) | |
| # -- tool calling: real parse + real local execution + bounded loop ----------- | |
| def _eval_arith(node: ast.AST, depth: int = 0) -> Any: | |
| if depth > 64: | |
| raise ValueError("expression too deeply nested") | |
| if isinstance(node, ast.Expression): | |
| return _eval_arith(node.body, depth + 1) | |
| if isinstance(node, ast.Constant): | |
| if isinstance(node.value, bool) or not isinstance(node.value, (int, float)): | |
| raise ValueError("only plain numbers are supported") | |
| if isinstance(node.value, float) and not math.isfinite(node.value): | |
| raise ValueError("non-finite literal") | |
| return node.value | |
| if isinstance(node, ast.UnaryOp) and isinstance(node.op, (ast.UAdd, ast.USub)): | |
| value = _eval_arith(node.operand, depth + 1) | |
| return value if isinstance(node.op, ast.UAdd) else -value | |
| if isinstance(node, ast.BinOp) and isinstance( | |
| node.op, (ast.Add, ast.Sub, ast.Mult, ast.Div, ast.FloorDiv, | |
| ast.Mod, ast.Pow)): | |
| left = _eval_arith(node.left, depth + 1) | |
| right = _eval_arith(node.right, depth + 1) | |
| try: | |
| if isinstance(node.op, ast.Add): | |
| return left + right | |
| if isinstance(node.op, ast.Sub): | |
| return left - right | |
| if isinstance(node.op, ast.Mult): | |
| return left * right | |
| if isinstance(node.op, ast.Div): | |
| return left / right | |
| if isinstance(node.op, ast.FloorDiv): | |
| return left // right | |
| if isinstance(node.op, ast.Mod): | |
| return left % right | |
| if isinstance(right, int) and right < 0 and isinstance(left, int): | |
| raise ValueError("negative integer exponents are unsupported") | |
| if isinstance(right, (int, float)) and abs(right) > 10**6: | |
| raise ValueError("exponent too large") | |
| return left ** right | |
| except ZeroDivisionError: | |
| raise ValueError("division by zero") from None | |
| except OverflowError: | |
| raise ValueError("arithmetic overflow") from None | |
| raise ValueError("only + - * / // % **, parentheses, and numbers are supported") | |
| def _tool_calc(arguments: Any) -> dict: | |
| """Exact local arithmetic: {"expression": "6*7"} -> {"result": 42}.""" | |
| if not isinstance(arguments, dict): | |
| raise ValueError("arguments must be an object") | |
| expression = arguments.get("expression", None) | |
| if not isinstance(expression, str) or not expression.strip(): | |
| raise ValueError("expression must be a nonempty string") | |
| if len(expression) > 4096: | |
| raise ValueError("expression too long") | |
| try: | |
| tree = ast.parse(expression, mode="eval") | |
| except SyntaxError as exc: | |
| raise ValueError(f"invalid expression: {exc.msg}") from None | |
| result = _eval_arith(tree) | |
| if isinstance(result, float) and not math.isfinite(result): | |
| raise ValueError("non-finite result") | |
| return {"result": result} | |
| _TOOL_REGISTRY: dict[str, tuple[Any, dict, str]] = { | |
| "calc": (_tool_calc, | |
| {"type": "object", | |
| "properties": {"expression": {"type": "string"}}, | |
| "required": ["expression"], "additionalProperties": False}, | |
| "Evaluate a decimal arithmetic expression (+ - * / // % **)."), | |
| } | |
| def _parse_tool_call(text: str) -> tuple[str, dict] | None: | |
| """Parse a ``{"name", "arguments"}`` tool call from assistant text. | |
| Accepts raw JSON or a single ```json fenced block; returns None when the | |
| text is plain assistant content (not a tool call). | |
| """ | |
| stripped = text.strip() | |
| if stripped.startswith("```"): | |
| lines = stripped.split("\n") | |
| try: | |
| end = next(i for i, line in enumerate(lines[1:], 1) | |
| if line.strip().startswith("```")) | |
| except StopIteration: | |
| return None | |
| stripped = "\n".join(lines[1:end]).strip() | |
| if stripped.lower().startswith("json"): | |
| stripped = stripped[4:].strip() | |
| candidate: Any = None | |
| if stripped.startswith("{"): | |
| try: | |
| candidate = json.loads(stripped) | |
| except ValueError: | |
| candidate = None | |
| if candidate is None: | |
| start, end = stripped.find("{"), stripped.rfind("}") | |
| if 0 <= start < end: | |
| try: | |
| candidate = json.loads(stripped[start:end + 1]) | |
| except ValueError: | |
| candidate = None | |
| if not isinstance(candidate, dict): | |
| return None | |
| name = candidate.get("name") | |
| arguments = candidate.get("arguments", {}) | |
| if not isinstance(name, str) or not name: | |
| return None | |
| if isinstance(arguments, str): | |
| try: | |
| arguments = json.loads(arguments) | |
| except ValueError: | |
| return None | |
| if not isinstance(arguments, dict): | |
| return None | |
| return name, arguments | |
| def _validate_a2_request(body: _CompletionRequest | _ChatRequest, engine: Any) -> None: | |
| """Fail-fast 400s for Stream A2 options (before queue admission).""" | |
| chat = isinstance(body, _ChatRequest) | |
| if _wants_logprobs(body): | |
| if body.stream: | |
| raise _APIError(400, "logprobs are not supported with stream=true; use non-streaming.", | |
| "invalid_parameter", "logprobs") | |
| if _needs_constrained(body) or _needs_tools(body): | |
| raise _APIError(400, "logprobs cannot be combined with response_format tools yet.", | |
| "invalid_parameter", "logprobs") | |
| if not callable(getattr(engine, "logits", None)): | |
| raise _APIError(400, "This engine does not expose logits; logprobs are unsupported.", | |
| "unsupported", "logprobs") | |
| target = _constrained_target(body) if chat else None | |
| if target is not None: | |
| if not chat: # pragma: no cover - completions carry no response_format | |
| raise _APIError(400, "response_format is only supported on chat completions.", | |
| "invalid_parameter", "response_format") | |
| assert isinstance(body, _ChatRequest) | |
| if body.stream: | |
| raise _APIError(400, "response_format json is not supported with stream=true; use non-streaming.", | |
| "invalid_parameter", "response_format") | |
| if body.temperature != 0 or body.top_p != 1.0 or body.top_k != 0 or body.spec_k != 0: | |
| raise _APIError(400, "response_format json requires greedy decoding " | |
| "(temperature=0, top_p=1, top_k=0, spec_k=0).", | |
| "invalid_parameter", "response_format") | |
| if body.stop: | |
| raise _APIError(400, "response_format json cannot be combined with stop strings.", | |
| "invalid_parameter", "response_format") | |
| if _is_pool_or_batch_engine(engine): | |
| raise _APIError(400, "Structured output is not supported for pool/batch engines.", | |
| "unsupported", "response_format") | |
| if target is not True: | |
| reason = _schema_support_error(target) | |
| if reason is not None: | |
| raise _APIError(400, f"Unsupported response_format schema: {reason}.", | |
| "unsupported_schema", "response_format") | |
| if not callable(getattr(engine, "logits", None)): | |
| raise _APIError(400, "This engine does not expose logits; structured output is unsupported.", | |
| "unsupported", "response_format") | |
| if chat and _needs_tools(body): | |
| assert isinstance(body, _ChatRequest) | |
| if body.stream: | |
| raise _APIError(400, "tools are not supported with stream=true; use non-streaming.", | |
| "invalid_parameter", "tools") | |
| if _is_pool_or_batch_engine(engine): | |
| raise _APIError(400, "Tool calling is not supported for pool/batch engines.", | |
| "unsupported", "tools") | |
| assert body.tools is not None | |
| for tool in body.tools: | |
| name = tool.function.name | |
| if name not in _TOOL_REGISTRY: | |
| raise _APIError(400, f"Unknown tool {name!r}: the server executes only its built-in " | |
| "registry and will not fake external tools.", | |
| "unknown_tool", "tools") | |
| declared = tool.function.parameters | |
| if declared is not None: | |
| reason = _schema_support_error(declared) | |
| if reason is not None: | |
| raise _APIError(400, f"Unsupported tool parameters schema: {reason}.", | |
| "unsupported_schema", "tools") | |
| choice = body.tool_choice | |
| if isinstance(choice, _NamedToolChoice): | |
| wanted = choice.function.name | |
| if all(tool.function.name != wanted for tool in body.tools): | |
| raise _APIError(400, f"tool_choice names {wanted!r} which is not in tools.", | |
| "invalid_parameter", "tool_choice") | |
| # -- Stream A1: paged-KV single-stream + FIFO micro-batching ------------------- | |
| #: Dense-only paged decode; Surjo/FWKV keep their own session types. | |
| _PAGED_EXCLUDED_MODEL_TYPES = frozenset({"surjo", "fwkv"}) | |
| #: Max co-queued jobs coalesced into one BatchSession micro-batch. | |
| _MICRO_BATCH_MAX = 8 | |
| def _serve_paged_enabled() -> bool: | |
| # Default-on since Stream S2 (shared BlockPool, 0.999x dense single-stream): | |
| return os.environ.get("CISM_SERVE_PAGED", "1") != "0" | |
| def _serve_prefixcache_enabled() -> bool: | |
| """Kill switch: ``CISM_SERVE_PREFIXCACHE=0`` disables automatic reuse. | |
| Default-on (Stream S3): the paged single-stream path matches each | |
| arrival against a per-engine radix prefix index and fork-shares the | |
| longest cached prefix via S2 copy-on-write, prefilling only the | |
| suffix. Misses and stale anchors fall back to full prefill (exact). | |
| """ | |
| return os.environ.get("CISM_SERVE_PREFIXCACHE", "1") != "0" | |
| def _prefix_cache_max_anchors(default: int = 8) -> int: | |
| """Retained prefix-anchor sessions per engine (pool-pressure bound).""" | |
| try: | |
| value = int(os.environ.get("CISM_SERVE_PREFIXCACHE_ANCHORS", str(default))) | |
| except ValueError: | |
| return default | |
| return value if value > 0 else default | |
| #: Priority demotion for retained prefix anchors: under pool pressure S2's | |
| #: ``evict_one`` frees the lowest-priority entry first, so idle anchors | |
| #: (never decoded again, only forked from) are evicted before active work. | |
| _PREFIX_ANCHOR_PRIORITY = -8 | |
| def _prefix_cache_for(engine: Any) -> Any | None: | |
| """Per-engine S3 radix index, or None when the path is ineligible. | |
| The cache lives on the engine object itself (freed with it -- no | |
| global registry to leak). Requires the S3 kill switch on, the paged | |
| path eligible (``_can_paged``), and a native fork entry point. | |
| """ | |
| if not _serve_prefixcache_enabled() or not _can_paged(engine): | |
| return None | |
| native = getattr(engine, "_native", None) | |
| if native is None or not callable(getattr(native, "create_paged_fork_session", None)): | |
| return None | |
| cache = getattr(engine, "_prefix_cache", None) | |
| if cache is None: | |
| try: | |
| from .paging import PrefixCache | |
| cache = PrefixCache(max_anchors=_prefix_cache_max_anchors()) | |
| engine._prefix_cache = cache | |
| except (AttributeError, TypeError, ValueError): | |
| return None | |
| return cache | |
| def prefix_cache_stats(engine: Any | None = None) -> dict[str, Any]: | |
| """Hit/miss counters for the S3 automatic prefix cache (ops/tests). | |
| With an engine returns its radix-index stats (``enabled: False`` when | |
| the kill switch or eligibility excludes it); with None reports whether | |
| the switch is on. Never raises: stats must not break serving. | |
| """ | |
| try: | |
| if engine is None: | |
| return {"enabled": _serve_prefixcache_enabled()} | |
| cache = getattr(engine, "_prefix_cache", None) | |
| if cache is None: | |
| return {"enabled": _serve_prefixcache_enabled() | |
| and _can_paged(engine), "hits": 0, "misses": 0, | |
| "fork_failures": 0, "inserts": 0, "evictions": 0, | |
| "anchors": 0, "hit_rate": None} | |
| stats = dict(cache.stats()) | |
| stats["enabled"] = _serve_prefixcache_enabled() | |
| return stats | |
| except Exception: | |
| return {"enabled": False} | |
| def _serve_microbatch_enabled() -> bool: | |
| return os.environ.get("CISM_SERVE_MICROBATCH", "1") != "0" | |
| def _serve_continuous_enabled() -> bool: | |
| """Kill switch: ``CISM_SERVE_CONTINUOUS=0`` falls back to A1 FIFO.""" | |
| return os.environ.get("CISM_SERVE_CONTINUOUS", "1") != "0" | |
| def _continuous_prefill_chunk(default: int) -> int: | |
| """New prompt tokens admitted per epoch rebuild (chunked prefill).""" | |
| try: | |
| value = int(os.environ.get("CISM_SERVE_PREFILL_CHUNK", str(default))) | |
| except ValueError: | |
| return default | |
| return value if value > 0 else default | |
| def _continuous_epoch_steps() -> int: | |
| """Max decode steps before an epoch rotates (starvation backstop).""" | |
| try: | |
| value = int(os.environ.get("CISM_SERVE_EPOCH_STEPS", "1024")) | |
| except ValueError: | |
| return 1024 | |
| return value if value > 0 else 1024 | |
| def _continuous_max_wait_s() -> float: | |
| """Starvation guard: bound on an admittable waiter's queue wait.""" | |
| try: | |
| value = float(os.environ.get("CISM_SERVE_MAX_WAIT_S", "30")) | |
| except ValueError: | |
| return 30.0 | |
| return value if value > 0 else 30.0 | |
| def _continuous_join_defer_tokens() -> int: | |
| """Skip a join rebuild when the live batch is this close to draining. | |
| A newcomer arriving with <= N tokens left in every active member waits | |
| for the drain instead of forcing a recompute-prefill that saves | |
| nothing (starved waiters bypass the deferral via the max-wait guard). | |
| """ | |
| try: | |
| value = int(os.environ.get("CISM_SERVE_JOIN_DEFER_TOKENS", "2")) | |
| except ValueError: | |
| return 2 | |
| return value if value >= 0 else 2 | |
| def _cont_encode(engine: Any, body: _CompletionRequest | _ChatRequest, | |
| ) -> tuple[list[int], list[str] | None]: | |
| """Validate + encode one epoch arrival (per-job isolation). | |
| Raises ``_APIError`` (400 invalid/context) without touching any batch. | |
| """ | |
| try: | |
| if isinstance(body, _ChatRequest): | |
| prompt = engine.format_chat( | |
| [message.model_dump() for message in body.messages]) | |
| prompt_ids = engine.encode(prompt) | |
| else: | |
| prompt_ids = engine.encode(body.prompt) | |
| except (ValueError, TypeError): | |
| raise _APIError(400, "Invalid prompt or chat messages.", | |
| "invalid_prompt") from None | |
| if len(prompt_ids) > engine.context_length: | |
| raise _APIError(400, "Prompt exceeds the model context length.", | |
| "context_length_exceeded") | |
| stop = ([body.stop] if isinstance(body.stop, str) | |
| else (list(body.stop) if body.stop else None)) | |
| return prompt_ids, stop | |
| def _can_paged(engine: Any) -> bool: | |
| """True when ``engine`` can serve a job on the native paged-KV path. | |
| Dense ``Engine`` only: needs ``_native.create_paged_session``, | |
| ``tokenizer``, ``config`` (model_type not surjo/fwkv), ``_eos``, and | |
| ``_generation_lock``. Pool/batch engines, test doubles, and Surjo/FWKV | |
| return False and keep their existing paths bitwise identical. | |
| """ | |
| native = getattr(engine, "_native", None) | |
| if native is None or not callable(getattr(native, "create_paged_session", None)): | |
| return False | |
| if getattr(engine, "tokenizer", None) is None: | |
| return False | |
| if getattr(engine, "_generation_lock", None) is None: | |
| return False | |
| config = getattr(engine, "config", None) | |
| if isinstance(config, dict) and config.get("model_type") in _PAGED_EXCLUDED_MODEL_TYPES: | |
| return False | |
| return isinstance(getattr(engine, "_eos", None), list) | |
| def _stream_paged(engine: Any, tokens: list[int], *, max_new_tokens: int = 128, | |
| temperature: float = 0.0, top_p: float = 1.0, top_k: int = 0, | |
| seed: int = 0, stop: list[str] | None = None, | |
| cancel: threading.Event | None = None, | |
| chunk_size: int = 16) -> Iterator[_Chunk]: | |
| """Yield :class:`_Chunk` deltas over a native ``PagedSession``. | |
| Exact-math mirror of ``Engine.stream`` for ``spec_k == 0`` (same | |
| validation, same context clamp, same lock/cancel/monitor discipline, same | |
| stop-string truncation with partial-prefix holdback, same incremental | |
| decode check). Native ``test_paging_native.py`` proves paged blocked-SDPA | |
| is bitwise-identical to the dense ``Session`` path, so greedy and sampled | |
| outputs match ``engine.stream`` token-for-token; spec tallies report 0 | |
| (no speculation on the paged path -- callers route ``spec_k > 0`` to the | |
| dense path). | |
| """ | |
| if type(chunk_size) is not int or not 1 <= chunk_size <= 1024: | |
| raise ValueError("chunk_size must be an integer in [1, 1024]") | |
| if type(max_new_tokens) is not int or max_new_tokens < 0: | |
| raise ValueError("max_new_tokens must be a nonnegative integer") | |
| if type(seed) is not int or not 0 <= seed < 2**64: | |
| raise ValueError("seed must be an unsigned 64-bit integer") | |
| if type(top_k) is not int or top_k < 0: | |
| raise ValueError("top_k must be a nonnegative integer") | |
| for name, value in (("temperature", temperature), ("top_p", top_p)): | |
| if type(value) not in (float, int) or not math.isfinite(value): | |
| raise ValueError(f"{name} must be finite") | |
| if temperature < 0 or not 0 < top_p <= 1: | |
| raise ValueError("temperature must be nonnegative and top_p in (0, 1]") | |
| if stop is not None and (not isinstance(stop, list) or any(not isinstance(s, str) or not s for s in stop)): | |
| raise ValueError("stop must be a list of nonempty strings") | |
| stops = list(stop or []) | |
| tokens = list(tokens) | |
| vocab = engine.config.get("vocab_size") if isinstance(engine.config, dict) else None | |
| for token in tokens: | |
| if type(token) is not int or (vocab is not None and not 0 <= token < vocab): | |
| raise ValueError("Token IDs must be integers within the model vocabulary") | |
| if len(tokens) > engine.context_length: | |
| raise ValueError("Prompt length exceeds the model context length") | |
| max_new_tokens = min(max_new_tokens, engine.context_length - len(tokens)) | |
| cancel = cancel if cancel is not None else threading.Event() | |
| lock = engine._generation_lock | |
| while not lock.acquire(timeout=0.05): | |
| if cancel.is_set(): | |
| return | |
| session = None | |
| monitor_done = threading.Event() | |
| monitor = None | |
| monitor_started = False | |
| released = False | |
| def cleanup() -> None: | |
| nonlocal released | |
| if released: | |
| return | |
| try: | |
| monitor_done.set() | |
| if session is not None: | |
| session.cancel() | |
| if monitor_started: | |
| monitor.join() | |
| finally: | |
| released = True | |
| lock.release() | |
| try: | |
| if cancel.is_set(): | |
| return | |
| # Stream S3: longest cached token prefix is fork-shared via S2 | |
| # copy-on-write (child prefills only the suffix). Sampling config | |
| # is irrelevant to reuse (KV is sampling-independent); the fork | |
| # re-checks token equality, so a hit is exact by construction. | |
| # Stale anchors (S2 evict_one / pool reset) raise here -> invalidate | |
| # the branch and fall back to full prefill (still exact). | |
| cache = _prefix_cache_for(engine) | |
| prefix_hit_len = 0 | |
| if cache is not None: | |
| anchor, hit_len = cache.match(tokens) | |
| if anchor is not None: | |
| try: | |
| session = engine._native.create_paged_fork_session( | |
| tokens, max_new_tokens=max_new_tokens, | |
| temperature=temperature, top_p=top_p, top_k=top_k, | |
| seed=seed, eos_token_ids=list(engine._eos), | |
| src=anchor, prefix_len=hit_len) | |
| prefix_hit_len = int(hit_len) | |
| cache.note_hit() | |
| except (ValueError, RuntimeError): | |
| cache.invalidate_session(anchor) | |
| cache.note_fork_failed() | |
| cache.note_miss() | |
| session = None | |
| else: | |
| cache.note_miss() | |
| if session is None: | |
| session = engine._native.create_paged_session( | |
| tokens, max_new_tokens=max_new_tokens, | |
| temperature=temperature, top_p=top_p, top_k=top_k, | |
| seed=seed, eos_token_ids=list(engine._eos)) | |
| def watch_cancel() -> None: | |
| while not monitor_done.wait(0.01): | |
| if cancel.is_set(): | |
| session.cancel() | |
| return | |
| monitor = threading.Thread(target=watch_cancel, name="cism-cancel", daemon=True) | |
| monitor.start() | |
| monitor_started = True | |
| generated: list[int] = [] | |
| emitted = "" | |
| first = True | |
| while True: | |
| if cancel.is_set(): | |
| session.cancel() | |
| return | |
| batch = session.next_tokens(1 if first or stops else chunk_size) | |
| first = False | |
| generated.extend(batch) | |
| reason = session.finish_reason or None | |
| if reason == "cancelled": | |
| return | |
| text = engine.tokenizer.decode(generated, skip_special_tokens=True, | |
| clean_up_tokenization_spaces=False) | |
| if reason is None: | |
| text = text.rstrip("\ufffd") | |
| matches = [text.find(s) for s in stops if s in text] | |
| if matches: | |
| text = text[:min(matches)] | |
| reason = "stop" | |
| session.cancel() | |
| elif reason is None: | |
| hold = 0 | |
| for sequence in stops: | |
| for size in range(1, min(len(sequence), len(text) + 1)): | |
| if text.endswith(sequence[:size]): | |
| hold = max(hold, size) | |
| if hold: | |
| text = text[:-hold] | |
| if not text.startswith(emitted): | |
| raise ValueError("Tokenizer decoding rewrote emitted text; incremental streaming is unsupported") | |
| delta = text[len(emitted):] | |
| emitted = text | |
| if reason is not None: | |
| # Retain this finished session as an S3 fork anchor (pool KV | |
| # stays resident; demoted priority so pressure evicts idle | |
| # anchors before active work). Cancelled streams never anchor. | |
| if (cache is not None and reason != "cancelled" | |
| and not cancel.is_set()): | |
| try: | |
| session.set_priority(_PREFIX_ANCHOR_PRIORITY) | |
| cache.insert(tokens, session) | |
| except (ValueError, RuntimeError): | |
| pass | |
| cleanup() | |
| yield _Chunk(delta, reason, len(tokens), len(generated), | |
| token_ids=list(batch), | |
| prefix_hit=bool(prefix_hit_len), | |
| prefix_len=prefix_hit_len) | |
| if reason is not None: | |
| break | |
| if not batch: | |
| raise RuntimeError("Native decoder made no progress") | |
| finally: | |
| cleanup() | |
| def _micro_batch_eligible(body: _CompletionRequest | _ChatRequest) -> bool: | |
| """Only non-streaming greedy jobs may join a micro-batch. | |
| Greedy (``temperature == 0``) decode is seed-independent, so coalescing | |
| jobs with different seeds/sampling trivia stays bitwise-identical to | |
| serial execution. ``spec_k`` has no batch path; streaming jobs keep the | |
| incremental serial path (v0 limit). Stream A2 opt-in jobs (logprobs | |
| rescoring, constrained JSON, tool loops) always run serially so the | |
| shared fast path is untouched by construction. | |
| """ | |
| return (not body.stream and body.spec_k == 0 | |
| and body.temperature == 0 | |
| and not _wants_logprobs(body) | |
| and not _needs_constrained(body) | |
| and not _needs_tools(body)) | |
| def _sampled_batch_eligible(body: _CompletionRequest | _ChatRequest) -> bool: | |
| """Only non-streaming sampled jobs may join a sampled micro-batch epoch. | |
| Same serial-path exclusions as greedy (streaming, ``spec_k``, A2 | |
| opt-ins). ``temperature > 0`` (``== 0`` is the greedy path). Sampling | |
| params must be in their valid ranges; per-sequence seeds ride along in | |
| the native session, so coalescing same-param jobs stays token-identical | |
| to serial execution. Sampled epochs never take mid-decode joins and | |
| never rotate (a rebuild would restart per-seq RNG streams) -- enforced | |
| in ``fits`` and the rotation gate, not here. | |
| """ | |
| try: | |
| temperature = float(body.temperature) | |
| top_p = float(body.top_p) | |
| top_k = int(body.top_k) | |
| except (TypeError, ValueError): | |
| return False | |
| return (not body.stream and body.spec_k == 0 | |
| and temperature > 0 and temperature == temperature | |
| and 0 < top_p <= 1.0 and top_k >= 0 | |
| and not _wants_logprobs(body) | |
| and not _needs_constrained(body) | |
| and not _needs_tools(body)) | |
| def _micro_batch_key(body: _CompletionRequest | _ChatRequest) -> tuple: | |
| stop = [body.stop] if isinstance(body.stop, str) else tuple(body.stop or ()) | |
| return (tuple(stop), body.token_limit) | |
| def _is_pool_or_batch_engine(engine: Any) -> bool: | |
| """True for EnginePool/BatchEngine (generate_batch, no stream). | |
| Plain Engine/FakeEngine expose ``stream``; pool/batch engines expose | |
| ``generate_batch`` without ``stream``. This keeps the single-stream | |
| ``_Worker`` path bitwise identical for plain engines. | |
| """ | |
| return (hasattr(engine, "generate_batch") and callable(getattr(engine, "generate_batch")) | |
| and not (hasattr(engine, "stream") and callable(getattr(engine, "stream")))) | |
| def _engine_eos(engine: Any) -> list[int]: | |
| eos = getattr(engine, "_eos", None) | |
| if isinstance(eos, list): | |
| return list(eos) | |
| config = getattr(engine, "config", None) | |
| tokenizer = getattr(engine, "tokenizer", None) | |
| raw = None | |
| if isinstance(config, dict): | |
| raw = config.get("eos_token_id", None) | |
| if raw is None: | |
| raw = getattr(tokenizer, "eos_token_id", None) | |
| if raw is None: | |
| return [] | |
| return raw if isinstance(raw, list) else [raw] | |
| def _can_native_batch(engine: Any) -> bool: | |
| """Plain dense Engine with a native BatchSession path (no Surjo).""" | |
| native = getattr(engine, "_native", None) | |
| if native is None or not hasattr(native, "create_batch_session"): | |
| return False | |
| config = getattr(engine, "config", None) | |
| if isinstance(config, dict) and config.get("model_type") == "surjo": | |
| return False | |
| return True | |
| def _run_batch_chunk_sync(engine: Any, chunk_prompts: list, token_limit: int, | |
| temperature: float, top_p: float, top_k: int, | |
| seed: int, stop_list: list[str] | None): | |
| """Run one scheduler chunk synchronously, preserving chunk order. | |
| Dispatch: | |
| - BatchEngine/EnginePool (``_is_pool_or_batch_engine``): use its | |
| ``generate_batch`` (pool = N weight streams in parallel threads; | |
| batch = single weight stream via native ``BatchSession``). | |
| - Plain dense Engine with native batch support: single-weight-stream | |
| ``_native.create_batch_session`` under ``_generation_lock`` so | |
| concurrent batches for *unlike* models (different engines/locks) run | |
| in parallel while same-engine batches serialize. | |
| - Otherwise (Surjo/no native batch): sequential ``engine.generate`` loop | |
| (order-preserving fallback). | |
| Returns a list of ``(text, token_ids, finish_reason, prompt_len, gen_len)``. | |
| """ | |
| stops = list(stop_list or []) | |
| if _is_pool_or_batch_engine(engine): | |
| try: | |
| results = engine.generate_batch( | |
| chunk_prompts, max_new_tokens=token_limit, | |
| temperature=temperature, top_p=top_p, top_k=top_k, | |
| seed=seed, **({"stop": stops} if stops else {})) | |
| except TypeError: | |
| # Older pool mocks without stop= support. | |
| results = engine.generate_batch( | |
| chunk_prompts, max_new_tokens=token_limit, | |
| temperature=temperature, top_p=top_p, top_k=top_k, seed=seed) | |
| if stops: | |
| from .paging import ContinuousSchedulerV0 as _S | |
| fixed = [] | |
| for r in results: | |
| text, reason = _S.apply_stop(r.text, stops) | |
| fixed.append(type(r)(text, r.token_ids, | |
| reason or r.finish_reason, | |
| r.prompt_tokens, r.completion_tokens, | |
| getattr(r, "spec_proposed", 0), | |
| getattr(r, "spec_accepted", 0))) | |
| results = fixed | |
| return [(r.text, list(r.token_ids), r.finish_reason, | |
| r.prompt_tokens, r.completion_tokens) for r in results] | |
| if _can_native_batch(engine): | |
| # Encode via the engine (BOS fallback + vocab checks identical to | |
| # the single-stream worker). | |
| encoded = [engine.encode(p) for p in chunk_prompts] | |
| context = int(engine.context_length) | |
| effective = min([token_limit] + [context - len(t) for t in encoded]) | |
| eos = _engine_eos(engine) | |
| lock = getattr(engine, "_generation_lock", None) | |
| acquire = lock.acquire if lock is not None else None | |
| # Blocking acquire is fine inside asyncio.to_thread. | |
| if acquire is not None: | |
| acquire() | |
| try: | |
| session = engine._native.create_batch_session( | |
| encoded, max_new_tokens=effective, temperature=temperature, | |
| top_p=top_p, top_k=top_k, seed=seed, eos_token_ids=eos) | |
| batched = session.next_tokens(effective) | |
| reasons = list(session.finish_reasons) | |
| finally: | |
| if lock is not None: | |
| lock.release() | |
| decode = engine.tokenizer.decode | |
| out = [] | |
| for tokens, token_ids, reason in zip(encoded, batched, reasons): | |
| text = decode(list(token_ids), skip_special_tokens=True, | |
| clean_up_tokenization_spaces=False) | |
| if stops: | |
| hits = [text.find(s) for s in stops if s in text] | |
| if hits: | |
| text = text[:min(hits)] | |
| reason = "stop" | |
| out.append((text, list(token_ids), reason, len(tokens), len(token_ids))) | |
| return out | |
| # Fallback: sequential generate/stream (Surjo / custom / test doubles). | |
| # Prefer Engine.generate when available; otherwise collect Engine.stream | |
| # chunks (FakeEngine in tests only implements stream). | |
| if hasattr(engine, "generate") and callable(getattr(engine, "generate")): | |
| out = [] | |
| for prompt in chunk_prompts: | |
| try: | |
| r = engine.generate(prompt, max_new_tokens=token_limit, | |
| temperature=temperature, top_p=top_p, | |
| top_k=top_k, seed=seed, | |
| stop=stops or None) | |
| except TypeError: | |
| r = engine.generate(prompt, max_new_tokens=token_limit, | |
| temperature=temperature, top_p=top_p, | |
| top_k=top_k, seed=seed) | |
| if stops: | |
| from .paging import ContinuousSchedulerV0 as _S2 | |
| text, reason = _S2.apply_stop(r.text, stops) | |
| r = type(r)(text, r.token_ids, reason or r.finish_reason, | |
| r.prompt_tokens, r.completion_tokens, | |
| getattr(r, "spec_proposed", 0), | |
| getattr(r, "spec_accepted", 0)) | |
| out.append((r.text, list(r.token_ids), r.finish_reason, | |
| r.prompt_tokens, r.completion_tokens)) | |
| return out | |
| # Stream-only backends (e.g. FakeEngine test double). | |
| out = [] | |
| for prompt in chunk_prompts: | |
| parts: list[str] = [] | |
| ids: list[int] = [] | |
| finish = None | |
| prompt_n = 0 | |
| gen_n = 0 | |
| for chunk in engine.stream(prompt, max_new_tokens=token_limit, | |
| temperature=temperature, top_p=top_p, | |
| top_k=top_k, seed=seed, | |
| stop=stops or None, | |
| cancel=threading.Event(), | |
| chunk_size=16, spec_k=0): | |
| parts.append(chunk.text) | |
| ids.extend(chunk.token_ids) | |
| prompt_n = chunk.prompt_tokens | |
| gen_n = chunk.completion_tokens | |
| if chunk.finish_reason is not None: | |
| finish = chunk.finish_reason | |
| break | |
| if finish is None: | |
| # Mirror the FIFO worker: a missing terminal chunk is a failure. | |
| raise RuntimeError("Generation did not complete") | |
| out.append(("".join(parts), ids, finish, prompt_n, gen_n)) | |
| return out | |
| def create_app(engine: _Engine | dict[str, _Engine], api_key: str | None = None, | |
| queue_size: int = 8, *, allow_remote_code_load: bool = False, | |
| load_lock_pages: bool = False, load_keep_warm: float = 0.0, | |
| load_threads_default: int = 2, | |
| load_local_files_only: bool = False, | |
| load_act_precision: str = "fp32", | |
| max_batch_tokens: int = 4096, | |
| scheduler_blocks: int = 512, | |
| prefill_chunk_tokens: int = 512) -> FastAPI: | |
| """Create an app around engine(s) owned by the caller. | |
| A mapping of model ID to engine serves several loaded models; requests route | |
| by ``model`` field through one shared FIFO worker, so only one generation | |
| runs at a time regardless of how many models are loaded. Passing a single | |
| engine keeps the original behavior. | |
| Phase2 batching (``POST /v1/batch``):: | |
| {"model": "<id>", "prompts": ["a", "b", [1, 2]], "max_tokens": 64} | |
| returns order-preserving ``choices`` (``choices[i]`` is ``prompts[i]``). | |
| Dispatch: same-model same-quant batches use one native ``BatchSession`` | |
| weight stream (``BatchEngine`` or plain dense ``Engine._native``); pool | |
| engines use ``EnginePool.generate_batch`` (N weight streams); concurrent | |
| batches for *unlike* models overlap in ``asyncio.to_thread`` (separate | |
| locks), while same-engine batches serialize. Single-stream ``/v1/completions`` + ``/v1/chat/completions`` for plain | |
| engines still go through the shared ``_Worker`` on the dense | |
| ``engine.stream`` path (unregressed by construction); the bitwise-identical | |
| native paged-KV path (``create_paged_session``) is opt-in via | |
| ``CISM_SERVE_PAGED=1``. Eligible co-queued greedy jobs on dense engines | |
| with a steppable native ``BatchSession`` run in an S1 iteration-level | |
| epoch (per-step add/evict, oldest-first + starvation guard, chunked | |
| prefill; ``CISM_SERVE_CONTINUOUS=0`` falls back to the A1 FIFO | |
| micro-batch); streaming/sampled/speculative/A2 jobs always run | |
| serially. Pool/batch engines serve single-stream via the same batch | |
| machinery with ``B=1``. ``/v1/batch`` chunks FIFO waiting->running requests by | |
| ``max_batch_tokens`` (alias ``max_batched_tokens``, ~512 operating point) | |
| plus ``prefill_chunk_tokens`` (128-512) via ``ContinuousSchedulerV0`` | |
| (block16, per-seq EOS/stop, BlockManager eviction, recompute-requeue); | |
| see its docstring for v0 limits (no preempt-swap, no cross-seq sharing). | |
| Runtime model management: ``POST /v1/models/load`` downloads and loads a | |
| HF model on a background thread (one load at a time). The load applies | |
| ``load_lock_pages``/``load_keep_warm``/``load_threads_default`` to newly | |
| loaded models. ``trust_remote_code=True`` in a load request is rejected | |
| unless ``allow_remote_code_load`` is enabled server-side, because loading | |
| executes downloaded tokenizer code with this process's permissions. | |
| ``POST /v1/models/unload`` releases idle models (409 if generating). | |
| ``None`` reads CISM_API_KEY once at creation; an empty string disables auth. | |
| Health/readiness probes are public. ``queue_size`` bounds all admitted jobs, | |
| including active inference and responses still being consumed. Cancellation | |
| is cooperative; shutdown waits at most one second for the daemon worker. | |
| """ | |
| if type(queue_size) is not int or queue_size < 1: | |
| raise ValueError("queue_size must be a positive integer") | |
| if type(max_batch_tokens) is not int or max_batch_tokens < 1: | |
| raise ValueError("max_batch_tokens must be a positive integer") | |
| if type(scheduler_blocks) is not int or scheduler_blocks < 1: | |
| raise ValueError("scheduler_blocks must be a positive integer") | |
| if type(prefill_chunk_tokens) is not int or prefill_chunk_tokens < 1: | |
| raise ValueError("prefill_chunk_tokens must be a positive integer") | |
| if isinstance(engine, dict): | |
| model_map = dict(engine) | |
| if not model_map: | |
| raise ValueError("At least one model is required") | |
| else: | |
| model_map = {engine.model_id: engine} | |
| registry_lock = threading.Lock() | |
| load_status: dict[str, str] = {} | |
| load_in_flight = threading.Event() | |
| bench_state: dict[str, Any] = {"status": "idle", "progress": "", "result": None, "error": None} | |
| bench_in_flight = threading.Event() | |
| if api_key is None: | |
| api_key = os.environ.get("CISM_API_KEY") | |
| if api_key is not None and not isinstance(api_key, str): | |
| raise TypeError("api_key must be a string or None") | |
| secret = api_key.encode("utf-8") if api_key else None | |
| async def lifespan(app: FastAPI): | |
| previous = app.state.worker | |
| if previous is not None and previous.thread.is_alive(): | |
| raise RuntimeError("The previous inference worker is still running") | |
| # Pool/Batch engines never use the FIFO worker's default engine (they | |
| # bypass it via the batch machinery), so keep it None for them to | |
| # avoid ever calling Pool.generate_batch as Engine.stream. | |
| default_engine = next(iter(model_map.values())) if len(model_map) == 1 else None | |
| if default_engine is not None and _is_pool_or_batch_engine(default_engine): | |
| default_engine = None | |
| worker = _Worker(default_engine, queue_size) | |
| worker.scheduler_config = { | |
| "scheduler_blocks": scheduler_blocks, | |
| "max_batch_tokens": max_batch_tokens, | |
| "prefill_chunk_tokens": prefill_chunk_tokens, | |
| } | |
| app.state.worker = worker | |
| worker.thread.start() | |
| try: | |
| yield | |
| finally: | |
| worker.stop() | |
| deadline = time.monotonic() + 1.0 | |
| while worker.thread.is_alive() and time.monotonic() < deadline: | |
| await asyncio.sleep(0.01) | |
| app = FastAPI( | |
| title="CISM Text Generation API", lifespan=lifespan, | |
| description="Text completions with exact-math logprobs, constrained JSON, and registry tool calls.", | |
| docs_url=None, redoc_url=None, openapi_url=None, | |
| ) | |
| app.state.worker = None | |
| async def api_error_handler(request: Request, error: _APIError): | |
| headers = {"WWW-Authenticate": "Bearer"} if error.status == 401 else None | |
| if error.status == 429: | |
| headers = {"Retry-After": "1"} | |
| return JSONResponse(error.body, status_code=error.status, headers=headers) | |
| async def validation_error_handler(request: Request, error: RequestValidationError): | |
| # Pydantic errors include raw inputs and exception context. Never expose them. | |
| return JSONResponse(_APIError( | |
| 400, "Invalid request body or unsupported parameter. Only text generation is supported.", | |
| "invalid_request", | |
| ).body, status_code=400) | |
| async def http_error_handler(request: Request, error: HTTPException): | |
| message = "Not found." if error.status_code == 404 else "HTTP request failed." | |
| return JSONResponse(_APIError(error.status_code, message, "http_error").body, | |
| status_code=error.status_code) | |
| async def unexpected_error_handler(request: Request, error: Exception): | |
| return JSONResponse(_APIError(503, "Server unavailable.", "server_unavailable").body, | |
| status_code=503) | |
| async def authorize(request: Request) -> None: | |
| if secret is None: | |
| return | |
| scheme, _, credential = request.headers.get("authorization", "").partition(" ") | |
| matches = hmac.compare_digest(credential.encode("utf-8"), secret) | |
| if scheme.lower() != "bearer" or not matches: | |
| raise _APIError(401, "Invalid API key.", "invalid_api_key") | |
| router = APIRouter(prefix="/v1", dependencies=[Depends(authorize)]) | |
| async def health(): | |
| return {"status": "ok"} | |
| async def ready(): | |
| worker = app.state.worker | |
| if worker is None or not worker.ready: | |
| raise _APIError(503, "Server is not ready.", "server_unavailable") | |
| return {"status": "ready"} | |
| async def index(): | |
| # Static, dependency-free tester page. It never contains the API key; | |
| # visitors paste their own credential, like any other API client. | |
| # no-cache keeps model dropdowns fresh across server restarts. | |
| return HTMLResponse(_WEBUI_HTML, headers={"Cache-Control": "no-cache"}) | |
| async def models(): | |
| with registry_lock: | |
| items = list(model_map.items()) | |
| data = [] | |
| for model_id, model in items: | |
| entry = {"id": model_id, "object": "model", "created": 0, "owned_by": "cism", | |
| "context_length": model.context_length} | |
| # Optional engine introspection (real engines provide info). | |
| info = getattr(model, "info", None) | |
| if callable(info): | |
| info = info() | |
| if isinstance(info, dict): | |
| for key in ("precision", "threads", "kernel", "architecture", "quantization", | |
| "act_precision"): | |
| if key in info: | |
| entry[key] = info[key] | |
| if isinstance(info.get("weight_bytes"), (int, float)): | |
| entry["weight_mb"] = round(info["weight_bytes"] / (1024 * 1024), 2) | |
| data.append(entry) | |
| return {"object": "list", "data": data} | |
| async def models_loading(): | |
| with registry_lock: | |
| return {"loading": dict(load_status)} | |
| def _apply_load_options(loaded_engine) -> None: | |
| if load_lock_pages and loaded_engine.lock_pages() == 0: | |
| load_status[loaded_engine.model_id] = "loaded (page locking failed)" | |
| if load_keep_warm: | |
| loaded_engine.start_keep_warm(load_keep_warm) | |
| def _register_loaded_engine(loaded_engine) -> None: | |
| with registry_lock: | |
| model_map[loaded_engine.model_id] = loaded_engine | |
| load_status.pop(loaded_engine.model_id, None) | |
| _apply_load_options(loaded_engine) | |
| def _load_via_loader(model: str, precision: str, threads: int, | |
| revision: str | None = None, trust_remote_code: bool = False, | |
| act_precision: str | None = None, reserved: bool = False): | |
| """Load through the single-slot loader; returns None if it is busy. | |
| ``reserved=True`` means the caller already holds the load slot (the | |
| ``/models/load`` endpoint reserves it under the registry lock before | |
| starting its worker); without it the flag would read as someone | |
| else's load and the worker would refuse its own reservation. | |
| """ | |
| with registry_lock: | |
| existing = model_map.get(model) | |
| existing_info = getattr(existing, "info", {}) if existing is not None else {} | |
| if callable(existing_info): | |
| existing_info = existing_info() | |
| if not isinstance(existing_info, dict): | |
| existing_info = {} | |
| want_act = act_precision or load_act_precision | |
| if (existing is not None and existing_info.get("precision") == precision and | |
| existing_info.get("act_precision", "fp32") == want_act and | |
| existing_info.get("threads", threads) == threads): | |
| if reserved: | |
| load_in_flight.clear() # the endpoint's reservation is done | |
| return existing | |
| if load_in_flight.is_set() and not reserved: | |
| return None | |
| load_in_flight.set() | |
| load_status[model] = "loading" | |
| try: | |
| from cism.engine import Engine | |
| loaded_engine = Engine.from_pretrained( | |
| model, precision=precision, revision=revision, threads=threads, | |
| trust_remote_code=trust_remote_code, | |
| local_files_only=load_local_files_only, | |
| act_precision=want_act) | |
| except Exception as error: | |
| with registry_lock: | |
| load_status[model] = f"failed: {type(error).__name__}: {error}" | |
| raise | |
| finally: | |
| # The loader releases its own slot on every exit path, whoever | |
| # called it (the /models/load worker or the bench harness). | |
| load_in_flight.clear() | |
| with registry_lock: | |
| if load_status.get(model) == "loading": | |
| load_status.pop(model, None) | |
| _register_loaded_engine(loaded_engine) | |
| return loaded_engine | |
| def _load_worker(request_body: _LoadRequest) -> None: | |
| try: | |
| _load_via_loader(request_body.model, request_body.precision, | |
| request_body.threads, revision=request_body.revision, | |
| trust_remote_code=request_body.trust_remote_code, | |
| act_precision=request_body.act_precision, | |
| reserved=True) | |
| except Exception: | |
| pass # failure is recorded in load_status for the pollers | |
| finally: | |
| load_in_flight.clear() | |
| def _bench_worker(request_body: _AutoBenchRequest) -> None: | |
| from . import autobench | |
| def report_progress(message: str) -> None: | |
| with registry_lock: | |
| bench_state["progress"] = message | |
| try: | |
| result = autobench.run_benchmark( | |
| request_body.model, precision=request_body.precision, | |
| threads=request_body.threads, max_new_tokens=request_body.max_tokens, | |
| local_files_only=load_local_files_only, | |
| thread_sweep=request_body.thread_sweep, | |
| spec_ks=(0, 2, 4, 8, 12, 16) if request_body.spec_sweep else (0,), | |
| act_modes=("fp32", "int8") if request_body.bench_act_int8 else ("fp32",), | |
| include_torch=request_body.include_torch, | |
| include_compile=request_body.compile, | |
| load_engine=lambda requested, threads=None, act_precision=None: _load_via_loader( | |
| request_body.model, requested or request_body.precision, | |
| threads or request_body.threads, act_precision=act_precision), | |
| progress=report_progress) | |
| with registry_lock: | |
| bench_state.update(status="done", result=result, progress="", error=None) | |
| except Exception as error: | |
| with registry_lock: | |
| bench_state.update(status="failed", error=f"{type(error).__name__}: {error}", | |
| progress="", result=None) | |
| finally: | |
| bench_in_flight.clear() | |
| async def bench_auto_status(): | |
| with registry_lock: | |
| return dict(bench_state) | |
| async def bench_auto(body: _AutoBenchRequest): | |
| with registry_lock: | |
| if bench_in_flight.is_set(): | |
| raise _APIError(409, "A benchmark is already running.", "bench_in_progress") | |
| if body.model not in model_map and load_in_flight.is_set(): | |
| raise _APIError(409, "A model load is already in progress.", "load_in_progress") | |
| bench_in_flight.set() | |
| bench_state.update(status="running", progress="starting", result=None, error=None) | |
| threading.Thread(target=_bench_worker, args=(body,), name="cism-bench", daemon=True).start() | |
| return {"status": "running", "model": body.model, "precision": body.precision} | |
| async def models_load(body: _LoadRequest): | |
| with registry_lock: | |
| if body.model in model_map: | |
| # Same weights + activation mode: no-op. A different precision | |
| # (the shootout's load-bench cycle) must fall through and reload. | |
| existing = model_map[body.model] | |
| existing_info = getattr(existing, "info", {}) | |
| if callable(existing_info): | |
| existing_info = existing_info() | |
| if not isinstance(existing_info, dict): | |
| existing_info = {} | |
| want_act = body.act_precision or load_act_precision | |
| if (existing_info.get("precision") == body.precision and | |
| existing_info.get("act_precision", "fp32") == want_act): | |
| return {"model": body.model, "status": "ready"} | |
| if body.trust_remote_code and not allow_remote_code_load: | |
| raise _APIError(403, | |
| "trust_remote_code is disabled on this server; restart with " | |
| "--allow-remote-code-load to permit downloaded tokenizer code.", | |
| "remote_code_disabled") | |
| if load_in_flight.is_set(): | |
| raise _APIError(429, "Another model load is already in progress.", "load_in_progress") | |
| load_in_flight.set() | |
| # Stale failures from earlier attempts would otherwise resurface in | |
| # every later poll; the UI has already shown them once. | |
| for failed_id in [m for m, s in load_status.items() if s.startswith("failed")]: | |
| del load_status[failed_id] | |
| load_status[body.model] = "loading" | |
| threading.Thread(target=_load_worker, args=(body,), name="cism-loader", daemon=True).start() | |
| return {"model": body.model, "status": "loading"} | |
| async def models_unload(body: _UnloadRequest): | |
| with registry_lock: | |
| loaded_engine = model_map.pop(body.model, None) | |
| if loaded_engine is None: | |
| raise _APIError(404, "Requested model is not available.", "model_not_found", "model") | |
| load_status.pop(body.model, None) | |
| # Idle check: an active generation holds the engine's generation lock. | |
| generation_lock = getattr(loaded_engine, "_generation_lock", None) | |
| if generation_lock is not None and not generation_lock.acquire(blocking=False): | |
| with registry_lock: | |
| model_map[body.model] = loaded_engine # keep serving in-flight work | |
| raise _APIError(409, "Model is generating; unload after it finishes.", "model_busy") | |
| try: | |
| info = loaded_engine.info if hasattr(loaded_engine, "info") else {} | |
| if callable(info): | |
| info = info() | |
| if isinstance(info, dict) and info.get("pages_locked"): | |
| loaded_engine.unlock_pages() | |
| if getattr(loaded_engine, "keep_warm_active", False): | |
| loaded_engine.stop_keep_warm() | |
| except Exception: | |
| with registry_lock: | |
| model_map[body.model] = loaded_engine | |
| raise _APIError(503, "Model unload failed; it remains available.", "unload_failed") | |
| if generation_lock is not None: | |
| generation_lock.release() | |
| return {"model": body.model, "status": "unloaded"} | |
| async def cache_report(body: _CacheRequest): | |
| engine = model_map.get(body.model) | |
| if engine is None: | |
| raise _APIError(404, "Requested model is not available.", "model_not_found", "model") | |
| def _run() -> dict: | |
| import numpy as np | |
| info = getattr(engine, "info", None) | |
| if callable(info): | |
| info = info() | |
| weight_bytes = info.get("weight_bytes") if isinstance(info, dict) else None | |
| if not isinstance(weight_bytes, (int, float)) or weight_bytes <= 0: | |
| raise _APIError(400, "This engine does not expose weight bytes.", | |
| "invalid_engine", "model") | |
| try: | |
| prompt = engine.encode("cache report probe") | |
| def timed_scan(repeats: int, evictions: int) -> float: | |
| # Unique 2x-L3 writes evict residency; the scan is timed alone. | |
| scrub = np.empty(body.scrub_mb * 1024 * 1024, dtype=np.uint8) | |
| samples = [] | |
| for _ in range(repeats): | |
| for _ in range(evictions): | |
| scrub[:] = 1 | |
| start = time.perf_counter() | |
| engine.scan() | |
| samples.append(time.perf_counter() - start) | |
| return statistics.median(samples) | |
| warm_seconds = timed_scan(20, evictions=0) | |
| cold_seconds = timed_scan(5, evictions=2) | |
| session = engine._native.create_session( | |
| prompt, max_new_tokens=body.decode_tokens, eos_token_ids=[]) | |
| session.next_tokens(1) | |
| start = time.perf_counter() | |
| generated = session.next_tokens(body.decode_tokens - 1) | |
| decode_seconds = time.perf_counter() - start | |
| except _APIError: | |
| raise | |
| except (ValueError, RuntimeError) as exc: | |
| raise _APIError(400, f"Cache probe failed: {exc}.", "cache_probe_failed") | |
| tokens_per_second = len(generated) / decode_seconds if generated and decode_seconds > 0 else 0 | |
| effective_gbps = weight_bytes * tokens_per_second / 1e9 | |
| warm_gbps = weight_bytes / warm_seconds / 1e9 if warm_seconds > 0 else 0 | |
| cold_gbps = weight_bytes / cold_seconds / 1e9 if cold_seconds > 0 else 0 | |
| ratio = cold_seconds / warm_seconds if warm_seconds > 0 else 0 | |
| if ratio >= 2: | |
| residency = "cache-resident when warm (cold scan is much slower)" | |
| elif ratio >= 1.3: | |
| residency = "partially cache-resident" | |
| else: | |
| residency = "no measurable residency benefit from this probe" | |
| if not tokens_per_second: | |
| bottleneck = "decode produced no tokens" | |
| elif effective_gbps <= cold_gbps: | |
| bottleneck = ("compute-bound: decode bandwidth is below even the cold DRAM scan; " | |
| "the cache is not the bottleneck, the kernels are") | |
| elif effective_gbps < warm_gbps * 0.8: | |
| bottleneck = "DRAM-bandwidth-limited: weights are effectively streaming from RAM" | |
| else: | |
| bottleneck = "bandwidth-limited at cache-resident rates" | |
| return {"model": engine.model_id, "weight_bytes": weight_bytes, | |
| "warm_scan_gbps": round(warm_gbps, 2), | |
| "cold_scan_gbps": round(cold_gbps, 2), | |
| "warm_to_cold_ratio": round(ratio, 2), | |
| "decode_tokens_per_second": round(tokens_per_second, 1), | |
| "decode_effective_gbps": round(effective_gbps, 2), | |
| "residency": residency, "decode_bottleneck": bottleneck} | |
| return await asyncio.to_thread(_run) | |
| async def _generate_single_via_batch(engine: Any, body: _CompletionRequest | _ChatRequest, | |
| request: Request): | |
| """Serve one completions/chat request via the batch machinery (B=1). | |
| Used only for pool/batch engines. Returns the same JSON/SSE shape as | |
| the FIFO worker path (non-streaming choices + usage; streaming SSE | |
| emits the full text as one delta for v0). Raises _APIError on | |
| validation/context failures with sanitized messages. | |
| """ | |
| chat = isinstance(body, _ChatRequest) | |
| stop = [body.stop] if isinstance(body.stop, str) else (list(body.stop) if body.stop else None) | |
| # Resolve the prompt without holding any lock (tokenizer-only). | |
| try: | |
| if chat: | |
| assert isinstance(body, _ChatRequest) | |
| prompt_tokens = engine.format_chat( | |
| [message.model_dump() for message in body.messages]) | |
| # format_chat may return tokens already; encode validates them. | |
| prompt: Any = engine.encode(prompt_tokens) | |
| else: | |
| assert isinstance(body, _CompletionRequest) | |
| prompt = body.prompt | |
| # Early context check for string prompts (encode to measure). | |
| if isinstance(prompt, str): | |
| prompt_tokens_probe = engine.encode(prompt) | |
| if len(prompt_tokens_probe) > engine.context_length: | |
| raise _APIError(400, "Prompt exceeds the model context length.", | |
| "context_length_exceeded") | |
| prompt = body.prompt | |
| except _APIError: | |
| raise | |
| except (ValueError, TypeError): | |
| raise _APIError(400, "Invalid prompt or chat messages.", "invalid_prompt") from None | |
| token_limit = body.token_limit | |
| def _run_one(): | |
| try: | |
| rows = _run_batch_chunk_sync( | |
| engine, [prompt], token_limit, body.temperature, | |
| body.top_p, body.top_k, body.seed, stop) | |
| except (ValueError, TypeError) as exc: | |
| msg = str(exc) | |
| if "context" in msg.lower() or "Prompt length" in msg: | |
| raise _APIError(400, "Prompt exceeds the model context length.", | |
| "context_length_exceeded") from None | |
| raise _APIError(400, "Invalid prompt or chat messages.", "invalid_prompt") from None | |
| if len(rows) != 1: | |
| raise _APIError(503, "Generation did not complete.", "generation_failed") | |
| return rows[0] | |
| try: | |
| text, _ids, finish, prompt_n, gen_n = await asyncio.to_thread(_run_one) | |
| except _APIError: | |
| raise | |
| except Exception: | |
| raise _APIError(503, "Generation failed.", "generation_failed") from None | |
| if finish is None: | |
| raise _APIError(503, "Generation did not complete.", "generation_failed") | |
| base = { | |
| "id": ("chatcmpl-" if chat else "cmpl-") + uuid.uuid4().hex, | |
| "object": "chat.completion" if (chat and not body.stream) else ( | |
| "chat.completion.chunk" if chat else "text_completion"), | |
| "created": int(time.time()), "model": engine.model_id, | |
| } | |
| usage = {"prompt_tokens": prompt_n, "completion_tokens": gen_n, | |
| "total_tokens": prompt_n + gen_n, | |
| "spec_proposed": 0, "spec_accepted": 0, "spec_accept_rate": None} | |
| if not body.stream: | |
| choice: dict[str, Any] = {"index": 0, "finish_reason": finish, "logprobs": None} | |
| if chat: | |
| choice["message"] = {"role": "assistant", "content": text} | |
| else: | |
| choice["text"] = text | |
| return {**base, "choices": [choice], "usage": usage} | |
| # Streaming via batch path (v0): single delta + terminal + DONE. | |
| include_usage = body.stream_options is not None and body.stream_options.include_usage | |
| def event(choices: list[dict], usage_payload: dict | None = None, | |
| completion_tokens: int | None = None) -> str: | |
| payload: dict[str, Any] = {**base, "choices": choices} | |
| if include_usage: | |
| payload["usage"] = usage_payload | |
| if completion_tokens is not None: | |
| payload["cism_completion_tokens"] = completion_tokens | |
| return "data: " + json.dumps(payload, ensure_ascii=True, allow_nan=False) + "\n\n" | |
| async def events(): | |
| try: | |
| if chat: | |
| yield event([{"index": 0, "delta": {"role": "assistant", "content": ""}, | |
| "finish_reason": None}], None, gen_n) | |
| if text: | |
| content = {"delta": {"content": text}} if chat else {"text": text, "logprobs": None} | |
| yield event([{"index": 0, **content, "finish_reason": None}], None, gen_n) | |
| content = {"delta": {}} if chat else {"text": "", "logprobs": None} | |
| yield event([{"index": 0, **content, "finish_reason": finish}], None, gen_n) | |
| if include_usage: | |
| yield event([], usage) | |
| yield "data: [DONE]\n\n" | |
| except Exception: | |
| error = _APIError(503, "Generation failed.", "generation_failed") | |
| yield "data: " + json.dumps(error.body) + "\n\n" | |
| yield "data: [DONE]\n\n" | |
| async def _gen_wrapper(): | |
| async for chunk in events(): | |
| yield chunk | |
| # Plain StreamingResponse (no _Worker job to release). | |
| return StreamingResponse(_gen_wrapper(), media_type="text/event-stream", headers={ | |
| "Cache-Control": "no-cache", "X-Accel-Buffering": "no", | |
| }) | |
| async def generate(body: _CompletionRequest | _ChatRequest, request: Request): | |
| engine = model_map.get(body.model) | |
| if engine is None: | |
| raise _APIError(404, "Requested model is not available.", "model_not_found", "model") | |
| # SDK engines expose config; lightweight engine implementations need not. | |
| vocab_size = getattr(engine, "config", {}).get("vocab_size") | |
| if vocab_size is not None and body.top_k > vocab_size: | |
| raise _APIError(400, "top_k exceeds the model vocabulary size.", | |
| "invalid_parameter", "top_k") | |
| known_length = len(body.prompt) if isinstance(body, _CompletionRequest) and isinstance(body.prompt, list) else 0 | |
| if known_length > engine.context_length: | |
| raise _APIError(400, "Prompt exceeds the model context length.", | |
| "context_length_exceeded") | |
| if body.spec_k >= 2 and body.temperature != 0: | |
| raise _APIError(400, "spec_k requires temperature=0 (greedy speculation).", | |
| "invalid_parameter", "spec_k") | |
| if not 0 <= body.seed < 2**64: | |
| raise _APIError(400, "seed must be an unsigned 64-bit integer.", | |
| "invalid_parameter", "seed") | |
| # Stream A2: fail-fast 400s before queue admission (never faked). | |
| _validate_a2_request(body, engine) | |
| # Phase2: pool/batch engines bypass the shared FIFO worker and use the | |
| # batch machinery with B=1 (single weight stream for BatchEngine / | |
| # native path, N streams for EnginePool). Plain engines keep the exact | |
| # FIFO _Worker path below (bitwise identical). | |
| if _is_pool_or_batch_engine(engine): | |
| if body.spec_k != 0: | |
| raise _APIError(400, "spec_k is not supported for pool/batch engines.", | |
| "invalid_parameter", "spec_k") | |
| worker = app.state.worker | |
| if worker is None or not worker.ready: | |
| raise _APIError(503, "Server is not ready.", "server_unavailable") | |
| with worker.lock: | |
| if len(worker.jobs) >= worker.capacity: | |
| raise _APIError(429, "Generation queue is full.", "queue_full") | |
| return await _generate_single_via_batch(engine, body, request) | |
| worker = app.state.worker | |
| if worker is None: | |
| raise _APIError(503, "Server is not ready.", "server_unavailable") | |
| job = worker.submit(body, engine) | |
| handed_off = False | |
| chat = isinstance(body, _ChatRequest) | |
| base = { | |
| "id": ("chatcmpl-" if chat else "cmpl-") + uuid.uuid4().hex, | |
| "object": "chat.completion.chunk" if chat else "text_completion", | |
| "created": int(time.time()), "model": engine.model_id, | |
| } | |
| try: | |
| first = await worker.receive(job, request) | |
| if isinstance(first, _APIError): | |
| raise first | |
| if not body.stream: | |
| parts = [first.text] | |
| ids = list(first.token_ids) | |
| tool_calls = list(first.tool_calls) | |
| chunk = first | |
| while chunk.finish_reason is None: | |
| chunk = await worker.receive(job, request) | |
| if isinstance(chunk, _APIError): | |
| raise chunk | |
| parts.append(chunk.text) | |
| ids.extend(chunk.token_ids) | |
| tool_calls.extend(chunk.tool_calls) | |
| text = "".join(parts) | |
| logprobs_payload: Any = None | |
| if _wants_logprobs(body): | |
| logprobs_payload = await _rescore_logprobs(engine, body, ids, text) | |
| choice = {"index": 0, "finish_reason": chunk.finish_reason, "logprobs": logprobs_payload} | |
| if chat: | |
| assert isinstance(body, _ChatRequest) | |
| message: dict[str, Any] = {"role": "assistant", "content": text} | |
| if tool_calls: | |
| message["tool_calls"] = tool_calls | |
| choice["message"] = message | |
| base["object"] = "chat.completion" | |
| else: | |
| choice["text"] = text | |
| return {**base, "choices": [choice], "usage": _usage(chunk)} | |
| include_usage = body.stream_options is not None and body.stream_options.include_usage | |
| def event(choices: list[dict], usage: dict | None = None, | |
| completion_tokens: int | None = None) -> str: | |
| payload = {**base, "choices": choices} | |
| if include_usage: | |
| payload["usage"] = usage | |
| if completion_tokens is not None: | |
| # Nonstandard live progress field; OpenAI clients ignore it. | |
| payload["cism_completion_tokens"] = completion_tokens | |
| return "data: " + json.dumps(payload, ensure_ascii=True, allow_nan=False) + "\n\n" | |
| async def events(): | |
| try: | |
| if chat: | |
| yield event([{"index": 0, "delta": {"role": "assistant", "content": ""}, | |
| "finish_reason": None}], None, first.completion_tokens) | |
| chunk = first | |
| while True: | |
| if chunk.text: | |
| content = {"delta": {"content": chunk.text}} if chat else {"text": chunk.text, "logprobs": None} | |
| yield event([{"index": 0, **content, "finish_reason": None}], | |
| None, chunk.completion_tokens) | |
| if chunk.finish_reason is not None: | |
| content = {"delta": {}} if chat else {"text": "", "logprobs": None} | |
| yield event([{"index": 0, **content, "finish_reason": chunk.finish_reason}], | |
| None, chunk.completion_tokens) | |
| if include_usage: | |
| yield event([], _usage(chunk)) | |
| break | |
| chunk = await worker.receive(job, request, streaming=True) | |
| if isinstance(chunk, _APIError): | |
| raise chunk | |
| yield "data: [DONE]\n\n" | |
| except _APIError as error: | |
| yield "data: " + json.dumps(error.body) + "\n\n" | |
| yield "data: [DONE]\n\n" | |
| except Exception: | |
| error = _APIError(503, "Generation failed.", "generation_failed") | |
| yield "data: " + json.dumps(error.body) + "\n\n" | |
| yield "data: [DONE]\n\n" | |
| response = _JobResponse(events(), worker, job) | |
| handed_off = True | |
| return response | |
| finally: | |
| if not handed_off: | |
| worker.release(job) | |
| async def tokenize(body: _TokenizeRequest): | |
| """Prompt accounting: token count, context fit, KV footprint.""" | |
| engine = model_map.get(body.model) | |
| if engine is None: | |
| raise _APIError(404, "Requested model is not available.", "model_not_found", "model") | |
| try: | |
| tokens = engine.encode(body.text) | |
| except (ValueError, TypeError) as exc: | |
| raise _APIError(400, f"Invalid prompt: {exc}.", "invalid_prompt") | |
| context = getattr(engine, "context_length", None) | |
| fits = len(tokens) <= context if isinstance(context, int) else None | |
| kv = _kv_cache_info(engine) | |
| if kv is not None: | |
| kv = {**kv, "bytes_at_prompt": kv["bytes_per_token"] * len(tokens)} | |
| return {"model": body.model, "tokens": tokens, "count": len(tokens), | |
| "context_length": context, "fits_context": fits, "kv_cache": kv} | |
| async def score(body: _NllRequest): | |
| """Standalone perplexity scoring over pasted text.""" | |
| engine = model_map.get(body.model) | |
| if engine is None: | |
| raise _APIError(404, "Requested model is not available.", "model_not_found", "model") | |
| try: | |
| result = await asyncio.to_thread(engine.nll, body.text, body.window) | |
| except ValueError as exc: | |
| raise _APIError(400, f"Scoring failed: {exc}.", "scoring_failed") | |
| return {"model": body.model, **result} | |
| async def models_runtime(body: _RuntimeRequest): | |
| """Page locking and keep-warm control for a loaded model.""" | |
| engine = model_map.get(body.model) | |
| if engine is None: | |
| raise _APIError(404, "Requested model is not available.", "model_not_found", "model") | |
| status: dict[str, Any] = {} | |
| try: | |
| if body.lock_pages is True: | |
| if not hasattr(engine, "lock_pages"): | |
| raise _APIError(400, "This engine does not support page locking.", | |
| "unsupported", "lock_pages") | |
| status["page_lock_bytes"] = engine.lock_pages() | |
| elif body.lock_pages is False: | |
| if not hasattr(engine, "unlock_pages"): | |
| raise _APIError(400, "This engine does not support page locking.", | |
| "unsupported", "lock_pages") | |
| status["page_unlock_bytes"] = engine.unlock_pages() | |
| if body.keep_warm is not None: | |
| if not hasattr(engine, "start_keep_warm"): | |
| raise _APIError(400, "This engine does not support keep-warm.", | |
| "unsupported", "keep_warm") | |
| if body.keep_warm == 0: | |
| engine.stop_keep_warm() | |
| status["keep_warm"] = "stopped" | |
| else: | |
| engine.start_keep_warm(body.keep_warm) | |
| status["keep_warm"] = body.keep_warm | |
| except _APIError: | |
| raise | |
| except ValueError as exc: | |
| raise _APIError(400, f"Invalid runtime option: {exc}.", "invalid_parameter") | |
| except RuntimeError as exc: | |
| raise _APIError(409, f"Runtime change refused: {exc}.", "runtime_busy") | |
| status["keep_warm_active"] = bool(getattr(engine, "keep_warm_active", False)) | |
| return {"model": body.model, **status} | |
| async def batch(body: _BatchRequest, request: Request): | |
| """Phase2 batched completions (order-preserving, non-streaming). | |
| Request ``{"model", "prompts": [...], ...sampling}`` returns | |
| ``{"object": "batch.completion", "choices": [{index, text, | |
| finish_reason}], "usage": {...}}`` with ``choices[i]`` for | |
| ``prompts[i]``. Same-model batches run as one native ``BatchSession`` | |
| weight stream when available (``BatchEngine`` or dense ``Engine``); | |
| pool engines fan out over ``EnginePool`` slots; concurrent batches for | |
| unlike models overlap (separate locks), same-engine batches serialize. | |
| Large batches are split FIFO waiting->running by ``max_batch_tokens`` | |
| plus ``prefill_chunk_tokens`` via ``ContinuousSchedulerV0`` (block16, | |
| per-seq EOS/stop, BlockManager eviction, recompute-requeue: evicted | |
| seqs restart from scratch). Limits: no preempt-swap yet, no | |
| cross-seq KV sharing. ``spec_k`` must be 0 (no speculation in batch). | |
| """ | |
| engine = model_map.get(body.model) | |
| if engine is None: | |
| raise _APIError(404, "Requested model is not available.", "model_not_found", "model") | |
| vocab_size = getattr(engine, "config", {}).get("vocab_size") | |
| if vocab_size is not None and body.top_k > vocab_size: | |
| raise _APIError(400, "top_k exceeds the model vocabulary size.", | |
| "invalid_parameter", "top_k") | |
| if body.spec_k != 0: | |
| raise _APIError(400, "spec_k is not supported for batch requests.", | |
| "invalid_parameter", "spec_k") | |
| if not 0 <= body.seed < 2**64: | |
| raise _APIError(400, "seed must be an unsigned 64-bit integer.", | |
| "invalid_parameter", "seed") | |
| # Early context check for token-list prompts with known lengths. | |
| for prompt in body.prompts: | |
| if isinstance(prompt, list) and len(prompt) > engine.context_length: | |
| raise _APIError(400, "Prompt exceeds the model context length.", | |
| "context_length_exceeded") | |
| worker = app.state.worker | |
| if worker is None or not worker.ready: | |
| raise _APIError(503, "Server is not ready.", "server_unavailable") | |
| with worker.lock: | |
| queued = len(worker.jobs) | |
| # Coarse admission: a batch counts as one job; oversized batches and a | |
| # full FIFO both return 429 with Retry-After (same as single-stream). | |
| if len(body.prompts) > worker.capacity: | |
| raise _APIError(429, "Batch is larger than the generation queue.", "queue_full") | |
| if queued >= worker.capacity: | |
| raise _APIError(429, "Generation queue is full.", "queue_full") | |
| if await request.is_disconnected(): | |
| raise _APIError(503, "Request disconnected.", "request_cancelled") | |
| stop = [body.stop] if isinstance(body.stop, str) else (list(body.stop) if body.stop else None) | |
| token_limit = body.token_limit | |
| def _run_all(): | |
| from .paging import ContinuousSchedulerV0 | |
| # Measure prompt lens via engine.encode (validates + BOS fallback). | |
| try: | |
| encoded_lens: list[int] = [] | |
| for p in body.prompts: | |
| encoded_lens.append(len(engine.encode(p))) | |
| except (ValueError, TypeError): | |
| raise _APIError(400, "Invalid prompt or chat messages.", "invalid_prompt") | |
| for length in encoded_lens: | |
| if length > engine.context_length: | |
| raise _APIError(400, "Prompt exceeds the model context length.", | |
| "context_length_exceeded") | |
| # FIFO scheduler: waiting->running budget + prefill chunk + | |
| # block16 eviction with recompute-requeue, trivial prefix-reuse | |
| # accounting (no KV sharing, no preempt-swap in v0). | |
| sched = ContinuousSchedulerV0( | |
| num_blocks=scheduler_blocks, block_size=16, | |
| max_batch_tokens=max_batch_tokens, | |
| prefill_chunk_tokens=prefill_chunk_tokens) | |
| for idx, length in enumerate(encoded_lens): | |
| sched.add(idx, length, token_limit, stop=stop) | |
| chunks = sched.plan_chunks() | |
| num_chunks = len(chunks) | |
| ordered: list | None = [None] * len(body.prompts) | |
| prompt_list = list(body.prompts) | |
| for chunk in chunks: | |
| indices = [entry["req_id"] for entry in chunk] | |
| sub_prompts = [prompt_list[i] for i in indices] | |
| # Seed offset preserves per-seq determinism across chunks for | |
| # the single-weight-stream path (seq i uses seed+i globally). | |
| base_seed = (body.seed + min(indices)) % (2**64) if indices else body.seed | |
| if _is_pool_or_batch_engine(engine): | |
| # Pool uses one seed for all seqs (existing semantics); | |
| # keep the request seed for every chunk. | |
| chunk_seed = body.seed | |
| else: | |
| chunk_seed = base_seed | |
| try: | |
| rows = _run_batch_chunk_sync( | |
| engine, sub_prompts, token_limit, body.temperature, | |
| body.top_p, body.top_k, chunk_seed, stop) | |
| except (ValueError, TypeError) as exc: | |
| msg = str(exc) | |
| if "context" in msg.lower() or "Prompt length" in msg: | |
| raise _APIError(400, "Prompt exceeds the model context length.", | |
| "context_length_exceeded") from None | |
| raise _APIError(400, "Invalid prompt or chat messages.", "invalid_prompt") from None | |
| if len(rows) != len(indices): | |
| raise _APIError(503, "Generation did not complete.", "generation_failed") | |
| for global_idx, row in zip(indices, rows): | |
| text, _ids, finish, _pn, _gn = row | |
| if finish is None: | |
| raise _APIError(503, "Generation did not complete.", "generation_failed") | |
| assert ordered is not None | |
| ordered[global_idx] = (text, _ids, finish, _pn, _gn) | |
| # Free the planner's per-chunk blocks (accounting only; the | |
| # native sessions already released their state). | |
| for entry in chunk: | |
| try: | |
| sched.free(entry["req_id"]) | |
| except Exception: | |
| pass | |
| assert ordered is not None and all(r is not None for r in ordered) | |
| return ordered, num_chunks | |
| try: | |
| ordered, num_chunks = await asyncio.to_thread(_run_all) | |
| except _APIError: | |
| raise | |
| except Exception: | |
| raise _APIError(503, "Generation failed.", "generation_failed") from None | |
| choices = [] | |
| prompt_tokens_total = 0 | |
| completion_tokens_total = 0 | |
| for index, row in enumerate(ordered): | |
| assert row is not None | |
| text, _ids, finish, prompt_n, gen_n = row | |
| prompt_tokens_total += prompt_n | |
| completion_tokens_total += gen_n | |
| choices.append({"index": index, "text": text, | |
| "finish_reason": finish, "logprobs": None}) | |
| return {"id": "batch-" + uuid.uuid4().hex, "object": "batch.completion", | |
| "created": int(time.time()), "model": engine.model_id, | |
| "choices": choices, | |
| "usage": {"prompt_tokens": prompt_tokens_total, | |
| "completion_tokens": completion_tokens_total, | |
| "total_tokens": prompt_tokens_total + completion_tokens_total}, | |
| "scheduler": {"max_batch_tokens": max_batch_tokens, | |
| "max_batched_tokens": max_batch_tokens, | |
| "prefill_chunk_tokens": prefill_chunk_tokens, | |
| "block_size": 16, "num_blocks": scheduler_blocks, | |
| "chunks": num_chunks}} | |
| async def completions(body: _CompletionRequest, request: Request): | |
| return await generate(body, request) | |
| async def chat_completions(body: _ChatRequest, request: Request): | |
| return await generate(body, request) | |
| app.include_router(router) | |
| return app | |
| def _kv_cache_info(engine: Any) -> dict | None: | |
| """Per-token and full-context KV cache bytes, or None without config.""" | |
| config = getattr(engine, "config", None) | |
| context = getattr(engine, "context_length", None) | |
| if not isinstance(config, dict) or not isinstance(context, int): | |
| return None | |
| try: | |
| per_token = (int(config["num_hidden_layers"]) * int(config["num_key_value_heads"]) * | |
| int(config["head_dim"]) * 2 * 4) | |
| except (KeyError, TypeError, ValueError): | |
| return None | |
| if per_token <= 0: | |
| return None | |
| return {"bytes_per_token": per_token, | |
| "bytes_at_full_context": per_token * context} | |
| _WEBUI_HTML = """<!doctype html> | |
| <html lang="en"> | |
| <head> | |
| <meta charset="utf-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1"> | |
| <title>CISM tester</title> | |
| <link rel="icon" href="data:,"> | |
| <meta name="description" content="CISM tester: generate, benchmark, and compare native CPU models."> | |
| <title>CISM tester</title> | |
| <link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Crect width='32' height='32' rx='7' fill='%23171310'/%3E%3Ctext x='16' y='23' font-family='Consolas,monospace' font-size='19' font-weight='bold' fill='%23e0974a' text-anchor='middle'%3EC%3C/text%3E%3C/svg%3E"> | |
| <style> | |
| :root { | |
| color-scheme: dark; | |
| --bg: #100d0b; | |
| --panel: #171310; | |
| --panel-2: #1f1815; | |
| --line: #2e2521; | |
| --text: #ece2d5; | |
| --muted: #ab9d8b; | |
| --accent: #e0974a; | |
| --accent-dim: #b06f2e; | |
| --good: #7fbf6a; | |
| --bad: #ef6a5a; | |
| --radius: 10px; | |
| --radius-s: 6px; | |
| } | |
| * { box-sizing: border-box; } | |
| html { scroll-behavior: smooth; } | |
| body { | |
| margin: 0 auto; padding: 20px 16px 48px; max-width: 1180px; | |
| font: 14px/1.55 -apple-system, "Segoe UI", system-ui, Roboto, sans-serif; | |
| color: var(--text); | |
| background: | |
| radial-gradient(1100px 480px at 15% -10%, #26190f 0%, transparent 60%), | |
| radial-gradient(900px 420px at 95% 0%, #1c130c 0%, transparent 55%), | |
| var(--bg); | |
| } | |
| .skip { position: absolute; left: -9999px; } | |
| .skip:focus { left: 12px; top: 12px; background: var(--panel-2); padding: 6px 10px; z-index: 10; } | |
| .topbar { | |
| display: flex; flex-wrap: wrap; gap: 12px 20px; align-items: center; | |
| justify-content: space-between; margin-bottom: 16px; | |
| } | |
| .brand { display: flex; gap: 12px; align-items: center; } | |
| .mark { | |
| width: 38px; height: 38px; border-radius: 9px; display: flex; | |
| align-items: center; justify-content: center; | |
| font: bold 21px Consolas, monospace; color: var(--accent); | |
| background: var(--panel); border: 1px solid var(--line); | |
| box-shadow: 0 4px 18px -8px #e0974a66; | |
| } | |
| .brand h1 { font-size: 19px; margin: 0; letter-spacing: -0.02em; } | |
| .brand p { margin: 0; color: var(--muted); font-size: 12.5px; } | |
| .top-actions { display: flex; flex-wrap: wrap; gap: 8px 12px; align-items: center; } | |
| .pill { | |
| font-size: 12.5px; color: var(--muted); border: 1px solid var(--line); | |
| background: var(--panel); border-radius: 999px; padding: 4px 12px; | |
| font-variant-numeric: tabular-nums; white-space: nowrap; | |
| } | |
| .pill[data-state="ok"] { color: var(--good); border-color: #3fb95055; } | |
| .pill[data-state="bad"] { color: var(--bad); border-color: #f8514955; } | |
| .pill.busy { animation: pulse 1.2s ease-in-out infinite; } | |
| @keyframes pulse { 50% { opacity: 0.55; } } | |
| .dash { display: grid; grid-template-columns: minmax(330px, 390px) 1fr; gap: 14px; } | |
| .span { grid-column: 1 / -1; } | |
| @media (max-width: 920px) { .dash { grid-template-columns: 1fr; } } | |
| .panel { | |
| background: var(--panel); border: 1px solid var(--line); | |
| border-radius: var(--radius); padding: 16px 16px 18px; margin: 0; | |
| box-shadow: 0 14px 34px -24px #000000cc; | |
| } | |
| .panel > h2 { font-size: 13px; margin: 0 0 12px; color: var(--muted); | |
| font-weight: 600; letter-spacing: 0.04em; text-transform: uppercase; font-size: 12px; } | |
| .panel > h2 .hint { text-transform: none; letter-spacing: 0; font-weight: 400; } | |
| label { display: inline-block; margin: 4px 10px 4px 0; color: var(--muted); font-size: 13px; } | |
| label.inline { margin: 0; } | |
| input, textarea, select, button { | |
| font: inherit; font-size: 13.5px; background: var(--panel-2); color: var(--text); | |
| border: 1px solid var(--line); border-radius: var(--radius-s); padding: 5px 9px; | |
| transition: border-color 0.2s, background 0.2s, transform 0.08s; | |
| } | |
| textarea { width: 100%; resize: vertical; line-height: 1.5; } | |
| textarea#output, pre#output { font-family: ui-monospace, Consolas, monospace; } | |
| input[type=number] { width: 84px; font-variant-numeric: tabular-nums; } | |
| input[type=text], input[type=password] { width: 210px; } | |
| select { max-width: 100%; } | |
| input:focus-visible, textarea:focus-visible, select:focus-visible, button:focus-visible { | |
| outline: 2px solid var(--accent); outline-offset: 1px; | |
| } | |
| button { cursor: pointer; } | |
| button:hover:not(:disabled) { border-color: var(--accent); transform: translateY(-1px); } | |
| button:active:not(:disabled) { transform: translateY(1px) scale(0.98); } | |
| button.primary { background: var(--accent-dim); border-color: var(--accent-dim); color: #fff; font-weight: 600; } | |
| button.primary:hover:not(:disabled) { background: #2f81f7; } | |
| button.danger:hover:not(:disabled) { border-color: var(--bad); color: var(--bad); } | |
| button:disabled { opacity: 0.5; cursor: default; } | |
| .row { display: flex; flex-wrap: wrap; gap: 6px 14px; align-items: center; } | |
| .row + .row { margin-top: 8px; } | |
| .btnrow { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 10px; } | |
| .cards { display: grid; grid-template-columns: 1fr; gap: 10px; margin-top: 12px; } | |
| .card { | |
| background: var(--panel-2); border: 1px solid var(--line); | |
| border-radius: var(--radius-s); padding: 10px 12px; | |
| } | |
| .card.active { border-color: var(--accent); } | |
| .card-title { font-weight: 600; overflow-wrap: anywhere; margin-bottom: 6px; } | |
| .badges { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 8px; } | |
| .badge { | |
| font-size: 12px; color: var(--muted); border: 1px solid var(--line); | |
| border-radius: 4px; padding: 1px 7px; font-variant-numeric: tabular-nums; | |
| } | |
| .badge.hot { color: var(--accent); border-color: #e0974a66; } | |
| .card-actions { display: flex; gap: 8px; } | |
| .empty { color: var(--muted); border: 1px dashed var(--line); border-radius: var(--radius-s); | |
| padding: 14px; margin-top: 12px; font-size: 13px; } | |
| #live { color: var(--accent); min-height: 20px; font-variant-numeric: tabular-nums; } | |
| #output { | |
| min-height: 120px; white-space: pre-wrap; word-break: break-word; | |
| background: var(--panel-2); border: 1px solid var(--line); border-radius: var(--radius-s); | |
| padding: 12px; margin: 8px 0 0; | |
| } | |
| #output:empty::before { content: "Run a generation to see output here."; color: var(--muted); } | |
| #stats, #error { min-height: 20px; margin: 8px 0 0; font-variant-numeric: tabular-nums; } | |
| #stats { color: var(--muted); } | |
| #error { color: var(--bad); white-space: pre-wrap; } | |
| #error:empty, #stats:empty { display: none; } | |
| table { border-collapse: collapse; margin-top: 10px; width: 100%; } | |
| th, td { padding: 5px 12px 5px 0; border-bottom: 1px solid var(--line); | |
| font-variant-numeric: tabular-nums; } | |
| th { color: var(--muted); font-weight: 600; font-size: 12px; text-transform: uppercase; | |
| letter-spacing: 0.05em; text-align: left; } | |
| td:last-child, th:last-child { padding-right: 0; } | |
| .statusline { color: var(--muted); min-height: 18px; font-size: 13px; } | |
| </style> | |
| </head> | |
| <body> | |
| <a class="skip" href="#panel-request">Skip to generation</a> | |
| <header class="topbar"> | |
| <div class="brand"><span class="mark" aria-hidden="true">C</span><div><h1>CISM tester</h1><p>native CPU inference console</p></div></div> | |
| <div class="top-actions"> | |
| <span id="health" class="pill">health: unknown</span> | |
| <label class="inline">API key <input id="key" type="password" autocomplete="off" placeholder="only if CISM_API_KEY is set"></label> | |
| <label class="inline"><input id="remember" type="checkbox"> remember</label> | |
| <button id="load">Refresh models</button> | |
| </div> | |
| </header> | |
| <main class="dash"> | |
| <section class="panel" id="panel-models"> | |
| <h2>Models <span class="hint">fetch one, pin it down, compare it</span></h2> | |
| <div class="row"> | |
| <label>Add model <input id="load-id" size="34" placeholder="HF repo id, e.g. SurjoLabs/Blaze"></label> | |
| <label>precision | |
| <select id="load-precision"> | |
| <option value="int8" selected>int8</option> | |
| <option value="fp32">fp32</option> | |
| <option value="hybrid-int4">hybrid-int4</option> | |
| <option value="hybrid-fp4">hybrid-fp4</option> | |
| </select> | |
| </label> | |
| <label>threads <input id="load-threads" type="number" min="1" max="64" value="2"></label> | |
| <label>act-precision | |
| <select id="load-act" title="activation precision for quantized matrices (empty = server default)"> | |
| <option value="" selected>server default</option> | |
| <option value="fp32">fp32</option> | |
| <option value="int8">int8 (experimental)</option> | |
| </select> | |
| </label> | |
| <label>revision <input id="load-revision" size="12" placeholder="optional"></label> | |
| <label><input id="load-trust" type="checkbox"> trust remote code</label> | |
| <button id="load-model">Load</button> | |
| <button id="unload-model">Unload selected</button> | |
| </div> | |
| <div id="load-status" class="statusline"></div> | |
| <div class="row" style="margin-top:8px"> | |
| <button id="lock-toggle">Pin pages</button> | |
| <label>keep-warm <input id="keep-warm-secs" type="number" min="0" value="0" title="seconds between sweeps; 0 with Stop turns it off"></label> | |
| <button id="keep-warm-start">Start warm</button> | |
| <button id="keep-warm-stop">Stop warm</button> | |
| <span id="runtime-status" class="statusline"></span> | |
| </div> | |
| <div class="cards" id="model-cards"></div> | |
| </section> | |
| <section class="panel" id="panel-target"> | |
| <h2>Active model</h2> | |
| <div class="row"> | |
| <label>Model <select id="model"></select></label> | |
| </div> | |
| <div class="row"> | |
| <span id="engine-info" style="color:#8b949e"></span> | |
| </div> | |
| </section> | |
| <section class="panel" id="panel-request"> | |
| <h2>Request <span class="hint">say it plain, tune what matters</span></h2> | |
| <div class="row"> | |
| <label><input type="radio" name="mode" value="completions" checked> completions</label> | |
| <label><input type="radio" name="mode" value="chat"> chat</label> | |
| <label><input id="stream" type="checkbox" checked> stream</label> | |
| </div> | |
| <label>System (chat only) | |
| <textarea id="system" rows="2" placeholder="optional"></textarea> | |
| </label> | |
| <label>Prompt | |
| <textarea id="prompt" rows="4">The future of artificial intelligence is</textarea> | |
| </label> | |
| <div class="row"> | |
| <button id="count-tokens">Count tokens</button> | |
| <span id="token-info" class="statusline"></span> | |
| </div> | |
| <div class="row"> | |
| <label>max tokens <input id="max" type="number" min="1" value="64"></label> | |
| <label>temperature <input id="temp" type="number" step="0.05" min="0" max="2" value="0"></label> | |
| <label>top_p <input id="topp" type="number" step="0.01" min="0" max="1" value="1"></label> | |
| <label>top_k <input id="topk" type="number" min="0" value="0"></label> | |
| <label>seed <input id="seed" type="number" min="0" value="0"></label> | |
| <label>spec K <input id="spec" type="number" min="0" max="16" value="0" title="greedy prompt-lookup speculation; 0=off, requires temperature 0"></label> | |
| <label>stop <input id="stopwords" size="22" placeholder="comma-separated, optional"></label> | |
| </div> | |
| <div class="row"> | |
| <button id="go" class="primary">Generate</button> | |
| <button id="stop" disabled>Stop</button> | |
| <button id="bench">Bench (3x128)</button> | |
| </div> | |
| </section> | |
| <section class="panel span" id="panel-measure"> | |
| <h2>Measure <span class="hint">numbers, not adjectives</span></h2> | |
| <div class="row"> | |
| <button id="autobench">AutoBench</button> | |
| <label>tokens <input id="bench-tokens" type="number" min="1" max="2048" value="512"></label> | |
| <label title="sweep threads 1,2,4 on CISM and eager paths"><input id="bench-sweep" type="checkbox"> thread sweep</label> | |
| <label title="time greedy spec K 0,2,4,8,12,16 per CISM variant"><input id="bench-spec" type="checkbox"> spec sweep</label> | |
| <label title="include experimental int8 activations on the CISM side"><input id="bench-act8" type="checkbox"> act-int8</label> | |
| <label title="uncheck to skip the slow torch baselines"><input id="bench-torch" type="checkbox" checked> torch</label> | |
| <label title="compile baselines too (slow: minutes of inductor warmup)"><input id="bench-compile" type="checkbox"> compile</label> | |
| <span id="bench-progress" style="color:#8b949e"></span> | |
| </div> | |
| <table id="bench-results" style="display:none"></table> | |
| <div id="bench-extra"></div> | |
| <h2 style="margin-top:16px">Precision shootout</h2> | |
| <div class="row"> | |
| <label>Model <input id="shootout-id" size="34" placeholder="defaults to selected model"></label> | |
| <label><input id="shoot-fp32" type="checkbox"> fp32</label> | |
| <label><input id="shoot-int8" type="checkbox" checked> int8</label> | |
| <label><input id="shoot-hybrid-int4" type="checkbox" checked> hybrid-int4</label> | |
| <label><input id="shoot-hybrid-fp4" type="checkbox" checked> hybrid-fp4</label> | |
| <label>tokens <input id="shootout-tokens" type="number" min="2" value="64"></label> | |
| <label>runs <input id="shootout-runs" type="number" min="2" value="3" title="first run is warmup"></label> | |
| <label>act <select id="shoot-act" title="activation precision for every shootout load"> | |
| <option value="" selected>server default</option> | |
| <option value="fp32">fp32</option> | |
| <option value="int8">int8 (experimental)</option> | |
| </select></label> | |
| <label>spec <input id="shoot-spec" type="number" min="0" max="16" value="0" title="prompt-lookup speculation per run; shootout always uses temperature 0"></label> | |
| <button id="shootout">Run shootout</button> | |
| <span id="shootout-status" style="color:#8b949e"></span> | |
| </div> | |
| <table id="shootout-results" style="display:none"></table> | |
| <h2 style="margin-top:16px">Perplexity scoring</h2> | |
| <div class="row"> | |
| <span style="color:#a89c8d">scores the prompt above on the selected model</span> | |
| <label>window <input id="ppl-window" type="number" min="2" value="128"></label> | |
| <button id="ppl-score">Score prompt</button> | |
| <span id="ppl-status" class="statusline"></span> | |
| </div> | |
| <div id="ppl-results"></div> | |
| <h2 style="margin-top:16px">Cache residency</h2> | |
| <div class="row"> | |
| <span style="color:#a89c8d">uses the selected model</span> | |
| <label>decode tokens <input id="cache-tokens" type="number" min="1" value="64"></label> | |
| <label>scrub MiB <input id="cache-scrub" type="number" min="1" value="96" title="cache-eviction buffer, sized near 2x L3"></label> | |
| <button id="cache-run">Run cache report</button> | |
| <span id="cache-status" class="statusline"></span> | |
| </div> | |
| <div id="cache-results"></div> | |
| </section> | |
| <section class="panel span" id="panel-output"> | |
| <h2>Output</h2> | |
| <div id="live" style="color:#58a6ff; min-height:18px"></div> | |
| <pre id="output"></pre> | |
| <div id="stats"></div> | |
| <div id="error"></div> | |
| <div class="btnrow"> | |
| <button id="copy">Copy output</button> | |
| </div> | |
| </section> | |
| </main> | |
| <script> | |
| "use strict"; | |
| const $ = id => document.getElementById(id); | |
| const output = $("output"), stats = $("stats"), errorBox = $("error"), live = $("live"); | |
| let controller = null; | |
| const contexts = {}; | |
| const engineInfo = {}; | |
| function capMax() { | |
| const limit = contexts[$("model").value]; | |
| if (!limit) return; | |
| $("max").max = limit; | |
| if (Number($("max").value) > limit) $("max").value = limit; | |
| } | |
| function showEngineInfo() { | |
| const info = engineInfo[$("model").value] || {}; | |
| const parts = []; | |
| if (info.precision) parts.push(info.precision); | |
| if (info.architecture) parts.push(info.architecture); | |
| if (info.threads) parts.push(info.threads + " thread" + (info.threads > 1 ? "s" : "")); | |
| if (info.kernel) parts.push(info.kernel); | |
| if (info.act_precision) parts.push("act " + info.act_precision); | |
| if (info.quantization) parts.push(info.quantization); | |
| if (info.weight_mb !== undefined) parts.push(info.weight_mb + " MB"); | |
| if (info.context_length) parts.push("ctx " + info.context_length); | |
| $("engine-info").textContent = parts.join(" · "); | |
| } | |
| function withWhitespaceHint(text) { | |
| if (output.textContent && !/\\S/.test(output.textContent)) | |
| return text + " | all-whitespace output: greedy loop on a space token; try temperature > 0"; | |
| return text; | |
| } | |
| function headers() { | |
| const key = $("key").value.trim(); | |
| return key ? { "Authorization": "Bearer " + key } : {}; | |
| } | |
| function showError(message) { | |
| errorBox.textContent = typeof message === "string" ? message : JSON.stringify(message); | |
| } | |
| function mode() { | |
| return document.querySelector('input[name=mode]:checked').value; | |
| } | |
| async function loadModels() { | |
| errorBox.textContent = ""; | |
| try { | |
| const response = await fetch("/v1/models", { headers: headers() }); | |
| const body = await response.json(); | |
| if (!response.ok) { showError(body.error && body.error.message || response.status); return; } | |
| const select = $("model"); | |
| select.replaceChildren(...body.data.map(m => { | |
| const option = document.createElement("option"); | |
| option.value = m.id; | |
| // A select renders option text, not option value; both must be set. | |
| option.textContent = m.id; | |
| if (m.context_length) contexts[m.id] = m.context_length; | |
| engineInfo[m.id] = m; | |
| return option; | |
| })); | |
| if (!select.value && body.data.length) select.value = body.data[0].id; | |
| capMax(); | |
| showEngineInfo(); | |
| refreshHealth(); | |
| renderModelCards(body.data); | |
| } catch (err) { | |
| showError(String(err)); | |
| } | |
| } | |
| function requestBody(streaming, maxOverride) { | |
| const spec = Number($("spec").value) || 0; | |
| if (spec && Number($("temp").value) !== 0) { | |
| throw new Error("Speculation (spec K > 0) requires temperature 0"); | |
| } | |
| const body = { | |
| model: $("model").value.trim(), | |
| max_tokens: maxOverride || Math.min(Number($("max").value) || 64, | |
| contexts[$("model").value] || Infinity), | |
| temperature: Number($("temp").value) || 0, | |
| top_p: Number($("topp").value) || 1, | |
| top_k: Number($("topk").value) || 0, | |
| seed: Number($("seed").value) || 0, | |
| spec_k: Number($("spec").value) || 0, | |
| stream: streaming, | |
| }; | |
| const stops = $("stopwords").value.split(",").map(s => s.trim()).filter(s => s); | |
| if (stops.length === 1) body.stop = stops[0]; | |
| else if (stops.length > 1) body.stop = stops; | |
| if (mode() === "chat") { | |
| const messages = []; | |
| const system = $("system").value.trim(); | |
| if (system) messages.push({ role: "system", content: system }); | |
| messages.push({ role: "user", content: $("prompt").value }); | |
| body.messages = messages; | |
| } else { | |
| body.prompt = $("prompt").value; | |
| } | |
| if (streaming) body.stream_options = { include_usage: true }; | |
| return body; | |
| } | |
| let loadWatch = null; | |
| async function pollLoadStatus() { | |
| // Returns "busy" while a load is in flight; "idle" once it settles. | |
| try { | |
| const response = await fetch("/v1/models/loading", { headers: headers() }); | |
| if (!response.ok) return "idle"; | |
| const body = await response.json(); | |
| const entries = Object.entries(body.loading || {}); | |
| const active = entries.filter(([, s]) => s === "loading"); | |
| const failed = entries.filter(([, s]) => s.startsWith("failed")); | |
| if (failed.length) { | |
| showError(failed.map(([id, s]) => id + ": " + s).join(" | ")); | |
| $("load-status").textContent = ""; | |
| } | |
| if (active.length) { | |
| $("load-status").textContent = active.map(([id, s]) => id + ": " + s).join(" | "); | |
| return "busy"; | |
| } | |
| // A watched load finished: the entry was removed server-side. | |
| if (loadWatch) { $("load-status").textContent = loadWatch + ": loaded"; loadWatch = null; } | |
| return "idle"; | |
| } catch (err) { | |
| return "busy"; | |
| } | |
| } | |
| async function loadModel() { | |
| const id = $("load-id").value.trim(); | |
| if (!id) { showError("Enter a model id to load"); return; } | |
| errorBox.textContent = ""; | |
| $("load-status").textContent = id + ": starting…"; | |
| try { | |
| const response = await fetch("/v1/models/load", { | |
| method: "POST", | |
| headers: Object.assign({ "Content-Type": "application/json" }, headers()), | |
| body: JSON.stringify({ | |
| model: id, | |
| precision: $("load-precision").value, | |
| act_precision: $("load-act").value || null, | |
| revision: $("load-revision").value.trim() || null, | |
| threads: Number($("load-threads").value) || 2, | |
| trust_remote_code: $("load-trust").checked, | |
| }), | |
| }); | |
| const body = await response.json(); | |
| if (!response.ok) { | |
| $("load-status").textContent = ""; | |
| showError(body.error && body.error.message || "HTTP " + response.status); | |
| return; | |
| } | |
| if (body.status === "ready") { | |
| $("load-status").textContent = id + ": already loaded"; | |
| await loadModels(); | |
| return; | |
| } | |
| $("load-id").value = ""; | |
| loadWatch = id; | |
| const poll = setInterval(async () => { | |
| const state = await pollLoadStatus(); | |
| if (state !== "busy") { | |
| clearInterval(poll); | |
| if (state === "idle") await loadModels(); | |
| } | |
| }, 1500); | |
| } catch (err) { | |
| showError(String(err)); | |
| } | |
| } | |
| function renderModelCards(models) { | |
| const host = $("model-cards"); | |
| host.replaceChildren(); | |
| if (!models.length) { | |
| const hint = document.createElement("div"); | |
| hint.className = "empty"; | |
| hint.textContent = "No models loaded. Enter an HF repo id above — or run a shootout, which loads each precision for you."; | |
| host.appendChild(hint); | |
| return; | |
| } | |
| for (const m of models) { | |
| const card = document.createElement("article"); | |
| card.className = "card" + (m.id === $("model").value ? " active" : ""); | |
| const title = document.createElement("div"); | |
| title.className = "card-title"; | |
| title.textContent = m.id; | |
| const badges = document.createElement("div"); | |
| badges.className = "badges"; | |
| const items = []; | |
| if (m.precision) items.push([m.precision, false]); | |
| if (m.act_precision) items.push(["act " + m.act_precision, m.act_precision !== "fp32"]); | |
| if (m.weight_mb !== undefined) items.push([m.weight_mb + " MB", false]); | |
| if (m.threads) items.push([m.threads + "t", false]); | |
| if (m.kernel) items.push([m.kernel, false]); | |
| for (const [text, hot] of items) { | |
| const badge = document.createElement("span"); | |
| badge.className = "badge" + (hot ? " hot" : ""); | |
| badge.textContent = text; | |
| badges.appendChild(badge); | |
| } | |
| const actions = document.createElement("div"); | |
| actions.className = "card-actions"; | |
| const use = document.createElement("button"); | |
| use.textContent = "Use"; | |
| use.addEventListener("click", () => { | |
| $("model").value = m.id; | |
| capMax(); | |
| showEngineInfo(); | |
| renderModelCards(models); | |
| }); | |
| const drop = document.createElement("button"); | |
| drop.textContent = "Unload"; | |
| drop.className = "danger"; | |
| drop.addEventListener("click", () => { unloadModel(m.id); }); | |
| actions.append(use, drop); | |
| card.append(title, badges, actions); | |
| host.appendChild(card); | |
| } | |
| } | |
| async function unloadModel(id) { | |
| id = id || $("model").value; | |
| if (!id) return; | |
| errorBox.textContent = ""; | |
| try { | |
| const response = await fetch("/v1/models/unload", { | |
| method: "POST", | |
| headers: Object.assign({ "Content-Type": "application/json" }, headers()), | |
| body: JSON.stringify({ model: id }), | |
| }); | |
| const body = await response.json(); | |
| if (!response.ok) { showError(body.error && body.error.message || "HTTP " + response.status); return; } | |
| $("load-status").textContent = id + ": unloaded"; | |
| await loadModels(); | |
| } catch (err) { | |
| showError(String(err)); | |
| } | |
| } | |
| // One generation; returns measurement or null on handled failure. | |
| async function runGeneration(maxOverride) { | |
| output.textContent = ""; | |
| stats.textContent = ""; | |
| live.textContent = ""; | |
| const started = performance.now(); | |
| let firstToken = 0, finish = null, usage = null; | |
| const streaming = $("stream").checked; | |
| const response = await fetch("/v1/" + (mode() === "chat" ? "chat/completions" : "completions"), { | |
| method: "POST", | |
| headers: Object.assign({ "Content-Type": "application/json" }, headers()), | |
| body: JSON.stringify(requestBody(streaming, maxOverride)), | |
| signal: controller.signal, | |
| }); | |
| if (!response.ok) { | |
| const body = await response.json().catch(() => null); | |
| showError(body && body.error ? body.error.message : "HTTP " + response.status); | |
| return null; | |
| } | |
| if (!streaming) { | |
| const body = await response.json(); | |
| const choice = body.choices[0]; | |
| output.textContent = mode() === "chat" ? choice.message.content : choice.text; | |
| const elapsed = (performance.now() - started) / 1000; | |
| let summary = "finish=" + choice.finish_reason + | |
| " prompt=" + body.usage.prompt_tokens + " completion=" + body.usage.completion_tokens + | |
| " total=" + body.usage.total_tokens + " elapsed=" + elapsed.toFixed(3) + "s"; | |
| if (body.usage.spec_accept_rate !== undefined && body.usage.spec_accept_rate !== null) | |
| summary += " accept=" + Math.round(body.usage.spec_accept_rate * 100) + "%"; | |
| stats.textContent = withWhitespaceHint(summary); | |
| return { tokens: body.usage.completion_tokens, ttft: null, seconds: elapsed, decode: null }; | |
| } | |
| const reader = response.body.getReader(); | |
| const decoder = new TextDecoder(); | |
| let buffer = ""; | |
| for (;;) { | |
| const block = await reader.read(); | |
| if (block.done) break; | |
| buffer += decoder.decode(block.value, { stream: true }); | |
| let index; | |
| while ((index = buffer.indexOf("\\n\\n")) >= 0) { | |
| const frame = buffer.slice(0, index); | |
| buffer = buffer.slice(index + 2); | |
| for (const line of frame.split("\\n")) { | |
| if (!line.startsWith("data: ")) continue; | |
| const payload = line.slice(6); | |
| if (payload === "[DONE]") continue; | |
| const event = JSON.parse(payload); | |
| if (event.usage) usage = event.usage; | |
| if (event.cism_completion_tokens !== undefined) { | |
| const rate = firstToken | |
| ? " · " + (event.cism_completion_tokens / ((performance.now() - firstToken) / 1000)).toFixed(0) + " tok/s live" | |
| : ""; | |
| live.textContent = event.cism_completion_tokens + " tokens" + rate; | |
| } | |
| const choice = event.choices && event.choices[0]; | |
| if (!choice) continue; | |
| if (choice.finish_reason) finish = choice.finish_reason; | |
| const text = mode() === "chat" | |
| ? (choice.delta && choice.delta.content) || "" | |
| : choice.text || ""; | |
| if (text && !firstToken) firstToken = performance.now(); | |
| if (text) output.textContent += text; | |
| } | |
| } | |
| } | |
| const parts = []; | |
| if (finish) parts.push("finish=" + finish); | |
| if (usage) { | |
| parts.push("prompt=" + usage.prompt_tokens, "completion=" + usage.completion_tokens, | |
| "total=" + usage.total_tokens); | |
| if (usage.spec_accept_rate !== undefined && usage.spec_accept_rate !== null) | |
| parts.push("accept=" + Math.round(usage.spec_accept_rate * 100) + "%"); | |
| if (firstToken) parts.push("decode=" + | |
| (usage.completion_tokens / ((performance.now() - firstToken) / 1000)).toFixed(1) + " tok/s"); | |
| } | |
| parts.push("ttft=" + (firstToken ? ((firstToken - started) / 1000).toFixed(3) + "s" : "n/a")); | |
| stats.textContent = withWhitespaceHint(parts.join(" ")); | |
| return { tokens: usage ? usage.completion_tokens : 0, | |
| ttft: firstToken ? (firstToken - started) / 1000 : null, | |
| seconds: (performance.now() - started) / 1000, | |
| decode: firstToken && usage ? usage.completion_tokens / ((performance.now() - firstToken) / 1000) : null }; | |
| } | |
| async function generate() { | |
| errorBox.textContent = ""; | |
| $("go").disabled = true; | |
| $("bench").disabled = true; | |
| $("stop").disabled = false; | |
| controller = new AbortController(); | |
| try { | |
| await runGeneration(null); | |
| } catch (err) { | |
| if (err && err.name === "AbortError") stats.textContent += " (cancelled)"; | |
| else showError(String(err)); | |
| } finally { | |
| $("go").disabled = false; | |
| $("bench").disabled = false; | |
| $("stop").disabled = true; | |
| controller = null; | |
| } | |
| } | |
| async function runAutoBench() { | |
| const model = $("model").value.trim(); | |
| if (!model) { showError("Select a model first"); return; } | |
| errorBox.textContent = ""; | |
| $("autobench").disabled = true; | |
| $("bench-progress").textContent = "starting…"; | |
| $("bench-results").style.display = "none"; | |
| try { | |
| const response = await fetch("/v1/bench/auto", { | |
| method: "POST", | |
| headers: Object.assign({ "Content-Type": "application/json" }, headers()), | |
| // No precision selector: every AutoBench evaluates all four CISM | |
| // variants (fp32, int8, hybrid-int4, hybrid-fp4) in one run. | |
| body: JSON.stringify({ model: model, | |
| threads: Number($("load-threads").value) || 2, | |
| max_tokens: Number($("bench-tokens").value) || 512, | |
| thread_sweep: $("bench-sweep").checked, | |
| spec_sweep: $("bench-spec").checked, | |
| bench_act_int8: $("bench-act8").checked, | |
| include_torch: $("bench-torch").checked, | |
| compile: $("bench-compile").checked }), | |
| }); | |
| const body = await response.json(); | |
| if (!response.ok) { | |
| $("bench-progress").textContent = ""; | |
| showError(body.error && body.error.message || "HTTP " + response.status); | |
| // A 409 means a bench is already running server-side (perhaps started | |
| // from another tab); adopt and display it instead of leaving the UI | |
| // claiming idle. | |
| if (response.status === 409) syncBenchState(); | |
| else $("autobench").disabled = false; | |
| return; | |
| } | |
| syncBenchState(); | |
| } catch (err) { | |
| $("autobench").disabled = false; | |
| $("bench-progress").textContent = ""; | |
| showError(String(err)); | |
| } | |
| } | |
| let benchPoll = null; | |
| function syncBenchState() { | |
| // One shared poller for every tab/click; tracks the server-side bench. | |
| if (benchPoll) return; | |
| benchPoll = setInterval(async () => { | |
| let state; | |
| try { state = await (await fetch("/v1/bench/auto", { headers: headers() })).json(); } | |
| catch { return; } | |
| if (state.status === "running") { | |
| $("autobench").disabled = true; | |
| $("bench-progress").textContent = state.progress || "running…"; | |
| return; | |
| } | |
| clearInterval(benchPoll); | |
| benchPoll = null; | |
| $("autobench").disabled = false; | |
| if (state.status === "done" && state.result) { | |
| $("bench-progress").textContent = "done"; | |
| renderBench(state.result); | |
| } else if (state.status === "failed") { | |
| $("bench-progress").textContent = ""; | |
| showError(state.error || "benchmark failed"); | |
| } | |
| }, 1000); | |
| } | |
| function benchCell(value) { | |
| return value === undefined || value === null ? "—" : String(value); | |
| } | |
| function renderBench(result) { | |
| if (!result) return; | |
| const table = $("bench-results"); | |
| const rows = []; | |
| const fmt = value => value === undefined || value === null ? "—" : | |
| Number(value).toLocaleString(undefined, { maximumFractionDigits: 2 }); | |
| if (result.cism) { | |
| let label = "CISM (" + result.precision + ")"; | |
| if (result.cism.weight_mb !== undefined) { | |
| label += " · " + result.cism.weight_mb + " MB"; | |
| if (result.cism.fits_l3 === true) label += " · L3-resident"; | |
| else if (result.cism.fits_l3 === false) label += " · DRAM-streaming"; | |
| } | |
| rows.push([label, fmt(result.cism.tokens_per_second), | |
| fmt(result.cism.perplexity && result.cism.perplexity.perplexity)]); | |
| } | |
| if (result.cism_fp32) { | |
| let label = "CISM fp32"; | |
| if (result.cism_fp32.weight_mb !== undefined) { | |
| label += " · " + result.cism_fp32.weight_mb + " MB"; | |
| if (result.cism_fp32.fits_l3 === true) label += " · L3-resident"; | |
| else if (result.cism_fp32.fits_l3 === false) label += " · DRAM-streaming"; | |
| } | |
| rows.push([label, fmt(result.cism_fp32.tokens_per_second), | |
| fmt(result.cism_fp32.perplexity && result.cism_fp32.perplexity.perplexity)]); | |
| } | |
| if (result.cism_hybrid_int4) { | |
| let label = "CISM hybrid-int4"; | |
| if (result.cism_hybrid_int4.weight_mb !== undefined) { | |
| label += " · " + result.cism_hybrid_int4.weight_mb + " MB"; | |
| if (result.cism_hybrid_int4.fits_l3 === true) label += " · L3-resident"; | |
| else if (result.cism_hybrid_int4.fits_l3 === false) label += " · DRAM-streaming"; | |
| } | |
| rows.push([label, fmt(result.cism_hybrid_int4.tokens_per_second), | |
| fmt(result.cism_hybrid_int4.perplexity && result.cism_hybrid_int4.perplexity.perplexity)]); | |
| } | |
| if (result.cism_hybrid_fp4) { | |
| let label = "CISM hybrid-fp4"; | |
| if (result.cism_hybrid_fp4.weight_mb !== undefined) { | |
| label += " · " + result.cism_hybrid_fp4.weight_mb + " MB"; | |
| if (result.cism_hybrid_fp4.fits_l3 === true) label += " · L3-resident"; | |
| else if (result.cism_hybrid_fp4.fits_l3 === false) label += " · DRAM-streaming"; | |
| } | |
| rows.push([label, fmt(result.cism_hybrid_fp4.tokens_per_second), | |
| fmt(result.cism_hybrid_fp4.perplexity && result.cism_hybrid_fp4.perplexity.perplexity)]); | |
| } | |
| if (result.hf_eager) { | |
| rows.push(["HF eager fp32 (generate)", fmt(result.hf_eager.tokens_per_second), | |
| fmt(result.hf_eager.perplexity && result.hf_eager.perplexity.perplexity)]); | |
| } | |
| if (result.hf_eager_int8) { | |
| rows.push(result.hf_eager_int8.failed | |
| ? ["HF eager int8 (failed)", result.hf_eager_int8.failed, "—"] | |
| : ["HF eager int8 (dynamic)", fmt(result.hf_eager_int8.tokens_per_second), | |
| fmt(result.hf_eager_int8.perplexity && result.hf_eager_int8.perplexity.perplexity)]); | |
| } | |
| if (result.hf_compile) { | |
| rows.push(result.hf_compile.failed | |
| ? ["HF torch.compile fp32 (failed)", result.hf_compile.failed, "—"] | |
| : ["HF torch.compile fp32", fmt(result.hf_compile.tokens_per_second), "—"]); | |
| } | |
| if (result.hf_compile_int8) { | |
| rows.push(result.hf_compile_int8.failed | |
| ? ["HF torch.compile int8 (failed)", result.hf_compile_int8.failed, "—"] | |
| : ["HF torch.compile int8", fmt(result.hf_compile_int8.tokens_per_second), "—"]); | |
| } | |
| if (result.hf_eager_int4) { | |
| rows.push(result.hf_eager_int4.failed || result.hf_eager_int4.skipped | |
| ? ["HF eager int4 (torchao)", result.hf_eager_int4.failed || result.hf_eager_int4.skipped, "—"] | |
| : ["HF eager int4 (torchao)", fmt(result.hf_eager_int4.tokens_per_second), | |
| fmt(result.hf_eager_int4.perplexity && result.hf_eager_int4.perplexity.perplexity)]); | |
| } | |
| if (result.hf_compile_int4) { | |
| rows.push(result.hf_compile_int4.failed | |
| ? ["HF torch.compile int4 (failed)", result.hf_compile_int4.failed, "—"] | |
| : ["HF torch.compile int4", fmt(result.hf_compile_int4.tokens_per_second), "—"]); | |
| } | |
| if (result.torch_baselines && result.torch_baselines.skipped) { | |
| rows.push(["HF baselines", result.torch_baselines.skipped, "—"]); | |
| } | |
| const speedups = []; | |
| if (result.speedup_vs_eager) speedups.push(result.speedup_vs_eager + "x vs eager"); | |
| if (result.speedup_vs_compile) speedups.push(result.speedup_vs_compile + "x vs compile"); | |
| if (result.ppl_delta_percent !== null && result.ppl_delta_percent !== undefined) { | |
| speedups.push((result.ppl_delta_percent >= 0 ? "+" : "") + result.ppl_delta_percent + "% PPL vs fp32"); | |
| } | |
| if (result.hybrid_int4_speedup_vs_eager) speedups.push("int4: " + result.hybrid_int4_speedup_vs_eager + "x vs eager"); | |
| if (result.hybrid_int4_speedup_vs_compile) speedups.push("int4: " + result.hybrid_int4_speedup_vs_compile + "x vs compile"); | |
| if (result.hybrid_int4_ppl_delta_percent !== null && result.hybrid_int4_ppl_delta_percent !== undefined) { | |
| speedups.push("int4: " + (result.hybrid_int4_ppl_delta_percent >= 0 ? "+" : "") + | |
| result.hybrid_int4_ppl_delta_percent + "% PPL vs fp32"); | |
| } | |
| if (result.hybrid_fp4_speedup_vs_eager) speedups.push("fp4: " + result.hybrid_fp4_speedup_vs_eager + "x vs eager"); | |
| if (result.hybrid_fp4_speedup_vs_compile) speedups.push("fp4: " + result.hybrid_fp4_speedup_vs_compile + "x vs compile"); | |
| if (result.hybrid_fp4_ppl_delta_percent !== null && result.hybrid_fp4_ppl_delta_percent !== undefined) { | |
| speedups.push("fp4: " + (result.hybrid_fp4_ppl_delta_percent >= 0 ? "+" : "") + | |
| result.hybrid_fp4_ppl_delta_percent + "% PPL vs fp32"); | |
| } | |
| table.replaceChildren(...[ | |
| (() => { | |
| const head = document.createElement("tr"); | |
| ["method", "tok/s", "PPL"].forEach((title, index) => { | |
| const th = document.createElement("th"); | |
| th.textContent = title; | |
| th.style.textAlign = index ? "right" : "left"; | |
| head.appendChild(th); | |
| }); | |
| return head; | |
| })(), | |
| ...rows.map(cells => { | |
| const tr = document.createElement("tr"); | |
| cells.forEach((cell, index) => { | |
| const td = document.createElement("td"); | |
| td.textContent = benchCell(cell); | |
| td.style.textAlign = index ? "right" : "left"; | |
| td.style.paddingRight = "12px"; | |
| tr.appendChild(td); | |
| }); | |
| return tr; | |
| }), | |
| (() => { | |
| const tr = document.createElement("tr"); | |
| const td = document.createElement("td"); | |
| td.colSpan = 3; | |
| td.textContent = speedups.join(" | "); | |
| td.style.paddingTop = "6px"; | |
| tr.appendChild(td); | |
| return tr; | |
| })(), | |
| ]); | |
| table.style.display = "table"; | |
| renderBenchExtra(result); | |
| } | |
| async function bench() { | |
| // Three fixed 128-token greedy runs; first is warmup, report the rest. | |
| const previousMax = $("max").value, previousTemp = $("temp").value, previousSpec = $("spec").value; | |
| $("max").value = 128; $("temp").value = 0; $("spec").value = 0; | |
| errorBox.textContent = ""; | |
| $("go").disabled = true; | |
| $("bench").disabled = true; | |
| $("stop").disabled = false; | |
| try { | |
| const rates = [], ttfts = []; | |
| for (let run = 0; run < 3; ++run) { | |
| controller = new AbortController(); | |
| const result = await runGeneration(128); | |
| if (!result) return; | |
| if (run > 0) { | |
| if (result.decode) rates.push(result.decode); | |
| if (result.ttft !== null) ttfts.push(result.ttft); | |
| } | |
| } | |
| const median = values => values.slice().sort((a, b) => a - b)[Math.floor(values.length / 2)]; | |
| const parts = ["bench (3x128, run 1 discarded):"]; | |
| if (rates.length) parts.push("median decode " + median(rates).toFixed(1) + " tok/s", | |
| "best " + Math.max(...rates).toFixed(1)); | |
| if (ttfts.length) parts.push("median ttft " + median(ttfts).toFixed(3) + "s"); | |
| stats.textContent = parts.join(" ") + " | params restored"; | |
| } catch (err) { | |
| if (err && err.name === "AbortError") stats.textContent += " (cancelled)"; | |
| else showError(String(err)); | |
| } finally { | |
| $("max").value = previousMax; $("temp").value = previousTemp; $("spec").value = previousSpec; | |
| capMax(); | |
| $("go").disabled = false; | |
| $("bench").disabled = false; | |
| $("stop").disabled = true; | |
| controller = null; | |
| } | |
| } | |
| $("remember").addEventListener("change", () => { | |
| if ($("remember").checked) localStorage.setItem("cism-webui-key", $("key").value); | |
| else localStorage.removeItem("cism-webui-key"); | |
| }); | |
| $("key").addEventListener("input", () => { | |
| if ($("remember").checked) localStorage.setItem("cism-webui-key", $("key").value); | |
| }); | |
| $("load").addEventListener("click", loadModels); | |
| $("model").addEventListener("change", () => { | |
| capMax(); | |
| showEngineInfo(); | |
| renderModelCards(Object.values(engineInfo)); | |
| }); | |
| $("load-model").addEventListener("click", loadModel); | |
| $("unload-model").addEventListener("click", () => unloadModel()); | |
| $("bench").addEventListener("click", bench); | |
| $("autobench").addEventListener("click", runAutoBench); | |
| $("go").addEventListener("click", generate); | |
| $("stop").addEventListener("click", () => { if (controller) controller.abort(); }); | |
| async function refreshHealth() { | |
| const pill = $("health"); | |
| try { | |
| const h = await (await fetch("/health")).json(); | |
| let text = "health: " + (h.status || "?"); | |
| let ok = h.status === "ok"; | |
| try { | |
| const r = await (await fetch("/ready")).json(); | |
| text += " · ready: " + (r.status || "?"); | |
| ok = ok && r.status === "ready"; | |
| } catch { text += " · ready: n/a (worker busy or starting)"; } | |
| pill.textContent = text; | |
| pill.dataset.state = ok ? "ok" : ""; | |
| } catch { | |
| pill.textContent = "health: unreachable"; | |
| pill.dataset.state = "bad"; | |
| } | |
| } | |
| $("copy").addEventListener("click", async () => { | |
| errorBox.textContent = ""; | |
| try { await navigator.clipboard.writeText(output.textContent); } | |
| catch (err) { showError(String(err)); } | |
| }); | |
| async function waitForLoadIdle(id) { | |
| for (;;) { | |
| const response = await fetch("/v1/models/loading", { headers: headers() }); | |
| const body = await response.json(); | |
| const state = (body.loading || {})[id]; | |
| if (!state) return; | |
| if (state.startsWith("failed")) throw new Error(id + ": " + state); | |
| if (state !== "loading") return; | |
| $("shootout-status").textContent = id + ": " + state + "…"; | |
| await new Promise(resolve => setTimeout(resolve, 1500)); | |
| } | |
| } | |
| async function shootoutTimedRun(maxTokens) { | |
| // One non-streaming greedy run; returns tok/s or throws on HTTP error. | |
| const started = performance.now(); | |
| const response = await fetch("/v1/completions", { | |
| method: "POST", | |
| headers: Object.assign({ "Content-Type": "application/json" }, headers()), | |
| body: JSON.stringify({ model: $("shootout-id").value.trim(), prompt: $("prompt").value, | |
| max_tokens: maxTokens, temperature: 0, stream: false, | |
| spec_k: Number($("shoot-spec").value) || 0 }), | |
| }); | |
| const body = await response.json().catch(() => null); | |
| if (!response.ok) throw new Error(body && body.error ? body.error.message : "HTTP " + response.status); | |
| return body.usage.completion_tokens / ((performance.now() - started) / 1000); | |
| } | |
| function renderShootout(rows) { | |
| const table = $("shootout-results"); | |
| const head = document.createElement("tr"); | |
| ["precision", "median tok/s"].forEach((title, index) => { | |
| const th = document.createElement("th"); | |
| th.textContent = title; | |
| th.style.textAlign = index ? "right" : "left"; | |
| head.appendChild(th); | |
| }); | |
| const bodyRows = rows.map(cells => { | |
| const tr = document.createElement("tr"); | |
| cells.forEach((cell, index) => { | |
| const td = document.createElement("td"); | |
| td.textContent = cell; | |
| td.style.textAlign = index ? "right" : "left"; | |
| td.style.paddingRight = "12px"; | |
| tr.appendChild(td); | |
| }); | |
| return tr; | |
| }); | |
| table.replaceChildren(head, ...bodyRows); | |
| table.style.display = "table"; | |
| } | |
| function renderBenchExtra(result) { | |
| const host = $("bench-extra"); | |
| host.replaceChildren(); | |
| if (!result || !result.matrix || !result.matrix.length) return; | |
| const fmt = value => value === undefined || value === null ? "—" : | |
| Number(value).toLocaleString(undefined, { maximumFractionDigits: 1 }); | |
| const variants = [...new Set(result.matrix.map(r => r.precision + (r.act !== "fp32" ? " " + r.act : "")))]; | |
| const keyOf = r => r.precision + (r.act !== "fp32" ? " " + r.act : ""); | |
| const pivot = (title, rows, preformatted) => { | |
| const table = document.createElement("table"); | |
| const head = document.createElement("tr"); | |
| ["", ...variants].forEach((text, index) => { | |
| const th = document.createElement("th"); | |
| th.textContent = text; | |
| th.style.textAlign = index ? "right" : "left"; | |
| head.appendChild(th); | |
| }); | |
| table.appendChild(head); | |
| for (const row of rows) { | |
| const tr = document.createElement("tr"); | |
| const label = document.createElement("td"); | |
| label.textContent = row.label; | |
| tr.appendChild(label); | |
| for (const v of variants) { | |
| const td = document.createElement("td"); | |
| td.textContent = preformatted ? row.cells[v] : fmt(row.cells[v]); | |
| td.style.textAlign = "right"; | |
| td.style.paddingRight = "12px"; | |
| tr.appendChild(td); | |
| } | |
| table.appendChild(tr); | |
| } | |
| const caption = document.createElement("div"); | |
| caption.textContent = title; | |
| caption.style.color = "#a89c8d"; | |
| caption.style.margin = "12px 0 2px"; | |
| host.append(caption, table); | |
| }; | |
| const threads = [...new Set(result.matrix.map(r => r.threads))].sort((a, b) => a - b); | |
| if (threads.length > 1 || result.spec_ks.length > 1 || result.act_modes.length > 1) { | |
| pivot("tok/s by threads (spec 0, fp32 act)", | |
| threads.map(t => ({ label: t + " threads", | |
| cells: Object.fromEntries(variants.map(v => [v, (result.matrix.find( | |
| r => keyOf(r) === v && r.threads === t && r.spec_k === 0) || {}).tokens_per_second])) }))); | |
| const specRows = [...new Set(result.matrix.map(r => r.spec_k))].sort((a, b) => a - b); | |
| if (specRows.length > 1) { | |
| const specThreads = [...new Set(result.matrix.map(r => r.threads))].sort((a, b) => a - b); | |
| for (const th of specThreads) { | |
| const specCell = (v, k) => { | |
| const row = result.matrix.find( | |
| r => keyOf(r) === v && r.spec_k === k && r.threads === th) || {}; | |
| let text = fmt(row.tokens_per_second); | |
| if (row.accept_rate !== undefined && row.accept_rate !== null) | |
| text += " (" + Math.round(row.accept_rate * 100) + "% acc)"; | |
| return text; | |
| }; | |
| pivot("tok/s by spec_k (" + th + " threads, fp32 act)", | |
| specRows.map(k => ({ label: "spec " + k, | |
| cells: Object.fromEntries(variants.map(v => [v, specCell(v, k)])) })), true); | |
| } | |
| } | |
| if (result.best) { | |
| const lines = Object.entries(result.best).map(([setup, info]) => | |
| setup + ": " + fmt(info.tokens_per_second) + " tok/s @" + info.threads + "t spec " + info.spec_k); | |
| const note = document.createElement("div"); | |
| note.textContent = "best: " + lines.join(" · "); | |
| note.style.color = "#a89c8d"; | |
| note.style.marginTop = "10px"; | |
| host.appendChild(note); | |
| } | |
| } | |
| } | |
| async function runShootout() { | |
| const id = $("shootout-id").value.trim() || $("model").value.trim(); | |
| if (!id) { showError("Enter a shootout model id"); return; } | |
| $("shootout-id").value = id; | |
| const precisions = ["fp32", "int8", "hybrid-int4", "hybrid-fp4"].filter(p => $("shoot-" + p).checked); | |
| if (!precisions.length) { showError("Tick at least one precision"); return; } | |
| const maxTokens = Math.min(Number($("shootout-tokens").value) || 64, contexts[id] || Infinity); | |
| const runs = Math.max(2, Number($("shootout-runs").value) || 3); | |
| errorBox.textContent = ""; | |
| $("shootout").disabled = true; | |
| $("shootout-results").style.display = "none"; | |
| const rows = []; | |
| try { | |
| for (const precision of precisions) { | |
| $("shootout-status").textContent = id + " " + precision + ": loading…"; | |
| const response = await fetch("/v1/models/load", { | |
| method: "POST", | |
| headers: Object.assign({ "Content-Type": "application/json" }, headers()), | |
| body: JSON.stringify({ model: id, precision: precision, | |
| threads: Number($("load-threads").value) || 2, | |
| act_precision: $("shoot-act").value || null }), | |
| }); | |
| const body = await response.json().catch(() => null); | |
| if (!response.ok) throw new Error(body && body.error ? body.error.message : "HTTP " + response.status); | |
| await waitForLoadIdle(id); | |
| await loadModels(); | |
| $("shootout-status").textContent = id + " " + precision + ": benching…"; | |
| const rates = []; | |
| for (let run = 0; run < runs; ++run) { | |
| const rate = await shootoutTimedRun(maxTokens); | |
| if (run > 0) rates.push(rate); | |
| } | |
| rates.sort((a, b) => a - b); | |
| const info = engineInfo[id] || {}; | |
| let label = precision; | |
| if (info.weight_mb !== undefined) label += " · " + info.weight_mb + " MB"; | |
| rows.push([label, rates[Math.floor(rates.length / 2)].toFixed(1)]); | |
| renderShootout(rows); | |
| } | |
| $("shootout-status").textContent = "done"; | |
| } catch (err) { | |
| showError(String((err && err.message) || err)); | |
| $("shootout-status").textContent = ""; | |
| } finally { | |
| $("shootout").disabled = false; | |
| } | |
| } | |
| $("shootout").addEventListener("click", runShootout); | |
| async function runCacheReport() { | |
| const id = $("model").value.trim(); | |
| if (!id) { showError("Select a model first"); return; } | |
| errorBox.textContent = ""; | |
| $("cache-run").disabled = true; | |
| $("cache-status").textContent = "scanning…"; | |
| try { | |
| const response = await fetch("/v1/cache/report", { | |
| method: "POST", | |
| headers: Object.assign({ "Content-Type": "application/json" }, headers()), | |
| body: JSON.stringify({ model: id, | |
| decode_tokens: Number($("cache-tokens").value) || 64, | |
| scrub_mb: Number($("cache-scrub").value) || 96 }), | |
| }); | |
| const body = await response.json().catch(() => null); | |
| if (!response.ok) throw new Error(body && body.error ? body.error.message : "HTTP " + response.status); | |
| $("cache-status").textContent = "done"; | |
| renderCache(body); | |
| } catch (err) { | |
| showError(String((err && err.message) || err)); | |
| $("cache-status").textContent = ""; | |
| } finally { | |
| $("cache-run").disabled = false; | |
| } | |
| } | |
| function renderCache(result) { | |
| const host = $("cache-results"); | |
| host.replaceChildren(); | |
| const rows = [ | |
| ["decode", (result.decode_tokens_per_second || "—") + " tok/s"], | |
| ["effective", (result.decode_effective_gbps || "—") + " GB/s"], | |
| ["warm scan", (result.warm_scan_gbps || "—") + " GB/s"], | |
| ["cold scan", (result.cold_scan_gbps || "—") + " GB/s"], | |
| ["warm/cold", result.warm_to_cold_ratio || "—"], | |
| ["residency", result.residency || "—"], | |
| ["bottleneck", result.decode_bottleneck || "—"], | |
| ]; | |
| for (const [name, value] of rows) { | |
| const div = document.createElement("div"); | |
| const key = document.createElement("span"); | |
| key.textContent = name + ": "; | |
| key.style.color = "#a89c8d"; | |
| const val = document.createElement("span"); | |
| val.textContent = String(value); | |
| div.append(name, val); | |
| host.appendChild(div); | |
| } | |
| } | |
| $("cache-run").addEventListener("click", runCacheReport); | |
| function fmtMB(bytes) { | |
| if (bytes === undefined || bytes === null) return "—"; | |
| return bytes >= 1048576 ? (bytes / 1048576).toFixed(1) + " MB" : Math.round(bytes / 1024) + " KB"; | |
| } | |
| async function countTokens() { | |
| const id = $("model").value.trim(); | |
| if (!id) { showError("Select a model first"); return; } | |
| $("token-info").textContent = "counting…"; | |
| try { | |
| const response = await fetch("/v1/tokenize", { | |
| method: "POST", | |
| headers: Object.assign({ "Content-Type": "application/json" }, headers()), | |
| body: JSON.stringify({ model: id, text: $("prompt").value }), | |
| }); | |
| const body = await response.json().catch(() => null); | |
| if (!response.ok) throw new Error(body && body.error ? body.error.message : "HTTP " + response.status); | |
| const parts = [body.count + " tokens"]; | |
| if (body.context_length) { | |
| parts.push(body.fits_context === false ? "exceeds context " + body.context_length : "of context " + body.context_length); | |
| } | |
| if (body.kv_cache) { | |
| parts.push("KV at prompt " + fmtMB(body.kv_cache.bytes_at_prompt) + | |
| " · full context " + fmtMB(body.kv_cache.bytes_at_full_context)); | |
| } | |
| $("token-info").textContent = parts.join(" · "); | |
| } catch (err) { | |
| showError(String((err && err.message) || err)); | |
| $("token-info").textContent = ""; | |
| } | |
| } | |
| async function scorePrompt() { | |
| const id = $("model").value.trim(); | |
| if (!id) { showError("Select a model first"); return; } | |
| errorBox.textContent = ""; | |
| $("ppl-status").textContent = "scoring…"; | |
| $("ppl-results").replaceChildren(); | |
| try { | |
| const response = await fetch("/v1/nll", { | |
| method: "POST", | |
| headers: Object.assign({ "Content-Type": "application/json" }, headers()), | |
| body: JSON.stringify({ model: id, text: $("prompt").value, | |
| window: Number($("ppl-window").value) || 128 }), | |
| }); | |
| const body = await response.json().catch(() => null); | |
| if (!response.ok) throw new Error(body && body.error ? body.error.message : "HTTP " + response.status); | |
| $("ppl-status").textContent = "done"; | |
| const host = $("ppl-results"); | |
| const rows = [ | |
| ["perplexity", String(Math.round(body.perplexity * 1000) / 1000)], | |
| ["mean NLL", String(Math.round(body.nll * 10000) / 10000)], | |
| ["scored tokens", String(body.scored_tokens)], | |
| ]; | |
| for (const [name, value] of rows) { | |
| const div = document.createElement("div"); | |
| const key = document.createElement("span"); | |
| key.textContent = name + ": "; | |
| key.style.color = "#a89c8d"; | |
| const val = document.createElement("span"); | |
| val.textContent = value; | |
| div.append(name, val); | |
| host.appendChild(div); | |
| } | |
| } catch (err) { | |
| showError(String((err && err.message) || err)); | |
| $("ppl-status").textContent = ""; | |
| } | |
| } | |
| const pinnedPages = {}; | |
| async function togglePin() { | |
| const id = $("model").value.trim(); | |
| if (!id) { showError("Select a model first"); return; } | |
| errorBox.textContent = ""; | |
| const pin = !pinnedPages[id]; | |
| $("runtime-status").textContent = pin ? "pinning…" : "unpinning…"; | |
| try { | |
| const response = await fetch("/v1/models/runtime", { | |
| method: "POST", | |
| headers: Object.assign({ "Content-Type": "application/json" }, headers()), | |
| body: JSON.stringify({ model: id, lock_pages: pin }), | |
| }); | |
| const body = await response.json().catch(() => null); | |
| if (!response.ok) throw new Error(body && body.error ? body.error.message : "HTTP " + response.status); | |
| pinnedPages[id] = pin; | |
| $("lock-toggle").textContent = pin ? "Unpin pages" : "Pin pages"; | |
| const bytes = pin ? body.page_lock_bytes : body.page_unlock_bytes; | |
| $("runtime-status").textContent = id + (pin ? " pinned " : " unpinned ") + fmtMB(bytes); | |
| } catch (err) { | |
| showError(String((err && err.message) || err)); | |
| $("runtime-status").textContent = ""; | |
| } | |
| } | |
| async function keepWarm(start) { | |
| const id = $("model").value.trim(); | |
| if (!id) { showError("Select a model first"); return; } | |
| errorBox.textContent = ""; | |
| $("runtime-status").textContent = start ? "starting…" : "stopping…"; | |
| try { | |
| const response = await fetch("/v1/models/runtime", { | |
| method: "POST", | |
| headers: Object.assign({ "Content-Type": "application/json" }, headers()), | |
| body: JSON.stringify({ model: id, keep_warm: start ? (Number($("keep-warm-secs").value) || 2) : 0 }), | |
| }); | |
| const body = await response.json().catch(() => null); | |
| if (!response.ok) throw new Error(body && body.error ? body.error.message : "HTTP " + response.status); | |
| $("runtime-status").textContent = start ? ("warm every " + body.keep_warm + "s") : "warm stopped"; | |
| } catch (err) { | |
| showError(String((err && err.message) || err)); | |
| $("runtime-status").textContent = ""; | |
| } | |
| } | |
| $("count-tokens").addEventListener("click", countTokens); | |
| $("ppl-score").addEventListener("click", scorePrompt); | |
| $("lock-toggle").addEventListener("click", togglePin); | |
| $("keep-warm-start").addEventListener("click", () => keepWarm(true)); | |
| $("keep-warm-stop").addEventListener("click", () => keepWarm(false)); | |
| $("key").value = localStorage.getItem("cism-webui-key") || ""; | |
| if ($("key").value) $("remember").checked = true; | |
| loadModels(); | |
| syncBenchState(); | |
| </script> | |
| </body> | |
| </html> | |
| """ | |