Skip to content

Commit 0fd666e

Browse files
Anai-Guomudler
andauthored
fix(openresponses): populate Content and accept bare {role,content} items (#10039) (#10040)
* fix(openresponses): populate Content and accept bare {role,content} items (#10039) Fixes #10039 — `/v1/responses` silently returned empty output on any model whose YAML doesn't include a Go-side `template.chat_message` block. Three cooperating bugs: * `convertORInputToMessages` populated only `StringContent` for string input and for the `input.Instructions` system message, leaving the `Content` (any) field nil. * `TemplateMessages` gated all fallback content-rendering branches on `Content != nil && StringContent != ""` — but every branch in that function consumes `StringContent`, not `Content`. The `&&` silently dropped messages that had StringContent set and Content nil, producing an empty prompt that the 5× empty-retry guard then turned into a 200 OK with `output: []`. * The array-input branch of `convertORInputToMessages` dispatched on `itemMap["type"]` with no default, dropping bare `{role, content}` items emitted by the OpenAI Python SDK helper `client.responses.create(input=[{...}])`. Fix: * Set both `Content` and `StringContent` in the two openresponses message-construction sites that only set one. * Treat a bare `{role, content}` item (no `type`) as `type: "message"` for OpenAI-SDK compatibility. * Gate `TemplateMessages` fallback rendering on `StringContent != ""`, which is what every downstream branch in that function actually reads. Regression test added to `evaluator_test.go` covering the fallback path (no `ChatMessage` template) with a StringContent-only message, both with and without a role mapping. * test(openresponses): guard Content population and ToProto path (#10039) Add regression tests for the two seams the original fix touched but left uncovered: * convertORInputToMessages must populate both Content and StringContent for plain string input and for bare {role, content} array items (the OpenAI SDK shape that omits the type discriminator). Both are functional reds against the pre-fix code. * Messages.ToProto reads Content, not StringContent — this is the path UseTokenizerTemplate backends (imported GGUFs) take. The cases pin that contract so a future regression on the producer side is caught. Signed-off-by: Ettore Di Giacinto <mudler@localai.io> Assisted-by: Claude:claude-opus-4-7 [Claude Code] --------- Signed-off-by: Ettore Di Giacinto <mudler@localai.io> Co-authored-by: Ettore Di Giacinto <mudler@localai.io>
1 parent 7763fb2 commit 0fd666e

5 files changed

Lines changed: 152 additions & 3 deletions

File tree

core/http/endpoints/openresponses/responses.go

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -95,7 +95,7 @@ func ResponsesEndpoint(cl *config.ModelConfigLoader, ml *model.ModelLoader, eval
9595

9696
// Add instructions as system message if provided
9797
if input.Instructions != "" {
98-
messages = append([]schema.Message{{Role: "system", StringContent: input.Instructions}}, messages...)
98+
messages = append([]schema.Message{{Role: "system", Content: input.Instructions, StringContent: input.Instructions}}, messages...)
9999
}
100100

101101
// Handle tools
@@ -299,7 +299,7 @@ func convertORInputToMessages(input any, cfg *config.ModelConfig) ([]schema.Mess
299299
switch v := input.(type) {
300300
case string:
301301
// Simple string = user message
302-
return []schema.Message{{Role: "user", StringContent: v}}, nil
302+
return []schema.Message{{Role: "user", Content: v, StringContent: v}}, nil
303303
case []any:
304304
// Array of items
305305
for _, itemRaw := range v {
@@ -309,6 +309,16 @@ func convertORInputToMessages(input any, cfg *config.ModelConfig) ([]schema.Mess
309309
}
310310

311311
itemType, _ := itemMap["type"].(string)
312+
// OpenAI SDK helpers (e.g. client.responses.create(input=[{"role":...,"content":...}]))
313+
// send message items without a "type" discriminator. Treat a bare {role, content}
314+
// object as type:"message" so the chat-completions and responses paths agree.
315+
if itemType == "" {
316+
if _, hasRole := itemMap["role"].(string); hasRole {
317+
if _, hasContent := itemMap["content"]; hasContent {
318+
itemType = "message"
319+
}
320+
}
321+
}
312322
switch itemType {
313323
case "message":
314324
msg, err := convertORMessageItem(itemMap, cfg)
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
package openresponses
2+
3+
import (
4+
"github.com/mudler/LocalAI/core/config"
5+
6+
. "github.com/onsi/ginkgo/v2"
7+
. "github.com/onsi/gomega"
8+
)
9+
10+
// Regression for mudler/LocalAI#10039. convertORInputToMessages must populate
11+
// both Content and StringContent: the templating fallback path reads
12+
// StringContent, while the UseTokenizerTemplate path serialises Content via
13+
// Messages.ToProto(). Leaving Content nil produced an empty prompt on any model
14+
// without a Go-side template.chat_message block (the default for imported GGUFs).
15+
var _ = Describe("convertORInputToMessages", func() {
16+
cfg := &config.ModelConfig{}
17+
18+
It("populates both Content and StringContent for plain string input", func() {
19+
msgs, err := convertORInputToMessages("Hello", cfg)
20+
Expect(err).NotTo(HaveOccurred())
21+
Expect(msgs).To(HaveLen(1))
22+
Expect(msgs[0].Role).To(Equal("user"))
23+
Expect(msgs[0].StringContent).To(Equal("Hello"))
24+
Expect(msgs[0].Content).To(Equal("Hello"))
25+
})
26+
27+
It("accepts a bare {role, content} item without a type discriminator", func() {
28+
// The OpenAI Python SDK helper client.responses.create(input=[{...}])
29+
// sends message items with no "type" field. They must not be dropped.
30+
input := []any{
31+
map[string]any{"role": "user", "content": "Hi there"},
32+
}
33+
msgs, err := convertORInputToMessages(input, cfg)
34+
Expect(err).NotTo(HaveOccurred())
35+
Expect(msgs).To(HaveLen(1))
36+
Expect(msgs[0].Role).To(Equal("user"))
37+
Expect(msgs[0].StringContent).To(Equal("Hi there"))
38+
Expect(msgs[0].Content).To(Equal("Hi there"))
39+
})
40+
41+
It("still populates both fields for an explicit type:message item", func() {
42+
input := []any{
43+
map[string]any{"type": "message", "role": "user", "content": "Typed"},
44+
}
45+
msgs, err := convertORInputToMessages(input, cfg)
46+
Expect(err).NotTo(HaveOccurred())
47+
Expect(msgs).To(HaveLen(1))
48+
Expect(msgs[0].StringContent).To(Equal("Typed"))
49+
Expect(msgs[0].Content).To(Equal("Typed"))
50+
})
51+
52+
It("does not treat a non-message item (no content key) as a message", func() {
53+
// An item with neither a known type nor a {role, content} shape must
54+
// keep falling through unchanged — no behaviour change for such inputs.
55+
input := []any{
56+
map[string]any{"role": "user"},
57+
}
58+
msgs, err := convertORInputToMessages(input, cfg)
59+
Expect(err).NotTo(HaveOccurred())
60+
Expect(msgs).To(BeEmpty())
61+
})
62+
})

core/schema/message_test.go

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -332,5 +332,41 @@ var _ = Describe("LLM tests", func() {
332332
// Should only extract text parts
333333
Expect(protoMessages[0].Content).To(Equal("Hello"))
334334
})
335+
336+
// Regression for mudler/LocalAI#10039: ToProto is the path taken by
337+
// UseTokenizerTemplate backends (e.g. imported GGUFs, where the backend
338+
// applies the GGUF's jinja template to the raw messages). It reads
339+
// Content, not StringContent — so a message that only populated
340+
// StringContent (the shape /v1/responses produced before the fix)
341+
// reached the backend with empty content. These two cases pin that
342+
// contract: Content is authoritative, and producers must set it.
343+
It("emits empty content when only StringContent is set (Content nil)", func() {
344+
messages := Messages{
345+
{
346+
Role: "user",
347+
StringContent: "Hello",
348+
},
349+
}
350+
351+
protoMessages := messages.ToProto()
352+
353+
Expect(protoMessages).To(HaveLen(1))
354+
Expect(protoMessages[0].Content).To(BeEmpty())
355+
})
356+
357+
It("carries Content through to proto regardless of StringContent", func() {
358+
messages := Messages{
359+
{
360+
Role: "user",
361+
Content: "Hello",
362+
StringContent: "Hello",
363+
},
364+
}
365+
366+
protoMessages := messages.ToProto()
367+
368+
Expect(protoMessages).To(HaveLen(1))
369+
Expect(protoMessages[0].Content).To(Equal("Hello"))
370+
})
335371
})
336372
})

core/templates/evaluator.go

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -111,7 +111,11 @@ func (e *Evaluator) TemplateMessages(input schema.OpenAIRequest, messages []sche
111111
}
112112
}
113113
r := config.Roles[role]
114-
contentExists := i.Content != nil && i.StringContent != ""
114+
// Treat StringContent as the source of truth — every downstream fallback branch in this
115+
// function reads StringContent, not Content. Gating on both with && silently drops
116+
// messages that have StringContent set but Content nil (e.g. /v1/responses string-input
117+
// before mudler/LocalAI#10039 fix).
118+
contentExists := i.StringContent != ""
115119

116120
fcall := i.FunctionCall
117121
if len(i.ToolCalls) > 0 {

core/templates/evaluator_test.go

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -218,4 +218,41 @@ var _ = Describe("Templates", func() {
218218
})
219219
}
220220
})
221+
// Regression test for mudler/LocalAI#10039: when a model has no Go-side
222+
// TemplateConfig.ChatMessage block (e.g. backends that rely on the GGUF's
223+
// jinja template), TemplateMessages falls through to the role-prefix path.
224+
// That path must still render messages whose StringContent is populated but
225+
// Content (any) is nil — which is the shape /v1/responses produced before
226+
// the fix to convertORInputToMessages.
227+
Context("fallback path with StringContent-only message (no ChatMessage template)", func() {
228+
var evaluator *Evaluator
229+
BeforeEach(func() {
230+
evaluator = NewEvaluator("")
231+
})
232+
It("renders the role prefix and content when only StringContent is set", func() {
233+
cfg := &config.ModelConfig{
234+
TemplateConfig: config.TemplateConfig{},
235+
Roles: map[string]string{"user": "USER: "},
236+
}
237+
messages := []schema.Message{
238+
{
239+
Role: "user",
240+
StringContent: "hello",
241+
// Content intentionally left nil — reproduces /v1/responses string-input.
242+
},
243+
}
244+
templated := evaluator.TemplateMessages(schema.OpenAIRequest{}, messages, cfg, []functions.Function{}, false)
245+
Expect(templated).To(Equal("USER: hello"), templated)
246+
})
247+
It("renders content even with no role mapping", func() {
248+
cfg := &config.ModelConfig{
249+
TemplateConfig: config.TemplateConfig{},
250+
}
251+
messages := []schema.Message{
252+
{Role: "user", StringContent: "hello"},
253+
}
254+
templated := evaluator.TemplateMessages(schema.OpenAIRequest{}, messages, cfg, []functions.Function{}, false)
255+
Expect(templated).To(Equal("hello"), templated)
256+
})
257+
})
221258
})

0 commit comments

Comments
 (0)