Skip to content

usage: the chat replys usage object is a pure function, so engine#2111 is testable without an NPU - #2188

Merged
bong-water-water-bong merged 1 commit into
mainfrom
lane/usage-json-testable
Nov 11, 2026
Merged

bong-water-water-bong merged 1 commit into
mainfrom
lane/usage-json-testable

Conversation

@bong-water-water-bong

Copy link
Copy Markdown
Collaborator

This is the last step of engine#2111 that needed a device, made CPU-only — I lead that row, and its ORCH line said it needed a ~10-minute NPU window to confirm the wiring. It does not, now.

Why the window existed

#2111 is about one field: completion_tokens_details.reasoning_tokens is what distinguishes "the budget ran out inside a thinking block we strip" from "the model said nothing". The field is emitted correctly (verified earlier: think_split_test 12 arms, npu_answer_parser_test 59 arms, rc 0). But the rule lived in a lambda inside the reply builder, and linking app/forward_serve.cpp drags in npu/rt.h, which refuses a build with no NPU runtime:

#error "the NPU lane builds only against the own NPU runtime (ONEBIT_NPU_RT_LIB or ONEBIT_NPU_RT_DIR, docs/npu.md)"

So the field's presence rule could only be observed by running a model on the device.

What this does

file change
app/forward_serve.h UsageCounters + usage_json_text() declared — no nlohmann in the header
app/serve_usage.cpp the definition, its own TU; the JSON stays beside the reply it belongs to
app/forward_serve.cpp the lambda fills the struct and parses the function's text → one implementation
tests/serve_usage_test.cpp + cmake/tests-serve-usage.cmake 8 arms, linking only app/serve_usage.cpp

The three cases that must stay three answers:

reasoning_tokens  > 0   → the field is PRESENT with that number
reasoning_tokens == 0   → PRESENT with 0 (counted, none)
reasoning_tokens == -1  → ABSENT, never a zero (the route could not count it, e.g. /v1/completions)

plus the prefix-cache fields (cached_tokens, slot, reason, pinned) unchanged by the extraction, and a default-constructed UsageCounters omitting the field so a caller that never set it is safe.

Verified three ways

$ g++ -std=c++17 -Iapp tests/serve_usage_test.cpp app/serve_usage.cpp && ./a.out     → 8 arms ok, rc 0
$ cmake -S . -B <scratch> && cmake --build . --target serve_usage_test && ctest -R serve_usage
  100% tests passed, 0 tests failed out of 1        (Test #230, via #1979's cmake/tests-*.cmake glob)
$ flipping the rule to `>= -1` in a scratch copy of app/serve_usage.cpp
  FAIL a route that could not count it omits the field entirely (never a zero)
  FAIL a default UsageCounters omits the field, so a caller that never set it is safe

Ratchets unmoved: ci_targets_check 9 (baseline 9, none new) · copyright rc 0 · tmp_path_check 44 sites, none new/stale.

Scope

+1 TU, +1 test, +1 family file, and a lambda replaced by three lines that delegate. app/forward_serve.cpp is a hot file (#1913 touches it), so the change there is deliberately mechanical: the JSON text is produced by one function that the reply parses, and nothing about the reply's shape changes.

…2111 is testable without an NPU

engine#2111 is about one field: `completion_tokens_details.reasoning_tokens` is what distinguishes "the budget ran
out inside a thinking block we strip" from "the model said nothing". The field was emitted correctly, but its rule
lived in a lambda inside the reply builder - and linking app/forward_serve.cpp drags in `npu/rt.h`, which refuses a
build with no NPU runtime. So the only way to check the rule was to run a model on the device, which is why the row
still had a device window as its last step.

    app/forward_serve.h    UsageCounters + usage_json_text() declared (no nlohmann in the header)
    app/serve_usage.cpp    the definition: the JSON stays beside the reply it belongs to
    app/forward_serve.cpp  the lambda now fills the struct and parses the function's text (one implementation)
    tests/serve_usage_test.cpp + cmake/tests-serve-usage.cmake   8 arms, links ONLY app/serve_usage.cpp

The three cases that must stay three answers:
  reasoning_tokens > 0   -> the field is PRESENT with that number
  reasoning_tokens == 0  -> PRESENT with 0 (counted, none)
  reasoning_tokens == -1 -> ABSENT, never a zero (the route could not count it, e.g. /v1/completions)
plus the prefix-cache fields (cached_tokens, slot, reason, pinned) unchanged by the extraction, and a
default-constructed UsageCounters omitting the field so a caller that never set it is safe.

Verified: the 8 arms pass standalone (`g++ tests/serve_usage_test.cpp app/serve_usage.cpp`, rc 0) and through ctest in
a real configure (`Test #230 serve_usage`, 100% passed). The test can fail: flipping the rule to `>= -1` in a scratch
copy makes exactly the two "-1 must be absent" arms go red. Ratchets unmoved: ci_targets 9 (baseline 9, none new),
copyright rc 0, tmp_path 44 sites none new/stale.
@github-actions

Copy link
Copy Markdown

PR Reviewer Guide 🔍

Here are some key observations to aid the review process:

🎫 Ticket compliance analysis 🔶

2111 - Partially compliant

Compliant requirements:

  • The usage object now distinguishes between the two cases via completion_tokens_details.reasoning_tokens
  • The field is present when reasoning_tokens >= 0 and absent when reasoning_tokens == -1
  • The implementation is a pure function (usage_json_text) that can be tested without an NPU
  • Tests are added in tests/serve_usage_test.cpp that verify the behavior

Non-compliant requirements:

  • None

Requires further human verification:

  • None

1979 - Partially compliant

Compliant requirements:

  • A new CMake file cmake/tests-serve-usage.cmake is added to handle test registration separately
  • The registration logic is moved out of CMakeLists.txt to avoid conflicts

Non-compliant requirements:

  • None

Requires further human verification:

  • None
⏱️ Estimated effort to review: 3 🔵🔵🔵⚪⚪
🧪 PR contains tests
🔒 No security concerns identified
⚡ Recommended focus areas for review

Usage JSON Logic Change

The logic for generating the usage JSON object has been refactored from an inline lambda into a dedicated function usage_json_text. This change ensures that the reasoning_tokens field is handled correctly and consistently, and allows for testing without an NPU. The refactoring also ensures that the field is only present when the route could count it, distinguishing it from cases where no counting was possible.

auto usage_json = [](const CallStats& st) {
    UsageCounters u;
    u.prompt_tokens = st.prompt_tokens;
    u.completion_tokens = st.completion_tokens;
    u.cached_tokens = st.prefix.cached_tokens;
    u.prefix_slot = st.prefix.slot;
    u.prefix_reason = st.prefix.reason;
    u.prefix_pinned = st.prefix.pinned;
    u.reasoning_tokens = st.reasoning_tokens;
    return json::parse(usage_json_text(u));
};
New Translation Unit for Usage Logic

A new translation unit app/serve_usage.cpp has been introduced to house the usage_json_text function. This allows for testing the usage logic without pulling in NPU dependencies, which was previously required due to the lambda being in forward_serve.cpp. This change is crucial for enabling CPU-only testing of the usage object.

// Copyright 2026 bong-water-water-bong
// SPDX-License-Identifier: Apache-2.0
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
//     http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

// engine#2111: the chat reply's `usage` object, as a pure function.
//
// Its own translation unit for one reason: tests/serve_usage_test.cpp has to link it, and linking
// app/forward_serve.cpp would drag in the NPU runtime (npu/rt.h refuses a build without ONEBIT_NPU_RT_LIB), so
// the field this issue is about could only ever be checked by running a model on the device. Here it is a decision
// table with known answers - see the header comment for the three cases that must stay distinct.

#include "forward_serve.h"

#include <nlohmann/json.hpp>

namespace onebit {

using json = nlohmann::json;

// The usage object, verbatim what the reply carries. Two fields are not OpenAI's and are here because a lane
// needed them: how many prompt tokens came from a saved prefix, and what the cache decided.
// `reasoning_tokens` is ABSENT when the route could not count it (-1), which is not the same as 0 (counted, none).
std::string usage_json_text(const UsageCounters& c) {
    json u{{"prompt_tokens", c.prompt_tokens},
           {"completion_tokens", c.completion_tokens},
           {"total_tokens", c.prompt_tokens + c.completion_tokens},
           {"prompt_tokens_details", {{"cached_tokens", c.cached_tokens}}},
           {"flm_prefix_cache", {{"slot", c.prefix_slot},
                                 {"cached_tokens", c.cached_tokens},
                                 {"reason", c.prefix_reason},
                                 {"pinned", c.prefix_pinned}}}};
    // With content "" and finish_reason "length", reasoning_tokens == completion_tokens is the sentence that says the
    // budget ran out inside a thinking block, rather than that the model said nothing.
    if (c.reasoning_tokens >= 0) u["completion_tokens_details"] = {{"reasoning_tokens", c.reasoning_tokens}};
    return u.dump();
}

}  // namespace onebit

@bong-water-water-bong bong-water-water-bong left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

VERDICT at c4555aa: READY

The usage object as a pure function (serve_usage.cpp, UsageCounters filled from st.prompt_tokens/st.completion_tokens) is the extraction the #2111/#2131 thread needed. Reasoning-token accounting and an empty-content refusal both depend on the same counters, and while they were computed inline in the server every assertion about them was really an assertion about a request.

Pulling them into a function that takes the counts and returns the object means a test can state the rule — prompt tokens, completion tokens, and the reasoning split — without a model, a socket or a template. Same move as #2062, #2054, #2055: pull the decision out where an arm can reach it, then let the runtime be a thin caller.

target_sources(onebit PRIVATE app/serve_usage.cpp) keeps it compiled into the real binary rather than into a test-only copy, so the pure function under test is the one that ships.

@bong-water-water-bong
bong-water-water-bong merged commit 65ac7b6 into main Nov 11, 2026
10 checks passed
@bong-water-water-bong
bong-water-water-bong deleted the lane/usage-json-testable branch November 11, 2026 13:42
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant