Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
248 changes: 240 additions & 8 deletions serve/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@
from serve import logit_bias as bias_api # noqa: E402
from serve.winjob import contain # noqa: E402
from serve.structured import StructuredOutputError, prepare_format, validated_json # noqa: E402
from serve.structured import _extract_json # noqa: E402
from serve import responses as responses_api # noqa: E402
from serve.responses import ResponsesError, error_body as responses_error_body # noqa: E402
from serve import images as images_api # noqa: E402
Expand Down Expand Up @@ -1195,6 +1196,15 @@ def sampling_keys(sampling: dict) -> str:
pn = prefix.get("tokens")
if isinstance(pn, int) and not isinstance(pn, bool) and pn > 0:
keys += f" pin={pn}"
# logprobs: the engine writes an `LP` line after each `T` line (an engine without them ignores
# the key, so an older engine answers exactly as it did). Absent = no LP lines at all.
k = top_logprobs(sampling)
if k is not None:
keys += f" logprobs={k}"
ids = allowed_ids(sampling)
if ids:
# only these ids are printed; the engine's comma-separated list, renormalised below
keys += " allowed=" + ",".join(str(i) for i in ids)
return keys + StrataEngine.projection_key(sampling) + bias_api.engine_key(sampling.get("logit_bias"))

@staticmethod
Expand Down Expand Up @@ -1863,6 +1873,12 @@ def generate(self, ids, max_new, sampling, cancel, embeddings=None):
return
self.progress, self.progress_ms, self.reused = None, 0, 0
self.prefill_tok_s_mean = None
# logprobs: the engine writes "LP <logprob> [id:logprob ...]" right after each "T <id>" (the same
# flush), read here before the token is yielded, so whoever takes the token finds its line in
# lp_lines. Not asked for = the deque stays empty and nothing on this path changes.
want_lp = top_logprobs(sampling or {}) is not None
self.lp_lines = collections.deque()
carry = NO_LINE # a line read for a token's LP line, held until it is used
# an image request takes the same sampling keys as text (#75: it used to decode greedily whatever was asked)
head = f"GENI {int(max_new)}{self.sampling_keys(sampling or {})} {embeddings}" if embeddings else \
f"GEN {int(max_new)}{self.sampling_keys(sampling or {})}"
Expand Down Expand Up @@ -1890,7 +1906,10 @@ def generate(self, ids, max_new, sampling, cancel, embeddings=None):
raise self._silent(f"the engine said nothing for {time.monotonic() - heard:.0f} s during "
"the request")
try:
line = self.lines.get(timeout=min(wait, 0.5))
if carry is not NO_LINE: # the line read after a token for its LP line
line, carry = carry, NO_LINE
else:
line = self.lines.get(timeout=min(wait, 0.5))
except queue.Empty:
if cancel.is_set():
return
Expand All @@ -1917,7 +1936,18 @@ def generate(self, ids, max_new, sampling, cancel, embeddings=None):
allow = silence
if cancel.is_set():
return
yield int(line[2:])
t = int(line[2:])
if want_lp:
# the engine writes the token and its `LP` line in one flush, so the line is
# here now; an engine without logprobs leaves the next line a normal one
try:
carry = self.lines.get(timeout=0.5)
except queue.Empty:
carry = NO_LINE
if isinstance(carry, str) and carry.startswith("LP "):
self.lp_lines.append((t, carry))
carry = NO_LINE
yield t
elif line.startswith("PP "):
f = line.split()
if len(f) >= 3 and f[1].isdigit() and f[2].isdigit():
Expand Down Expand Up @@ -1960,9 +1990,12 @@ def generate(self, ids, max_new, sampling, cancel, embeddings=None):
while True:
left = allow - (time.monotonic() - heard) if allow > 0 else None
try:
if left is not None and left <= 0:
if carry is not NO_LINE: # read already, after the last token
line, carry = carry, NO_LINE
elif left is not None and left <= 0:
raise queue.Empty
line = self.lines.get(timeout=left)
else:
line = self.lines.get(timeout=left)
except queue.Empty:
raise self._silent("the engine did not finish the request after it was stopped (STOP) "
f"within {allow:.0f} s") from None
Expand Down Expand Up @@ -2925,6 +2958,31 @@ def push(self, t: int) -> str:
OPENAI_MAX_STOP = 4 # OpenAI's limit on `stop`


def top_logprobs(req: dict):
"""The K for the engine's logprobs=K, or None when the request did not ask.

OpenAI: "logprobs": true with "top_logprobs" 0..20; the legacy completions form,
"logprobs": N, is taken as N."""
lp = req.get("logprobs")
if lp is True:
k = req.get("top_logprobs")
return max(0, min(20, k)) if isinstance(k, int) and not isinstance(k, bool) else 0
if isinstance(lp, int) and not isinstance(lp, bool) and lp >= 0:
return min(20, lp)
return None


def allowed_ids(req: dict) -> list:
"""`allowed_token_ids`: the ids a caller named, or [] (vLLM's semantics, see LogprobEntries)."""
ids = req.get("allowed_token_ids")
if not isinstance(ids, (list, tuple)):
return []
return [x for x in ids if isinstance(x, int) and not isinstance(x, bool) and x >= 0]


NO_LINE = object() # StrataEngine.generate: no line held after a token


def stop_strings(req: dict) -> list[str]:
"""The request's stop strings: OpenAI's `stop` (a string or a list of up to 4) or Anthropic's `stop_sequences`
(a list). Empty strings are left out. A bad value is a ValueError, so a 400 before anything is generated."""
Expand Down Expand Up @@ -4714,6 +4772,90 @@ def work(c=c, box=box):
PP_DONE_TAIL = 64



class LogprobEntries:
"""The engine's LP lines as OpenAI's logprobs.content entries.

Each token's "token" is the text it completes (an incremental UTF-8 decode, as the reply's
text is made), so a character split across tokens lands in the token that finishes it and
the tokens, joined, spell the reply. "bytes" are the token's own bytes. The stop token
ends the reply and is not listed (OpenAI does not list it either). Tokens that make no
text of the reply are left out, because spell() gives each piece of text the tokens that
spell it.

`allowed_token_ids` (vLLM's semantics): only those ids are kept, and the logprobs are
renormalised over them, so a caller asking about specific candidates gets a distribution
over exactly those instead of a top-K list it has to hope contains them.
"""

def __init__(self, svc, allowed=()):
self.tok, self.stop_ids = svc.tok, svc.stop_ids
self.allowed = [i for i in allowed]
self.inc = codecs.getincrementaldecoder("utf-8")(errors="replace")
self.pending = [] # entries read but not yet given to a piece of text

def spell(self, eng, text: str) -> list:
"""The entries whose tokens spell `text`, the next piece of the reply."""
self.pending += self.take(eng)
at = [0]
for e in self.pending:
at.append(at[-1] + len(e["token"]))
joined = "".join(e["token"] for e in self.pending)
i = joined.find(text)
while i >= 0 and text:
if i in at:
a = at.index(i) # the first token there
end = i + len(text)
b = next((j for j in range(a + 1, len(at)) if at[j] == end), None)
if b is not None:
out, self.pending = self.pending[a:b], self.pending[b:]
return out
i = joined.find(text, i + 1)
out, self.pending = self.pending, []
return out

def raw(self, t):
if hasattr(self.tok, "token_bytes"):
return self.tok.token_bytes(t)
return self.tok.decode([t]).encode("utf-8")

def take(self, eng) -> list:
lines, out = getattr(eng, "lp_lines", None), []
while lines:
t, line = lines.popleft()
if t in self.stop_ids:
continue
f = line.split()
if len(f) < 2 or f[1] == "nan": # no head logits for that window row: no distribution
continue
b = self.raw(t)
e = {"token": self.inc.decode(b), "logprob": float(f[1]), "bytes": list(b),
"top_logprobs": []}
alts = f[2:]
if self.allowed:
alts = [x for x in alts if int(x.partition(":")[0]) in self.allowed]
for x in alts:
i, _, v = x.partition(":")
alt = self.raw(int(i))
e["top_logprobs"].append({"token": alt.decode("utf-8", "replace"),
"logprob": float(v), "bytes": list(alt)})
if self.allowed:
e = self._renormalise(e)
out.append(e)
return out

def _renormalise(self, e):
"""vLLM: keep only the named ids and normalise over them, so they sum to 1."""
if not e["top_logprobs"]:
return None
mx = max(x["logprob"] for x in e["top_logprobs"])
se = sum(math.exp(x["logprob"] - mx) for x in e["top_logprobs"])
lse = mx + math.log(se)
for x in e["top_logprobs"]:
x["logprob"] -= lse
e["logprob"] = e["top_logprobs"][0]["logprob"] if e["top_logprobs"] else e["logprob"]
return e

def prompt_progress(svc: Service):
"""The last PP line as llama.cpp's `prompt_progress`, so a client written for llama.cpp reads both servers: the
same four fields, `time_ms` elapsed since prompt reading started, and `processed` counting from token 0 so that a
Expand Down Expand Up @@ -4747,6 +4889,13 @@ def chunk(delta, finish=None):
"choices": [{"index": 0, "delta": delta, "finish_reason": finish}]}

yield chunk({"role": "assistant", "content": ""})
lp = LogprobEntries(svc, allowed_ids(req)) if top_logprobs(req) is not None else None

def with_lp(c, key):
got = lp.spell(svc.engine, c["choices"][0]["delta"][key]) if lp is not None else None
if got:
c["choices"][0]["logprobs"] = {key: got}
return c
calls = 0
streamed = {} # tool call id -> index, for calls sent piece by piece
finished = set() # ... and the ones whose final tool_call came (#211)
Expand All @@ -4763,9 +4912,9 @@ def chunk(delta, finish=None):
elif kind == "event":
ev: Event = x
if ev.kind == "reasoning" and ev.text:
yield chunk({"reasoning_content": ev.text})
yield with_lp(chunk({"reasoning_content": ev.text}), "reasoning_content")
elif ev.kind == "content" and ev.text:
yield chunk({"content": ev.text})
yield with_lp(chunk({"content": ev.text}), "content")
elif ev.kind == "tool_start":
streamed[ev.call.id] = calls
calls += 1
Expand Down Expand Up @@ -4805,11 +4954,14 @@ def _is_json(text: str) -> bool:

def openai_collect(chunks) -> dict:
content, reasoning, by_index, last, mcp = [], [], {}, None, []
lps = {} # "content" / "reasoning_content" -> logprob entries
for c in chunks:
if c is None: # a heartbeat
continue
if c.get("strata_mcp"):
mcp.append(c["strata_mcp"])
for k, v in (c["choices"][0].get("logprobs") or {}).items():
lps.setdefault(k, []).extend(v)
d = c["choices"][0]["delta"]
content.append(d.get("content") or "")
reasoning.append(d.get("reasoning_content") or "")
Expand All @@ -4834,11 +4986,80 @@ def openai_collect(chunks) -> dict:
out = {"id": last["id"], "object": "chat.completion", "created": last["created"], "model": last["model"],
"choices": [{"index": 0, "message": msg, "finish_reason": last["choices"][0]["finish_reason"]}],
"usage": last["usage"]}
if lps:
out["choices"][0]["logprobs"] = {"content": lps.get("content", []), "refusal": None, **(
{"reasoning_content": lps["reasoning_content"]} if lps.get("reasoning_content") else {})}
if last.get("timings"):
out["timings"] = last["timings"]
return out


def logprob_span(text: str, part: str, entries: list):
"""The entries whose tokens spell `part`, a piece of `text`, or None when the tokens do not
spell `text` or no token boundary falls at both ends of `part`."""
if "".join(e["token"] for e in entries) != text:
return None
start = text.find(part)
if start < 0:
return None
at, first = 0, None
for i, e in enumerate(entries):
if at == start and first is None:
first = i
if at == start + len(part) and first is not None:
return entries[first:i]
at += len(e["token"])
return entries[first:] if first is not None and at == start + len(part) else None


def _same_json(a: str, b: str) -> bool:
"""Are two strings the same JSON value (whitespace and key order aside)? No -> False."""
try:
return json.loads(a) == json.loads(b)
except Exception:
return False


def _structured_logprobs(choice: dict, content: str):
"""`content`'s logprobs, and the content the tokens actually spell.

Returns `(content, logprobs)`: on failure `(the content as validated, None)`.

`structured_chunks` returns the canonical JSON the validator produced, which the model never
generated, so its tokens do not spell it. Two honest outcomes:

1. a run of tokens spells what is being returned -> use it as it is;
2. the tokens spell the model's own JSON, which is the same JSON value (the fenced-with-spaces
case, the common one) -> return the model's form, so `''.join(token) == content` again.

Neither holds (a rearranged key order, or a reply that is not that JSON at all) and there are no
logprobs: a caller that joins the tokens must not silently get something that does not match.
"""
lps = choice.get("logprobs")
if not lps or not lps.get("content"):
return content, None
raw = choice["message"]["content"] or ""
entries = lps["content"]
if raw == content: # not rewritten: the entries are already its
return content, _with_reasoning(lps, entries)
span = logprob_span(raw, content, entries)
if span is not None:
return content, _with_reasoning(lps, span)
inner = _extract_json(raw) if _extract_json else ""
if inner and _same_json(inner, content):
span = logprob_span(raw, inner, entries)
if span is not None:
return inner, _with_reasoning(lps, span) # the model's own form: the tokens spell it
return content, None


def _with_reasoning(lps: dict, entries: list) -> dict:
out = {"content": entries}
if lps.get("reasoning_content"):
out["reasoning_content"] = lps["reasoning_content"]
return out


def structured_chunks(chunks, validator):
"""Buffer structured streams so a client never receives unvalidated content."""
buffered = []
Expand All @@ -4854,12 +5075,23 @@ def structured_chunks(chunks, validator):
result = openai_collect(buffered)
choice = result["choices"][0]
content = validated_json(choice["message"]["content"], validator, choice["finish_reason"])
# logprobs: the entries spell what the model wrote, which this function replaces with the
# canonical JSON. `_structured_logprobs` gives back the form the tokens spell, or None.
content, logprobs = _structured_logprobs(choice, content)
yield buffered[0]
delta = {"content": content}
if choice["message"].get("reasoning_content"):
delta["reasoning_content"] = choice["message"]["reasoning_content"]
yield {**buffered[0], "choices": [{"index": 0, "delta": delta, "finish_reason": None}]}
yield buffered[-1]
piece = {"index": 0, "delta": delta, "finish_reason": None}
if logprobs:
piece["logprobs"] = logprobs
yield {**buffered[0], "choices": [piece]}
end = buffered[-1]
if end["choices"][0].get("logprobs"):
# those entries went out in the piece above (or were dropped with it): the closing
# chunk must not carry a second copy
end = {**end, "choices": [{k: v for k, v in end["choices"][0].items() if k != "logprobs"}]}
yield end
finally:
chunks.close()

Expand Down
Loading
Loading