Hacktoberfest 2026: the issues maintainers tagged for October, open and beginner-friendly. Browse Hacktoberfest issues

Decision APIs: logprobs with reasoning on — reference implementation for 0.6.5, input for the Zig server

Closed
#449 8 comments 0 reactions 0 assignees View on GitHub

Maintainers usually reply within 1 day

Nobody has claimed this yet.

Assessment

Difficulty
3/5
Estimated time
1-2 days
Newbie friendliness
66/100
Issue type
Feature
Clarity
Clearly specified
Activity status
Active
Tech stack
python
Domain
api

Research direction

Start from the refusal at src/tensorfold/cuda/server.py:254-256 and the supplied diff, then read src/tensorfold/server/probabilities.py (TokenBytes.answer) and tests/test_cuda_logprobs_fields.py to see the expected rows after </think>. Note the Python engine is frozen at 0.6.5 (#286), so confirm whether the target is the reference patch itself or the Zig CUDA server before coding. Done means the new tests (answer-only rows, reply cut inside reasoning, refusals for stream/tools/stop/thinking_budget) pass and docs/api.md matches; also answer the open question about reasoning-inclusive vs answer-only logprobs.

Written by the indexing model from the issue text.

Description

Measured on 1× NVIDIA DGX Spark (GB10, 128 GB), TensorFold 0.6.5 (--parallel 5 --kv-dtype int8 --mtp-drafts 6), checkpoint Mia-AiLab/Qwen3.8-Flash-Next-NVFP4, against vLLM (MiaAI-Lab single-Spark recipe, same checkpoint, MAX_NUM_SEQS 6) on an identical second Spark. We know the Python engine is frozen at 0.6.5 (#286): we post this as input for the Zig CUDA server. Bench scripts and raw results will be public shortly; happy to share them now or run any build on our two Sparks.

Use case. One-token decisions after reasoning (« think, then answer A/B/C ») need top_logprobs (up to 20) of the first token of the visible answer. On 0.6.5 CUDA, logprobs with thinking on returns 400 (src/tensorfold/cuda/server.py:254-256), although the engine already takes logprobs from the verified target logits (decode.py:474-476, cuda/logprobs.py:31); only the split between reasoning and answer was missing.

Reference implementation (0.6.5, 5 files, +78/−17). Logprobs are kept only after the first </think> (and the \n\n that follows), so content[0] is the answer token; a reply cut inside reasoning returns []; streaming, tools, stop and response_format stay refused with thinking on. Logprob tests: 22/22 on a Mac (GPU tests not run). Offered as a spec for the Zig server; happy to port it or test on our two Sparks.

Open question: vLLM returns logprobs for every token, reasoning included; this returns answer tokens only. Which do you prefer?

Patch against v0.6.5
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 47732d1..6fc1ba3 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -3,6 +3,14 @@
 `tensorfold update` prints the sections below that are newer than the version you had. Each release's page on
 GitHub has the full notes and the measurements behind them.
 
+## Unreleased
+
+- **`logprobs` with thinking on, on the CUDA Flash Next server.** A nonstreamed chat request with
+  `enable_thinking: true` (or a server started with thinking on) and `logprobs: true` no longer gets HTTP 400. The rows
+  cover the answer only, as `content` does, so `logprobs.content[0]` is the first answer token after `</think>`, with
+  up to 20 `top_logprobs` from the target model's verify rows, drafts on or off. `thinking_budget`, streaming, tools,
+  stop strings and structured output stay refused with `logprobs`.
+
 ## 0.6.6 (6 Oct 2026)
 
 - **`--name-priority ID=background` on the CUDA server.** A request that names that served id (`--name` or an
diff --git a/docs/api.md b/docs/api.md
index 1c6950b..4bc2569 100644
--- a/docs/api.md
+++ b/docs/api.md
@@ -92,10 +92,14 @@ For decisions, `chat_template_kwargs` may be omitted, null, or an object contain
 `n` must be 1; multiple choices receive HTTP 400.
 
 CUDA Flash Next on one GPU supports `logprobs: true` and `top_logprobs` from 0 through 20 for nonstreamed text chat
-with thinking off, without tools, stop strings or structured output. Each visible token has its `token`, `bytes`,
-`logprob` and requested `top_logprobs` in `choices[0].logprobs.content`. Probabilities describe the raw target-model
-distribution at temperature 1, before temperature, top-k or top-p sampling filters, including when generation is
-greedy. Alternatives are tokenizer tokens and may include leading spaces; `bytes` preserves partial UTF-8 sequences.
+without tools, stop strings, structured output or a `thinking_budget`. Each visible token has its `token`, `bytes`,
+`logprob` and requested `top_logprobs` in `choices[0].logprobs.content`. With thinking on, the rows cover the answer
+only, as `content` does: none for the reasoning, `</think>` or the newlines after it, so the first row is the
+answer's first token. A reply cut inside its think block has no rows. vLLM, by contrast, returns rows for the
+reasoning tokens too.
+Probabilities describe the raw target-model distribution at temperature 1, before temperature, top-k or top-p
+sampling filters, including when generation is greedy. Alternatives are tokenizer tokens and may include leading
+spaces; `bytes` preserves partial UTF-8 sequences.
 Unsupported backends, engines and request modes return HTTP 400 when probabilities are requested.
 `ignore_eos: true` keeps user-supplied `stop` strings active, including when a stop string spans streamed chunks.
 Both backends reject a non-boolean `ignore_eos` or a malformed `stop` with HTTP 400 before a stream opens.
diff --git a/src/tensorfold/cuda/server.py b/src/tensorfold/cuda/server.py
index 12e8edc..31e6cfc 100644
--- a/src/tensorfold/cuda/server.py
+++ b/src/tensorfold/cuda/server.py
@@ -251,9 +251,10 @@ class App:
         spec = grammar.request_spec(body)
         top = probability_options(body, supported=bool(getattr(self.engine, "supports_logprobs", False)))
         if top is not None:
-            if not chat or body.get("stream") or thinking or tools or stop or spec is not None or budget:
-                raise RequestError("logprobs support nonstreamed text chat with thinking off, without tools, "
-                                   "stop strings or structured output")
+            unsplit = thinking and self.tok.token_to_id("</think>") is None    # no token to find the answer by
+            if not chat or body.get("stream") or unsplit or tools or stop or spec is not None or budget:
+                raise RequestError("logprobs support nonstreamed text chat without tools, stop strings, structured "
+                                   "output or a thinking budget")
             if not hasattr(self, "_probability_decoder"):
                 self._probability_decoder = TokenBytes(self.tok)
         compiled = (spec, self._grammars().compile(spec)) if spec is not None else None
@@ -560,8 +561,12 @@ class App:
         print_done(len(prompt), (cached or [0])[0], thinking, out, finish, stats, request)
         if body.get("return_token_ids"):              # the reply's ids in the "tensorfold" block, for exactness checks
             stats = {**(stats or {}), "token_ids": [int(t) for t in out]}
-        logprobs = (self._probability_decoder.format(probabilities.emitted(out), ends)
-                    if probabilities is not None else None)
+        logprobs = None
+        if probabilities is not None:               # a thinking reply's rows are its answer's, as ``content`` is
+            rows = probabilities.emitted(out)
+            if chat and thinking:
+                rows = self._probability_decoder.answer(rows, self.tok.token_to_id("</think>"))
+            logprobs = self._probability_decoder.format(rows, ends)
         # the calls already sent as deltas; the handler sends the rest (a call the streamer could not follow)
         streamed = calls_stream.index + 1 if calls_stream is not None and calls_stream.streamed else 0
         return {"final": final, "calls": calls, "finish": finish, "content": content, "reasoning": reasoning,
diff --git a/src/tensorfold/server/probabilities.py b/src/tensorfold/server/probabilities.py
index bf6a247..2f3f3c9 100644
--- a/src/tensorfold/server/probabilities.py
+++ b/src/tensorfold/server/probabilities.py
@@ -56,6 +56,17 @@ class TokenBytes:
             self.cache[token_id] = value
         return self.cache[token_id]
 
+    def answer(self, rows, think_end):
+        """A thinking reply's answer rows: after its first ``think_end``, less the newlines its text drops."""
+
+        ids = [row["id"] for row in rows]
+        if think_end not in ids:
+            return []
+        rows = rows[ids.index(think_end) + 1:]
+        while rows and self.token(rows[0]["id"])["token"] and not self.token(rows[0]["id"])["token"].strip("\n"):
+            rows = rows[1:]
+        return rows
+
     def format(self, rows, ends):
         return {"content": [{**self.token(row["id"]), "logprob": row["logprob"],
                              "top_logprobs": [{**self.token(token), "logprob": value}
diff --git a/tests/test_cuda_logprobs_fields.py b/tests/test_cuda_logprobs_fields.py
index f2bcf31..ab0c3b9 100644
--- a/tests/test_cuda_logprobs_fields.py
+++ b/tests/test_cuda_logprobs_fields.py
@@ -55,7 +55,9 @@ def test_mac_handler_also_refuses_unsupported_options(fields):
         server.server_close()
 
 
-def probability_app(tmp_path):
+def probability_app(tmp_path, reply=(1,), think_end=True):
+    """A target that writes ``reply`` (ids: 1 "A", 2 " B", 3 "r", 4 "</think>", 5 two newlines), a row for each."""
+
     from tokenizers import Tokenizer, decoders, models
     from tests.test_cuda_server_errors import Engine
 
@@ -64,14 +66,17 @@ def probability_app(tmp_path):
 
         def generate(self, prompt, max_tokens, sampling, on_tokens, draft=True, probabilities=None):
             self.calls.append(list(prompt))
+            out = list(reply)[:max_tokens]
             if probabilities is not None:
-                probabilities.add([len(prompt)], [1], [-0.25], [[1, 2]], [[-0.25, -1.5]])
-            on_tokens([1])
+                at = range(len(prompt), len(prompt) + len(out))
+                probabilities.add(at, out, [-0.25] * len(out), [[t, 2] for t in out], [[-0.25, -1.5]] * len(out))
+            on_tokens(out)
             return {}
 
+    vocab = {"[UNK]": 0, "A": 1, "ĠB": 2, "r": 3, **({"</think>": 4} if think_end else {}), "ĊĊ": 5}
     app = app_for(tmp_path)
     app.engine = Target()
-    app.tok = Tokenizer(models.WordLevel({"[UNK]": 0, "A": 1, "ĠB": 2}, unk_token="[UNK]"))
+    app.tok = Tokenizer(models.WordLevel(vocab, unk_token="[UNK]"))
     app.tok.decoder = decoders.ByteLevel()
     return app
 
@@ -89,16 +94,44 @@ def test_one_token_chat_exposes_the_target_probabilities(tmp_path):
                         {"token": " B", "bytes": [32, 66], "logprob": -1.5}]}]
 
 
[email protected]("fields", [{"stream": True}, {"stop": ["A"]},
-                                   {"chat_template_kwargs": {"enable_thinking": True}}])
-def test_unaligned_probability_modes_fail_before_generation(tmp_path, fields):
-    app = probability_app(tmp_path)
+THINK = {"chat_template_kwargs": {"enable_thinking": True}}
+
+
[email protected]("fields, think_end", [({"stream": True}, True), ({"stop": ["A"]}, True),
+                                              ({**THINK, "thinking_budget": 8}, True), (THINK, False)])
+def test_unaligned_probability_modes_fail_before_generation(tmp_path, fields, think_end):
+    app = probability_app(tmp_path, think_end=think_end)
     with http_server(app) as port:
         status, _, text = request(port, {"messages": HI, "logprobs": True, **fields})
     assert status == 400 and "logprobs" in json.loads(text)["error"]["message"]
     assert app.engine.calls == []
 
 
+def test_thinking_reply_reports_the_answer_rows_only(tmp_path):
+    app = probability_app(tmp_path, reply=(3, 3, 4, 5, 1, 2, 0))       # r r </think> \n\n A " B" end
+    with http_server(app) as port:
+        status, _, text = request(port, {"messages": HI, "max_tokens": 16, "logprobs": True, "top_logprobs": 2,
+                                        **THINK})
+    assert status == 200, text
+    reply = json.loads(text)
+    choice = reply["choices"][0]
+    assert choice["message"]["reasoning_content"] == "rr" and choice["message"]["content"] == "A B"
+    rows = choice["logprobs"]["content"]
+    assert [row["token"] for row in rows] == ["A", " B"]
+    assert rows[0]["top_logprobs"] == [{"token": "A", "bytes": [65], "logprob": -0.25},
+                                       {"token": " B", "bytes": [32, 66], "logprob": -1.5}]
+    assert reply["usage"]["completion_tokens_details"]["reasoning_tokens"] == 3
+
+
+def test_thinking_reply_cut_inside_its_block_reports_no_rows(tmp_path):
+    app = probability_app(tmp_path, reply=(3, 3, 3))
+    with http_server(app) as port:
+        status, _, text = request(port, {"messages": HI, "max_tokens": 3, "logprobs": True, **THINK})
+    assert status == 200, text
+    choice = json.loads(text)["choices"][0]
+    assert choice["message"]["content"] in ("", None) and choice["logprobs"]["content"] == []
+
+
 def test_split_utf8_probabilities_preserve_the_original_bytes():
     from tokenizers import Tokenizer, decoders, models
     from tensorfold.server.probabilities import TokenBytes

Dominant language
Python
Stars
995
Forks
169
Avg merge
1d 14h
Merged PRs (30d)
280

Getting set up

This project ships no dev container, Dockerfile or contributing guide, so setting up is up to you: start from its README, and see our first-contribution guide for the general steps.

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

More from ashhart/TensorFold

All issues in ashhart/TensorFold

Similar issues

More Python issues

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.