mirror of
https://github.com/mudler/LocalAI.git
synced 2026-09-08 12:14:06 -04:00
With `template.use_tokenizer_template: true` the sglang and vllm backends
render the prompt themselves via `tokenizer.apply_chat_template()`, and they
hand it plain string content. A chat template only emits the model's own media
tokens when the content is a list of parts, so the rendered prompt carries no
`<|vision_start|><|image_pad|><|vision_end|>`. The pixels do reach the engine
(`image_data` / `multi_modal_data`), but both engines locate them by scanning
the prompt for that token, so they are discarded silently: HTTP 200, no
warning, and the model answers as if no image had been attached.
Add `attach_media_parts()` to the shared `python_utils` helper and call it in
both backends: the last user turn is rebuilt as
`[{"type": "image"} * n, {"type": "video"} * n, {"type": "text", ...}]` before
templating, which makes the template emit the placeholders. The pixels keep
travelling out of band exactly as before.
Text-only requests are untouched - with no media the helper returns None and
the original string-content path runs unchanged. If a template cannot iterate
content parts (a text-only model), the parts render is caught and the request
falls back to the previous string-content prompt instead of failing.
Signed-off-by: Tai An <antai12232931@outlook.com>
117 lines
4.4 KiB
Python
117 lines
4.4 KiB
Python
"""Generic utilities shared across Python gRPC backends.
|
|
|
|
These helpers don't depend on any specific inference framework and can be
|
|
imported by any backend that needs to parse LocalAI gRPC options or build a
|
|
chat-template-compatible message list from proto Message objects.
|
|
"""
|
|
import json
|
|
|
|
|
|
def parse_options(options_list):
|
|
"""Parse Options[] list of ``key:value`` strings into a dict.
|
|
|
|
Supports type inference for common cases (bool, int, float). Unknown or
|
|
mixed-case values are returned as strings.
|
|
|
|
Used by LoadModel to extract backend-specific options passed via
|
|
``ModelOptions.Options`` in ``backend.proto``.
|
|
"""
|
|
opts = {}
|
|
for opt in options_list:
|
|
if ":" not in opt:
|
|
continue
|
|
key, value = opt.split(":", 1)
|
|
key = key.strip()
|
|
value = value.strip()
|
|
# Try type conversion
|
|
if value.lower() in ("true", "false"):
|
|
opts[key] = value.lower() == "true"
|
|
else:
|
|
try:
|
|
opts[key] = int(value)
|
|
except ValueError:
|
|
try:
|
|
opts[key] = float(value)
|
|
except ValueError:
|
|
opts[key] = value
|
|
return opts
|
|
|
|
|
|
def attach_media_parts(messages_dicts, n_images=0, n_videos=0):
|
|
"""Rebuild the last user message as content *parts* carrying media markers.
|
|
|
|
Backends that let the tokenizer do the templating hand plain string content
|
|
to ``apply_chat_template``, but a chat template only emits the model's own
|
|
media tokens (``<|vision_start|><|image_pad|><|vision_end|>`` for the
|
|
Qwen-VL family, and the equivalents elsewhere) when the content is a list
|
|
of parts. Without those markers the engine's multimodal processor finds
|
|
nothing to substitute and silently discards the pixels, even though they
|
|
were forwarded correctly out of band.
|
|
|
|
Returns a new list whose last user message has
|
|
``[{"type": "image"} * n_images, {"type": "video"} * n_videos, text]`` as
|
|
its content, or ``None`` when there is nothing to attach - no media, no
|
|
user turn, or content that is already a list of parts - so the caller can
|
|
keep using the original string-content list.
|
|
"""
|
|
if not n_images and not n_videos:
|
|
return None
|
|
idx = next(
|
|
(
|
|
i
|
|
for i in reversed(range(len(messages_dicts)))
|
|
if messages_dicts[i].get("role") == "user"
|
|
),
|
|
None,
|
|
)
|
|
if idx is None:
|
|
return None
|
|
text = messages_dicts[idx].get("content") or ""
|
|
if not isinstance(text, str):
|
|
return None
|
|
parts = [{"type": "image"}] * n_images + [{"type": "video"}] * n_videos
|
|
if text:
|
|
parts.append({"type": "text", "text": text})
|
|
patched = list(messages_dicts)
|
|
patched[idx] = dict(patched[idx], content=parts)
|
|
return patched
|
|
|
|
|
|
def messages_to_dicts(proto_messages):
|
|
"""Convert proto ``Message`` objects to dicts suitable for ``apply_chat_template``.
|
|
|
|
Handles: ``role``, ``content``, ``name``, ``tool_call_id``,
|
|
``reasoning_content``, ``tool_calls`` (JSON string → Python list).
|
|
|
|
HuggingFace chat templates (and their MLX/vLLM wrappers) expect a list of
|
|
plain dicts — proto Message objects don't work directly with Jinja, so
|
|
this conversion is needed before every ``apply_chat_template`` call.
|
|
"""
|
|
result = []
|
|
for msg in proto_messages:
|
|
d = {"role": msg.role, "content": msg.content or ""}
|
|
if msg.name:
|
|
d["name"] = msg.name
|
|
if msg.tool_call_id:
|
|
d["tool_call_id"] = msg.tool_call_id
|
|
if msg.reasoning_content:
|
|
d["reasoning_content"] = msg.reasoning_content
|
|
if msg.tool_calls:
|
|
try:
|
|
tool_calls = json.loads(msg.tool_calls)
|
|
# Chat templates (e.g. Qwen) iterate function.arguments as a
|
|
# mapping, but the OpenAI wire format carries it as a JSON
|
|
# string — decode it back so the template's .items() works.
|
|
for tc in tool_calls:
|
|
fn = tc.get("function") if isinstance(tc, dict) else None
|
|
if isinstance(fn, dict) and isinstance(fn.get("arguments"), str):
|
|
try:
|
|
fn["arguments"] = json.loads(fn["arguments"])
|
|
except json.JSONDecodeError:
|
|
pass
|
|
d["tool_calls"] = tool_calls
|
|
except json.JSONDecodeError:
|
|
pass
|
|
result.append(d)
|
|
return result
|