Files
wehub-resource-sync 1b8708893a
Security Scan / tests (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:12:26 +08:00

360 lines
14 KiB
Go

package openai
import (
"github.com/mudler/LocalAI/core/config"
"github.com/mudler/LocalAI/pkg/functions"
pb "github.com/mudler/LocalAI/pkg/grpc/proto"
reason "github.com/mudler/LocalAI/pkg/reasoning"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
"github.com/mudler/LocalAI/core/schema"
)
var _ = Describe("handleQuestion", func() {
var cfg *config.ModelConfig
BeforeEach(func() {
cfg = &config.ModelConfig{}
})
Context("with no function results but non-empty result", func() {
It("should return the result directly", func() {
result, err := handleQuestion(cfg, nil, "Hello world", "prompt")
Expect(err).ToNot(HaveOccurred())
Expect(result).To(Equal("Hello world"))
})
})
Context("with no function results and empty result", func() {
It("should return empty string", func() {
result, err := handleQuestion(cfg, nil, "", "prompt")
Expect(err).ToNot(HaveOccurred())
Expect(result).To(BeEmpty())
})
})
Context("with function result containing a message argument", func() {
It("should extract the message from function arguments", func() {
funcResults := []functions.FuncCallResults{
{
Name: "answer",
Arguments: `{"message": "This is the answer"}`,
},
}
result, err := handleQuestion(cfg, funcResults, "", "prompt")
Expect(err).ToNot(HaveOccurred())
Expect(result).To(Equal("This is the answer"))
})
})
Context("with function result containing empty message", func() {
It("should return empty string when message is empty", func() {
funcResults := []functions.FuncCallResults{
{
Name: "answer",
Arguments: `{"message": ""}`,
},
}
result, err := handleQuestion(cfg, funcResults, "", "prompt")
Expect(err).ToNot(HaveOccurred())
Expect(result).To(BeEmpty())
})
})
Context("with function result containing invalid JSON arguments", func() {
It("should return empty string gracefully", func() {
funcResults := []functions.FuncCallResults{
{
Name: "answer",
Arguments: "not json",
},
}
result, err := handleQuestion(cfg, funcResults, "", "prompt")
Expect(err).ToNot(HaveOccurred())
Expect(result).To(BeEmpty())
})
})
Context("with cleaned content (no think tags)", func() {
It("should return content without think tags", func() {
// This tests the bug fix: handleQuestion should receive cleaned content,
// not raw text with <think> tags
result, err := handleQuestion(cfg, nil, "Just the answer", "prompt")
Expect(err).ToNot(HaveOccurred())
Expect(result).To(Equal("Just the answer"))
Expect(result).ToNot(ContainSubstring("<think>"))
})
})
Context("with raw think tags passed as result", func() {
It("would return content with think tags", func() {
result, err := handleQuestion(cfg, nil, "<think>reasoning</think>answer", "prompt")
Expect(err).ToNot(HaveOccurred())
Expect(result).To(Equal("<think>reasoning</think>answer"))
})
})
})
var _ = Describe("applyAutoparserOverride", func() {
// Regression test for https://github.com/mudler/LocalAI/issues/9985.
// When LocalAI templates a <think>-style reasoning model outside of jinja
// (e.g. the gallery qwen3 entry), the llama.cpp autoparser falls back to
// the "pure content" PEG parser which dumps the entire raw response,
// including <think>…</think>, into ChatDelta.Content and leaves
// ChatDelta.ReasoningContent empty. The Go side previously trusted that
// content verbatim and clobbered the tokenCallback's correctly-split
// reasoning, so <think> blocks leaked into the OpenAI `content` field.
Context("autoparser delivered content with embedded <think> tags and empty reasoning (issue #9985)", func() {
It("splits <think>…</think> out of content into the reasoning field", func() {
raw := "<think>\nOkay, the user said \"Hello\". I should reply warmly.\n</think>\n\nHello! How can I assist you today? 😊"
chatDeltas := []*pb.ChatDelta{
{Content: raw, ReasoningContent: ""},
}
result := applyAutoparserOverride(chatDeltas, "", reason.Config{}, nil)
Expect(result).To(HaveLen(1))
Expect(result[0].Message).ToNot(BeNil())
Expect(result[0].Message.Content).ToNot(BeNil())
content := *(result[0].Message.Content.(*string))
Expect(content).ToNot(ContainSubstring("<think>"),
"raw <think> tag must not leak into OpenAI content field")
Expect(content).ToNot(ContainSubstring("</think>"),
"raw </think> tag must not leak into OpenAI content field")
Expect(content).To(ContainSubstring("Hello! How can I assist you today?"),
"the model's actual answer must still be in content")
Expect(result[0].Message.Reasoning).ToNot(BeNil(),
"reasoning extracted from <think>…</think> must populate Reasoning")
Expect(*result[0].Message.Reasoning).To(ContainSubstring("Okay, the user said"))
})
It("does not run extraction when the autoparser already populated reasoning", func() {
// When the autoparser actually classified reasoning, leave its
// content/reasoning split untouched.
content := "Hello! How can I assist you today?"
reasoning := "Already split by the C++ autoparser."
chatDeltas := []*pb.ChatDelta{
{Content: content, ReasoningContent: reasoning},
}
result := applyAutoparserOverride(chatDeltas, "", reason.Config{}, nil)
Expect(result).To(HaveLen(1))
Expect(*(result[0].Message.Content.(*string))).To(Equal(content))
Expect(result[0].Message.Reasoning).ToNot(BeNil())
Expect(*result[0].Message.Reasoning).To(Equal(reasoning))
})
It("passes plain content through unchanged when no reasoning tags are present", func() {
content := "Just a normal answer with no reasoning at all."
chatDeltas := []*pb.ChatDelta{
{Content: content, ReasoningContent: ""},
}
result := applyAutoparserOverride(chatDeltas, "", reason.Config{}, nil)
Expect(result).To(HaveLen(1))
Expect(*(result[0].Message.Content.(*string))).To(Equal(content))
Expect(result[0].Message.Reasoning).To(BeNil())
})
It("strips an empty <think></think> block (qwen3 /no_think mode)", func() {
// qwen3 with the /no_think directive still emits an empty thinking
// block. The Go-side fallback must strip it from content rather than
// pass <think></think> through verbatim. No reasoning is set because
// the block has no body.
raw := "<think>\n\n</think>\n\nHello! How can I assist you today?"
chatDeltas := []*pb.ChatDelta{
{Content: raw, ReasoningContent: ""},
}
result := applyAutoparserOverride(chatDeltas, "", reason.Config{}, nil)
Expect(result).To(HaveLen(1))
content := *(result[0].Message.Content.(*string))
Expect(content).ToNot(ContainSubstring("<think>"))
Expect(content).ToNot(ContainSubstring("</think>"))
Expect(content).To(ContainSubstring("Hello! How can I assist you today?"))
})
It("returns the existing result when chatDeltas is empty", func() {
existing := []schema.Choice{{Index: 7}}
result := applyAutoparserOverride(nil, "", reason.Config{}, existing)
Expect(result).To(Equal(existing))
})
})
// Regression tests for the prefilled-thinking-token path (thinkingStartToken
// != ""). This is the configuration the gallery qwen3 family runs in: the
// chat template injects <think> into the prompt, so DetectThinkingStartToken
// returns "<think>" and the model's output begins *inside* a reasoning block
// — it emits a closing </think> but no opening tag.
//
// The defensive Go-side fallback prepends the start token so the standard
// extractor can pair it with the model's </think>. But on a *complete*
// response that contains NO closing tag (the model answered directly with no
// reasoning at all), prepending <think> manufactures an unclosed block that
// swallows the entire answer into reasoning, leaving content empty. That is
// the bug: short/direct answers (session names, JSON summaries) come back
// with an empty content field.
Context("autoparser delivered content with empty reasoning and a prefilled thinking token", func() {
const startToken = "<think>"
It("keeps a tag-less direct answer as content instead of swallowing it as reasoning", func() {
// Model answered directly: no <think>, no </think> anywhere.
chatDeltas := []*pb.ChatDelta{
{Content: "hello", ReasoningContent: ""},
}
result := applyAutoparserOverride(chatDeltas, startToken, reason.Config{}, nil)
Expect(result).To(HaveLen(1))
Expect(result[0].Message.Content).ToNot(BeNil())
Expect(*(result[0].Message.Content.(*string))).To(Equal("hello"),
"a complete answer with no closing reasoning tag must stay in content")
Expect(result[0].Message.Reasoning).To(BeNil(),
"no reasoning block was emitted, so Reasoning must not be set")
})
It("keeps a tag-less JSON answer as content (the summary case)", func() {
raw := `{"short":"Tests pass","long":"go test ./... succeeded."}`
chatDeltas := []*pb.ChatDelta{
{Content: raw, ReasoningContent: ""},
}
result := applyAutoparserOverride(chatDeltas, startToken, reason.Config{}, nil)
Expect(result).To(HaveLen(1))
Expect(*(result[0].Message.Content.(*string))).To(Equal(raw))
Expect(result[0].Message.Reasoning).To(BeNil())
})
It("still splits reasoning when the model emits the closing tag (prefill paired with </think>)", func() {
// The legitimate prefill case: <think> was in the prompt, so the
// output carries only the closing tag. The closing tag is the proof
// that a reasoning block exists, so extraction must run.
raw := "The user wants a greeting.\n</think>\n\nHello there!"
chatDeltas := []*pb.ChatDelta{
{Content: raw, ReasoningContent: ""},
}
result := applyAutoparserOverride(chatDeltas, startToken, reason.Config{}, nil)
Expect(result).To(HaveLen(1))
content := *(result[0].Message.Content.(*string))
Expect(content).To(ContainSubstring("Hello there!"))
Expect(content).ToNot(ContainSubstring("</think>"))
Expect(content).ToNot(ContainSubstring("The user wants a greeting"))
Expect(result[0].Message.Reasoning).ToNot(BeNil())
Expect(*result[0].Message.Reasoning).To(ContainSubstring("The user wants a greeting"))
})
It("still splits a fully-tagged <think>…</think> block with a prefill token set", func() {
raw := "<think>Reasoning here.</think>Final answer."
chatDeltas := []*pb.ChatDelta{
{Content: raw, ReasoningContent: ""},
}
result := applyAutoparserOverride(chatDeltas, startToken, reason.Config{}, nil)
Expect(result).To(HaveLen(1))
Expect(*(result[0].Message.Content.(*string))).To(Equal("Final answer."))
Expect(result[0].Message.Reasoning).ToNot(BeNil())
Expect(*result[0].Message.Reasoning).To(ContainSubstring("Reasoning here"))
})
// End-to-end regression for the real production failure: a request with
// enable_thinking=false against a <think>-capable model (qwen3 family).
//
// In non-thinking mode the model emits no reasoning block, so llama.cpp's
// autoparser correctly returns ChatDeltas with Content set and
// ReasoningContent EMPTY (verified against stock llama-server: the same
// model with chat_template_kwargs.enable_thinking=false returns
// reasoning_content=null and content="hello"). But thinkingStartToken is
// detected per-model from the enable_thinking=TRUE render
// (grpc-server renders with enable_thinking=true; DetectThinkingStartToken
// does not evaluate the jinja {% if enable_thinking %} conditional), so it
// is "<think>" even for this non-thinking request. The old code prepended
// it and swallowed the answer. This is the case that broke session
// summaries and auto-titles and was NOT covered before.
It("preserves content for a non-thinking-mode request (enable_thinking=false, empty reasoning_content)", func() {
// What llama.cpp's autoparser actually returns in non-thinking mode.
chatDeltas := []*pb.ChatDelta{
{Content: `{"short":"Go tests passed for internal/session"}`, ReasoningContent: ""},
}
result := applyAutoparserOverride(chatDeltas, startToken, reason.Config{}, nil)
Expect(result).To(HaveLen(1))
Expect(*(result[0].Message.Content.(*string))).To(Equal(`{"short":"Go tests passed for internal/session"}`),
"non-thinking-mode answers must reach the client intact, not be swallowed as reasoning")
Expect(result[0].Message.Reasoning).To(BeNil())
})
})
})
var _ = Describe("mergeToolCallDeltas", func() {
Context("with new tool calls", func() {
It("should append new tool calls", func() {
existing := []schema.ToolCall{}
deltas := []schema.ToolCall{
{Index: 0, ID: "tc1", Type: "function", FunctionCall: schema.FunctionCall{Name: "search"}},
}
result := mergeToolCallDeltas(existing, deltas)
Expect(result).To(HaveLen(1))
Expect(result[0].ID).To(Equal("tc1"))
Expect(result[0].FunctionCall.Name).To(Equal("search"))
})
})
Context("with argument appending", func() {
It("should append arguments to existing tool call", func() {
existing := []schema.ToolCall{
{Index: 0, ID: "tc1", Type: "function", FunctionCall: schema.FunctionCall{Name: "search", Arguments: `{"q":`}},
}
deltas := []schema.ToolCall{
{Index: 0, FunctionCall: schema.FunctionCall{Arguments: `"hello"}`}},
}
result := mergeToolCallDeltas(existing, deltas)
Expect(result).To(HaveLen(1))
Expect(result[0].FunctionCall.Arguments).To(Equal(`{"q":"hello"}`))
})
})
Context("with multiple tool calls", func() {
It("should track multiple tool calls by index", func() {
existing := []schema.ToolCall{}
deltas1 := []schema.ToolCall{
{Index: 0, ID: "tc1", Type: "function", FunctionCall: schema.FunctionCall{Name: "search"}},
}
result := mergeToolCallDeltas(existing, deltas1)
deltas2 := []schema.ToolCall{
{Index: 1, ID: "tc2", Type: "function", FunctionCall: schema.FunctionCall{Name: "browse"}},
}
result = mergeToolCallDeltas(result, deltas2)
Expect(result).To(HaveLen(2))
Expect(result[0].FunctionCall.Name).To(Equal("search"))
Expect(result[1].FunctionCall.Name).To(Equal("browse"))
})
})
Context("with ID update on existing tool call", func() {
It("should update ID when provided in delta", func() {
existing := []schema.ToolCall{
{Index: 0, FunctionCall: schema.FunctionCall{Name: "search"}},
}
deltas := []schema.ToolCall{
{Index: 0, ID: "new-id"},
}
result := mergeToolCallDeltas(existing, deltas)
Expect(result).To(HaveLen(1))
Expect(result[0].ID).To(Equal("new-id"))
Expect(result[0].FunctionCall.Name).To(Equal("search"))
})
})
})