Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 17 additions & 1 deletion api/src/main/models/base.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import logging
import re
from re import finditer, DOTALL
from dataclasses import dataclass
from typing import Generator, Optional, List, Dict, Union
Expand Down Expand Up @@ -172,6 +173,21 @@ def adaptive_special_tag_buffering(outs, wait_tokens_for=6):
else:
yield wd

tag_pattern = re.compile("(" + "|".join(re.escape(t) for t in (
self.special_tags.REASONING, self.special_tags.REASONING_END,
self.special_tags.TOOLCALL, self.special_tags.TOOLCALL_END,
)) + ")")

def split_special_tags(outs):
"""
Give every special tag a frame of its own. The app matches `<think>`/`</think>` exactly, and a
streamer that emits at word boundaries glues tags to whitespace or text (`'<think>\\n'`).
"""
for wd in outs:
for piece in tag_pattern.split(wd):
if piece:
yield piece

initial_operation = True
function_called = True
while function_called:
Expand Down Expand Up @@ -206,7 +222,7 @@ def adaptive_special_tag_buffering(outs, wait_tokens_for=6):
)
generation_kwargs.update(kwargs)
outputs = self.parse_tool_calling(
adaptive_special_tag_buffering(self.runtime(**generation_kwargs)),
split_special_tags(adaptive_special_tag_buffering(self.runtime(**generation_kwargs))),
chat_history=chat_history,
tools=tools,
stream=stream,
Expand Down
44 changes: 44 additions & 0 deletions api/tests/test_think_frames.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
"""
Reasoning and tool tags reach the app as frames of their own (SPEC S1.4, #112): the app matches
`<think>` and `</think>` exactly, and the one-at-a-time path's streamer glues them to whitespace
(`'<think>\n'`, `'</think>\n\n'`).
"""
from api.src.main.models.base import BaseModel
from api.src.main.models.config import ChatHistory
from api.src.main.utils import FunctionCalling


class ScriptedEngine:
def __init__(self, chunks):
self.chunks = chunks

def __call__(self, messages, **kwargs):
yield from self.chunks


class Plain(BaseModel):
model_id = "test"
supported_tools = FunctionCalling.DISABLED


def frames(chunks):
model = Plain(engine=ScriptedEngine(chunks))
return [f for f in model.chat(ChatHistory(), "Hi", tools=[]) if f]


def test_think_tags_glued_to_whitespace_become_their_own_frames():
out = frames(["<think>\n", "The user ", "greets me.", "\n</think>\n\n", "Hello", "!"])

assert "<think>" in out
assert "</think>" in out
assert "".join(out) == "<think>\nThe user greets me.\n</think>\n\nHello!"


def test_text_and_tag_in_one_chunk_are_split():
out = frames(["ok<think>", "x", "</think>done"])

assert out == ["ok", "<think>", "x", "</think>", "done"]


def test_text_without_tags_is_untouched():
assert frames(["Hello", " world"]) == ["Hello", " world"]
3 changes: 2 additions & 1 deletion docs/SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,8 @@ and S3.4 also record the maintainer's decisions of the same day ([`serving/engin

The server then sends one text frame per generated chunk, then `<EOS>`, then closes. An unknown
session closes the socket with code `1008`. Reasoning arrives between `<think>` and `</think>`
frames; tool activity arrives as `<tool_call> … </tool_call>` frames (S1.6). Generation runs off
frames; tool activity arrives as `<tool_call> … </tool_call>` frames (S1.6). Each tag is a frame of its own,
even when the model's streamer emits it glued to whitespace or text (#112). Generation runs off
the event loop; if the client disconnects mid-stream, generation stops and the model is free for
the next request (#35).

Expand Down
Loading