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) + }) + }) })