Skip to content

Commit 5b22a51

Browse files
feat(telemetry): capture prefix-cache effectiveness (cache_n, cached_tokens) and route-switch counts (#2968)
* feat(telemetry): capture prefix-cache effectiveness and route-switch counts Closes #2955. - Record llama-server timings.cache_n and OpenAI-wire usage.prompt_tokens_details.cached_tokens as cache_tokens (latest per model + cumulative totals), on streaming and non-streaming paths, local and cloud. - Count collection.router decisions and per-conversation route switches, keyed by a stable conversation fingerprint (hash of system prompt + first user message; metrics key only). - Expose everything in GET /v1/stats and Prometheus /metrics. - TelemetryCallback now passes StreamingProxy::TelemetryData instead of five scalars so backend-reported fields extend without churn. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(telemetry): address review — stale cache gauge, cloud usage injection, Responses field - cache_tokens latest gauge resets to unreported (JSON null) on every recorded request, so a request that reports nothing can no longer inherit the previous request's value. - Cloud streaming injects stream_options.include_usage when the client did not request it and swallows the resulting usage-only frame, so telemetry gets provider usage while the client-visible stream is unchanged; client-requested usage frames pass through untouched. - Streaming parser also reads Responses-API usage.input_tokens_details.cached_tokens. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(telemetry): atomic per-request recording and non-streaming Responses capture - Collapse the three per-request telemetry recorders into one record_request_telemetry_for_model() under a single lock hold, so concurrent requests can no longer interleave fields (one request's cache reset landing on another's value). - TelemetryData carries prompt_tokens; StreamingProxy::extract_telemetry() parses complete response bodies (usage chat/Responses field names, cached-token details, llama.cpp timings), replacing the duplicated handler blocks. - Non-streaming /v1/responses now records telemetry, including usage.input_tokens_details.cached_tokens. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent f9865cd commit 5b22a51

21 files changed

Lines changed: 982 additions & 341 deletions

docs/api/lemonade.md

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1250,7 +1250,15 @@ curl http://localhost:13305/v1/stats
12501250
"tokens_per_second": 33.33,
12511251
"input_tokens": 128,
12521252
"output_tokens": 5,
1253-
"prompt_tokens": 9
1253+
"prompt_tokens": 9,
1254+
"cache_tokens": 96,
1255+
"request_count_total": 12,
1256+
"input_tokens_total": 1536,
1257+
"output_tokens_total": 60,
1258+
"prompt_tokens_total": 108,
1259+
"cache_tokens_total": 1152,
1260+
"routing_decisions_total": 4,
1261+
"routing_switches_total": 1
12541262
}
12551263
```
12561264

@@ -1261,6 +1269,10 @@ curl http://localhost:13305/v1/stats
12611269
- `input_tokens` - Number of tokens processed
12621270
- `output_tokens` - Number of tokens generated
12631271
- `prompt_tokens` - Total prompt tokens including cached tokens
1272+
- `cache_tokens` - Prompt tokens served from the backend's prefix cache on the last request (llama.cpp `timings.cache_n`, or `usage.prompt_tokens_details.cached_tokens` / Responses-API `input_tokens_details.cached_tokens` from OpenAI-compatible cloud providers). `null` when the last request did not report cache usage
1273+
- `*_total` - Cumulative counters since server start
1274+
- `routing_decisions_total` - Routing decisions made by `collection.router` dispatch
1275+
- `routing_switches_total` - Routing decisions that changed a conversation's routed model (a proxy for route ping-pong; conversations are identified by a hash of the system prompt and first user message)
12641276

12651277
## `GET /v1/system-stats`
12661278
<sub>![Status](https://img.shields.io/badge/status-fully_available-green)</sub>
162 KB
Loading
31.1 KB
Loading
284 KB
Loading
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
# Search ModelScope from Lemonade
2+
3+
**Date:** July 27, 2026 · **Author:** Lemonade Team
4+
5+
> Lemonade v11.5 adds ModelScope as a second model registry, right next to Hugging Face. Same search box, twice the catalog.
6+
7+
## Twice the catalog, same search box
8+
9+
Starting with v11.5, Lemonade supports [ModelScope](https://modelscope.cn) as a model registry alongside Hugging Face. Search it the same way you search Hugging Face — right from the Model Manager — and download models from whichever registry has what you need. This matters most for users in regions where ModelScope mirrors are significantly faster, and for models that publish to ModelScope first.
10+
11+
There's nothing to set up — no accounts, no extra configuration. Type a name, browse the GGUF variants, and hit download.
12+
13+
And you won't waste a download on a repo that can't run. Before a result earns a download button, Lemonade inspects the repository's actual file tree and only offers models with GGUF files it can serve. What you see is what will run.
14+
15+
## Take it for a spin
16+
17+
Open the Lemonade app (or the web app at `http://localhost:13305/app`) and head to the **Model Manager**. Start typing — `Qwen 2.5`, say. After three characters the search goes live, and two new sections appear below your local models: **FROM HUGGING FACE** and **FROM MODELSCOPE**.
18+
19+
![Model Manager showing search results from Hugging Face and ModelScope](assets/modelscope-search-results.png)
20+
21+
Every result tells you what you're getting before you commit: the repository name, a source badge (**MS** or **HF**), the download size, and how many times the community has pulled it. Want a different quantization? Pick one from the dropdown — Q4_K_M is preselected as a sensible default. Then click download and watch it go.
22+
23+
![Download Manager pulling a model from ModelScope](assets/modelscope-download.png)
24+
25+
When the download finishes, the model registers itself and is ready to use — select it in the chat panel, point your favorite OpenAI-compatible client at it, whatever you'd do with any other Lemonade model. Where it came from stops mattering the moment it lands on your disk.
26+
27+
![Chatting with a model downloaded from ModelScope](assets/modelscope-chat.png)
28+
29+
## Prefer a terminal?
30+
31+
The CLI speaks ModelScope too. Give `lemonade pull` a checkpoint and tell it where to look — or just paste a `modelscope.cn` model URL and it figures out the rest.
32+
33+
```bash
34+
lemonade pull Qwen/Qwen2.5-3B-Instruct-GGUF --source modelscope
35+
```
36+
37+
If you're building on top of the server, the same search that powers the Model Manager is one GET away:
38+
39+
```bash
40+
curl "http://localhost:13305/v1/registry/search?source=modelscope&query=qwen"
41+
```
42+
43+
Add `format=gguf` to bias results toward GGUF repositories, or `limit` to control how many come back (1–50, default 12). Responses include tags, task, download counts, and a GGUF hint per model — see the [API docs](https://lemonade-server.ai/docs/api/lemonade/) for the full shape.
44+
45+
## Go find something new
46+
47+
Update to Lemonade v11.5 and search both registries from one box.
48+
49+
[Install Lemonade](https://lemonade-server.ai/#getting-started) · [Join the Discord](https://discord.gg/5xXzkMu8Zk)

src/cpp/include/lemon/router.h

Lines changed: 18 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -5,11 +5,13 @@
55
#include <memory>
66
#include <deque>
77
#include <functional>
8+
#include <list>
89
#include <map>
910
#include <mutex>
1011
#include <set>
1112
#include <condition_variable>
1213
#include <thread>
14+
#include <unordered_map>
1315
#include <vector>
1416
#include <optional>
1517
#include <nlohmann/json.hpp>
@@ -241,11 +243,14 @@ class Router {
241243
// Get loaded backend metadata and per-model telemetry for metrics rendering.
242244
json get_metrics_snapshot() const;
243245

244-
void update_telemetry(const std::string& model_name,
245-
int input_tokens, int output_tokens,
246-
double time_to_first_token, double tokens_per_second);
246+
// Record one completed request's telemetry as a single atomic update.
247+
void update_request_telemetry(const std::string& model_name,
248+
const StreamingProxy::TelemetryData& telemetry);
247249

248-
void update_prompt_tokens(const std::string& model_name, int prompt_tokens);
250+
// Route-stability accounting for collection.router dispatch. The
251+
// fingerprint is a metrics key only (hash of the conversation's stable
252+
// prefix) — it never influences routing and stores no message content.
253+
void note_route_decision(uint64_t conversation_fingerprint, const std::string& route_to);
249254

250255
bool begin_exclusive(std::atomic<bool>* cancel = nullptr);
251256
void end_exclusive();
@@ -277,6 +282,13 @@ class Router {
277282
Telemetry aggregate_telemetry_;
278283
std::map<std::string, ModelTelemetryRecord> telemetry_by_model_;
279284

285+
uint64_t routing_decisions_total_ = 0;
286+
uint64_t routing_switches_total_ = 0;
287+
std::list<uint64_t> route_fingerprint_lru_;
288+
std::unordered_map<uint64_t,
289+
std::pair<std::string, std::list<uint64_t>::iterator>>
290+
route_last_target_;
291+
280292
// Concurrency control for load operations
281293
mutable std::mutex load_mutex_; // Protects loading state and loaded_servers_
282294
bool is_loading_ = false; // True when a load operation is in progress
@@ -378,12 +390,8 @@ class Router {
378390
std::unique_ptr<WrappedServer> create_backend_server(const ModelInfo& model_info);
379391
std::string resolve_model_name(const std::string& model_name) const;
380392
ModelTelemetryIdentity get_telemetry_identity(WrappedServer* server) const;
381-
void record_telemetry_for_model(const ModelTelemetryIdentity& identity,
382-
int input_tokens,
383-
int output_tokens,
384-
double time_to_first_token,
385-
double tokens_per_second);
386-
void record_prompt_tokens_for_model(const ModelTelemetryIdentity& identity, int prompt_tokens);
393+
void record_request_telemetry_for_model(const ModelTelemetryIdentity& identity,
394+
const StreamingProxy::TelemetryData& telemetry);
387395

388396
template<typename Func>
389397
auto execute_inference(const json& request, Func&& inference_func) -> decltype(inference_func(nullptr));

src/cpp/include/lemon/server.h

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,10 @@ class Server {
126126
void handle_model_update_check(const httplib::Request& req, httplib::Response& res);
127127
void handle_model_files(const httplib::Request& req, httplib::Response& res);
128128
void handle_chat_completions(const httplib::Request& req, httplib::Response& res);
129+
// Log and atomically record one non-streaming response's telemetry
130+
// (usage/timings, cached tokens) against the serving model.
131+
void record_response_telemetry(const nlohmann::json& response,
132+
const nlohmann::json& request_json);
129133
// Server-side tool-calling orchestration for Omni "collection" models.
130134
void handle_collection_chat_completions(const nlohmann::json& request_json,
131135
const ModelInfo& collection_info,

src/cpp/include/lemon/streaming_proxy.h

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,13 @@ class StreamingProxy {
1717
struct TelemetryData {
1818
int input_tokens = 0;
1919
int output_tokens = 0;
20+
// usage.prompt_tokens / usage.input_tokens as reported (includes cached
21+
// tokens). -1 = not reported; llama.cpp timings then leave input_tokens
22+
// holding only the processed count.
23+
int prompt_tokens = -1;
24+
// Prompt tokens served from the backend's prefix cache. -1 = the
25+
// backend did not report cache usage (0 is a real cold-prefill value).
26+
int cache_tokens = -1;
2027
double time_to_first_token = 0.0;
2128
double tokens_per_second = 0.0;
2229
std::string error_message = "";
@@ -62,6 +69,11 @@ class StreamingProxy {
6269

6370
static TelemetryData parse_telemetry(const std::string& buffer);
6471

72+
// Extract telemetry from a complete (non-streaming) response body or a
73+
// single SSE chunk payload: OpenAI usage (chat and Responses field names,
74+
// cached-token details) and llama.cpp timings.
75+
static TelemetryData extract_telemetry(const nlohmann::json& payload);
76+
6577
static void accumulate_responses_delta(const nlohmann::json& parsed, std::string& accumulated_text);
6678

6779
private:
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
#pragma once
2+
3+
#include <cstdint>
4+
#include <nlohmann/json.hpp>
5+
#include <string>
6+
7+
namespace lemon {
8+
namespace utils {
9+
10+
// 64-bit FNV-1a fingerprint of a conversation's stable prefix (system text
11+
// plus the first user message text). Used as a metrics key for route-stability
12+
// counters only — it never influences routing and no message content is
13+
// retained alongside it. Later turns append to the conversation, so the
14+
// fingerprint stays constant for the lifetime of one conversation.
15+
16+
inline void fingerprint_append(uint64_t& hash, const std::string& text) {
17+
for (unsigned char c : text) {
18+
hash ^= static_cast<uint64_t>(c);
19+
hash *= 1099511628211ULL;
20+
}
21+
}
22+
23+
inline void fingerprint_append_content(uint64_t& hash, const nlohmann::json& content) {
24+
if (content.is_string()) {
25+
fingerprint_append(hash, content.get<std::string>());
26+
} else if (content.is_array()) {
27+
for (const auto& part : content) {
28+
if (part.is_string()) {
29+
fingerprint_append(hash, part.get<std::string>());
30+
} else if (part.is_object() && part.contains("text") && part["text"].is_string()) {
31+
fingerprint_append(hash, part["text"].get<std::string>());
32+
}
33+
}
34+
}
35+
}
36+
37+
inline uint64_t conversation_fingerprint(const nlohmann::json& request_json) {
38+
uint64_t hash = 14695981039346656037ULL;
39+
40+
const nlohmann::json* messages = nullptr;
41+
if (request_json.contains("messages") && request_json["messages"].is_array()) {
42+
messages = &request_json["messages"];
43+
} else if (request_json.contains("input") && request_json["input"].is_array()) {
44+
messages = &request_json["input"];
45+
}
46+
47+
if (messages) {
48+
for (const auto& message : *messages) {
49+
if (message.is_object() && message.value("role", "") == "system" &&
50+
message.contains("content")) {
51+
fingerprint_append_content(hash, message["content"]);
52+
break;
53+
}
54+
}
55+
fingerprint_append(hash, "\x1f");
56+
for (const auto& message : *messages) {
57+
if (message.is_object() && message.value("role", "") == "user" &&
58+
message.contains("content")) {
59+
fingerprint_append_content(hash, message["content"]);
60+
break;
61+
}
62+
}
63+
return hash;
64+
}
65+
66+
if (request_json.contains("prompt")) {
67+
fingerprint_append(hash, "\x1f");
68+
fingerprint_append_content(hash, request_json["prompt"]);
69+
} else if (request_json.contains("input") && request_json["input"].is_string()) {
70+
fingerprint_append(hash, "\x1f");
71+
fingerprint_append(hash, request_json["input"].get<std::string>());
72+
}
73+
return hash;
74+
}
75+
76+
} // namespace utils
77+
} // namespace lemon

src/cpp/include/lemon/wrapped_server.h

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
#include "model_residency.h"
1919
#include "backend_manager.h"
2020
#include "recipe_options.h"
21+
#include "streaming_proxy.h"
2122
#include "backends/backend_descriptor.h"
2223

2324
namespace lemon {
@@ -38,21 +39,28 @@ struct Telemetry {
3839
double time_to_first_token = 0.0;
3940
double tokens_per_second = 0.0;
4041
int prompt_tokens = 0; // From usage.prompt_tokens (includes cached tokens)
42+
// Prompt tokens served from the backend's prefix cache on the latest
43+
// request. -1 = the latest request did not report cache usage; rendered as
44+
// JSON null so a stale numeric value is never attributed to it.
45+
int cache_tokens = -1;
4146
uint64_t request_count_total = 0;
4247
uint64_t input_tokens_total = 0;
4348
uint64_t output_tokens_total = 0;
4449
uint64_t prompt_tokens_total = 0;
50+
uint64_t cache_tokens_total = 0;
4551

4652
void reset() {
4753
input_tokens = 0;
4854
output_tokens = 0;
4955
time_to_first_token = 0.0;
5056
tokens_per_second = 0.0;
5157
prompt_tokens = 0;
58+
cache_tokens = -1;
5259
request_count_total = 0;
5360
input_tokens_total = 0;
5461
output_tokens_total = 0;
5562
prompt_tokens_total = 0;
63+
cache_tokens_total = 0;
5664
}
5765

5866
json to_json() const {
@@ -62,10 +70,12 @@ struct Telemetry {
6270
{"time_to_first_token", time_to_first_token},
6371
{"tokens_per_second", tokens_per_second},
6472
{"prompt_tokens", prompt_tokens},
73+
{"cache_tokens", cache_tokens >= 0 ? json(cache_tokens) : json(nullptr)},
6574
{"request_count_total", request_count_total},
6675
{"input_tokens_total", input_tokens_total},
6776
{"output_tokens_total", output_tokens_total},
68-
{"prompt_tokens_total", prompt_tokens_total}
77+
{"prompt_tokens_total", prompt_tokens_total},
78+
{"cache_tokens_total", cache_tokens_total}
6979
};
7080
}
7181
};
@@ -467,11 +477,7 @@ class WrappedServer : public ICompletionServer {
467477

468478
// Forward streaming requests to the wrapped server (public for Router access)
469479
// Virtual so backends can transform request (e.g., FLM needs checkpoint in model field)
470-
using TelemetryCallback = std::function<void(int input_tokens,
471-
int output_tokens,
472-
double time_to_first_token,
473-
double tokens_per_second,
474-
const std::string& error_message)>;
480+
using TelemetryCallback = std::function<void(const StreamingProxy::TelemetryData& telemetry)>;
475481

476482
virtual void forward_streaming_request(const std::string& endpoint,
477483
const std::string& request_body,

0 commit comments

Comments
 (0)