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

640 lines
21 KiB
Go

package openai
import (
"context"
"github.com/mudler/LocalAI/core/backend"
"github.com/mudler/LocalAI/core/config"
"github.com/mudler/LocalAI/core/schema"
pb "github.com/mudler/LocalAI/pkg/grpc/proto"
model "github.com/mudler/LocalAI/pkg/model"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
type modelInferenceFunc = func(
ctx context.Context, s string, messages schema.Messages,
images, videos, audios []string,
loader *model.ModelLoader, c *config.ModelConfig, cl *config.ModelConfigLoader,
o *config.ApplicationConfig,
tokenCallback func(string, backend.TokenUsage) bool,
tools, toolChoice string,
logprobs, topLogprobs *int,
logitBias map[string]float64,
metadata map[string]string,
) (func() (backend.LLMResponse, error), error)
var _ = Describe("ComputeChoices", func() {
var (
origInference modelInferenceFunc
cfg *config.ModelConfig
appCfg *config.ApplicationConfig
)
// mockInference installs a stub that yields the given responses sequentially.
// After all responses are consumed, the last one is repeated.
mockInference := func(responses []backend.LLMResponse) {
idx := 0
backend.ModelInferenceFunc = func(
ctx context.Context, s string, messages schema.Messages,
images, videos, audios []string,
loader *model.ModelLoader, c *config.ModelConfig, cl *config.ModelConfigLoader,
o *config.ApplicationConfig,
tokenCallback func(string, backend.TokenUsage) bool,
tools, toolChoice string,
logprobs, topLogprobs *int,
logitBias map[string]float64,
metadata map[string]string,
) (func() (backend.LLMResponse, error), error) {
predFunc := func() (backend.LLMResponse, error) {
resp := responses[idx]
if idx < len(responses)-1 {
idx++
}
return resp, nil
}
return predFunc, nil
}
}
BeforeEach(func() {
origInference = backend.ModelInferenceFunc
cfg = &config.ModelConfig{}
appCfg = config.NewApplicationConfig()
})
AfterEach(func() {
backend.ModelInferenceFunc = origInference
})
makeReq := func() *schema.OpenAIRequest {
ctx, cancel := context.WithCancel(context.Background())
_ = cancel
return &schema.OpenAIRequest{
Context: ctx,
Cancel: cancel,
}
}
Context("normal response (no retry needed)", func() {
It("should return choices on first attempt", func() {
mockInference([]backend.LLMResponse{
{Response: "Hello world", Usage: backend.TokenUsage{Prompt: 10, Completion: 5}},
})
var captured string
choices, usage, _, err := ComputeChoices(
makeReq(), "test prompt", cfg, nil, appCfg, nil,
func(s string, c *[]schema.Choice) {
captured = s
*c = append(*c, schema.Choice{Text: s})
},
nil,
)
Expect(err).ToNot(HaveOccurred())
Expect(choices).To(HaveLen(1))
Expect(captured).To(Equal("Hello world"))
Expect(usage.Prompt).To(Equal(10))
Expect(usage.Completion).To(Equal(5))
})
})
Context("empty response triggers built-in retry", func() {
It("should retry and eventually return non-empty response", func() {
mockInference([]backend.LLMResponse{
{Response: ""}, // attempt 0: empty
{Response: " "}, // attempt 1: whitespace-only
{Response: "Got it", Usage: backend.TokenUsage{Prompt: 8, Completion: 3}}, // attempt 2: success
})
choices, usage, _, err := ComputeChoices(
makeReq(), "test", cfg, nil, appCfg, nil,
func(s string, c *[]schema.Choice) {
*c = append(*c, schema.Choice{Text: s})
},
nil,
)
Expect(err).ToNot(HaveOccurred())
Expect(choices).To(HaveLen(1))
Expect(choices[0].Text).To(Equal("Got it"))
Expect(usage.Prompt).To(Equal(8))
Expect(usage.Completion).To(Equal(3))
})
})
Context("all retries exhausted on empty response", func() {
It("should return the empty response after max retries", func() {
mockInference([]backend.LLMResponse{
{Response: ""}, // always empty
})
choices, _, _, err := ComputeChoices(
makeReq(), "test", cfg, nil, appCfg, nil,
func(s string, c *[]schema.Choice) {
*c = append(*c, schema.Choice{Text: s})
},
nil,
)
Expect(err).ToNot(HaveOccurred())
// After maxRetries, it proceeds with the empty response
Expect(choices).To(HaveLen(1))
Expect(choices[0].Text).To(BeEmpty())
})
})
Context("shouldRetry callback", func() {
It("should call shouldRetry and retry when it returns true", func() {
callCount := 0
mockInference([]backend.LLMResponse{
{Response: "reasoning-only", Usage: backend.TokenUsage{Prompt: 5, Completion: 2}},
{Response: "actual-answer", Usage: backend.TokenUsage{Prompt: 5, Completion: 4}},
})
retryAttempts := []int{}
choices, usage, _, err := ComputeChoices(
makeReq(), "test", cfg, nil, appCfg, nil,
func(s string, c *[]schema.Choice) {
callCount++
*c = append(*c, schema.Choice{Text: s})
},
nil,
func(attempt int) bool {
retryAttempts = append(retryAttempts, attempt)
// Retry on first attempt only
return attempt == 0
},
)
Expect(err).ToNot(HaveOccurred())
Expect(choices).To(HaveLen(1))
Expect(choices[0].Text).To(Equal("actual-answer"))
// shouldRetry was called twice: once returning true (retry), once returning false (proceed)
Expect(retryAttempts).To(Equal([]int{0, 1}))
// cb was called twice (once per attempt)
Expect(callCount).To(Equal(2))
// Token usage should be from the LATEST attempt
Expect(usage.Prompt).To(Equal(5))
Expect(usage.Completion).To(Equal(4))
})
It("should not retry when shouldRetry returns false", func() {
mockInference([]backend.LLMResponse{
{Response: "first-response"},
})
shouldRetryCalled := false
choices, _, _, err := ComputeChoices(
makeReq(), "test", cfg, nil, appCfg, nil,
func(s string, c *[]schema.Choice) {
*c = append(*c, schema.Choice{Text: s})
},
nil,
func(attempt int) bool {
shouldRetryCalled = true
return false
},
)
Expect(err).ToNot(HaveOccurred())
Expect(choices).To(HaveLen(1))
Expect(choices[0].Text).To(Equal("first-response"))
Expect(shouldRetryCalled).To(BeTrue())
})
})
Context("shouldRetry not provided (variadic omitted)", func() {
It("should work without shouldRetry parameter", func() {
mockInference([]backend.LLMResponse{
{Response: "works"},
})
choices, _, _, err := ComputeChoices(
makeReq(), "test", cfg, nil, appCfg, nil,
func(s string, c *[]schema.Choice) {
*c = append(*c, schema.Choice{Text: s})
},
nil,
)
Expect(err).ToNot(HaveOccurred())
Expect(choices).To(HaveLen(1))
Expect(choices[0].Text).To(Equal("works"))
})
})
Context("token usage from latest attempt", func() {
It("should use token usage from the last attempt, not accumulated", func() {
mockInference([]backend.LLMResponse{
{Response: "retry-me", Usage: backend.TokenUsage{Prompt: 100, Completion: 50}},
{Response: "final", Usage: backend.TokenUsage{Prompt: 10, Completion: 5}},
})
_, usage, _, err := ComputeChoices(
makeReq(), "test", cfg, nil, appCfg, nil,
func(s string, c *[]schema.Choice) {
*c = append(*c, schema.Choice{Text: s})
},
nil,
func(attempt int) bool { return attempt == 0 },
)
Expect(err).ToNot(HaveOccurred())
// Should be the LATEST attempt's usage, not accumulated
Expect(usage.Prompt).To(Equal(10))
Expect(usage.Completion).To(Equal(5))
})
})
Context("chat deltas from latest attempt", func() {
It("should return chat deltas from the last attempt when retry is allowed", func() {
// When the first attempt has only reasoning (no content/tool calls),
// the caller-driven retry proceeds and we get deltas from the last attempt.
mockInference([]backend.LLMResponse{
{
Response: "retry-me",
ChatDeltas: []*pb.ChatDelta{{ReasoningContent: "thinking..."}},
},
{
Response: "final",
ChatDeltas: []*pb.ChatDelta{{Content: "new"}},
},
})
_, _, deltas, err := ComputeChoices(
makeReq(), "test", cfg, nil, appCfg, nil,
func(s string, c *[]schema.Choice) {
*c = append(*c, schema.Choice{Text: s})
},
nil,
func(attempt int) bool { return attempt == 0 },
)
Expect(err).ToNot(HaveOccurred())
Expect(deltas).To(HaveLen(1))
Expect(deltas[0].Content).To(Equal("new"))
})
It("should keep first attempt deltas when ChatDeltas have content (skip retry)", func() {
// When the first attempt has content in ChatDeltas, skipCallerRetry
// prevents the retry — the autoparser already parsed successfully.
mockInference([]backend.LLMResponse{
{
Response: "autoparser-content",
ChatDeltas: []*pb.ChatDelta{{Content: "first-content"}},
},
{
Response: "should-not-reach",
ChatDeltas: []*pb.ChatDelta{{Content: "second-content"}},
},
})
retryRequested := false
_, _, deltas, err := ComputeChoices(
makeReq(), "test", cfg, nil, appCfg, nil,
func(s string, c *[]schema.Choice) {
*c = append(*c, schema.Choice{Text: s})
},
nil,
func(attempt int) bool {
retryRequested = true
return true
},
)
Expect(err).ToNot(HaveOccurred())
Expect(retryRequested).To(BeFalse(),
"shouldRetry should not be called when ChatDeltas have content")
Expect(deltas).To(HaveLen(1))
Expect(deltas[0].Content).To(Equal("first-content"))
})
})
Context("result choices cleared on retry", func() {
It("should only contain choices from the final attempt", func() {
mockInference([]backend.LLMResponse{
{Response: "bad-choice"},
{Response: "good-choice"},
})
choices, _, _, err := ComputeChoices(
makeReq(), "test", cfg, nil, appCfg, nil,
func(s string, c *[]schema.Choice) {
*c = append(*c, schema.Choice{Text: s})
},
nil,
func(attempt int) bool { return attempt == 0 },
)
Expect(err).ToNot(HaveOccurred())
Expect(choices).To(HaveLen(1))
Expect(choices[0].Text).To(Equal("good-choice"))
})
})
Context("shouldRetry with max retries cap", func() {
It("should stop retrying after maxRetries even if shouldRetry returns true", func() {
attempts := 0
mockInference([]backend.LLMResponse{
{Response: "always-retry"},
})
choices, _, _, err := ComputeChoices(
makeReq(), "test", cfg, nil, appCfg, nil,
func(s string, c *[]schema.Choice) {
*c = append(*c, schema.Choice{Text: s})
},
nil,
func(attempt int) bool {
attempts++
return true // always want to retry
},
)
Expect(err).ToNot(HaveOccurred())
Expect(choices).To(HaveLen(1))
// maxRetries is 5, so shouldRetry is called for attempts 0..4,
// but attempt 5 is the final one where shouldRetry can't trigger continue
Expect(attempts).To(BeNumerically("<=", 6))
})
})
Context("N > 1 completions", func() {
It("should produce N separate completions", func() {
callIdx := 0
responses := []string{"first", "second", "third"}
backend.ModelInferenceFunc = func(
ctx context.Context, s string, messages schema.Messages,
images, videos, audios []string,
loader *model.ModelLoader, c *config.ModelConfig, cl *config.ModelConfigLoader,
o *config.ApplicationConfig,
tokenCallback func(string, backend.TokenUsage) bool,
tools, toolChoice string,
logprobs, topLogprobs *int,
logitBias map[string]float64,
metadata map[string]string,
) (func() (backend.LLMResponse, error), error) {
predFunc := func() (backend.LLMResponse, error) {
resp := backend.LLMResponse{Response: responses[callIdx]}
if callIdx < len(responses)-1 {
callIdx++
}
return resp, nil
}
return predFunc, nil
}
req := makeReq()
req.N = 3
choices, _, _, err := ComputeChoices(
req, "test", cfg, nil, appCfg, nil,
func(s string, c *[]schema.Choice) {
*c = append(*c, schema.Choice{Text: s})
},
nil,
)
Expect(err).ToNot(HaveOccurred())
Expect(choices).To(HaveLen(3))
Expect(choices[0].Text).To(Equal("first"))
Expect(choices[1].Text).To(Equal("second"))
Expect(choices[2].Text).To(Equal("third"))
})
})
Context("reachedTokenBudget", func() {
ptr := func(i int) *int { return &i }
It("is false when no limit is configured", func() {
Expect(reachedTokenBudget(1000, nil)).To(BeFalse())
Expect(reachedTokenBudget(1000, ptr(0))).To(BeFalse())
Expect(reachedTokenBudget(1000, ptr(-1))).To(BeFalse())
})
It("is false when generation stopped below the limit", func() {
Expect(reachedTokenBudget(99, ptr(100))).To(BeFalse())
})
It("is true when generation reached or exceeded the limit", func() {
Expect(reachedTokenBudget(100, ptr(100))).To(BeTrue())
Expect(reachedTokenBudget(101, ptr(100))).To(BeTrue())
})
// Truncation labeling for agentic clients (cogito): a reasoning model
// that meets its output ceiling must report finish_reason=length so the
// caller can detect the cutoff rather than treat it as a clean stop.
It("reports the budget as reached when completion meets max_tokens", func() {
max := 16
Expect(reachedTokenBudget(16, &max)).To(BeTrue())
Expect(reachedTokenBudget(15, &max)).To(BeFalse())
})
It("does not flag a budget when max_tokens is zero or nil", func() {
zero := 0
Expect(reachedTokenBudget(100, &zero)).To(BeFalse())
Expect(reachedTokenBudget(100, nil)).To(BeFalse())
})
})
Context("max_tokens budget exhausted on reasoning (issue #9716)", func() {
// Reproduces the streaming retry loop: when a thinking model spends its
// entire max_tokens budget on the reasoning block, the C++ autoparser
// clears the raw Response and delivers reasoning-only ChatDeltas (no
// content, no tool calls). The built-in empty-response retry then fires
// and regenerates from scratch up to maxRetries times, each re-consuming
// the whole budget — instead of terminating with finish_reason "length".
It("should NOT retry when the token budget was exhausted", func() {
maxTokens := 100
cfg.Maxtokens = &maxTokens
calls := 0
backend.ModelInferenceFunc = func(
ctx context.Context, s string, messages schema.Messages,
images, videos, audios []string,
loader *model.ModelLoader, c *config.ModelConfig, cl *config.ModelConfigLoader,
o *config.ApplicationConfig,
tokenCallback func(string, backend.TokenUsage) bool,
tools, toolChoice string,
logprobs, topLogprobs *int,
logitBias map[string]float64,
metadata map[string]string,
) (func() (backend.LLMResponse, error), error) {
predFunc := func() (backend.LLMResponse, error) {
calls++
// Autoparser cleared Response; only reasoning was produced,
// and the completion count reached the max_tokens budget.
return backend.LLMResponse{
Response: "",
ChatDeltas: []*pb.ChatDelta{{ReasoningContent: "thinking..."}},
Usage: backend.TokenUsage{Prompt: 5, Completion: maxTokens},
}, nil
}
return predFunc, nil
}
_, usage, _, err := ComputeChoices(
makeReq(), "test", cfg, nil, appCfg, nil,
func(s string, c *[]schema.Choice) {
*c = append(*c, schema.Choice{Text: s})
},
nil,
)
Expect(err).ToNot(HaveOccurred())
// The model hit its token ceiling; regenerating would just hit it
// again and multiply token consumption. Exactly one call expected.
Expect(calls).To(Equal(1), "budget-exhausted generation must not be retried")
Expect(usage.Completion).To(Equal(maxTokens))
})
})
Context("with streaming token callback", func() {
It("should call tokenCallback for streaming responses", func() {
var streamedTokens []string
backend.ModelInferenceFunc = func(
ctx context.Context, s string, messages schema.Messages,
images, videos, audios []string,
loader *model.ModelLoader, c *config.ModelConfig, cl *config.ModelConfigLoader,
o *config.ApplicationConfig,
tokenCallback func(string, backend.TokenUsage) bool,
tools, toolChoice string,
logprobs, topLogprobs *int,
logitBias map[string]float64,
metadata map[string]string,
) (func() (backend.LLMResponse, error), error) {
predFunc := func() (backend.LLMResponse, error) {
if tokenCallback != nil {
tokenCallback("Hello", backend.TokenUsage{Prompt: 5})
tokenCallback(" world", backend.TokenUsage{Prompt: 5, Completion: 2})
}
return backend.LLMResponse{
Response: "Hello world",
Usage: backend.TokenUsage{Prompt: 5, Completion: 2},
}, nil
}
return predFunc, nil
}
choices, _, _, err := ComputeChoices(
makeReq(), "test", cfg, nil, appCfg, nil,
func(s string, c *[]schema.Choice) {
*c = append(*c, schema.Choice{Text: s})
},
func(s string, usage backend.TokenUsage) bool {
streamedTokens = append(streamedTokens, s)
return true
},
)
Expect(err).ToNot(HaveOccurred())
Expect(choices).To(HaveLen(1))
Expect(streamedTokens).To(Equal([]string{"Hello", " world"}))
})
It("should pass chat deltas through TokenUsage during streaming", func() {
var receivedDeltas [][]*pb.ChatDelta
backend.ModelInferenceFunc = func(
ctx context.Context, s string, messages schema.Messages,
images, videos, audios []string,
loader *model.ModelLoader, c *config.ModelConfig, cl *config.ModelConfigLoader,
o *config.ApplicationConfig,
tokenCallback func(string, backend.TokenUsage) bool,
tools, toolChoice string,
logprobs, topLogprobs *int,
logitBias map[string]float64,
metadata map[string]string,
) (func() (backend.LLMResponse, error), error) {
predFunc := func() (backend.LLMResponse, error) {
if tokenCallback != nil {
// Simulate C++ autoparser sending reasoning in chat deltas
tokenCallback("<|channel>thought\nthinking\n<channel|>", backend.TokenUsage{
Prompt: 5,
ChatDeltas: []*pb.ChatDelta{
{ReasoningContent: "thinking"},
},
})
tokenCallback("Hello!", backend.TokenUsage{
Prompt: 5, Completion: 3,
ChatDeltas: []*pb.ChatDelta{
{Content: "Hello!"},
},
})
}
return backend.LLMResponse{
Response: "<|channel>thought\nthinking\n<channel|>Hello!",
Usage: backend.TokenUsage{Prompt: 5, Completion: 3},
ChatDeltas: []*pb.ChatDelta{
{ReasoningContent: "thinking"},
{Content: "Hello!"},
},
}, nil
}
return predFunc, nil
}
choices, _, deltas, err := ComputeChoices(
makeReq(), "test", cfg, nil, appCfg, nil,
func(s string, c *[]schema.Choice) {
*c = append(*c, schema.Choice{Text: s})
},
func(s string, usage backend.TokenUsage) bool {
// Capture chat deltas received per-chunk
if len(usage.ChatDeltas) > 0 {
receivedDeltas = append(receivedDeltas, usage.ChatDeltas)
}
return true
},
)
Expect(err).ToNot(HaveOccurred())
Expect(choices).To(HaveLen(1))
// Verify per-chunk deltas were received during streaming
Expect(receivedDeltas).To(HaveLen(2))
Expect(receivedDeltas[0][0].ReasoningContent).To(Equal("thinking"))
Expect(receivedDeltas[1][0].Content).To(Equal("Hello!"))
// Verify final accumulated deltas are also returned
Expect(deltas).To(HaveLen(2))
Expect(deltas[0].ReasoningContent).To(Equal("thinking"))
Expect(deltas[1].Content).To(Equal("Hello!"))
})
It("should prefer chat deltas over raw text when HasChatDeltaContent is true", func() {
// Verify that the callback can distinguish between
// chunks with and without chat deltas
var withDeltas, withoutDeltas int
backend.ModelInferenceFunc = func(
ctx context.Context, s string, messages schema.Messages,
images, videos, audios []string,
loader *model.ModelLoader, c *config.ModelConfig, cl *config.ModelConfigLoader,
o *config.ApplicationConfig,
tokenCallback func(string, backend.TokenUsage) bool,
tools, toolChoice string,
logprobs, topLogprobs *int,
logitBias map[string]float64,
metadata map[string]string,
) (func() (backend.LLMResponse, error), error) {
predFunc := func() (backend.LLMResponse, error) {
if tokenCallback != nil {
// Chunk with chat deltas (C++ autoparser active)
tokenCallback("raw-text", backend.TokenUsage{
ChatDeltas: []*pb.ChatDelta{{Content: "parsed-content"}},
})
// Chunk without chat deltas (fallback)
tokenCallback("fallback-text", backend.TokenUsage{})
}
return backend.LLMResponse{Response: "raw-textfallback-text"}, nil
}
return predFunc, nil
}
_, _, _, err := ComputeChoices(
makeReq(), "test", cfg, nil, appCfg, nil,
func(s string, c *[]schema.Choice) {
*c = append(*c, schema.Choice{Text: s})
},
func(s string, usage backend.TokenUsage) bool {
if usage.HasChatDeltaContent() {
withDeltas++
r, c := usage.ChatDeltaReasoningAndContent()
Expect(c).To(Equal("parsed-content"))
Expect(r).To(BeEmpty())
} else {
withoutDeltas++
}
return true
},
)
Expect(err).ToNot(HaveOccurred())
Expect(withDeltas).To(Equal(1))
Expect(withoutDeltas).To(Equal(1))
})
})
})