mirror of
https://github.com/mudler/LocalAI.git
synced 2026-07-12 09:20:23 -04:00
* 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 <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>
This commit is contained in:
@@ -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)
|
||||
|
||||
62
core/http/endpoints/openresponses/responses_convert_test.go
Normal file
62
core/http/endpoints/openresponses/responses_convert_test.go
Normal file
@@ -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())
|
||||
})
|
||||
})
|
||||
@@ -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"))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user