@@ -242,6 +242,47 @@ public void Dispose()
242242
243243 public int CountMessageTokens ( List < SingleMessage > messages )
244244 {
245+ // In chat mode the authoritative count must match what /v1/chat/completions actually builds:
246+ // names, macro expansion and the model's chat-template scaffolding all affect the real token
247+ // count. Counting the raw message text (as the legacy /v1/messages/count_tokens path did)
248+ // systematically under-counts, and the gap grows with the number of messages. To get an exact
249+ // count we render the messages through the model's chat template via /apply-template (using the
250+ // same content generation produces: name prefixes, macros and tool-call structure), then
251+ // tokenize that string with BOS.
252+ if ( CompletionType == CompletionType . Chat )
253+ {
254+ try
255+ {
256+ var chatMessages = new List < ApplyTemplateMessage > ( ) ;
257+ foreach ( var message in messages )
258+ {
259+ var built = BuildTemplateMessage ( message ) ;
260+ if ( built is not null )
261+ chatMessages . Add ( built ) ;
262+ }
263+ if ( chatMessages . Count == 0 )
264+ return 0 ;
265+
266+ var templated = _client . ApplyTemplateSync ( new ApplyTemplateQuery { messages = chatMessages } ) ;
267+ if ( ! string . IsNullOrEmpty ( templated ? . prompt ) )
268+ {
269+ var tokens = _client . TokenizeSync ( new TokenRequest
270+ {
271+ content = templated . prompt ,
272+ add_special = true ,
273+ parse_special = true
274+ } ) ;
275+ return tokens . GetTokenCount ( ) ;
276+ }
277+ LLMEngine . Logger ? . LogWarning ( "[LlamaCpp] /apply-template returned an empty prompt; falling back to legacy token count." ) ;
278+ }
279+ catch ( Exception ex )
280+ {
281+ // Older servers may not expose /apply-template. Fall back to the legacy path below.
282+ LLMEngine . Logger ? . LogWarning ( ex , "[LlamaCpp] /apply-template unavailable; falling back to legacy token count." ) ;
283+ }
284+ }
285+
245286 var request = new MessageListQuery ( ) ;
246287 foreach ( var message in messages )
247288 {
@@ -268,6 +309,71 @@ public int CountMessageTokens(List<SingleMessage> messages)
268309 return token . input_tokens ;
269310 }
270311
312+ /// <summary>
313+ /// Builds a Newtonsoft-serializable /apply-template message from a <see cref="SingleMessage"/>,
314+ /// mirroring the three cases in <see cref="SingleMessage.ToChatCompletion"/> (tool result,
315+ /// assistant tool-call-only, and normal text). Tool-call arguments are kept as the raw JSON
316+ /// *string* (never a System.Text.Json JsonNode) so serialization cannot self-reference.
317+ /// Returns null for roles that are not sent to the template.
318+ /// </summary>
319+ private static ApplyTemplateMessage ? BuildTemplateMessage ( SingleMessage message )
320+ {
321+ // Tool result messages
322+ if ( message . Role == AuthorRole . Tool && message . ToolCalls . Count > 0 )
323+ {
324+ return new ApplyTemplateMessage
325+ {
326+ role = "tool" ,
327+ content = message . Message ,
328+ tool_call_id = message . ToolCalls [ 0 ] . CallId
329+ } ;
330+ }
331+
332+ // Assistant tool-call-only messages (no text content)
333+ if ( message . Role == AuthorRole . Assistant && message . ToolCalls . Count > 0 && string . IsNullOrEmpty ( message . Message ) )
334+ {
335+ var calls = new List < ApplyTemplateToolCall > ( ) ;
336+ foreach ( var record in message . ToolCalls )
337+ {
338+ calls . Add ( new ApplyTemplateToolCall
339+ {
340+ id = record . CallId ,
341+ type = "function" ,
342+ function = new ApplyTemplateFunction
343+ {
344+ name = record . FunctionName ,
345+ arguments = record . ArgumentsJson
346+ }
347+ } ) ;
348+ }
349+ return new ApplyTemplateMessage
350+ {
351+ role = "assistant" ,
352+ // OpenAI schema requires content (may be null) alongside tool_calls.
353+ content = string . IsNullOrEmpty ( message . Message ) ? null : message . Message ,
354+ tool_calls = calls
355+ } ;
356+ }
357+
358+ // Normal messages (System / User / Assistant text)
359+ var role = message . Role switch
360+ {
361+ AuthorRole . User => "user" ,
362+ AuthorRole . Assistant => "assistant" ,
363+ AuthorRole . System => "system" ,
364+ AuthorRole . Tool => "tool" ,
365+ _ => null
366+ } ;
367+ if ( role is null )
368+ return null ;
369+
370+ return new ApplyTemplateMessage
371+ {
372+ role = role ,
373+ content = message . ToChatContentText ( )
374+ } ;
375+ }
376+
271377 private async Task GenerateChatCompletionStreaming ( object parameters )
272378 {
273379 if ( parameters is not ChatRequest input )
0 commit comments