Files
LocalAI/core/schema/message_test.go
LocalAI [bot] f0d0bff232 fix(llama-cpp): stop reinterpreting plain-string message content as JSON (#10524) (#10538)
The llama-cpp gRPC backend reconstructs OpenAI messages from proto for the
tokenizer-template path and blindly json::parse'd each message's content
string. LocalAI's Go layer always flattens content to a plain string, so a
user prompt that merely looks like JSON (e.g. mealie's ingredient array
["1/4 cup brown sugar", ...]) was reinterpreted as structured content parts and
rejected by oaicompat_chat_params_parse with "unsupported content[].type".

Normalize content per role instead: user/system/developer content is opaque
text and is never JSON-sniffed; assistant/tool content still collapses a literal
JSON null/object (tool-call bookkeeping) to a string, but a plain string is
never turned into an array/scalar. The array defense is role-independent, so the
role gate only governs the benign null/object case.

While here, extract the duplicated per-message reconstruction and the
pre-template content sanitization into shared, unit-tested helpers
(message_content.h) so the streaming (PredictStream) and non-streaming (Predict)
paths cannot drift. This removes ~490 lines of copy-pasted defensive code, the
dead tool-role parse branches, and the redundant Predict-only tool_calls branch,
while preserving the prior #7324 (null content -> "") and #7528 (tool array
content -> string) fixes.

Tests:
- backend/cpp/llama-cpp/message_content_test.cpp: standalone C++ unit tests for
  all three helpers (#10524, #7324, #7528, multimodal), discovered and run by
  `make test-backend-cpp` and a new generic tests-backend-cpp CI job. Also wired
  as an opt-in CMake/ctest target (-DLLAMA_GRPC_BUILD_TESTS=ON).
- core/schema/message_test.go: Go regression pinning that ToProto flattens a
  JSON-array-looking text part to the verbatim string.
- prepare.sh now copies message_content.h into the build tree.

Assisted-by: Claude:claude-opus-4-8 [Claude Code]

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Co-authored-by: Ettore Di Giacinto <mudler@localai.io>
2026-06-27 01:42:05 +02:00

399 lines
11 KiB
Go

package schema_test
import (
"encoding/json"
. "github.com/mudler/LocalAI/core/schema"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("LLM tests", func() {
Context("ToProtoMessages conversion", func() {
It("should convert basic message with string content", func() {
messages := Messages{
{
Role: "user",
Content: "Hello, world!",
},
}
protoMessages := messages.ToProto()
Expect(protoMessages).To(HaveLen(1))
Expect(protoMessages[0].Role).To(Equal("user"))
Expect(protoMessages[0].Content).To(Equal("Hello, world!"))
Expect(protoMessages[0].Name).To(BeEmpty())
Expect(protoMessages[0].ToolCalls).To(BeEmpty())
})
It("should convert message with nil content to empty string", func() {
messages := Messages{
{
Role: "assistant",
Content: nil,
},
}
protoMessages := messages.ToProto()
Expect(protoMessages).To(HaveLen(1))
Expect(protoMessages[0].Role).To(Equal("assistant"))
Expect(protoMessages[0].Content).To(Equal(""))
})
It("should convert message with array content (multimodal)", func() {
messages := Messages{
{
Role: "user",
Content: []any{
map[string]any{
"type": "text",
"text": "Hello",
},
map[string]any{
"type": "text",
"text": " World",
},
},
},
}
protoMessages := messages.ToProto()
Expect(protoMessages).To(HaveLen(1))
Expect(protoMessages[0].Role).To(Equal("user"))
Expect(protoMessages[0].Content).To(Equal("Hello World"))
})
// Regression for mudler/LocalAI#10524: a text part whose inner text is
// itself a JSON-array string (mealie sends an ingredient list) must
// flatten to that exact string verbatim. ToProto must NOT escape or
// restructure it - the C++ backend then treats it as opaque text. This
// pins the precise Go-side input that produced the "unsupported
// content[].type" gRPC error before the backend stopped re-parsing it.
It("flattens a JSON-array-looking text part to the verbatim string (#10524)", func() {
ingredients := `["1/4 cup brown sugar, packed","1 pound ground beef"]`
messages := Messages{
{
Role: "user",
Content: []any{
map[string]any{
"type": "text",
"text": ingredients,
},
},
},
}
protoMessages := messages.ToProto()
Expect(protoMessages).To(HaveLen(1))
Expect(protoMessages[0].Content).To(Equal(ingredients))
})
It("should convert message with tool_calls", func() {
messages := Messages{
{
Role: "assistant",
Content: "I'll call a function",
ToolCalls: []ToolCall{
{
Index: 0,
ID: "call_123",
Type: "function",
FunctionCall: FunctionCall{
Name: "get_weather",
Arguments: `{"location": "San Francisco"}`,
},
},
},
},
}
protoMessages := messages.ToProto()
Expect(protoMessages).To(HaveLen(1))
Expect(protoMessages[0].Role).To(Equal("assistant"))
Expect(protoMessages[0].Content).To(Equal("I'll call a function"))
Expect(protoMessages[0].ToolCalls).NotTo(BeEmpty())
// Verify tool_calls JSON is valid
var toolCalls []ToolCall
err := json.Unmarshal([]byte(protoMessages[0].ToolCalls), &toolCalls)
Expect(err).NotTo(HaveOccurred())
Expect(toolCalls).To(HaveLen(1))
Expect(toolCalls[0].ID).To(Equal("call_123"))
Expect(toolCalls[0].FunctionCall.Name).To(Equal("get_weather"))
})
It("should convert message with name field", func() {
messages := Messages{
{
Role: "tool",
Content: "Function result",
Name: "get_weather",
},
}
protoMessages := messages.ToProto()
Expect(protoMessages).To(HaveLen(1))
Expect(protoMessages[0].Role).To(Equal("tool"))
Expect(protoMessages[0].Content).To(Equal("Function result"))
Expect(protoMessages[0].Name).To(Equal("get_weather"))
})
It("should convert message with tool_calls and nil content", func() {
messages := Messages{
{
Role: "assistant",
Content: nil,
ToolCalls: []ToolCall{
{
Index: 0,
ID: "call_456",
Type: "function",
FunctionCall: FunctionCall{
Name: "search",
Arguments: `{"query": "test"}`,
},
},
},
},
}
protoMessages := messages.ToProto()
Expect(protoMessages).To(HaveLen(1))
Expect(protoMessages[0].Role).To(Equal("assistant"))
Expect(protoMessages[0].Content).To(Equal(""))
Expect(protoMessages[0].ToolCalls).NotTo(BeEmpty())
var toolCalls []ToolCall
err := json.Unmarshal([]byte(protoMessages[0].ToolCalls), &toolCalls)
Expect(err).NotTo(HaveOccurred())
Expect(toolCalls).To(HaveLen(1))
Expect(toolCalls[0].FunctionCall.Name).To(Equal("search"))
})
It("should convert multiple messages", func() {
messages := Messages{
{
Role: "user",
Content: "Hello",
},
{
Role: "assistant",
Content: "Hi there!",
},
{
Role: "user",
Content: "How are you?",
},
}
protoMessages := messages.ToProto()
Expect(protoMessages).To(HaveLen(3))
Expect(protoMessages[0].Role).To(Equal("user"))
Expect(protoMessages[0].Content).To(Equal("Hello"))
Expect(protoMessages[1].Role).To(Equal("assistant"))
Expect(protoMessages[1].Content).To(Equal("Hi there!"))
Expect(protoMessages[2].Role).To(Equal("user"))
Expect(protoMessages[2].Content).To(Equal("How are you?"))
})
It("should handle empty messages slice", func() {
messages := Messages{}
protoMessages := messages.ToProto()
Expect(protoMessages).To(HaveLen(0))
})
It("should handle message with all optional fields", func() {
messages := Messages{
{
Role: "assistant",
Content: "I'll help you",
Name: "test_tool",
ToolCalls: []ToolCall{
{
Index: 0,
ID: "call_789",
Type: "function",
FunctionCall: FunctionCall{
Name: "test_function",
Arguments: `{"param": "value"}`,
},
},
},
},
}
protoMessages := messages.ToProto()
Expect(protoMessages).To(HaveLen(1))
Expect(protoMessages[0].Role).To(Equal("assistant"))
Expect(protoMessages[0].Content).To(Equal("I'll help you"))
Expect(protoMessages[0].Name).To(Equal("test_tool"))
Expect(protoMessages[0].ToolCalls).NotTo(BeEmpty())
var toolCalls []ToolCall
err := json.Unmarshal([]byte(protoMessages[0].ToolCalls), &toolCalls)
Expect(err).NotTo(HaveOccurred())
Expect(toolCalls).To(HaveLen(1))
})
It("should handle message with empty string content", func() {
messages := Messages{
{
Role: "user",
Content: "",
},
}
protoMessages := messages.ToProto()
Expect(protoMessages).To(HaveLen(1))
Expect(protoMessages[0].Role).To(Equal("user"))
Expect(protoMessages[0].Content).To(Equal(""))
})
It("should serialize ToolCallID and Reasoning fields", func() {
reasoning := "thinking..."
messages := Messages{
{
Role: "tool",
Content: "result",
ToolCallID: "call_123",
Reasoning: &reasoning,
},
}
protoMessages := messages.ToProto()
Expect(protoMessages).To(HaveLen(1))
Expect(protoMessages[0].ToolCallId).To(Equal("call_123"))
Expect(protoMessages[0].ReasoningContent).To(Equal("thinking..."))
})
It("should not leak unset LocalAI-only or cross-endpoint request fields into JSON", func() {
// OpenAIRequest is a union over chat / completion /
// embedding / image / whisper. Strict upstream providers
// (OpenAI, Anthropic) 400 on unknown parameters when
// cloud-proxy passthrough re-marshals a chat request and
// whisper's `file`, image's `step`, embedding's `input`,
// etc. tag along as empty zero values.
req := OpenAIRequest{}
req.Model = "gpt-4"
data, err := json.Marshal(req)
Expect(err).NotTo(HaveOccurred())
body := string(data)
// Anchor with the trailing `:` so e.g. `"stream"` doesn't
// false-match `"stream_options"` if a future test setup
// populates the latter.
for _, key := range []string{
// LocalAI-only fields
`"backend":`, `"grammar":`, `"grammar_json_functions":`,
`"model_base_name":`, `"reasoning_effort":`,
// Cross-endpoint fields that don't belong on chat
`"file":`, `"size":`, `"prompt":`, `"instruction":`,
`"input":`, `"stop":`, `"messages":`, `"functions":`,
`"function_call":`, `"stream":`, `"quality":`, `"step":`,
`"metadata":`,
} {
Expect(body).NotTo(ContainSubstring(key), "unset field "+key+" must not appear in marshalled JSON")
}
})
It("should not leak internal String* staging fields into JSON", func() {
// Regression: the request middleware copies decoded
// Content into StringContent/StringImages/etc. for
// templating. When cloud-proxy passthrough re-marshals
// the request, strict providers (Anthropic) 400 with
// "messages.0.string_content: Extra inputs are not
// permitted" if these leak.
msg := Message{
Role: "user",
Content: "Hello",
StringContent: "Hello",
StringImages: []string{"base64-blob"},
StringVideos: []string{"base64-blob"},
StringAudios: []string{"base64-blob"},
}
data, err := json.Marshal(msg)
Expect(err).NotTo(HaveOccurred())
Expect(string(data)).NotTo(ContainSubstring("string_content"))
Expect(string(data)).NotTo(ContainSubstring("string_images"))
Expect(string(data)).NotTo(ContainSubstring("string_videos"))
Expect(string(data)).NotTo(ContainSubstring("string_audios"))
Expect(string(data)).To(ContainSubstring(`"content":"Hello"`))
})
It("should handle message with array content containing non-text parts", func() {
messages := Messages{
{
Role: "user",
Content: []any{
map[string]any{
"type": "text",
"text": "Hello",
},
map[string]any{
"type": "image",
"url": "https://example.com/image.jpg",
},
},
},
}
protoMessages := messages.ToProto()
Expect(protoMessages).To(HaveLen(1))
Expect(protoMessages[0].Role).To(Equal("user"))
// Should only extract text parts
Expect(protoMessages[0].Content).To(Equal("Hello"))
})
// Regression for mudler/LocalAI#10039: ToProto is the path taken by
// UseTokenizerTemplate backends (e.g. imported GGUFs, where the backend
// applies the GGUF's jinja template to the raw messages). It reads
// Content, not StringContent — so a message that only populated
// StringContent (the shape /v1/responses produced before the fix)
// reached the backend with empty content. These two cases pin that
// contract: Content is authoritative, and producers must set it.
It("emits empty content when only StringContent is set (Content nil)", func() {
messages := Messages{
{
Role: "user",
StringContent: "Hello",
},
}
protoMessages := messages.ToProto()
Expect(protoMessages).To(HaveLen(1))
Expect(protoMessages[0].Content).To(BeEmpty())
})
It("carries Content through to proto regardless of StringContent", func() {
messages := Messages{
{
Role: "user",
Content: "Hello",
StringContent: "Hello",
},
}
protoMessages := messages.ToProto()
Expect(protoMessages).To(HaveLen(1))
Expect(protoMessages[0].Content).To(Equal("Hello"))
})
})
})