From 498d18d4854ba1af2d2cec2236f571d1c4a1e4c4 Mon Sep 17 00:00:00 2001 From: localai-org-maint-bot <306269227+localai-org-maint-bot@users.noreply.github.com> Date: Mon, 28 Sep 2026 20:05:09 +0000 Subject: [PATCH] fix(responses): preserve replayed output_text Accept response output text when clients replay it as request input. Keep assistant history in both prompt content fields and preserve user message boundaries when merging adjacent assistant messages. Assisted-by: Codex:GPT-6 Signed-off-by: Ettore Di Giacinto (cherry picked from commit 1cc5336aaab728a1f571f94e08ab721a035426b5) --- .../http/endpoints/openresponses/responses.go | 2 +- .../openresponses/responses_convert_test.go | 62 +++++++++++++++++++ docs/content/features/text-generation.md | 31 ++++++++++ 3 files changed, 94 insertions(+), 1 deletion(-) diff --git a/core/http/endpoints/openresponses/responses.go b/core/http/endpoints/openresponses/responses.go index d88c95326..88b0c8c28 100644 --- a/core/http/endpoints/openresponses/responses.go +++ b/core/http/endpoints/openresponses/responses.go @@ -755,7 +755,7 @@ func convertORMessageItem(itemMap map[string]any, cfg *config.ModelConfig) (sche partType, _ := partMap["type"].(string) switch partType { - case "input_text": + case "input_text", "output_text": if text, ok := partMap["text"].(string); ok { textContent += text } diff --git a/core/http/endpoints/openresponses/responses_convert_test.go b/core/http/endpoints/openresponses/responses_convert_test.go index 2a204b4a4..5326f008d 100644 --- a/core/http/endpoints/openresponses/responses_convert_test.go +++ b/core/http/endpoints/openresponses/responses_convert_test.go @@ -1,6 +1,8 @@ package openresponses import ( + "encoding/json" + "github.com/mudler/LocalAI/core/config" "github.com/mudler/LocalAI/core/schema" @@ -50,6 +52,66 @@ var _ = Describe("convertORInputToMessages", func() { Expect(msgs[0].Content).To(Equal("Typed")) }) + Context("replaying serialized response output", func() { + outputItems := func(items ...schema.ORItemField) []any { + data, err := json.Marshal(schema.ORResponseResource{Output: items}) + Expect(err).NotTo(HaveOccurred()) + var response struct { + Output []any `json:"output"` + } + Expect(json.Unmarshal(data, &response)).To(Succeed()) + return response.Output + } + convertRequest := func(input []any) []schema.Message { + data, err := json.Marshal(schema.OpenResponsesRequest{Input: input}) + Expect(err).NotTo(HaveOccurred()) + var request schema.OpenResponsesRequest + Expect(json.Unmarshal(data, &request)).To(Succeed()) + msgs, err := convertORInputToMessages(request.Input, cfg) + Expect(err).NotTo(HaveOccurred()) + return msgs + } + assistant := func(parts ...string) schema.ORItemField { + var content []schema.ORContentPart + for _, text := range parts { + content = append(content, schema.ORContentPart{Type: "output_text", Text: text}) + } + return schema.ORItemField{Type: "message", Role: "assistant", Content: content} + } + user := func(text string) any { + return map[string]any{"role": "user", "content": []any{ + map[string]any{"type": "input_text", "text": text}, + }} + } + + It("preserves assistant output_text in both content fields", func() { + msgs := convertRequest(outputItems(assistant("Hello again"))) + Expect(msgs).To(HaveLen(1)) + Expect(msgs[0].Role).To(Equal("assistant")) + Expect(msgs[0].Content).To(Equal("Hello again")) + Expect(msgs[0].StringContent).To(Equal("Hello again")) + }) + + It("joins text parts and contiguous assistant messages across user input_text boundaries", func() { + input := []any{user("First question")} + input = append(input, outputItems(assistant("First ", "answer"), assistant("More detail"))...) + input = append(input, user("Next question")) + input = append(input, outputItems(assistant("Second answer"))...) + msgs := convertRequest(input) + Expect(msgs).To(HaveLen(4)) + for i, expected := range []struct{ role, text string }{ + {"user", "First question"}, + {"assistant", "First answer\nMore detail"}, + {"user", "Next question"}, + {"assistant", "Second answer"}, + } { + Expect(msgs[i].Role).To(Equal(expected.role)) + Expect(msgs[i].Content).To(Equal(expected.text)) + Expect(msgs[i].StringContent).To(Equal(expected.text)) + } + }) + }) + 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. diff --git a/docs/content/features/text-generation.md b/docs/content/features/text-generation.md index b133f75e4..fecd61eb0 100644 --- a/docs/content/features/text-generation.md +++ b/docs/content/features/text-generation.md @@ -242,6 +242,37 @@ curl http://localhost:8080/v1/responses \ }' ``` +#### Reuse assistant messages + +When you manage conversation history in your client, append assistant messages from a response's `output` array to the next request's `input`. +Keep their content type as `output_text`. Use `input_text` for new user messages. +Include earlier conversation items that the model still needs. + +For example, submit this body to `POST /v1/responses`: + +```json +{ + "model": "ggml-koala-7b-model-q4_0-r2.bin", + "input": [ + { + "type": "message", + "role": "user", + "content": [{"type": "input_text", "text": "What is the capital of France?"}] + }, + { + "type": "message", + "role": "assistant", + "content": [{"type": "output_text", "text": "Paris is the capital of France."}] + }, + { + "type": "message", + "role": "user", + "content": [{"type": "input_text", "text": "Which river flows through it?"}] + } + ] +} +``` + #### WebSocket Responses Connect to `ws://localhost:8080/v1/responses` (or `wss://` when TLS is