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
15 changes: 12 additions & 3 deletions python/src/edge0/engine/ling.py
Original file line number Diff line number Diff line change
Expand Up @@ -343,13 +343,15 @@ def _chat_template(self):
).from_string(src)
return self._chat_tpl

def encode_chat(self, messages, think=None) -> list:
def encode_chat(self, messages, think=None, tools=None) -> list:
"""Tokenize chat messages with the deployment chat template.

``think`` mirrors THINK_MODE: True renders "detailed thinking on"
(the model answers with a reasoning preamble), False renders
"detailed thinking off" (direct answer). Defaults to the
engine's ``think`` flag.
engine's ``think`` flag. ``tools`` is the OpenAI-style function
list from the request, forwarded to the template's own ``# Tools``
system-prompt section and ``<tool_call>`` format instructions.
"""
if think is None:
think = self.think
Expand All @@ -366,8 +368,15 @@ def encode_chat(self, messages, think=None) -> list:
messages=msgs,
add_generation_prompt=True,
enable_thinking=bool(think),
tools=None,
tools=tools,
)
# transformers encode would prepend/append special tokens by
# default; the template text is already complete.
return self._tok.encode(text, add_special_tokens=False)

def parse_tool_calls(self, text: str):
"""Split generated ``text`` into (content, OpenAI tool_calls) per
this checkpoint's chat template ``<tool_call>name<arg_key>...``
dialect (see edge0.server.tool_calls)."""
from edge0.server.tool_calls import parse_ling_tool_calls
return parse_ling_tool_calls(text)
7 changes: 7 additions & 0 deletions python/src/edge0/engine/qwen.py
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,13 @@ class Qwen35Engine(Edge0Engine):

name = "edge0-35b"

def parse_tool_calls(self, text: str):
"""Split generated ``text`` into (content, OpenAI tool_calls) per
this checkpoint's chat template ``<tool_call><function=...>``
dialect (see edge0.server.tool_calls)."""
from edge0.server.tool_calls import parse_qwen_tool_calls
return parse_qwen_tool_calls(text)

def __init__(self, model_dir: str, cfg, tokenizer=None):
# NAN_BANG_COLLAPSE_FIX parity (engine/ling.py): hidden clip default
# 1000 unless the deployer overrides QWEN_HIDDEN_CLIP explicitly.
Expand Down
99 changes: 77 additions & 22 deletions python/src/edge0/server/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,9 +28,7 @@


def _error(status: int, msg: str) -> tuple:
if _HAS_FLASK:
return jsonify({"error": {"message": msg, "type": "invalid_request"}}), status
return (json.dumps({"error": {"message": msg}}), status)
return {"error": {"message": msg, "type": "invalid_request"}}, status


def _split_think(text: str, think: bool):
Expand All @@ -47,26 +45,45 @@ def _split_think(text: str, think: bool):
return "", text


def _extract_tool_calls(server: QueueServer, req, content: str):
"""(content, tool_calls) after stripping any ``<tool_call>`` block the
engine's chat template asked the model to emit. ``tool_calls`` is None
when the engine has no parser (unsupported family) or the request
didn't ask for tools, or when no call was found in ``content``."""
if req.tool_choice == "none" or (not req.tools
and req.tool_choice != "required"):
return content, None
parse = getattr(server.engine, "parse_tool_calls", None)
if not callable(parse):
return content, None
content, calls = parse(content)
return content, (calls or None)


def _chat_once(server: QueueServer, payload: dict):
req = parse_chat_request(payload)
tokens, meta = server.chat(req)
text = decode_tokens(server.engine, tokens)
think = bool(req.enable_thinking if req.enable_thinking is not None
else getattr(server.engine, "think", False))
reasoning, content = _split_think(text, think)
content, tool_calls = _extract_tool_calls(server, req, content)
message = {
"role": "assistant",
"content": content,
"reasoning_content": reasoning,
}
if tool_calls:
message["tool_calls"] = tool_calls
return {
"id": f"chatcmpl-{int(time.time() * 1000)}",
"object": "chat.completion",
"created": int(time.time()),
"model": server.model_name,
"choices": [{
"index": 0,
"message": {
"role": "assistant",
"content": content,
"reasoning_content": reasoning,
},
"finish_reason": "stop",
"message": message,
"finish_reason": "tool_calls" if tool_calls else "stop",
}],
"usage": meta["usage"],
}
Expand All @@ -78,8 +95,20 @@ def _chat_stream(server: QueueServer, payload: dict):
finished = object()
request_id = f"chatcmpl-{int(time.time() * 1000)}"
created = int(time.time())
# Tool-call XML (<tool_call>...</tool_call>) must not leak into content
# deltas verbatim -- the whole point of #115 is that a client asking
# for tools gets a parsed message.tool_calls, not raw template markup.
# Detecting a block incrementally, token by token, needs a lookback
# buffer for a tag that can straddle token boundaries; simpler and
# just as correct: buffer the full response and parse once, same as
# the non-streaming path. Requests with no tools (the common case)
# keep the existing immediate per-token streaming, unchanged.
buffering = bool(req.tools) and req.tool_choice != "none" and callable(
getattr(server.engine, "parse_tool_calls", None))

def on_token(tid: int):
if buffering:
return
text = decode_tokens(server.engine, [tid])
events.put(sse_format({
"id": request_id, "object": "chat.completion.chunk",
Expand All @@ -91,12 +120,29 @@ def on_token(tid: int):

def produce():
try:
_, meta = server.chat(req, on_token=on_token)
tokens, meta = server.chat(req, on_token=on_token)
finish_reason = "stop"
delta = {}
if buffering:
text = decode_tokens(server.engine, tokens)
think = bool(
req.enable_thinking if req.enable_thinking is not None
else getattr(server.engine, "think", False))
_, content = _split_think(text, think)
content, tool_calls = _extract_tool_calls(
server, req, content)
if tool_calls:
delta = {"content": content, "tool_calls": [
{"index": i, **call} for i, call in enumerate(tool_calls)
]}
finish_reason = "tool_calls"
else:
delta = {"content": content}
events.put(sse_format({
"id": request_id, "object": "chat.completion.chunk",
"created": created, "model": server.model_name,
"choices": [{"index": 0, "delta": {},
"finish_reason": "stop"}],
"choices": [{"index": 0, "delta": delta,
"finish_reason": finish_reason}],
"usage": meta["usage"],
}).encode("utf-8"))
events.put(b"data: [DONE]\n\n")
Expand All @@ -119,6 +165,11 @@ def build_app_handlers(server: QueueServer):
"""Return a handler dispatch dict shared by both transports."""

def handle_chat(payload: dict):
# Validate before sending streaming response headers.
try:
parse_chat_request(payload)
except ValueError as exc:
return _error(400, str(exc))
if payload.get("stream"):
if _HAS_FLASK:
return Response(
Expand Down Expand Up @@ -171,16 +222,18 @@ def chat():
out = handlers["POST /v1/chat/completions"](payload)
if isinstance(out, Response):
return out
if isinstance(out, tuple) and out and isinstance(out[0], Response):
return out
if isinstance(out, tuple):
body, status = out
return jsonify(body), status
return jsonify(out)

@app.post("/v1/completions")
def completions():
payload = request.get_json(force=True, silent=True) or {}
out = handlers["POST /v1/completions"](payload)
if isinstance(out, tuple):
return out
body, status = out
return jsonify(body), status
return jsonify(out)

return app
Expand Down Expand Up @@ -237,18 +290,20 @@ def do_POST(self): # noqa: N802
return
length = int(self.headers.get("Content-Length", 0))
payload = json.loads(self.rfile.read(length) or b"{}")
if path == "/v1/chat/completions":
try:
parse_chat_request(payload)
except ValueError as exc:
self._json(400, {"error": {"message": str(exc),
"type": "invalid_request"}})
return
if path == "/v1/chat/completions" and payload.get("stream"):
self._sse(_chat_stream(self.server_q, payload))
return
out = handlers[("POST " + path)](payload)
if isinstance(out, tuple):
body, status, headers = out
self.send_response(status)
for k, v in headers.items():
self.send_header(k, v)
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
body, status = out
self._json(status, body)
else:
self._json(200, out)

Expand Down
56 changes: 52 additions & 4 deletions python/src/edge0/server/chat.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,9 @@
class ChatMessage:
role: str
content: str
tool_calls: list[dict] | None = None
tool_call_id: str | None = None
name: str | None = None


@dataclass
Expand All @@ -34,18 +37,50 @@ class ChatRequest:
seed: int | None = None
stream: bool = False
enable_thinking: bool | None = None
tools: list[dict] | None = None
tool_choice: Any = None
raw: dict[str, Any] = field(default_factory=dict)


def _template_tool_calls(calls) -> list[dict] | None:
if calls is None:
return None
if not isinstance(calls, list):
raise ValueError("tool_calls must be a list of function calls")
normalized = []
for call in calls:
if not isinstance(call, dict) or not isinstance(call.get("function"), dict):
raise ValueError("tool_calls entries must contain a function object")
function = call["function"]
arguments = function.get("arguments", {})
# OpenAI messages serialize arguments; checkpoint templates iterate them.
if isinstance(arguments, str):
try:
arguments = json.loads(arguments)
except ValueError as exc:
raise ValueError(
"tool_calls function arguments must be a JSON object") from exc
if not isinstance(arguments, dict):
raise ValueError("tool_calls function arguments must be a JSON object")
normalized.append({**call, "function": {**function, "arguments": arguments}})
return normalized


def parse_chat_request(payload: dict) -> ChatRequest:
msgs = []
for m in payload.get("messages", []):
role = str(m.get("role", "user"))
content = m.get("content", "")
content = m.get("content") or "" # tool-call-only assistant turns
# send content: null, not an omitted key
if isinstance(content, list): # multi-part content: join text parts
content = "".join(
p.get("text", "") for p in content if isinstance(p, dict))
msgs.append(ChatMessage(role=role, content=str(content)))
msgs.append(ChatMessage(
role=role, content=str(content),
tool_calls=_template_tool_calls(m.get("tool_calls")),
tool_call_id=m.get("tool_call_id"),
name=m.get("name"),
))
return ChatRequest(
model=str(payload.get("model", "")),
messages=msgs,
Expand All @@ -56,6 +91,8 @@ def parse_chat_request(payload: dict) -> ChatRequest:
seed=payload.get("seed"),
stream=bool(payload.get("stream", False)),
enable_thinking=payload.get("enable_thinking"),
tools=payload.get("tools"),
tool_choice=payload.get("tool_choice"),
raw=payload,
)

Expand All @@ -68,6 +105,16 @@ def __init__(self, engine: Edge0Engine, req: ChatRequest):
self.req = req
self._tok = engine._tok

def _tools(self) -> list[dict] | None:
"""Tool definitions to forward to the chat template, or None if
the request has none or explicitly disabled calling
(``tool_choice: "none"``). Forcing a specific function or
``tool_choice: "required"`` would need constrained decoding,
which isn't implemented; both currently behave like "auto"."""
if not self.req.tools or self.req.tool_choice == "none":
return None
return self.req.tools

def prompt_ids(self) -> list[int]:
tok = self._tok
# Families with a vendored chat template
Expand All @@ -82,7 +129,8 @@ def prompt_ids(self) -> list[int]:
if think is None:
think = getattr(self.engine, "think", False)
return encode(
[m.__dict__ for m in self.req.messages], think=bool(think))
[m.__dict__ for m in self.req.messages], think=bool(think),
tools=self._tools())
if hasattr(tok, "apply_chat_template"):
# The qwen35 template supports ``enable_thinking``: False
# renders the canonical no-think prompt — an EMPTY think
Expand All @@ -101,7 +149,7 @@ def prompt_ids(self) -> list[int]:
try:
text = tok.apply_chat_template(
msgs, tokenize=False, add_generation_prompt=True,
enable_thinking=bool(think))
enable_thinking=bool(think), tools=self._tools())
except TypeError:
# tokenizer template without the kwarg: plain render
try:
Expand Down
Loading
Loading