Decision APIs: logprobs with reasoning on — reference implementation for 0.6.5, input for the Zig server
Maintainers usually reply within 1 day
Nobody has claimed this yet.
Assessment
- Difficulty
- 3/5
- Estimated time
- 1-2 days
- Newbie friendliness
- 66/100
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
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
More from ashhart/TensorFold
-
Proposal: Qwen3.8-27B ModelOpt NVFP4 checkpoints on the native CUDA engine (both precision modes)Open
Difficulty 5/5 Over a week Newbie friendliness 15/100
ashhart/TensorFold#1144 ·
Maintainers usually reply within 1 day
-
Difficulty 5/5 Over a week Newbie friendliness 20/100
ashhart/TensorFold#1142 ·
Maintainers usually reply within 1 day
-
Difficulty 5/5 Over a week Newbie friendliness 25/100
ashhart/TensorFold#1136 ·
Maintainers usually reply within 1 day
-
Difficulty 3/5 1-2 days Newbie friendliness 62/100
ashhart/TensorFold#1121 ·
Maintainers usually reply within 1 day
-
Difficulty 4/5 3-5 days Newbie friendliness 48/100
ashhart/TensorFold#1117 · 1 comment ·
Maintainers usually reply within 1 day
All issues in ashhart/TensorFold
Similar issues
-
documentation good first issue
Difficulty 2/5 1-3 hours Newbie friendliness 82/100
selimfirat/pysad#379 ·
Maintainers usually reply within 1 day
-
bug
Difficulty 2/5 1-3 hours Newbie friendliness 75/100
open-webui/open-webui#32805 ·
Maintainers usually reply within 1 day
-
fix: [bug description] HTML CVE metric table shows only the last CVE for each productPossibly taken @Chris-wh233 claimed this today. Openbug
Difficulty 2/5 1-3 hours Newbie friendliness 66/100
ossf/cve-bin-tool#5976 ·
Maintainers usually reply within 1 day
-
needs-triage
Difficulty 2/5 1-3 hours Newbie friendliness 72/100
gke-labs/kube-agents#3615 ·
Maintainers usually reply within 1 day
-
deferred
Difficulty 2/5 1-3 hours Newbie friendliness 70/100
Maintainers usually reply within 1 day