From 0fd666ee6e70cbd7e33d96b12c424ab946c8b218 Mon Sep 17 00:00:00 2001 From: Tai An Date: Thu, 28 May 2026 00:21:48 -0700 Subject: [PATCH] fix(openresponses): populate Content and accept bare {role,content} items (#10039) (#10040) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(openresponses): populate Content and accept bare {role,content} items (#10039) Fixes mudler/LocalAI#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 Assisted-by: Claude:claude-opus-4-7 [Claude Code] --------- Signed-off-by: Ettore Di Giacinto Co-authored-by: Ettore Di Giacinto --- .../http/endpoints/openresponses/responses.go | 14 ++++- .../openresponses/responses_convert_test.go | 62 +++++++++++++++++++ core/schema/message_test.go | 36 +++++++++++ core/templates/evaluator.go | 6 +- core/templates/evaluator_test.go | 37 +++++++++++ 5 files changed, 152 insertions(+), 3 deletions(-) create mode 100644 core/http/endpoints/openresponses/responses_convert_test.go diff --git a/core/http/endpoints/openresponses/responses.go b/core/http/endpoints/openresponses/responses.go index ef2a1addd..2b986cc61 100644 --- a/core/http/endpoints/openresponses/responses.go +++ b/core/http/endpoints/openresponses/responses.go @@ -95,7 +95,7 @@ func ResponsesEndpoint(cl *config.ModelConfigLoader, ml *model.ModelLoader, eval // Add instructions as system message if provided if input.Instructions != "" { - messages = append([]schema.Message{{Role: "system", StringContent: input.Instructions}}, messages...) + messages = append([]schema.Message{{Role: "system", Content: input.Instructions, StringContent: input.Instructions}}, messages...) } // Handle tools @@ -299,7 +299,7 @@ func convertORInputToMessages(input any, cfg *config.ModelConfig) ([]schema.Mess switch v := input.(type) { case string: // Simple string = user message - return []schema.Message{{Role: "user", StringContent: v}}, nil + return []schema.Message{{Role: "user", Content: v, StringContent: v}}, nil case []any: // Array of items for _, itemRaw := range v { @@ -309,6 +309,16 @@ func convertORInputToMessages(input any, cfg *config.ModelConfig) ([]schema.Mess } itemType, _ := itemMap["type"].(string) + // OpenAI SDK helpers (e.g. client.responses.create(input=[{"role":...,"content":...}])) + // send message items without a "type" discriminator. Treat a bare {role, content} + // object as type:"message" so the chat-completions and responses paths agree. + if itemType == "" { + if _, hasRole := itemMap["role"].(string); hasRole { + if _, hasContent := itemMap["content"]; hasContent { + itemType = "message" + } + } + } switch itemType { case "message": msg, err := convertORMessageItem(itemMap, cfg) diff --git a/core/http/endpoints/openresponses/responses_convert_test.go b/core/http/endpoints/openresponses/responses_convert_test.go new file mode 100644 index 000000000..9dfe861bb --- /dev/null +++ b/core/http/endpoints/openresponses/responses_convert_test.go @@ -0,0 +1,62 @@ +package openresponses + +import ( + "github.com/mudler/LocalAI/core/config" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +// Regression for mudler/LocalAI#10039. convertORInputToMessages must populate +// both Content and StringContent: the templating fallback path reads +// StringContent, while the UseTokenizerTemplate path serialises Content via +// Messages.ToProto(). Leaving Content nil produced an empty prompt on any model +// without a Go-side template.chat_message block (the default for imported GGUFs). +var _ = Describe("convertORInputToMessages", func() { + cfg := &config.ModelConfig{} + + It("populates both Content and StringContent for plain string input", func() { + msgs, err := convertORInputToMessages("Hello", cfg) + Expect(err).NotTo(HaveOccurred()) + Expect(msgs).To(HaveLen(1)) + Expect(msgs[0].Role).To(Equal("user")) + Expect(msgs[0].StringContent).To(Equal("Hello")) + Expect(msgs[0].Content).To(Equal("Hello")) + }) + + It("accepts a bare {role, content} item without a type discriminator", func() { + // The OpenAI Python SDK helper client.responses.create(input=[{...}]) + // sends message items with no "type" field. They must not be dropped. + input := []any{ + map[string]any{"role": "user", "content": "Hi there"}, + } + msgs, err := convertORInputToMessages(input, cfg) + Expect(err).NotTo(HaveOccurred()) + Expect(msgs).To(HaveLen(1)) + Expect(msgs[0].Role).To(Equal("user")) + Expect(msgs[0].StringContent).To(Equal("Hi there")) + Expect(msgs[0].Content).To(Equal("Hi there")) + }) + + It("still populates both fields for an explicit type:message item", func() { + input := []any{ + map[string]any{"type": "message", "role": "user", "content": "Typed"}, + } + msgs, err := convertORInputToMessages(input, cfg) + Expect(err).NotTo(HaveOccurred()) + Expect(msgs).To(HaveLen(1)) + Expect(msgs[0].StringContent).To(Equal("Typed")) + Expect(msgs[0].Content).To(Equal("Typed")) + }) + + It("does not treat a non-message item (no content key) as a message", func() { + // An item with neither a known type nor a {role, content} shape must + // keep falling through unchanged — no behaviour change for such inputs. + input := []any{ + map[string]any{"role": "user"}, + } + msgs, err := convertORInputToMessages(input, cfg) + Expect(err).NotTo(HaveOccurred()) + Expect(msgs).To(BeEmpty()) + }) +}) diff --git a/core/schema/message_test.go b/core/schema/message_test.go index d14b0c16c..da11d9d20 100644 --- a/core/schema/message_test.go +++ b/core/schema/message_test.go @@ -332,5 +332,41 @@ var _ = Describe("LLM tests", func() { // 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")) + }) }) }) diff --git a/core/templates/evaluator.go b/core/templates/evaluator.go index 7b64a2455..8ee74e43d 100644 --- a/core/templates/evaluator.go +++ b/core/templates/evaluator.go @@ -111,7 +111,11 @@ func (e *Evaluator) TemplateMessages(input schema.OpenAIRequest, messages []sche } } r := config.Roles[role] - contentExists := i.Content != nil && i.StringContent != "" + // Treat StringContent as the source of truth — every downstream fallback branch in this + // function reads StringContent, not Content. Gating on both with && silently drops + // messages that have StringContent set but Content nil (e.g. /v1/responses string-input + // before mudler/LocalAI#10039 fix). + contentExists := i.StringContent != "" fcall := i.FunctionCall if len(i.ToolCalls) > 0 { diff --git a/core/templates/evaluator_test.go b/core/templates/evaluator_test.go index b97abc32d..36723713c 100644 --- a/core/templates/evaluator_test.go +++ b/core/templates/evaluator_test.go @@ -218,4 +218,41 @@ var _ = Describe("Templates", func() { }) } }) + // Regression test for mudler/LocalAI#10039: when a model has no Go-side + // TemplateConfig.ChatMessage block (e.g. backends that rely on the GGUF's + // jinja template), TemplateMessages falls through to the role-prefix path. + // That path must still render messages whose StringContent is populated but + // Content (any) is nil — which is the shape /v1/responses produced before + // the fix to convertORInputToMessages. + Context("fallback path with StringContent-only message (no ChatMessage template)", func() { + var evaluator *Evaluator + BeforeEach(func() { + evaluator = NewEvaluator("") + }) + It("renders the role prefix and content when only StringContent is set", func() { + cfg := &config.ModelConfig{ + TemplateConfig: config.TemplateConfig{}, + Roles: map[string]string{"user": "USER: "}, + } + messages := []schema.Message{ + { + Role: "user", + StringContent: "hello", + // Content intentionally left nil — reproduces /v1/responses string-input. + }, + } + templated := evaluator.TemplateMessages(schema.OpenAIRequest{}, messages, cfg, []functions.Function{}, false) + Expect(templated).To(Equal("USER: hello"), templated) + }) + It("renders content even with no role mapping", func() { + cfg := &config.ModelConfig{ + TemplateConfig: config.TemplateConfig{}, + } + messages := []schema.Message{ + {Role: "user", StringContent: "hello"}, + } + templated := evaluator.TemplateMessages(schema.OpenAIRequest{}, messages, cfg, []functions.Function{}, false) + Expect(templated).To(Equal("hello"), templated) + }) + }) })