mirror of
https://github.com/mudler/LocalAI.git
synced 2026-05-23 08:10:48 -04:00
Compare commits
8 Commits
dependabot
...
fix/backen
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
96f0ad90c6 | ||
|
|
8849701418 | ||
|
|
831573efde | ||
|
|
61bf34ea2f | ||
|
|
0b2ae3c6ca | ||
|
|
4735345105 | ||
|
|
7384fd800b | ||
|
|
6942713d85 |
3
.gitignore
vendored
3
.gitignore
vendored
@@ -77,3 +77,6 @@ local-backends/
|
||||
tests/e2e-ui/ui-test-server
|
||||
core/http/react-ui/playwright-report/
|
||||
core/http/react-ui/test-results/
|
||||
|
||||
# Local worktrees
|
||||
.worktrees/
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
# ds4 backend Makefile.
|
||||
#
|
||||
# Upstream pin lives below as DS4_VERSION?=2606543be7a8c125a32cee37f5d1d85dc78f2fcf
|
||||
# Upstream pin lives below as DS4_VERSION?=8d576642c39b9a2d782a80159ba84ef5a81c0b81
|
||||
# (.github/bump_deps.sh) can find and update it - matches the
|
||||
# llama-cpp / ik-llama-cpp / turboquant convention.
|
||||
|
||||
DS4_VERSION?=2606543be7a8c125a32cee37f5d1d85dc78f2fcf
|
||||
DS4_VERSION?=8d576642c39b9a2d782a80159ba84ef5a81c0b81
|
||||
DS4_REPO?=https://github.com/antirez/ds4
|
||||
|
||||
CURRENT_MAKEFILE_DIR := $(dir $(abspath $(lastword $(MAKEFILE_LIST))))
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
|
||||
LLAMA_VERSION?=ad277572619fcfb6ddd38f4c6437283a4b2b8636
|
||||
LLAMA_VERSION?=bb28c1fe246b72276ee1d00ce89306be7b865766
|
||||
LLAMA_REPO?=https://github.com/ggerganov/llama.cpp
|
||||
|
||||
CMAKE_ARGS?=
|
||||
|
||||
@@ -8,7 +8,7 @@ JOBS?=$(shell nproc --ignore=1)
|
||||
|
||||
# stablediffusion.cpp (ggml)
|
||||
STABLEDIFFUSION_GGML_REPO?=https://github.com/leejet/stable-diffusion.cpp
|
||||
STABLEDIFFUSION_GGML_VERSION?=5b0267e941cade15bd80089d89838795d9f4baa6
|
||||
STABLEDIFFUSION_GGML_VERSION?=3a8788cb7d74f185d6b18688e9563015524ecaf5
|
||||
|
||||
CMAKE_ARGS+=-DGGML_MAX_NAME=128
|
||||
|
||||
|
||||
@@ -552,6 +552,13 @@ func loadRuntimeSettingsFromFile(options *config.ApplicationConfig) {
|
||||
options.TracingMaxItems = *settings.TracingMaxItems
|
||||
}
|
||||
}
|
||||
if settings.TracingMaxBodyBytes != nil {
|
||||
// Allow the on-disk setting to override the CLI/env default. The
|
||||
// startup default is non-zero (see NewApplicationConfig), so a plain
|
||||
// `== 0` guard like the others would never trigger; we instead respect
|
||||
// any value the file specifies. 0 in the file means "uncapped".
|
||||
options.TracingMaxBodyBytes = *settings.TracingMaxBodyBytes
|
||||
}
|
||||
|
||||
// Branding / whitelabeling. There are no env vars for these — the file is
|
||||
// the only source — so apply unconditionally. Without this block a server
|
||||
|
||||
@@ -78,7 +78,7 @@ func ModelAudioTransform(
|
||||
|
||||
var startTime time.Time
|
||||
if appConfig.EnableTracing {
|
||||
trace.InitBackendTracingIfEnabled(appConfig.TracingMaxItems)
|
||||
trace.InitBackendTracingIfEnabled(appConfig.TracingMaxItems, appConfig.TracingMaxBodyBytes)
|
||||
startTime = time.Now()
|
||||
}
|
||||
|
||||
@@ -104,7 +104,7 @@ func ModelAudioTransform(
|
||||
data["sample_rate"] = res.SampleRate
|
||||
data["samples"] = res.Samples
|
||||
data["reference_provided"] = res.ReferenceProvided
|
||||
if snippet := trace.AudioSnippet(dst); snippet != nil {
|
||||
if snippet := trace.AudioSnippet(dst, appConfig.TracingMaxBodyBytes); snippet != nil {
|
||||
maps.Copy(data, snippet)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,7 +35,7 @@ func Detection(
|
||||
|
||||
var startTime time.Time
|
||||
if appConfig.EnableTracing {
|
||||
trace.InitBackendTracingIfEnabled(appConfig.TracingMaxItems)
|
||||
trace.InitBackendTracingIfEnabled(appConfig.TracingMaxItems, appConfig.TracingMaxBodyBytes)
|
||||
startTime = time.Now()
|
||||
}
|
||||
|
||||
|
||||
@@ -67,7 +67,7 @@ func ModelEmbedding(s string, tokens []int, loader *model.ModelLoader, modelConf
|
||||
}
|
||||
|
||||
if appConfig.EnableTracing {
|
||||
trace.InitBackendTracingIfEnabled(appConfig.TracingMaxItems)
|
||||
trace.InitBackendTracingIfEnabled(appConfig.TracingMaxItems, appConfig.TracingMaxBodyBytes)
|
||||
|
||||
traceData := map[string]any{
|
||||
"input_text": trace.TruncateString(s, 1000),
|
||||
|
||||
@@ -32,7 +32,7 @@ func FaceAnalyze(
|
||||
|
||||
var startTime time.Time
|
||||
if appConfig.EnableTracing {
|
||||
trace.InitBackendTracingIfEnabled(appConfig.TracingMaxItems)
|
||||
trace.InitBackendTracingIfEnabled(appConfig.TracingMaxItems, appConfig.TracingMaxBodyBytes)
|
||||
startTime = time.Now()
|
||||
}
|
||||
|
||||
|
||||
@@ -32,7 +32,7 @@ func FaceVerify(
|
||||
|
||||
var startTime time.Time
|
||||
if appConfig.EnableTracing {
|
||||
trace.InitBackendTracingIfEnabled(appConfig.TracingMaxItems)
|
||||
trace.InitBackendTracingIfEnabled(appConfig.TracingMaxItems, appConfig.TracingMaxBodyBytes)
|
||||
startTime = time.Now()
|
||||
}
|
||||
|
||||
|
||||
@@ -41,7 +41,7 @@ func ImageGeneration(height, width, step, seed int, positive_prompt, negative_pr
|
||||
}
|
||||
|
||||
if appConfig.EnableTracing {
|
||||
trace.InitBackendTracingIfEnabled(appConfig.TracingMaxItems)
|
||||
trace.InitBackendTracingIfEnabled(appConfig.TracingMaxItems, appConfig.TracingMaxBodyBytes)
|
||||
|
||||
traceData := map[string]any{
|
||||
"positive_prompt": positive_prompt,
|
||||
|
||||
@@ -305,7 +305,7 @@ func ModelInference(ctx context.Context, s string, messages schema.Messages, ima
|
||||
}
|
||||
|
||||
if o.EnableTracing {
|
||||
trace.InitBackendTracingIfEnabled(o.TracingMaxItems)
|
||||
trace.InitBackendTracingIfEnabled(o.TracingMaxItems, o.TracingMaxBodyBytes)
|
||||
|
||||
traceData := map[string]any{
|
||||
"chat_template": c.TemplateConfig.Chat,
|
||||
@@ -316,9 +316,13 @@ func ModelInference(ctx context.Context, s string, messages schema.Messages, ima
|
||||
"audios_count": len(audios),
|
||||
}
|
||||
|
||||
// Cap the captured fields up front: agent-pool LLM calls embed the
|
||||
// full augmented chat history in messages and the full reply in
|
||||
// response, so without a per-field cap a single trace can dwarf the
|
||||
// rest of the buffer. The cap matches the API-trace body cap.
|
||||
if len(messages) > 0 {
|
||||
if msgJSON, err := json.Marshal(messages); err == nil {
|
||||
traceData["messages"] = string(msgJSON)
|
||||
traceData["messages"] = trace.TruncateToBytes(string(msgJSON), o.TracingMaxBodyBytes)
|
||||
}
|
||||
}
|
||||
if reasoningJSON, err := json.Marshal(c.ReasoningConfig); err == nil {
|
||||
@@ -337,7 +341,7 @@ func ModelInference(ctx context.Context, s string, messages schema.Messages, ima
|
||||
resp, err := originalFn()
|
||||
duration := time.Since(startTime)
|
||||
|
||||
traceData["response"] = resp.Response
|
||||
traceData["response"] = trace.TruncateToBytes(resp.Response, o.TracingMaxBodyBytes)
|
||||
traceData["token_usage"] = map[string]any{
|
||||
"prompt": resp.Usage.Prompt,
|
||||
"completion": resp.Usage.Completion,
|
||||
@@ -359,10 +363,10 @@ func ModelInference(ctx context.Context, s string, messages schema.Messages, ima
|
||||
toolCallCount += len(d.ToolCalls)
|
||||
}
|
||||
if len(contentParts) > 0 {
|
||||
chatDeltasInfo["content"] = strings.Join(contentParts, "")
|
||||
chatDeltasInfo["content"] = trace.TruncateToBytes(strings.Join(contentParts, ""), o.TracingMaxBodyBytes)
|
||||
}
|
||||
if len(reasoningParts) > 0 {
|
||||
chatDeltasInfo["reasoning_content"] = strings.Join(reasoningParts, "")
|
||||
chatDeltasInfo["reasoning_content"] = trace.TruncateToBytes(strings.Join(reasoningParts, ""), o.TracingMaxBodyBytes)
|
||||
}
|
||||
if toolCallCount > 0 {
|
||||
chatDeltasInfo["tool_call_count"] = toolCallCount
|
||||
|
||||
@@ -21,7 +21,7 @@ func recordModelLoadFailure(appConfig *config.ApplicationConfig, modelName, back
|
||||
if !appConfig.EnableTracing {
|
||||
return
|
||||
}
|
||||
trace.InitBackendTracingIfEnabled(appConfig.TracingMaxItems)
|
||||
trace.InitBackendTracingIfEnabled(appConfig.TracingMaxItems, appConfig.TracingMaxBodyBytes)
|
||||
trace.RecordBackendTrace(trace.BackendTrace{
|
||||
Timestamp: time.Now(),
|
||||
Type: trace.BackendTraceModelLoad,
|
||||
|
||||
@@ -25,7 +25,7 @@ func Rerank(ctx context.Context, request *proto.RerankRequest, loader *model.Mod
|
||||
|
||||
var startTime time.Time
|
||||
if appConfig.EnableTracing {
|
||||
trace.InitBackendTracingIfEnabled(appConfig.TracingMaxItems)
|
||||
trace.InitBackendTracingIfEnabled(appConfig.TracingMaxItems, appConfig.TracingMaxBodyBytes)
|
||||
startTime = time.Now()
|
||||
}
|
||||
|
||||
|
||||
@@ -98,7 +98,7 @@ func SoundGeneration(
|
||||
|
||||
var startTime time.Time
|
||||
if appConfig.EnableTracing {
|
||||
trace.InitBackendTracingIfEnabled(appConfig.TracingMaxItems)
|
||||
trace.InitBackendTracingIfEnabled(appConfig.TracingMaxItems, appConfig.TracingMaxBodyBytes)
|
||||
startTime = time.Now()
|
||||
}
|
||||
|
||||
|
||||
@@ -27,7 +27,7 @@ func ModelTokenize(s string, loader *model.ModelLoader, modelConfig config.Model
|
||||
|
||||
var startTime time.Time
|
||||
if appConfig.EnableTracing {
|
||||
trace.InitBackendTracingIfEnabled(appConfig.TracingMaxItems)
|
||||
trace.InitBackendTracingIfEnabled(appConfig.TracingMaxItems, appConfig.TracingMaxBodyBytes)
|
||||
startTime = time.Now()
|
||||
}
|
||||
|
||||
|
||||
@@ -76,10 +76,10 @@ func ModelTranscriptionWithOptions(ctx context.Context, req TranscriptionRequest
|
||||
var startTime time.Time
|
||||
var audioSnippet map[string]any
|
||||
if appConfig.EnableTracing {
|
||||
trace.InitBackendTracingIfEnabled(appConfig.TracingMaxItems)
|
||||
trace.InitBackendTracingIfEnabled(appConfig.TracingMaxItems, appConfig.TracingMaxBodyBytes)
|
||||
startTime = time.Now()
|
||||
// Capture audio before the backend call — the backend may delete the file.
|
||||
audioSnippet = trace.AudioSnippet(req.Audio)
|
||||
audioSnippet = trace.AudioSnippet(req.Audio, appConfig.TracingMaxBodyBytes)
|
||||
}
|
||||
|
||||
r, err := transcriptionModel.AudioTranscription(ctx, req.toProto(uint32(*modelConfig.Threads)))
|
||||
|
||||
@@ -67,7 +67,7 @@ func ModelTTS(
|
||||
|
||||
var startTime time.Time
|
||||
if appConfig.EnableTracing {
|
||||
trace.InitBackendTracingIfEnabled(appConfig.TracingMaxItems)
|
||||
trace.InitBackendTracingIfEnabled(appConfig.TracingMaxItems, appConfig.TracingMaxBodyBytes)
|
||||
startTime = time.Now()
|
||||
}
|
||||
|
||||
@@ -93,7 +93,7 @@ func ModelTTS(
|
||||
"language": language,
|
||||
}
|
||||
if err == nil && res.Success {
|
||||
if snippet := trace.AudioSnippet(filePath); snippet != nil {
|
||||
if snippet := trace.AudioSnippet(filePath, appConfig.TracingMaxBodyBytes); snippet != nil {
|
||||
maps.Copy(data, snippet)
|
||||
}
|
||||
}
|
||||
@@ -161,7 +161,7 @@ func ModelTTSStream(
|
||||
|
||||
var startTime time.Time
|
||||
if appConfig.EnableTracing {
|
||||
trace.InitBackendTracingIfEnabled(appConfig.TracingMaxItems)
|
||||
trace.InitBackendTracingIfEnabled(appConfig.TracingMaxItems, appConfig.TracingMaxBodyBytes)
|
||||
startTime = time.Now()
|
||||
}
|
||||
|
||||
@@ -260,7 +260,7 @@ func ModelTTSStream(
|
||||
"streaming": true,
|
||||
}
|
||||
if resultErr == nil && len(snippetPCM) > 0 {
|
||||
if snippet := trace.AudioSnippetFromPCM(snippetPCM, int(sampleRate), totalPCMBytes); snippet != nil {
|
||||
if snippet := trace.AudioSnippetFromPCM(snippetPCM, int(sampleRate), totalPCMBytes, appConfig.TracingMaxBodyBytes); snippet != nil {
|
||||
maps.Copy(data, snippet)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,7 +42,7 @@ func VideoGeneration(height, width int32, prompt, negativePrompt, startImage, en
|
||||
}
|
||||
|
||||
if appConfig.EnableTracing {
|
||||
trace.InitBackendTracingIfEnabled(appConfig.TracingMaxItems)
|
||||
trace.InitBackendTracingIfEnabled(appConfig.TracingMaxItems, appConfig.TracingMaxBodyBytes)
|
||||
|
||||
traceData := map[string]any{
|
||||
"prompt": prompt,
|
||||
|
||||
@@ -31,7 +31,7 @@ func VoiceAnalyze(
|
||||
|
||||
var startTime time.Time
|
||||
if appConfig.EnableTracing {
|
||||
trace.InitBackendTracingIfEnabled(appConfig.TracingMaxItems)
|
||||
trace.InitBackendTracingIfEnabled(appConfig.TracingMaxItems, appConfig.TracingMaxBodyBytes)
|
||||
startTime = time.Now()
|
||||
}
|
||||
|
||||
|
||||
@@ -34,7 +34,7 @@ func VoiceEmbed(
|
||||
|
||||
var startTime time.Time
|
||||
if appConfig.EnableTracing {
|
||||
trace.InitBackendTracingIfEnabled(appConfig.TracingMaxItems)
|
||||
trace.InitBackendTracingIfEnabled(appConfig.TracingMaxItems, appConfig.TracingMaxBodyBytes)
|
||||
startTime = time.Now()
|
||||
}
|
||||
|
||||
|
||||
@@ -32,7 +32,7 @@ func VoiceVerify(
|
||||
|
||||
var startTime time.Time
|
||||
if appConfig.EnableTracing {
|
||||
trace.InitBackendTracingIfEnabled(appConfig.TracingMaxItems)
|
||||
trace.InitBackendTracingIfEnabled(appConfig.TracingMaxItems, appConfig.TracingMaxBodyBytes)
|
||||
startTime = time.Now()
|
||||
}
|
||||
|
||||
|
||||
@@ -100,6 +100,7 @@ type RunCMD struct {
|
||||
LoadToMemory []string `env:"LOCALAI_LOAD_TO_MEMORY,LOAD_TO_MEMORY" help:"A list of models to load into memory at startup" group:"models"`
|
||||
EnableTracing bool `env:"LOCALAI_ENABLE_TRACING,ENABLE_TRACING" help:"Enable API tracing" group:"api"`
|
||||
TracingMaxItems int `env:"LOCALAI_TRACING_MAX_ITEMS" default:"1024" help:"Maximum number of traces to keep" group:"api"`
|
||||
TracingMaxBodyBytes int `env:"LOCALAI_TRACING_MAX_BODY_BYTES" default:"65536" help:"Maximum bytes captured per request/response body in the trace buffer (0 = uncapped). Caps memory growth from chatty endpoints like /embeddings." group:"api"`
|
||||
AgentJobRetentionDays int `env:"LOCALAI_AGENT_JOB_RETENTION_DAYS,AGENT_JOB_RETENTION_DAYS" default:"30" help:"Number of days to keep agent job history (default: 30)" group:"api"`
|
||||
OpenResponsesStoreTTL string `env:"LOCALAI_OPEN_RESPONSES_STORE_TTL,OPEN_RESPONSES_STORE_TTL" default:"0" help:"TTL for Open Responses store (e.g., 1h, 30m, 0 = no expiration)" group:"api"`
|
||||
|
||||
@@ -273,6 +274,7 @@ func (r *RunCMD) Run(ctx *cliContext.Context) error {
|
||||
opts = append(opts, config.EnableTracing)
|
||||
}
|
||||
opts = append(opts, config.WithTracingMaxItems(r.TracingMaxItems))
|
||||
opts = append(opts, config.WithTracingMaxBodyBytes(r.TracingMaxBodyBytes))
|
||||
|
||||
token := ""
|
||||
if r.Peer2Peer || r.Peer2PeerToken != "" {
|
||||
|
||||
@@ -21,6 +21,7 @@ type ApplicationConfig struct {
|
||||
Debug bool
|
||||
EnableTracing bool
|
||||
TracingMaxItems int
|
||||
TracingMaxBodyBytes int // Per-body cap for captured request/response bodies; 0 disables the cap
|
||||
EnableBackendLogging bool
|
||||
GeneratedContentDir string
|
||||
|
||||
@@ -187,6 +188,7 @@ func NewApplicationConfig(o ...AppOption) *ApplicationConfig {
|
||||
LRUEvictionRetryInterval: 1 * time.Second, // Default: 1 second
|
||||
WatchDogInterval: 500 * time.Millisecond, // Default: 500ms
|
||||
TracingMaxItems: 1024,
|
||||
TracingMaxBodyBytes: 64 * 1024, // 64 KiB - caps each request/response body in the trace buffer
|
||||
AgentPool: AgentPoolConfig{
|
||||
Enabled: true,
|
||||
Timeout: "5m",
|
||||
@@ -578,6 +580,12 @@ func WithTracingMaxItems(items int) AppOption {
|
||||
}
|
||||
}
|
||||
|
||||
func WithTracingMaxBodyBytes(bytes int) AppOption {
|
||||
return func(o *ApplicationConfig) {
|
||||
o.TracingMaxBodyBytes = bytes
|
||||
}
|
||||
}
|
||||
|
||||
func WithGeneratedContentDir(generatedContentDir string) AppOption {
|
||||
return func(o *ApplicationConfig) {
|
||||
o.GeneratedContentDir = generatedContentDir
|
||||
@@ -920,6 +928,7 @@ func (o *ApplicationConfig) ToRuntimeSettings() RuntimeSettings {
|
||||
f16 := o.F16
|
||||
debug := o.Debug
|
||||
tracingMaxItems := o.TracingMaxItems
|
||||
tracingMaxBodyBytes := o.TracingMaxBodyBytes
|
||||
enableTracing := o.EnableTracing
|
||||
enableBackendLogging := o.EnableBackendLogging
|
||||
cors := o.CORS
|
||||
@@ -1008,6 +1017,7 @@ func (o *ApplicationConfig) ToRuntimeSettings() RuntimeSettings {
|
||||
F16: &f16,
|
||||
Debug: &debug,
|
||||
TracingMaxItems: &tracingMaxItems,
|
||||
TracingMaxBodyBytes: &tracingMaxBodyBytes,
|
||||
EnableTracing: &enableTracing,
|
||||
EnableBackendLogging: &enableBackendLogging,
|
||||
CORS: &cors,
|
||||
@@ -1146,6 +1156,9 @@ func (o *ApplicationConfig) ApplyRuntimeSettings(settings *RuntimeSettings) (req
|
||||
if settings.TracingMaxItems != nil {
|
||||
o.TracingMaxItems = *settings.TracingMaxItems
|
||||
}
|
||||
if settings.TracingMaxBodyBytes != nil {
|
||||
o.TracingMaxBodyBytes = *settings.TracingMaxBodyBytes
|
||||
}
|
||||
if settings.EnableBackendLogging != nil {
|
||||
o.EnableBackendLogging = *settings.EnableBackendLogging
|
||||
}
|
||||
|
||||
@@ -38,6 +38,7 @@ type RuntimeSettings struct {
|
||||
Debug *bool `json:"debug,omitempty"`
|
||||
EnableTracing *bool `json:"enable_tracing,omitempty"`
|
||||
TracingMaxItems *int `json:"tracing_max_items,omitempty"`
|
||||
TracingMaxBodyBytes *int `json:"tracing_max_body_bytes,omitempty"` // Per-body cap in bytes; 0 disables the cap
|
||||
EnableBackendLogging *bool `json:"enable_backend_logging,omitempty"`
|
||||
|
||||
// Security/CORS settings
|
||||
|
||||
@@ -73,363 +73,6 @@ func mergeToolCallDeltas(existing []schema.ToolCall, deltas []schema.ToolCall) [
|
||||
// @Success 200 {object} schema.OpenAIResponse "Response"
|
||||
// @Router /v1/chat/completions [post]
|
||||
func ChatEndpoint(cl *config.ModelConfigLoader, ml *model.ModelLoader, evaluator *templates.Evaluator, startupOptions *config.ApplicationConfig, natsClient mcpTools.MCPNATSClient, assistantHolder *mcpTools.LocalAIAssistantHolder) echo.HandlerFunc {
|
||||
process := func(s string, req *schema.OpenAIRequest, config *config.ModelConfig, loader *model.ModelLoader, responses chan schema.OpenAIResponse, extraUsage bool, id string, created int) error {
|
||||
initialMessage := schema.OpenAIResponse{
|
||||
ID: id,
|
||||
Created: created,
|
||||
Model: req.Model, // we have to return what the user sent here, due to OpenAI spec.
|
||||
Choices: []schema.Choice{{Delta: &schema.Message{Role: "assistant"}, Index: 0, FinishReason: nil}},
|
||||
Object: "chat.completion.chunk",
|
||||
}
|
||||
responses <- initialMessage
|
||||
|
||||
// Detect if thinking token is already in prompt or template
|
||||
// When UseTokenizerTemplate is enabled, predInput is empty, so we check the template
|
||||
var template string
|
||||
if config.TemplateConfig.UseTokenizerTemplate {
|
||||
template = config.GetModelTemplate()
|
||||
} else {
|
||||
template = s
|
||||
}
|
||||
thinkingStartToken := reason.DetectThinkingStartToken(template, &config.ReasoningConfig)
|
||||
extractor := reason.NewReasoningExtractor(thinkingStartToken, config.ReasoningConfig)
|
||||
|
||||
_, _, _, err := ComputeChoices(req, s, config, cl, startupOptions, loader, func(s string, c *[]schema.Choice) {}, func(s string, tokenUsage backend.TokenUsage) bool {
|
||||
var reasoningDelta, contentDelta string
|
||||
|
||||
// Always keep the Go-side extractor in sync with raw tokens so it
|
||||
// can serve as fallback for backends without an autoparser (e.g. vLLM).
|
||||
goReasoning, goContent := extractor.ProcessToken(s)
|
||||
|
||||
// When C++ autoparser chat deltas are available, prefer them — they
|
||||
// handle model-specific formats (Gemma 4, etc.) without Go-side tags.
|
||||
// Otherwise fall back to Go-side extraction.
|
||||
if tokenUsage.HasChatDeltaContent() {
|
||||
rawReasoning, cd := tokenUsage.ChatDeltaReasoningAndContent()
|
||||
contentDelta = cd
|
||||
reasoningDelta = extractor.ProcessChatDeltaReasoning(rawReasoning)
|
||||
} else {
|
||||
reasoningDelta = goReasoning
|
||||
contentDelta = goContent
|
||||
}
|
||||
|
||||
usage := schema.OpenAIUsage{
|
||||
PromptTokens: tokenUsage.Prompt,
|
||||
CompletionTokens: tokenUsage.Completion,
|
||||
TotalTokens: tokenUsage.Prompt + tokenUsage.Completion,
|
||||
}
|
||||
if extraUsage {
|
||||
usage.TimingTokenGeneration = tokenUsage.TimingTokenGeneration
|
||||
usage.TimingPromptProcessing = tokenUsage.TimingPromptProcessing
|
||||
}
|
||||
|
||||
delta := &schema.Message{}
|
||||
if contentDelta != "" {
|
||||
delta.Content = &contentDelta
|
||||
}
|
||||
if reasoningDelta != "" {
|
||||
delta.Reasoning = &reasoningDelta
|
||||
}
|
||||
|
||||
// Usage rides as a struct field for the consumer to track the
|
||||
// running cumulative — it is stripped before JSON marshal so the
|
||||
// wire chunk stays spec-compliant (no `usage` on intermediate
|
||||
// chunks). The dedicated trailer chunk (when include_usage=true)
|
||||
// carries the final totals.
|
||||
usageForChunk := usage
|
||||
resp := schema.OpenAIResponse{
|
||||
ID: id,
|
||||
Created: created,
|
||||
Model: req.Model, // we have to return what the user sent here, due to OpenAI spec.
|
||||
Choices: []schema.Choice{{Delta: delta, Index: 0, FinishReason: nil}},
|
||||
Object: "chat.completion.chunk",
|
||||
Usage: &usageForChunk,
|
||||
}
|
||||
|
||||
responses <- resp
|
||||
return true
|
||||
})
|
||||
close(responses)
|
||||
return err
|
||||
}
|
||||
processTools := func(noAction string, prompt string, req *schema.OpenAIRequest, config *config.ModelConfig, loader *model.ModelLoader, responses chan schema.OpenAIResponse, extraUsage bool, id string, created int, textContentToReturn *string) error {
|
||||
// Detect if thinking token is already in prompt or template
|
||||
var template string
|
||||
if config.TemplateConfig.UseTokenizerTemplate {
|
||||
template = config.GetModelTemplate()
|
||||
} else {
|
||||
template = prompt
|
||||
}
|
||||
thinkingStartToken := reason.DetectThinkingStartToken(template, &config.ReasoningConfig)
|
||||
extractor := reason.NewReasoningExtractor(thinkingStartToken, config.ReasoningConfig)
|
||||
|
||||
result := ""
|
||||
lastEmittedCount := 0
|
||||
sentInitialRole := false
|
||||
sentReasoning := false
|
||||
hasChatDeltaToolCalls := false
|
||||
hasChatDeltaContent := false
|
||||
|
||||
_, _, chatDeltas, err := ComputeChoices(req, prompt, config, cl, startupOptions, loader, func(s string, c *[]schema.Choice) {}, func(s string, usage backend.TokenUsage) bool {
|
||||
result += s
|
||||
|
||||
// Track whether ChatDeltas from the C++ autoparser contain
|
||||
// tool calls or content, so the retry decision can account for them.
|
||||
for _, d := range usage.ChatDeltas {
|
||||
if len(d.ToolCalls) > 0 {
|
||||
hasChatDeltaToolCalls = true
|
||||
}
|
||||
if d.Content != "" {
|
||||
hasChatDeltaContent = true
|
||||
}
|
||||
}
|
||||
|
||||
var reasoningDelta, contentDelta string
|
||||
|
||||
goReasoning, goContent := extractor.ProcessToken(s)
|
||||
|
||||
if usage.HasChatDeltaContent() {
|
||||
rawReasoning, cd := usage.ChatDeltaReasoningAndContent()
|
||||
contentDelta = cd
|
||||
reasoningDelta = extractor.ProcessChatDeltaReasoning(rawReasoning)
|
||||
} else {
|
||||
reasoningDelta = goReasoning
|
||||
contentDelta = goContent
|
||||
}
|
||||
|
||||
// Emit reasoning deltas in their own SSE chunks before any tool-call chunks
|
||||
// (OpenAI spec: reasoning and tool_calls never share a delta)
|
||||
if reasoningDelta != "" {
|
||||
responses <- schema.OpenAIResponse{
|
||||
ID: id,
|
||||
Created: created,
|
||||
Model: req.Model,
|
||||
Choices: []schema.Choice{{
|
||||
Delta: &schema.Message{Reasoning: &reasoningDelta},
|
||||
Index: 0,
|
||||
}},
|
||||
Object: "chat.completion.chunk",
|
||||
}
|
||||
sentReasoning = true
|
||||
}
|
||||
|
||||
// Stream content deltas (cleaned of reasoning tags) while no tool calls
|
||||
// have been detected. Once the incremental parser finds tool calls,
|
||||
// content stops — per OpenAI spec, content and tool_calls don't mix.
|
||||
if lastEmittedCount == 0 && contentDelta != "" {
|
||||
if !sentInitialRole {
|
||||
responses <- schema.OpenAIResponse{
|
||||
ID: id, Created: created, Model: req.Model,
|
||||
Choices: []schema.Choice{{Delta: &schema.Message{Role: "assistant"}, Index: 0}},
|
||||
Object: "chat.completion.chunk",
|
||||
}
|
||||
sentInitialRole = true
|
||||
}
|
||||
responses <- schema.OpenAIResponse{
|
||||
ID: id, Created: created, Model: req.Model,
|
||||
Choices: []schema.Choice{{
|
||||
Delta: &schema.Message{Content: &contentDelta},
|
||||
Index: 0,
|
||||
}},
|
||||
Object: "chat.completion.chunk",
|
||||
}
|
||||
}
|
||||
|
||||
// Try incremental XML parsing for streaming support using iterative parser
|
||||
// This allows emitting partial tool calls as they're being generated
|
||||
cleanedResult := functions.CleanupLLMResult(result, config.FunctionsConfig)
|
||||
|
||||
// Determine XML format from config
|
||||
var xmlFormat *functions.XMLToolCallFormat
|
||||
if config.FunctionsConfig.XMLFormat != nil {
|
||||
xmlFormat = config.FunctionsConfig.XMLFormat
|
||||
} else if config.FunctionsConfig.XMLFormatPreset != "" {
|
||||
xmlFormat = functions.GetXMLFormatPreset(config.FunctionsConfig.XMLFormatPreset)
|
||||
}
|
||||
|
||||
// Use iterative parser for streaming (partial parsing enabled)
|
||||
// Try XML parsing first
|
||||
partialResults, parseErr := functions.ParseXMLIterative(cleanedResult, xmlFormat, true)
|
||||
if parseErr == nil && len(partialResults) > 0 {
|
||||
// Emit new XML tool calls that weren't emitted before
|
||||
if len(partialResults) > lastEmittedCount {
|
||||
for i := lastEmittedCount; i < len(partialResults); i++ {
|
||||
toolCall := partialResults[i]
|
||||
initialMessage := schema.OpenAIResponse{
|
||||
ID: id,
|
||||
Created: created,
|
||||
Model: req.Model,
|
||||
Choices: []schema.Choice{{
|
||||
Delta: &schema.Message{
|
||||
Role: "assistant",
|
||||
ToolCalls: []schema.ToolCall{
|
||||
{
|
||||
Index: i,
|
||||
ID: id,
|
||||
Type: "function",
|
||||
FunctionCall: schema.FunctionCall{
|
||||
Name: toolCall.Name,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
Index: 0,
|
||||
FinishReason: nil,
|
||||
}},
|
||||
Object: "chat.completion.chunk",
|
||||
}
|
||||
select {
|
||||
case responses <- initialMessage:
|
||||
default:
|
||||
}
|
||||
}
|
||||
lastEmittedCount = len(partialResults)
|
||||
}
|
||||
} else {
|
||||
// Try JSON tool call parsing for streaming.
|
||||
// Only emit NEW tool calls (same guard as XML parser above).
|
||||
jsonResults, jsonErr := functions.ParseJSONIterative(cleanedResult, true)
|
||||
if jsonErr == nil && len(jsonResults) > lastEmittedCount {
|
||||
for i := lastEmittedCount; i < len(jsonResults); i++ {
|
||||
jsonObj := jsonResults[i]
|
||||
name, ok := jsonObj["name"].(string)
|
||||
if !ok || name == "" {
|
||||
continue
|
||||
}
|
||||
args := "{}"
|
||||
if argsVal, ok := jsonObj["arguments"]; ok {
|
||||
if argsStr, ok := argsVal.(string); ok {
|
||||
args = argsStr
|
||||
} else {
|
||||
argsBytes, _ := json.Marshal(argsVal)
|
||||
args = string(argsBytes)
|
||||
}
|
||||
}
|
||||
initialMessage := schema.OpenAIResponse{
|
||||
ID: id,
|
||||
Created: created,
|
||||
Model: req.Model,
|
||||
Choices: []schema.Choice{{
|
||||
Delta: &schema.Message{
|
||||
Role: "assistant",
|
||||
ToolCalls: []schema.ToolCall{
|
||||
{
|
||||
Index: i,
|
||||
ID: id,
|
||||
Type: "function",
|
||||
FunctionCall: schema.FunctionCall{
|
||||
Name: name,
|
||||
Arguments: args,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
Index: 0,
|
||||
FinishReason: nil,
|
||||
}},
|
||||
Object: "chat.completion.chunk",
|
||||
}
|
||||
responses <- initialMessage
|
||||
}
|
||||
lastEmittedCount = len(jsonResults)
|
||||
}
|
||||
}
|
||||
return true
|
||||
},
|
||||
func(attempt int) bool {
|
||||
// After streaming completes: check if we got actionable content
|
||||
cleaned := extractor.CleanedContent()
|
||||
// Check for tool calls from chat deltas (will be re-checked after ComputeChoices,
|
||||
// but we need to know here whether to retry).
|
||||
// Also check ChatDelta flags — when the C++ autoparser is active,
|
||||
// tool calls and content are delivered via ChatDeltas while the
|
||||
// raw message is cleared. Without this check, we'd retry
|
||||
// unnecessarily, losing valid results and concatenating output.
|
||||
hasToolCalls := lastEmittedCount > 0 || hasChatDeltaToolCalls
|
||||
hasContent := cleaned != "" || hasChatDeltaContent
|
||||
if !hasContent && !hasToolCalls {
|
||||
xlog.Warn("Streaming: backend produced only reasoning, retrying",
|
||||
"reasoning_len", len(extractor.Reasoning()), "attempt", attempt+1)
|
||||
extractor.ResetAndSuppressReasoning()
|
||||
result = ""
|
||||
lastEmittedCount = 0
|
||||
sentInitialRole = false
|
||||
hasChatDeltaToolCalls = false
|
||||
hasChatDeltaContent = false
|
||||
return true
|
||||
}
|
||||
return false
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// Try using pre-parsed tool calls from C++ autoparser (chat deltas)
|
||||
var functionResults []functions.FuncCallResults
|
||||
var reasoning string
|
||||
|
||||
if deltaToolCalls := functions.ToolCallsFromChatDeltas(chatDeltas); len(deltaToolCalls) > 0 {
|
||||
xlog.Debug("[ChatDeltas] Using pre-parsed tool calls from C++ autoparser", "count", len(deltaToolCalls))
|
||||
functionResults = deltaToolCalls
|
||||
// Use content/reasoning from deltas too
|
||||
*textContentToReturn = functions.ContentFromChatDeltas(chatDeltas)
|
||||
reasoning = functions.ReasoningFromChatDeltas(chatDeltas)
|
||||
} else {
|
||||
// Fallback: parse tool calls from raw text (no chat deltas from backend)
|
||||
xlog.Debug("[ChatDeltas] no pre-parsed tool calls, falling back to Go-side text parsing")
|
||||
reasoning = extractor.Reasoning()
|
||||
cleanedResult := extractor.CleanedContent()
|
||||
*textContentToReturn = functions.ParseTextContent(cleanedResult, config.FunctionsConfig)
|
||||
cleanedResult = functions.CleanupLLMResult(cleanedResult, config.FunctionsConfig)
|
||||
functionResults = functions.ParseFunctionCall(cleanedResult, config.FunctionsConfig)
|
||||
}
|
||||
xlog.Debug("[ChatDeltas] final tool call decision", "tool_calls", len(functionResults), "text_content", *textContentToReturn)
|
||||
// noAction is a sentinel "just answer" pseudo-function — not a real
|
||||
// tool call. Scan the whole slice rather than only index 0 so we
|
||||
// don't drop a real tool call that happens to follow a noAction
|
||||
// entry, and so the default branch isn't entered with only noAction
|
||||
// entries to emit as tool_calls.
|
||||
noActionToRun := !hasRealCall(functionResults, noAction)
|
||||
|
||||
switch {
|
||||
case noActionToRun:
|
||||
// Token-cumulative usage is communicated to the streaming
|
||||
// consumer via the per-token callback's chunk struct (stripped
|
||||
// before wire marshal). The final usage trailer — when the
|
||||
// caller opted in with stream_options.include_usage — is built
|
||||
// by the outer streaming loop, not here.
|
||||
var result string
|
||||
if !sentInitialRole {
|
||||
var hqErr error
|
||||
result, hqErr = handleQuestion(config, functionResults, extractor.CleanedContent(), prompt)
|
||||
if hqErr != nil {
|
||||
xlog.Error("error handling question", "error", hqErr)
|
||||
return hqErr
|
||||
}
|
||||
}
|
||||
for _, chunk := range buildNoActionFinalChunks(
|
||||
id, req.Model, created,
|
||||
sentInitialRole, sentReasoning,
|
||||
result, reasoning,
|
||||
) {
|
||||
responses <- chunk
|
||||
}
|
||||
|
||||
default:
|
||||
for _, chunk := range buildDeferredToolCallChunks(
|
||||
id, req.Model, created,
|
||||
functionResults, lastEmittedCount,
|
||||
sentInitialRole, *textContentToReturn,
|
||||
sentReasoning, reasoning,
|
||||
) {
|
||||
responses <- chunk
|
||||
}
|
||||
}
|
||||
|
||||
close(responses)
|
||||
return err
|
||||
}
|
||||
|
||||
return func(c echo.Context) error {
|
||||
var textContentToReturn string
|
||||
id := uuid.New().String()
|
||||
@@ -697,17 +340,19 @@ func ChatEndpoint(cl *config.ModelConfigLoader, ml *model.ModelLoader, evaluator
|
||||
}
|
||||
|
||||
responses := make(chan schema.OpenAIResponse)
|
||||
ended := make(chan error, 1)
|
||||
ended := make(chan streamWorkerResult, 1)
|
||||
|
||||
go func() {
|
||||
if !shouldUseFn {
|
||||
ended <- process(predInput, input, config, ml, responses, extraUsage, id, created)
|
||||
u, err := processStream(predInput, input, config, cl, startupOptions, ml, responses, id, created)
|
||||
ended <- streamWorkerResult{usage: u, err: err}
|
||||
} else {
|
||||
ended <- processTools(noActionName, predInput, input, config, ml, responses, extraUsage, id, created, &textContentToReturn)
|
||||
u, err := processStreamWithTools(noActionName, predInput, input, config, cl, startupOptions, ml, responses, id, created, &textContentToReturn)
|
||||
ended <- streamWorkerResult{usage: u, err: err}
|
||||
}
|
||||
}()
|
||||
|
||||
usage := &schema.OpenAIUsage{}
|
||||
var finalUsage backend.TokenUsage
|
||||
toolsCalled := false
|
||||
var collectedToolCalls []schema.ToolCall
|
||||
var collectedContent string
|
||||
@@ -725,13 +370,6 @@ func ChatEndpoint(cl *config.ModelConfigLoader, ml *model.ModelLoader, evaluator
|
||||
xlog.Debug("No choices in the response, skipping")
|
||||
continue
|
||||
}
|
||||
// Capture the running cumulative usage from this chunk
|
||||
// (when present) so the include_usage trailer can carry
|
||||
// the final totals. Usage is stripped before marshal
|
||||
// below so the wire chunk stays spec-compliant.
|
||||
if ev.Usage != nil {
|
||||
usage = ev.Usage
|
||||
}
|
||||
if len(ev.Choices[0].Delta.ToolCalls) > 0 {
|
||||
toolsCalled = true
|
||||
// Collect and merge tool call deltas for MCP execution
|
||||
@@ -747,11 +385,6 @@ func ChatEndpoint(cl *config.ModelConfigLoader, ml *model.ModelLoader, evaluator
|
||||
collectedContent += *sp
|
||||
}
|
||||
}
|
||||
// OpenAI streaming spec: intermediate chunks must NOT
|
||||
// carry a `usage` field. Strip the tracking copy
|
||||
// before marshalling — usage is delivered via the
|
||||
// dedicated trailer chunk when include_usage=true.
|
||||
ev.Usage = nil
|
||||
respData, err := json.Marshal(ev)
|
||||
if err != nil {
|
||||
xlog.Debug("Failed to marshal response", "error", err)
|
||||
@@ -766,15 +399,16 @@ func ChatEndpoint(cl *config.ModelConfigLoader, ml *model.ModelLoader, evaluator
|
||||
return err
|
||||
}
|
||||
c.Response().Flush()
|
||||
case err := <-ended:
|
||||
if err == nil {
|
||||
case res := <-ended:
|
||||
if res.err == nil {
|
||||
finalUsage = res.usage
|
||||
break LOOP
|
||||
}
|
||||
xlog.Error("Stream ended with error", "error", err)
|
||||
xlog.Error("Stream ended with error", "error", res.err)
|
||||
|
||||
errorResp := schema.ErrorResponse{
|
||||
Error: &schema.APIError{
|
||||
Message: err.Error(),
|
||||
Message: res.err.Error(),
|
||||
Type: "server_error",
|
||||
Code: "server_error",
|
||||
},
|
||||
@@ -797,7 +431,10 @@ func ChatEndpoint(cl *config.ModelConfigLoader, ml *model.ModelLoader, evaluator
|
||||
// still trying to send (e.g., after client disconnect). The goroutine
|
||||
// calls close(responses) when done, which terminates the drain.
|
||||
if input.Context.Err() != nil {
|
||||
go func() { for range responses {} }()
|
||||
go func() {
|
||||
for range responses {
|
||||
}
|
||||
}()
|
||||
<-ended
|
||||
}
|
||||
|
||||
@@ -921,8 +558,16 @@ func ChatEndpoint(cl *config.ModelConfigLoader, ml *model.ModelLoader, evaluator
|
||||
// Trailing usage chunk per OpenAI spec: emit only when the
|
||||
// caller opted in via stream_options.include_usage. Shape:
|
||||
// {"choices":[],"usage":{...},"object":"chat.completion.chunk",...}
|
||||
if input.StreamOptions != nil && input.StreamOptions.IncludeUsage && usage != nil {
|
||||
trailer := streamUsageTrailerJSON(id, input.Model, created, *usage)
|
||||
//
|
||||
// finalUsage is the authoritative TokenUsage returned by the
|
||||
// worker function (process / processTools) via the `ended`
|
||||
// channel. The worker reads it from ComputeChoices' return
|
||||
// value, which is the cumulative count produced by the backend
|
||||
// over the whole prediction. Issue #9927 was caused by the
|
||||
// tools-path worker not surfacing this value at all.
|
||||
if input.StreamOptions != nil && input.StreamOptions.IncludeUsage {
|
||||
trailerUsage := streamUsageFromTokenUsage(finalUsage, extraUsage)
|
||||
trailer := streamUsageTrailerJSON(id, input.Model, created, trailerUsage)
|
||||
_, _ = fmt.Fprintf(c.Response().Writer, "data: %s\n\n", trailer)
|
||||
}
|
||||
|
||||
|
||||
@@ -4,10 +4,39 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"github.com/mudler/LocalAI/core/backend"
|
||||
"github.com/mudler/LocalAI/core/schema"
|
||||
"github.com/mudler/LocalAI/pkg/functions"
|
||||
)
|
||||
|
||||
// streamWorkerResult is what the streaming workers (process / processTools)
|
||||
// hand back to the outer ChatEndpoint loop through the `ended` channel.
|
||||
// Threading the final TokenUsage here, instead of piggy-backing it on the
|
||||
// `responses` SSE channel, keeps the SSE channel single-purpose (wire chunks)
|
||||
// and gives the trailer emitter a plain Go value to read after LOOP exits.
|
||||
// Fix for issue #9927: the previous tools-path worker never surfaced the
|
||||
// cumulative token counts at all, so the include_usage trailer reported zeros.
|
||||
type streamWorkerResult struct {
|
||||
usage backend.TokenUsage
|
||||
err error
|
||||
}
|
||||
|
||||
// streamUsageFromTokenUsage converts the backend's cumulative TokenUsage into
|
||||
// the OpenAI-spec OpenAIUsage shape used on the wire. `extraUsage` controls
|
||||
// whether the non-standard timing fields are forwarded.
|
||||
func streamUsageFromTokenUsage(usage backend.TokenUsage, extraUsage bool) schema.OpenAIUsage {
|
||||
out := schema.OpenAIUsage{
|
||||
PromptTokens: usage.Prompt,
|
||||
CompletionTokens: usage.Completion,
|
||||
TotalTokens: usage.Prompt + usage.Completion,
|
||||
}
|
||||
if extraUsage {
|
||||
out.TimingTokenGeneration = usage.TimingTokenGeneration
|
||||
out.TimingPromptProcessing = usage.TimingPromptProcessing
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// streamUsageTrailerJSON returns the bytes of the OpenAI-spec trailing usage
|
||||
// chunk emitted in streaming completions when the request opts in via
|
||||
// `stream_options.include_usage: true`. The shape is:
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
package openai
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
|
||||
"github.com/mudler/LocalAI/core/backend"
|
||||
"github.com/mudler/LocalAI/core/config"
|
||||
"github.com/mudler/LocalAI/core/schema"
|
||||
"github.com/mudler/LocalAI/pkg/functions"
|
||||
"github.com/mudler/LocalAI/pkg/model"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
@@ -152,6 +156,28 @@ var _ = Describe("streaming usage spec compliance", func() {
|
||||
})
|
||||
})
|
||||
|
||||
Describe("streamUsageFromTokenUsage", func() {
|
||||
It("converts backend TokenUsage to schema OpenAIUsage", func() {
|
||||
tu := backend.TokenUsage{Prompt: 18, Completion: 213}
|
||||
u := streamUsageFromTokenUsage(tu, false)
|
||||
Expect(u.PromptTokens).To(Equal(18))
|
||||
Expect(u.CompletionTokens).To(Equal(213))
|
||||
Expect(u.TotalTokens).To(Equal(231))
|
||||
Expect(u.TimingTokenGeneration).To(BeZero())
|
||||
Expect(u.TimingPromptProcessing).To(BeZero())
|
||||
})
|
||||
It("includes timings when extraUsage is true", func() {
|
||||
tu := backend.TokenUsage{
|
||||
Prompt: 10, Completion: 20,
|
||||
TimingPromptProcessing: 0.5,
|
||||
TimingTokenGeneration: 1.5,
|
||||
}
|
||||
u := streamUsageFromTokenUsage(tu, true)
|
||||
Expect(u.TimingPromptProcessing).To(Equal(0.5))
|
||||
Expect(u.TimingTokenGeneration).To(Equal(1.5))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("OpenAIRequest.StreamOptions", func() {
|
||||
It("parses stream_options.include_usage=true", func() {
|
||||
body := []byte(`{
|
||||
@@ -177,3 +203,160 @@ var _ = Describe("streaming usage spec compliance", func() {
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// Functional regression coverage for issue #9927: the streaming workers
|
||||
// must surface the cumulative TokenUsage returned by ComputeChoices to
|
||||
// their caller. The earlier broken implementations discarded that value
|
||||
// (`_, _, chatDeltas, err := ComputeChoices(...)`) and threw away the
|
||||
// counts on the floor, so the include_usage trailer always reported
|
||||
// zeros when tools were enabled.
|
||||
//
|
||||
// These tests stub backend.ModelInferenceFunc so the worker exercises the
|
||||
// real ComputeChoices → predFunc → LLMResponse pipeline. If a future change
|
||||
// drops the TokenUsage somewhere along that path, the assertions on the
|
||||
// returned value fail with a concrete count mismatch (e.g. 0 vs 213),
|
||||
// not with a "function undefined" compile error.
|
||||
var _ = Describe("streaming workers surface final TokenUsage (issue #9927)", func() {
|
||||
var (
|
||||
origInference modelInferenceFunc
|
||||
appCfg *config.ApplicationConfig
|
||||
)
|
||||
|
||||
BeforeEach(func() {
|
||||
origInference = backend.ModelInferenceFunc
|
||||
appCfg = config.NewApplicationConfig()
|
||||
})
|
||||
|
||||
AfterEach(func() {
|
||||
backend.ModelInferenceFunc = origInference
|
||||
})
|
||||
|
||||
// mockBackendUsage installs a stub backend that yields one LLMResponse
|
||||
// carrying the supplied TokenUsage. ComputeChoices' single-attempt path
|
||||
// copies these counts into the value it returns to the worker.
|
||||
mockBackendUsage := func(usage backend.TokenUsage, response 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) {
|
||||
return func() (backend.LLMResponse, error) {
|
||||
return backend.LLMResponse{
|
||||
Response: response,
|
||||
Usage: usage,
|
||||
}, nil
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
|
||||
makeReq := func() *schema.OpenAIRequest {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
req := &schema.OpenAIRequest{
|
||||
Context: ctx,
|
||||
Cancel: cancel,
|
||||
}
|
||||
req.Model = "test-model" // promoted from BasicModelRequest
|
||||
return req
|
||||
}
|
||||
|
||||
// drainResponses consumes everything the worker pushes onto the channel
|
||||
// so the worker is never blocked on its send. The channel is unbuffered
|
||||
// (matching production), so the drain goroutine must be running before
|
||||
// the worker is called.
|
||||
drainResponses := func(ch <-chan schema.OpenAIResponse) <-chan struct{} {
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
for range ch {
|
||||
}
|
||||
close(done)
|
||||
}()
|
||||
return done
|
||||
}
|
||||
|
||||
Describe("processStream (no-tools path)", func() {
|
||||
It("returns the cumulative TokenUsage produced by the backend", func() {
|
||||
mockBackendUsage(backend.TokenUsage{Prompt: 18, Completion: 213}, "Hello there")
|
||||
|
||||
req := makeReq()
|
||||
cfg := &config.ModelConfig{}
|
||||
responses := make(chan schema.OpenAIResponse)
|
||||
done := drainResponses(responses)
|
||||
|
||||
actual, err := processStream("prompt", req, cfg, nil, appCfg, nil, responses, "req-1", 0)
|
||||
<-done
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(actual.Prompt).To(Equal(18),
|
||||
"prompt tokens must round-trip from backend through processStream")
|
||||
Expect(actual.Completion).To(Equal(213),
|
||||
"completion tokens must round-trip from backend through processStream")
|
||||
})
|
||||
|
||||
It("returns zero TokenUsage when the backend reports zero (negative control)", func() {
|
||||
mockBackendUsage(backend.TokenUsage{}, "x")
|
||||
|
||||
req := makeReq()
|
||||
cfg := &config.ModelConfig{}
|
||||
responses := make(chan schema.OpenAIResponse)
|
||||
done := drainResponses(responses)
|
||||
|
||||
actual, err := processStream("prompt", req, cfg, nil, appCfg, nil, responses, "req-1", 0)
|
||||
<-done
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(actual.Prompt).To(BeZero())
|
||||
Expect(actual.Completion).To(BeZero())
|
||||
})
|
||||
})
|
||||
|
||||
Describe("processStreamWithTools (tools path)", func() {
|
||||
It("returns the cumulative TokenUsage produced by the backend", func() {
|
||||
// This is the direct regression check for issue #9927: with tools
|
||||
// enabled, the trailer was reporting {0,0,0} because the worker
|
||||
// discarded ComputeChoices' second return value.
|
||||
mockBackendUsage(backend.TokenUsage{Prompt: 18, Completion: 213}, "answer")
|
||||
|
||||
req := makeReq()
|
||||
cfg := &config.ModelConfig{}
|
||||
responses := make(chan schema.OpenAIResponse)
|
||||
done := drainResponses(responses)
|
||||
var textContent string
|
||||
|
||||
actual, err := processStreamWithTools("none", "prompt", req, cfg, nil, appCfg, nil, responses, "req-1", 0, &textContent)
|
||||
<-done
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(actual.Prompt).To(Equal(18),
|
||||
"prompt tokens must round-trip from backend through processStreamWithTools (issue #9927)")
|
||||
Expect(actual.Completion).To(Equal(213),
|
||||
"completion tokens must round-trip from backend through processStreamWithTools (issue #9927)")
|
||||
})
|
||||
|
||||
It("forwards timing fields when the backend supplies them", func() {
|
||||
mockBackendUsage(backend.TokenUsage{
|
||||
Prompt: 10, Completion: 20,
|
||||
TimingPromptProcessing: 0.5,
|
||||
TimingTokenGeneration: 1.5,
|
||||
}, "answer")
|
||||
|
||||
req := makeReq()
|
||||
cfg := &config.ModelConfig{}
|
||||
responses := make(chan schema.OpenAIResponse)
|
||||
done := drainResponses(responses)
|
||||
var textContent string
|
||||
|
||||
actual, err := processStreamWithTools("none", "prompt", req, cfg, nil, appCfg, nil, responses, "req-1", 0, &textContent)
|
||||
<-done
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(actual.TimingPromptProcessing).To(Equal(0.5))
|
||||
Expect(actual.TimingTokenGeneration).To(Equal(1.5))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
390
core/http/endpoints/openai/chat_stream_workers.go
Normal file
390
core/http/endpoints/openai/chat_stream_workers.go
Normal file
@@ -0,0 +1,390 @@
|
||||
package openai
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
|
||||
"github.com/mudler/LocalAI/core/backend"
|
||||
"github.com/mudler/LocalAI/core/config"
|
||||
"github.com/mudler/LocalAI/core/schema"
|
||||
"github.com/mudler/LocalAI/pkg/functions"
|
||||
"github.com/mudler/LocalAI/pkg/model"
|
||||
reason "github.com/mudler/LocalAI/pkg/reasoning"
|
||||
"github.com/mudler/xlog"
|
||||
)
|
||||
|
||||
// processStream is the streaming worker for chat completions with no
|
||||
// tool/function calling involved. It pushes SSE-shaped chunks onto
|
||||
// `responses` and returns the authoritative cumulative TokenUsage from
|
||||
// the prediction so the caller can populate the include_usage trailer
|
||||
// without having to peek inside the chunks.
|
||||
//
|
||||
// The caller owns the `responses` channel and is expected to read from
|
||||
// it while this function runs; processStream closes the channel before
|
||||
// returning.
|
||||
func processStream(
|
||||
s string,
|
||||
req *schema.OpenAIRequest,
|
||||
cfg *config.ModelConfig,
|
||||
cl *config.ModelConfigLoader,
|
||||
startupOptions *config.ApplicationConfig,
|
||||
loader *model.ModelLoader,
|
||||
responses chan schema.OpenAIResponse,
|
||||
id string,
|
||||
created int,
|
||||
) (backend.TokenUsage, error) {
|
||||
responses <- schema.OpenAIResponse{
|
||||
ID: id,
|
||||
Created: created,
|
||||
Model: req.Model, // we have to return what the user sent here, due to OpenAI spec.
|
||||
Choices: []schema.Choice{{Delta: &schema.Message{Role: "assistant"}, Index: 0, FinishReason: nil}},
|
||||
Object: "chat.completion.chunk",
|
||||
}
|
||||
|
||||
// Detect if thinking token is already in prompt or template
|
||||
// When UseTokenizerTemplate is enabled, predInput is empty, so we check the template
|
||||
var template string
|
||||
if cfg.TemplateConfig.UseTokenizerTemplate {
|
||||
template = cfg.GetModelTemplate()
|
||||
} else {
|
||||
template = s
|
||||
}
|
||||
thinkingStartToken := reason.DetectThinkingStartToken(template, &cfg.ReasoningConfig)
|
||||
extractor := reason.NewReasoningExtractor(thinkingStartToken, cfg.ReasoningConfig)
|
||||
|
||||
_, finalUsage, _, err := ComputeChoices(req, s, cfg, cl, startupOptions, loader, func(s string, c *[]schema.Choice) {}, func(s string, tokenUsage backend.TokenUsage) bool {
|
||||
var reasoningDelta, contentDelta string
|
||||
|
||||
// Always keep the Go-side extractor in sync with raw tokens so it
|
||||
// can serve as fallback for backends without an autoparser (e.g. vLLM).
|
||||
goReasoning, goContent := extractor.ProcessToken(s)
|
||||
|
||||
// When C++ autoparser chat deltas are available, prefer them: they
|
||||
// handle model-specific formats (Gemma 4, etc.) without Go-side tags.
|
||||
// Otherwise fall back to Go-side extraction.
|
||||
if tokenUsage.HasChatDeltaContent() {
|
||||
rawReasoning, cd := tokenUsage.ChatDeltaReasoningAndContent()
|
||||
contentDelta = cd
|
||||
reasoningDelta = extractor.ProcessChatDeltaReasoning(rawReasoning)
|
||||
} else {
|
||||
reasoningDelta = goReasoning
|
||||
contentDelta = goContent
|
||||
}
|
||||
|
||||
delta := &schema.Message{}
|
||||
if contentDelta != "" {
|
||||
delta.Content = &contentDelta
|
||||
}
|
||||
if reasoningDelta != "" {
|
||||
delta.Reasoning = &reasoningDelta
|
||||
}
|
||||
|
||||
responses <- schema.OpenAIResponse{
|
||||
ID: id,
|
||||
Created: created,
|
||||
Model: req.Model, // we have to return what the user sent here, due to OpenAI spec.
|
||||
Choices: []schema.Choice{{Delta: delta, Index: 0, FinishReason: nil}},
|
||||
Object: "chat.completion.chunk",
|
||||
}
|
||||
return true
|
||||
})
|
||||
close(responses)
|
||||
return finalUsage, err
|
||||
}
|
||||
|
||||
// processStreamWithTools is the streaming worker for chat completions
|
||||
// with tools / function calling. Same contract as processStream: pushes
|
||||
// chunks onto `responses`, closes the channel, returns the cumulative
|
||||
// TokenUsage.
|
||||
//
|
||||
// Returning the TokenUsage as a normal Go value (rather than smuggling
|
||||
// it on a sentinel chunk) is the fix for issue #9927 — the previous
|
||||
// implementation discarded the value from ComputeChoices, so the
|
||||
// include_usage trailer reported zeros whenever `tools` was in play.
|
||||
func processStreamWithTools(
|
||||
noAction string,
|
||||
prompt string,
|
||||
req *schema.OpenAIRequest,
|
||||
cfg *config.ModelConfig,
|
||||
cl *config.ModelConfigLoader,
|
||||
startupOptions *config.ApplicationConfig,
|
||||
loader *model.ModelLoader,
|
||||
responses chan schema.OpenAIResponse,
|
||||
id string,
|
||||
created int,
|
||||
textContentToReturn *string,
|
||||
) (backend.TokenUsage, error) {
|
||||
// Detect if thinking token is already in prompt or template
|
||||
var template string
|
||||
if cfg.TemplateConfig.UseTokenizerTemplate {
|
||||
template = cfg.GetModelTemplate()
|
||||
} else {
|
||||
template = prompt
|
||||
}
|
||||
thinkingStartToken := reason.DetectThinkingStartToken(template, &cfg.ReasoningConfig)
|
||||
extractor := reason.NewReasoningExtractor(thinkingStartToken, cfg.ReasoningConfig)
|
||||
|
||||
result := ""
|
||||
lastEmittedCount := 0
|
||||
sentInitialRole := false
|
||||
sentReasoning := false
|
||||
hasChatDeltaToolCalls := false
|
||||
hasChatDeltaContent := false
|
||||
|
||||
_, finalUsage, chatDeltas, err := ComputeChoices(req, prompt, cfg, cl, startupOptions, loader, func(s string, c *[]schema.Choice) {}, func(s string, usage backend.TokenUsage) bool {
|
||||
result += s
|
||||
|
||||
// Track whether ChatDeltas from the C++ autoparser contain
|
||||
// tool calls or content, so the retry decision can account for them.
|
||||
for _, d := range usage.ChatDeltas {
|
||||
if len(d.ToolCalls) > 0 {
|
||||
hasChatDeltaToolCalls = true
|
||||
}
|
||||
if d.Content != "" {
|
||||
hasChatDeltaContent = true
|
||||
}
|
||||
}
|
||||
|
||||
var reasoningDelta, contentDelta string
|
||||
|
||||
goReasoning, goContent := extractor.ProcessToken(s)
|
||||
|
||||
if usage.HasChatDeltaContent() {
|
||||
rawReasoning, cd := usage.ChatDeltaReasoningAndContent()
|
||||
contentDelta = cd
|
||||
reasoningDelta = extractor.ProcessChatDeltaReasoning(rawReasoning)
|
||||
} else {
|
||||
reasoningDelta = goReasoning
|
||||
contentDelta = goContent
|
||||
}
|
||||
|
||||
// Emit reasoning deltas in their own SSE chunks before any tool-call chunks
|
||||
// (OpenAI spec: reasoning and tool_calls never share a delta)
|
||||
if reasoningDelta != "" {
|
||||
responses <- schema.OpenAIResponse{
|
||||
ID: id,
|
||||
Created: created,
|
||||
Model: req.Model,
|
||||
Choices: []schema.Choice{{
|
||||
Delta: &schema.Message{Reasoning: &reasoningDelta},
|
||||
Index: 0,
|
||||
}},
|
||||
Object: "chat.completion.chunk",
|
||||
}
|
||||
sentReasoning = true
|
||||
}
|
||||
|
||||
// Stream content deltas (cleaned of reasoning tags) while no tool calls
|
||||
// have been detected. Once the incremental parser finds tool calls,
|
||||
// content stops: per OpenAI spec, content and tool_calls don't mix.
|
||||
if lastEmittedCount == 0 && contentDelta != "" {
|
||||
if !sentInitialRole {
|
||||
responses <- schema.OpenAIResponse{
|
||||
ID: id, Created: created, Model: req.Model,
|
||||
Choices: []schema.Choice{{Delta: &schema.Message{Role: "assistant"}, Index: 0}},
|
||||
Object: "chat.completion.chunk",
|
||||
}
|
||||
sentInitialRole = true
|
||||
}
|
||||
responses <- schema.OpenAIResponse{
|
||||
ID: id, Created: created, Model: req.Model,
|
||||
Choices: []schema.Choice{{
|
||||
Delta: &schema.Message{Content: &contentDelta},
|
||||
Index: 0,
|
||||
}},
|
||||
Object: "chat.completion.chunk",
|
||||
}
|
||||
}
|
||||
|
||||
// Try incremental XML parsing for streaming support using iterative parser
|
||||
// This allows emitting partial tool calls as they're being generated
|
||||
cleanedResult := functions.CleanupLLMResult(result, cfg.FunctionsConfig)
|
||||
|
||||
// Determine XML format from config
|
||||
var xmlFormat *functions.XMLToolCallFormat
|
||||
if cfg.FunctionsConfig.XMLFormat != nil {
|
||||
xmlFormat = cfg.FunctionsConfig.XMLFormat
|
||||
} else if cfg.FunctionsConfig.XMLFormatPreset != "" {
|
||||
xmlFormat = functions.GetXMLFormatPreset(cfg.FunctionsConfig.XMLFormatPreset)
|
||||
}
|
||||
|
||||
// Use iterative parser for streaming (partial parsing enabled)
|
||||
// Try XML parsing first
|
||||
partialResults, parseErr := functions.ParseXMLIterative(cleanedResult, xmlFormat, true)
|
||||
if parseErr == nil && len(partialResults) > 0 {
|
||||
// Emit new XML tool calls that weren't emitted before
|
||||
if len(partialResults) > lastEmittedCount {
|
||||
for i := lastEmittedCount; i < len(partialResults); i++ {
|
||||
toolCall := partialResults[i]
|
||||
initialMessage := schema.OpenAIResponse{
|
||||
ID: id,
|
||||
Created: created,
|
||||
Model: req.Model,
|
||||
Choices: []schema.Choice{{
|
||||
Delta: &schema.Message{
|
||||
Role: "assistant",
|
||||
ToolCalls: []schema.ToolCall{
|
||||
{
|
||||
Index: i,
|
||||
ID: id,
|
||||
Type: "function",
|
||||
FunctionCall: schema.FunctionCall{
|
||||
Name: toolCall.Name,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
Index: 0,
|
||||
FinishReason: nil,
|
||||
}},
|
||||
Object: "chat.completion.chunk",
|
||||
}
|
||||
select {
|
||||
case responses <- initialMessage:
|
||||
default:
|
||||
}
|
||||
}
|
||||
lastEmittedCount = len(partialResults)
|
||||
}
|
||||
} else {
|
||||
// Try JSON tool call parsing for streaming.
|
||||
// Only emit NEW tool calls (same guard as XML parser above).
|
||||
jsonResults, jsonErr := functions.ParseJSONIterative(cleanedResult, true)
|
||||
if jsonErr == nil && len(jsonResults) > lastEmittedCount {
|
||||
for i := lastEmittedCount; i < len(jsonResults); i++ {
|
||||
jsonObj := jsonResults[i]
|
||||
name, ok := jsonObj["name"].(string)
|
||||
if !ok || name == "" {
|
||||
continue
|
||||
}
|
||||
args := "{}"
|
||||
if argsVal, ok := jsonObj["arguments"]; ok {
|
||||
if argsStr, ok := argsVal.(string); ok {
|
||||
args = argsStr
|
||||
} else {
|
||||
argsBytes, _ := json.Marshal(argsVal)
|
||||
args = string(argsBytes)
|
||||
}
|
||||
}
|
||||
initialMessage := schema.OpenAIResponse{
|
||||
ID: id,
|
||||
Created: created,
|
||||
Model: req.Model,
|
||||
Choices: []schema.Choice{{
|
||||
Delta: &schema.Message{
|
||||
Role: "assistant",
|
||||
ToolCalls: []schema.ToolCall{
|
||||
{
|
||||
Index: i,
|
||||
ID: id,
|
||||
Type: "function",
|
||||
FunctionCall: schema.FunctionCall{
|
||||
Name: name,
|
||||
Arguments: args,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
Index: 0,
|
||||
FinishReason: nil,
|
||||
}},
|
||||
Object: "chat.completion.chunk",
|
||||
}
|
||||
responses <- initialMessage
|
||||
}
|
||||
lastEmittedCount = len(jsonResults)
|
||||
}
|
||||
}
|
||||
return true
|
||||
},
|
||||
func(attempt int) bool {
|
||||
// After streaming completes: check if we got actionable content
|
||||
cleaned := extractor.CleanedContent()
|
||||
// Check for tool calls from chat deltas (will be re-checked after ComputeChoices,
|
||||
// but we need to know here whether to retry).
|
||||
// Also check ChatDelta flags: when the C++ autoparser is active,
|
||||
// tool calls and content are delivered via ChatDeltas while the
|
||||
// raw message is cleared. Without this check, we'd retry
|
||||
// unnecessarily, losing valid results and concatenating output.
|
||||
hasToolCalls := lastEmittedCount > 0 || hasChatDeltaToolCalls
|
||||
hasContent := cleaned != "" || hasChatDeltaContent
|
||||
if !hasContent && !hasToolCalls {
|
||||
xlog.Warn("Streaming: backend produced only reasoning, retrying",
|
||||
"reasoning_len", len(extractor.Reasoning()), "attempt", attempt+1)
|
||||
extractor.ResetAndSuppressReasoning()
|
||||
result = ""
|
||||
lastEmittedCount = 0
|
||||
sentInitialRole = false
|
||||
hasChatDeltaToolCalls = false
|
||||
hasChatDeltaContent = false
|
||||
return true
|
||||
}
|
||||
return false
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
return finalUsage, err
|
||||
}
|
||||
// Try using pre-parsed tool calls from C++ autoparser (chat deltas)
|
||||
var functionResults []functions.FuncCallResults
|
||||
var reasoning string
|
||||
|
||||
if deltaToolCalls := functions.ToolCallsFromChatDeltas(chatDeltas); len(deltaToolCalls) > 0 {
|
||||
xlog.Debug("[ChatDeltas] Using pre-parsed tool calls from C++ autoparser", "count", len(deltaToolCalls))
|
||||
functionResults = deltaToolCalls
|
||||
// Use content/reasoning from deltas too
|
||||
*textContentToReturn = functions.ContentFromChatDeltas(chatDeltas)
|
||||
reasoning = functions.ReasoningFromChatDeltas(chatDeltas)
|
||||
} else {
|
||||
// Fallback: parse tool calls from raw text (no chat deltas from backend)
|
||||
xlog.Debug("[ChatDeltas] no pre-parsed tool calls, falling back to Go-side text parsing")
|
||||
reasoning = extractor.Reasoning()
|
||||
cleanedResult := extractor.CleanedContent()
|
||||
*textContentToReturn = functions.ParseTextContent(cleanedResult, cfg.FunctionsConfig)
|
||||
cleanedResult = functions.CleanupLLMResult(cleanedResult, cfg.FunctionsConfig)
|
||||
functionResults = functions.ParseFunctionCall(cleanedResult, cfg.FunctionsConfig)
|
||||
}
|
||||
xlog.Debug("[ChatDeltas] final tool call decision", "tool_calls", len(functionResults), "text_content", *textContentToReturn)
|
||||
// noAction is a sentinel "just answer" pseudo-function: not a real
|
||||
// tool call. Scan the whole slice rather than only index 0 so we
|
||||
// don't drop a real tool call that happens to follow a noAction
|
||||
// entry, and so the default branch isn't entered with only noAction
|
||||
// entries to emit as tool_calls.
|
||||
noActionToRun := !hasRealCall(functionResults, noAction)
|
||||
|
||||
switch {
|
||||
case noActionToRun:
|
||||
// The final usage trailer (when the caller opted in with
|
||||
// stream_options.include_usage) is built by the outer streaming
|
||||
// loop from the TokenUsage this function returns, not from any
|
||||
// chunk on the responses channel.
|
||||
var result string
|
||||
if !sentInitialRole {
|
||||
var hqErr error
|
||||
result, hqErr = handleQuestion(cfg, functionResults, extractor.CleanedContent(), prompt)
|
||||
if hqErr != nil {
|
||||
xlog.Error("error handling question", "error", hqErr)
|
||||
return finalUsage, hqErr
|
||||
}
|
||||
}
|
||||
for _, chunk := range buildNoActionFinalChunks(
|
||||
id, req.Model, created,
|
||||
sentInitialRole, sentReasoning,
|
||||
result, reasoning,
|
||||
) {
|
||||
responses <- chunk
|
||||
}
|
||||
|
||||
default:
|
||||
for _, chunk := range buildDeferredToolCallChunks(
|
||||
id, req.Model, created,
|
||||
functionResults, lastEmittedCount,
|
||||
sentInitialRole, *textContentToReturn,
|
||||
sentReasoning, reasoning,
|
||||
) {
|
||||
responses <- chunk
|
||||
}
|
||||
}
|
||||
|
||||
close(responses)
|
||||
return finalUsage, err
|
||||
}
|
||||
@@ -17,16 +17,20 @@ import (
|
||||
)
|
||||
|
||||
type APIExchangeRequest struct {
|
||||
Method string `json:"method"`
|
||||
Path string `json:"path"`
|
||||
Headers *http.Header `json:"headers"`
|
||||
Body *[]byte `json:"body"`
|
||||
Method string `json:"method"`
|
||||
Path string `json:"path"`
|
||||
Headers *http.Header `json:"headers"`
|
||||
Body *[]byte `json:"body"`
|
||||
BodyTruncated bool `json:"body_truncated,omitempty"`
|
||||
BodyBytes int `json:"body_bytes,omitempty"` // original size before truncation
|
||||
}
|
||||
|
||||
type APIExchangeResponse struct {
|
||||
Status int `json:"status"`
|
||||
Headers *http.Header `json:"headers"`
|
||||
Body *[]byte `json:"body"`
|
||||
Status int `json:"status"`
|
||||
Headers *http.Header `json:"headers"`
|
||||
Body *[]byte `json:"body"`
|
||||
BodyTruncated bool `json:"body_truncated,omitempty"`
|
||||
BodyBytes int `json:"body_bytes,omitempty"` // original size before truncation
|
||||
}
|
||||
|
||||
type APIExchange struct {
|
||||
@@ -66,11 +70,29 @@ var doInitializeTracing = sync.OnceFunc(func() {
|
||||
|
||||
type bodyWriter struct {
|
||||
http.ResponseWriter
|
||||
body *bytes.Buffer
|
||||
body *bytes.Buffer
|
||||
maxBytes int // 0 = unlimited capture
|
||||
truncated bool
|
||||
totalBytes int // bytes the upstream handler wrote, even past the cap
|
||||
}
|
||||
|
||||
func (w *bodyWriter) Write(b []byte) (int, error) {
|
||||
w.body.Write(b)
|
||||
// Capture into the trace buffer up to maxBytes, then drop the overflow
|
||||
// so a chatty endpoint can't grow the buffer without bound. The full
|
||||
// payload still flows through to the real client below.
|
||||
w.totalBytes += len(b)
|
||||
if w.maxBytes <= 0 {
|
||||
w.body.Write(b)
|
||||
} else if remain := w.maxBytes - w.body.Len(); remain > 0 {
|
||||
if remain >= len(b) {
|
||||
w.body.Write(b)
|
||||
} else {
|
||||
w.body.Write(b[:remain])
|
||||
w.truncated = true
|
||||
}
|
||||
} else {
|
||||
w.truncated = true
|
||||
}
|
||||
return w.ResponseWriter.Write(b)
|
||||
}
|
||||
|
||||
@@ -80,6 +102,20 @@ func (w *bodyWriter) Flush() {
|
||||
}
|
||||
}
|
||||
|
||||
// truncateForTrace returns a defensive copy of body capped at maxBytes,
|
||||
// and a flag indicating whether the cap forced truncation. maxBytes <= 0
|
||||
// disables the cap.
|
||||
func truncateForTrace(body []byte, maxBytes int) ([]byte, bool) {
|
||||
if maxBytes <= 0 || len(body) <= maxBytes {
|
||||
out := make([]byte, len(body))
|
||||
copy(out, body)
|
||||
return out, false
|
||||
}
|
||||
out := make([]byte, maxBytes)
|
||||
copy(out, body[:maxBytes])
|
||||
return out, true
|
||||
}
|
||||
|
||||
func initializeTracing(maxItems int) {
|
||||
tracingMaxItems = maxItems
|
||||
doInitializeTracing()
|
||||
@@ -134,11 +170,18 @@ func TraceMiddleware(app *application.Application) echo.MiddlewareFunc {
|
||||
|
||||
startTime := time.Now()
|
||||
|
||||
// Cap captured payload size. Without this, /embeddings and
|
||||
// streaming /chat/completions blow the in-memory buffer into the
|
||||
// tens of MB, which then locks the admin Traces UI fetching the
|
||||
// JSON dump faster than the 5s auto-refresh.
|
||||
maxBodyBytes := app.ApplicationConfig().TracingMaxBodyBytes
|
||||
|
||||
// Wrap response writer to capture body
|
||||
resBody := new(bytes.Buffer)
|
||||
mw := &bodyWriter{
|
||||
ResponseWriter: c.Response().Writer,
|
||||
body: resBody,
|
||||
maxBytes: maxBodyBytes,
|
||||
}
|
||||
c.Response().Writer = mw
|
||||
|
||||
@@ -159,8 +202,7 @@ func TraceMiddleware(app *application.Application) echo.MiddlewareFunc {
|
||||
// via any heap-dump-style introspection, and tokens shouldn't
|
||||
// outlive the request that carried them.
|
||||
requestHeaders := redactSensitiveHeaders(c.Request().Header)
|
||||
requestBody := make([]byte, len(body))
|
||||
copy(requestBody, body)
|
||||
requestBody, requestTruncated := truncateForTrace(body, maxBodyBytes)
|
||||
responseHeaders := redactSensitiveHeaders(c.Response().Header())
|
||||
responseBody := make([]byte, resBody.Len())
|
||||
copy(responseBody, resBody.Bytes())
|
||||
@@ -168,15 +210,19 @@ func TraceMiddleware(app *application.Application) echo.MiddlewareFunc {
|
||||
Timestamp: startTime,
|
||||
Duration: time.Since(startTime),
|
||||
Request: APIExchangeRequest{
|
||||
Method: c.Request().Method,
|
||||
Path: c.Path(),
|
||||
Headers: &requestHeaders,
|
||||
Body: &requestBody,
|
||||
Method: c.Request().Method,
|
||||
Path: c.Path(),
|
||||
Headers: &requestHeaders,
|
||||
Body: &requestBody,
|
||||
BodyTruncated: requestTruncated,
|
||||
BodyBytes: len(body),
|
||||
},
|
||||
Response: APIExchangeResponse{
|
||||
Status: status,
|
||||
Headers: &responseHeaders,
|
||||
Body: &responseBody,
|
||||
Status: status,
|
||||
Headers: &responseHeaders,
|
||||
Body: &responseBody,
|
||||
BodyTruncated: mw.truncated,
|
||||
BodyBytes: mw.totalBytes,
|
||||
},
|
||||
}
|
||||
if handlerErr != nil {
|
||||
|
||||
116
core/http/middleware/trace_body_cap_test.go
Normal file
116
core/http/middleware/trace_body_cap_test.go
Normal file
@@ -0,0 +1,116 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
// The trace middleware copies request and response bodies into an in-memory
|
||||
// buffer that backs the admin /api/traces endpoint. With no upper bound a
|
||||
// chatty workload (embeddings, large completions) trivially produces a
|
||||
// multi-MB response that locks the Traces UI in a loading state — fetching
|
||||
// and parsing the payload outruns the 5-second auto-refresh. These specs
|
||||
// pin the capping contract so future refactors keep both the cap and the
|
||||
// passthrough to the real client intact.
|
||||
|
||||
var _ = Describe("bodyWriter capping", func() {
|
||||
It("captures the full body when maxBytes is 0 (unlimited)", func() {
|
||||
downstream := httptest.NewRecorder()
|
||||
buf := &bytes.Buffer{}
|
||||
bw := &bodyWriter{ResponseWriter: downstream, body: buf, maxBytes: 0}
|
||||
|
||||
payload := []byte(strings.Repeat("x", 4096))
|
||||
n, err := bw.Write(payload)
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(n).To(Equal(len(payload)))
|
||||
Expect(buf.Len()).To(Equal(len(payload)))
|
||||
Expect(downstream.Body.Len()).To(Equal(len(payload)))
|
||||
Expect(bw.truncated).To(BeFalse())
|
||||
})
|
||||
|
||||
It("stops appending to the trace buffer once maxBytes is reached but still forwards to the client", func() {
|
||||
downstream := httptest.NewRecorder()
|
||||
buf := &bytes.Buffer{}
|
||||
bw := &bodyWriter{ResponseWriter: downstream, body: buf, maxBytes: 100}
|
||||
|
||||
payload := []byte(strings.Repeat("a", 250))
|
||||
n, err := bw.Write(payload)
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(n).To(Equal(len(payload)), "Write must return the full byte count so callers see no short write")
|
||||
Expect(buf.Len()).To(Equal(100), "trace buffer should hold exactly maxBytes")
|
||||
Expect(downstream.Body.Len()).To(Equal(len(payload)), "client must still receive every byte")
|
||||
Expect(bw.truncated).To(BeTrue())
|
||||
})
|
||||
|
||||
It("handles a write that straddles the cap by keeping only the leading slice", func() {
|
||||
downstream := httptest.NewRecorder()
|
||||
buf := &bytes.Buffer{}
|
||||
bw := &bodyWriter{ResponseWriter: downstream, body: buf, maxBytes: 10}
|
||||
|
||||
_, err := bw.Write([]byte("12345"))
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(bw.truncated).To(BeFalse())
|
||||
|
||||
_, err = bw.Write([]byte("67890ABCDE"))
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
Expect(buf.String()).To(Equal("1234567890"))
|
||||
Expect(downstream.Body.String()).To(Equal("1234567890ABCDE"))
|
||||
Expect(bw.truncated).To(BeTrue())
|
||||
})
|
||||
|
||||
It("ignores further writes after the cap was already hit", func() {
|
||||
downstream := httptest.NewRecorder()
|
||||
buf := &bytes.Buffer{}
|
||||
bw := &bodyWriter{ResponseWriter: downstream, body: buf, maxBytes: 4}
|
||||
|
||||
_, _ = bw.Write([]byte("AAAA"))
|
||||
_, _ = bw.Write([]byte("BBBB"))
|
||||
_, _ = bw.Write([]byte("CCCC"))
|
||||
|
||||
Expect(buf.String()).To(Equal("AAAA"))
|
||||
Expect(downstream.Body.String()).To(Equal("AAAABBBBCCCC"))
|
||||
Expect(bw.truncated).To(BeTrue())
|
||||
})
|
||||
})
|
||||
|
||||
var _ = Describe("truncateForTrace", func() {
|
||||
It("returns the input unchanged when below the cap", func() {
|
||||
in := []byte("hello")
|
||||
out, truncated := truncateForTrace(in, 1024)
|
||||
Expect(truncated).To(BeFalse())
|
||||
Expect(out).To(Equal(in))
|
||||
})
|
||||
|
||||
It("truncates when the input exceeds the cap and signals truncation", func() {
|
||||
in := []byte(strings.Repeat("z", 200))
|
||||
out, truncated := truncateForTrace(in, 64)
|
||||
Expect(truncated).To(BeTrue())
|
||||
Expect(out).To(HaveLen(64))
|
||||
Expect(string(out)).To(Equal(strings.Repeat("z", 64)))
|
||||
})
|
||||
|
||||
It("treats maxBytes <= 0 as unlimited (back-compat with current default)", func() {
|
||||
in := []byte(strings.Repeat("q", 10_000))
|
||||
out, truncated := truncateForTrace(in, 0)
|
||||
Expect(truncated).To(BeFalse())
|
||||
Expect(out).To(HaveLen(len(in)))
|
||||
})
|
||||
|
||||
It("does not retain the caller's backing array (defensive copy)", func() {
|
||||
in := []byte("abcdefghij")
|
||||
out, truncated := truncateForTrace(in, 4)
|
||||
Expect(truncated).To(BeTrue())
|
||||
Expect(string(out)).To(Equal("abcd"))
|
||||
|
||||
// Mutating the source must not corrupt the trace copy.
|
||||
in[0] = 'Z'
|
||||
Expect(string(out)).To(Equal("abcd"))
|
||||
})
|
||||
})
|
||||
@@ -52,11 +52,22 @@ test.describe('Traces Settings', () => {
|
||||
await page.locator('button', { hasText: 'Tracing is' }).click()
|
||||
await expect(page.locator('text=Enable Tracing')).toBeVisible()
|
||||
|
||||
const maxItemsInput = page.locator('input[type="number"]')
|
||||
// The Tracing panel has two numeric inputs (Max Items and Max Body Bytes).
|
||||
// Disambiguate by placeholder so adding a third field later doesn't break this.
|
||||
const maxItemsInput = page.getByPlaceholder('100')
|
||||
await maxItemsInput.fill('500')
|
||||
await expect(maxItemsInput).toHaveValue('500')
|
||||
})
|
||||
|
||||
test('set max body bytes value', async ({ page }) => {
|
||||
await page.locator('button', { hasText: 'Tracing is' }).click()
|
||||
await expect(page.locator('text=Enable Tracing')).toBeVisible()
|
||||
|
||||
const maxBodyBytesInput = page.getByPlaceholder('65536')
|
||||
await maxBodyBytesInput.fill('16384')
|
||||
await expect(maxBodyBytesInput).toHaveValue('16384')
|
||||
})
|
||||
|
||||
test('save shows toast', async ({ page }) => {
|
||||
// Expand settings
|
||||
await page.locator('button', { hasText: 'Tracing is' }).click()
|
||||
|
||||
@@ -435,6 +435,9 @@ export default function Settings() {
|
||||
<SettingRow label="Max Items" description="Maximum number of trace items to retain (0 = unlimited)">
|
||||
<input className="input" type="number" style={{ width: 120 }} value={settings.tracing_max_items ?? ''} onChange={(e) => update('tracing_max_items', parseInt(e.target.value) || 0)} placeholder="100" disabled={!settings.enable_tracing} />
|
||||
</SettingRow>
|
||||
<SettingRow label="Max Body Bytes" description="Per-field cap (bytes) for captured request/response bodies and backend trace Data fields. Prevents large LLM histories or TTS audio snippets from locking the Traces UI. 0 = uncapped.">
|
||||
<input className="input" type="number" style={{ width: 120 }} value={settings.tracing_max_body_bytes ?? ''} onChange={(e) => update('tracing_max_body_bytes', parseInt(e.target.value) || 0)} placeholder="65536" disabled={!settings.enable_tracing} />
|
||||
</SettingRow>
|
||||
<SettingRow label="Enable Backend Logging" description="Capture backend process output per model (without requiring debug mode)">
|
||||
<Toggle checked={settings.enable_backend_logging} onChange={(v) => update('enable_backend_logging', v)} />
|
||||
</SettingRow>
|
||||
|
||||
@@ -406,7 +406,15 @@ export default function Traces() {
|
||||
<button className="btn btn-secondary btn-sm" onClick={fetchTraces}><i className="fas fa-rotate" /> Refresh</button>
|
||||
<button className="btn btn-secondary btn-sm" onClick={handleExport} disabled={traces.length === 0}><i className="fas fa-download" /> Export</button>
|
||||
<div style={{ flex: 1 }} />
|
||||
<button className="btn btn-danger btn-sm" onClick={handleClear} disabled={traces.length === 0}><i className="fas fa-trash" /> Clear</button>
|
||||
<button
|
||||
className="btn btn-danger btn-sm"
|
||||
onClick={handleClear}
|
||||
/* Stay enabled while loading: a massive in-memory trace buffer is
|
||||
precisely the case where the user can't see the table yet and
|
||||
needs Clear to recover. Clearing an already-empty server-side
|
||||
buffer is a harmless no-op. */
|
||||
disabled={!loading && traces.length === 0}
|
||||
><i className="fas fa-trash" /> Clear</button>
|
||||
</div>
|
||||
|
||||
{settings && (() => {
|
||||
@@ -459,6 +467,17 @@ export default function Traces() {
|
||||
disabled={!settings.enable_tracing}
|
||||
/>
|
||||
</SettingRow>
|
||||
<SettingRow label="Max Body Bytes" description="Per-field cap for captured bodies and backend trace Data (0 = uncapped). Prevents oversized LLM histories or TTS snippets from locking this page in loading.">
|
||||
<input
|
||||
className="input"
|
||||
type="number"
|
||||
style={{ width: 120 }}
|
||||
value={settings.tracing_max_body_bytes ?? ''}
|
||||
onChange={(e) => setSettings(prev => ({ ...prev, tracing_max_body_bytes: parseInt(e.target.value) || 0 }))}
|
||||
placeholder="65536"
|
||||
disabled={!settings.enable_tracing}
|
||||
/>
|
||||
</SettingRow>
|
||||
<SettingRow label="Enable Backend Logging" description="Capture backend process output per model (without requiring debug mode)">
|
||||
<Toggle
|
||||
checked={settings.enable_backend_logging}
|
||||
|
||||
@@ -16,8 +16,12 @@ const MaxSnippetSeconds = 30
|
||||
|
||||
// AudioSnippet captures the first MaxSnippetSeconds of a WAV file and computes
|
||||
// quality metrics. The result is a map suitable for merging into a BackendTrace
|
||||
// Data field.
|
||||
func AudioSnippet(wavPath string) map[string]any {
|
||||
// Data field. maxBytes caps the embedded base64 waveform so a single TTS or
|
||||
// transcription trace cannot blow past the backend-trace body cap (~1.3 MiB
|
||||
// of base64 per 30s of 16 kHz mono int16 PCM otherwise); when the encoded
|
||||
// waveform would exceed the cap the audio_wav_base64 field is dropped and
|
||||
// the rest of the metrics are returned. maxBytes <= 0 disables the cap.
|
||||
func AudioSnippet(wavPath string, maxBytes int) map[string]any {
|
||||
raw, err := os.ReadFile(wavPath)
|
||||
if err != nil {
|
||||
xlog.Warn("audio snippet: read failed", "path", wavPath, "error", err)
|
||||
@@ -34,12 +38,14 @@ func AudioSnippet(wavPath string) map[string]any {
|
||||
sampleRate = 16000
|
||||
}
|
||||
|
||||
return AudioSnippetFromPCM(pcm, sampleRate, len(pcm))
|
||||
return AudioSnippetFromPCM(pcm, sampleRate, len(pcm), maxBytes)
|
||||
}
|
||||
|
||||
// AudioSnippetFromPCM builds an audio snippet from raw PCM bytes (int16 LE mono).
|
||||
// totalPCMBytes is the full audio size before truncation (used to compute total duration).
|
||||
func AudioSnippetFromPCM(pcm []byte, sampleRate int, totalPCMBytes int) map[string]any {
|
||||
// totalPCMBytes is the full audio size before truncation (used to compute
|
||||
// total duration). maxBytes caps the embedded base64 waveform as described
|
||||
// on AudioSnippet.
|
||||
func AudioSnippetFromPCM(pcm []byte, sampleRate, totalPCMBytes, maxBytes int) map[string]any {
|
||||
if len(pcm) == 0 || len(pcm)%2 != 0 {
|
||||
return nil
|
||||
}
|
||||
@@ -89,8 +95,7 @@ func AudioSnippetFromPCM(pcm []byte, sampleRate int, totalPCMBytes int) map[stri
|
||||
}
|
||||
buf.Write(snippetPCM)
|
||||
|
||||
return map[string]any{
|
||||
"audio_wav_base64": base64.StdEncoding.EncodeToString(buf.Bytes()),
|
||||
out := map[string]any{
|
||||
"audio_duration_s": math.Round(durationS*100) / 100,
|
||||
"audio_snippet_s": math.Round(snippetDuration*100) / 100,
|
||||
"audio_sample_rate": sampleRate,
|
||||
@@ -99,4 +104,15 @@ func AudioSnippetFromPCM(pcm []byte, sampleRate int, totalPCMBytes int) map[stri
|
||||
"audio_peak_dbfs": math.Round(peakDBFS*10) / 10,
|
||||
"audio_dc_offset": math.Round(dcOffset*10000) / 10000,
|
||||
}
|
||||
// Skip the embedded waveform when it would dominate the trace payload.
|
||||
// Truncating mid-base64 produces an undecodable string, so the right
|
||||
// move is to drop the field and let the UI render just the metrics.
|
||||
encodedSize := base64.StdEncoding.EncodedLen(buf.Len())
|
||||
if maxBytes <= 0 || encodedSize <= maxBytes {
|
||||
out["audio_wav_base64"] = base64.StdEncoding.EncodeToString(buf.Bytes())
|
||||
} else {
|
||||
xlog.Debug("audio snippet: dropping audio_wav_base64", "encoded_bytes", encodedSize, "max_bytes", maxBytes)
|
||||
out["audio_wav_base64_dropped_bytes"] = encodedSize
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
49
core/trace/audio_snippet_test.go
Normal file
49
core/trace/audio_snippet_test.go
Normal file
@@ -0,0 +1,49 @@
|
||||
package trace_test
|
||||
|
||||
import (
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
|
||||
"github.com/mudler/LocalAI/core/trace"
|
||||
)
|
||||
|
||||
// One second of mono 16-bit PCM at 16 kHz: 32 KiB raw. After the 44-byte
|
||||
// WAV header and base64 encoding the snippet runs ~42 KiB, which is well
|
||||
// over the small caps used here and matches the smallest realistic TTS
|
||||
// output size.
|
||||
const (
|
||||
snippetSampleRate = 16000
|
||||
snippetSeconds = 1
|
||||
)
|
||||
|
||||
func makePCM(seconds, sampleRate int) []byte {
|
||||
return make([]byte, seconds*sampleRate*2) // int16 mono
|
||||
}
|
||||
|
||||
var _ = Describe("AudioSnippetFromPCM byte cap", func() {
|
||||
pcm := makePCM(snippetSeconds, snippetSampleRate)
|
||||
totalPCM := len(pcm)
|
||||
|
||||
It("omits audio_wav_base64 when the encoded snippet would exceed the cap, keeping the metrics", func() {
|
||||
out := trace.AudioSnippetFromPCM(pcm, snippetSampleRate, totalPCM, 1024)
|
||||
|
||||
Expect(out).ToNot(BeNil(), "metrics must still be returned even when the waveform is dropped")
|
||||
Expect(out).ToNot(HaveKey("audio_wav_base64"), "oversized base64 must be dropped so the UI does not try to render invalid audio data")
|
||||
Expect(out).To(HaveKey("audio_duration_s"))
|
||||
Expect(out).To(HaveKey("audio_sample_rate"))
|
||||
Expect(out).To(HaveKey("audio_rms_dbfs"))
|
||||
})
|
||||
|
||||
It("includes audio_wav_base64 when the snippet fits under the cap", func() {
|
||||
out := trace.AudioSnippetFromPCM(pcm, snippetSampleRate, totalPCM, 1024*1024)
|
||||
|
||||
Expect(out).To(HaveKey("audio_wav_base64"))
|
||||
Expect(out["audio_wav_base64"]).ToNot(BeEmpty())
|
||||
})
|
||||
|
||||
It("includes audio_wav_base64 when the cap is disabled (0)", func() {
|
||||
out := trace.AudioSnippetFromPCM(pcm, snippetSampleRate, totalPCM, 0)
|
||||
|
||||
Expect(out).To(HaveKey("audio_wav_base64"))
|
||||
})
|
||||
})
|
||||
@@ -2,6 +2,7 @@ package trace
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"slices"
|
||||
"sync"
|
||||
"time"
|
||||
@@ -49,13 +50,25 @@ var backendMu sync.Mutex
|
||||
var backendLogChan = make(chan *BackendTrace, 100)
|
||||
var backendInitOnce sync.Once
|
||||
|
||||
func InitBackendTracingIfEnabled(maxItems int) {
|
||||
// backendMaxBodyBytes caps each captured string value in a BackendTrace.Data
|
||||
// field to keep the /api/backend-traces JSON small enough for the admin UI to
|
||||
// load on every 5s auto-refresh. Mirrors the API-trace body cap added in
|
||||
// commit 61bf34ea: without it a chatty LLM workload (full message history per
|
||||
// trace) or any TTS run (~1.3 MiB of audio_wav_base64 per trace) blows the
|
||||
// payload past tens of MiB and locks the Traces page in a loading state.
|
||||
//
|
||||
// 0 disables the cap. Set on the first InitBackendTracingIfEnabled call only,
|
||||
// matching the sync.Once-guarded maxItems semantics.
|
||||
var backendMaxBodyBytes int
|
||||
|
||||
func InitBackendTracingIfEnabled(maxItems, maxBodyBytes int) {
|
||||
backendInitOnce.Do(func() {
|
||||
if maxItems <= 0 {
|
||||
maxItems = 100
|
||||
}
|
||||
backendMu.Lock()
|
||||
backendTraceBuffer = circularbuffer.New[*BackendTrace](maxItems)
|
||||
backendMaxBodyBytes = maxBodyBytes
|
||||
backendMu.Unlock()
|
||||
|
||||
go func() {
|
||||
@@ -71,6 +84,9 @@ func InitBackendTracingIfEnabled(maxItems int) {
|
||||
}
|
||||
|
||||
func RecordBackendTrace(t BackendTrace) {
|
||||
if t.Data != nil && backendMaxBodyBytes > 0 {
|
||||
t.Data = capDataStrings(t.Data, backendMaxBodyBytes)
|
||||
}
|
||||
select {
|
||||
case backendLogChan <- &t:
|
||||
default:
|
||||
@@ -78,6 +94,35 @@ func RecordBackendTrace(t BackendTrace) {
|
||||
}
|
||||
}
|
||||
|
||||
// capDataStrings walks a trace Data map and replaces any string value (at any
|
||||
// depth) that exceeds maxBytes with a fixed-size marker that names the
|
||||
// original byte count. The replacement is intentionally short and not valid
|
||||
// base64/JSON: the goal is to flag "this was dropped" cheaply, not to keep a
|
||||
// partial value that the UI might try to render. Non-string scalars and
|
||||
// non-map containers pass through untouched so structural fields like
|
||||
// total_deltas or audio_sample_rate remain useful.
|
||||
func capDataStrings(data map[string]any, maxBytes int) map[string]any {
|
||||
out := make(map[string]any, len(data))
|
||||
for k, v := range data {
|
||||
out[k] = capValue(v, maxBytes)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func capValue(v any, maxBytes int) any {
|
||||
switch val := v.(type) {
|
||||
case string:
|
||||
if len(val) > maxBytes {
|
||||
return fmt.Sprintf("<truncated: %d bytes>", len(val))
|
||||
}
|
||||
return val
|
||||
case map[string]any:
|
||||
return capDataStrings(val, maxBytes)
|
||||
default:
|
||||
return v
|
||||
}
|
||||
}
|
||||
|
||||
func GetBackendTraces() []BackendTrace {
|
||||
backendMu.Lock()
|
||||
if backendTraceBuffer == nil {
|
||||
@@ -136,3 +181,24 @@ func TruncateString(s string, maxLen int) string {
|
||||
}
|
||||
return s[:maxLen] + "..."
|
||||
}
|
||||
|
||||
// TruncateToBytes caps a string at exactly maxBytes, preserving the leading
|
||||
// content and appending a marker so the UI knows the value was clipped.
|
||||
// Unlike TruncateString it guarantees output <= maxBytes, which matters for
|
||||
// fields that feed back into the trace pipeline: capDataStrings in
|
||||
// RecordBackendTrace re-checks size and would otherwise replace a producer's
|
||||
// head-preserving truncation with the bare marker, losing the prefix.
|
||||
//
|
||||
// maxBytes <= 0 disables the cap, matching backendMaxBodyBytes semantics.
|
||||
func TruncateToBytes(s string, maxBytes int) string {
|
||||
if maxBytes <= 0 || len(s) <= maxBytes {
|
||||
return s
|
||||
}
|
||||
suffix := fmt.Sprintf("...[truncated, %d bytes]", len(s))
|
||||
if len(suffix) >= maxBytes {
|
||||
// Pathologically small caps can't fit the marker; fall back to a
|
||||
// hard cut so the contract (output <= maxBytes) still holds.
|
||||
return s[:maxBytes]
|
||||
}
|
||||
return s[:maxBytes-len(suffix)] + suffix
|
||||
}
|
||||
|
||||
160
core/trace/backend_trace_cap_test.go
Normal file
160
core/trace/backend_trace_cap_test.go
Normal file
@@ -0,0 +1,160 @@
|
||||
package trace_test
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
|
||||
"github.com/mudler/LocalAI/core/trace"
|
||||
)
|
||||
|
||||
// The /api/backend-traces endpoint ships up to TracingMaxItems entries to the
|
||||
// admin Traces UI on every 5s auto-refresh. Without a cap on the per-trace
|
||||
// Data field, a chatty agent-pool workload (LLM traces carry the full
|
||||
// `messages` array, TTS traces carry ~1.3 MiB of audio_wav_base64) makes the
|
||||
// response tens of MiB. The UI then stays in "loading" forever because the
|
||||
// download + parse runs longer than the refresh interval: the same symptom
|
||||
// the API-trace fix (commit 61bf34ea) addressed on the other side.
|
||||
//
|
||||
// These specs pin the generic safety net (Option A) so any future producer
|
||||
// that stuffs a large string into Data is automatically bounded.
|
||||
|
||||
const (
|
||||
smallCap = 1024
|
||||
smallCapStep = 16
|
||||
)
|
||||
|
||||
var _ = Describe("RecordBackendTrace Data capping", func() {
|
||||
BeforeEach(func() {
|
||||
// Init is sync.Once so the first test wins; subsequent tests just
|
||||
// clear the buffer. The cap value below has to match the first call.
|
||||
trace.InitBackendTracingIfEnabled(64, smallCap)
|
||||
trace.ClearBackendTraces()
|
||||
})
|
||||
|
||||
It("replaces oversized top-level string values with a truncation marker", func() {
|
||||
oversized := strings.Repeat("x", smallCap*4)
|
||||
|
||||
trace.RecordBackendTrace(trace.BackendTrace{
|
||||
Timestamp: time.Now(),
|
||||
Type: trace.BackendTraceLLM,
|
||||
ModelName: "m",
|
||||
Data: map[string]any{
|
||||
"messages": oversized,
|
||||
"small": "fits",
|
||||
},
|
||||
})
|
||||
|
||||
Eventually(trace.GetBackendTraces).Should(HaveLen(1))
|
||||
got := trace.GetBackendTraces()[0]
|
||||
|
||||
Expect(got.Data["small"]).To(Equal("fits"), "fields under the cap must pass through untouched")
|
||||
|
||||
// The marker is the contract the UI reads to show truncation; the
|
||||
// concrete shape can evolve but it must be a short fixed-size string
|
||||
// that encodes the original byte count so users know what was dropped.
|
||||
msg, ok := got.Data["messages"].(string)
|
||||
Expect(ok).To(BeTrue(), "string fields stay strings after capping")
|
||||
Expect(len(msg)).To(BeNumerically("<", smallCap), "capped value must fit under the configured cap")
|
||||
Expect(msg).To(ContainSubstring("truncated"))
|
||||
Expect(msg).To(ContainSubstring("4096"), "marker should reference the original byte count for diagnostics")
|
||||
})
|
||||
|
||||
It("recurses into nested maps so deeply nested oversized strings are also bounded", func() {
|
||||
oversized := strings.Repeat("y", smallCap*2)
|
||||
|
||||
trace.RecordBackendTrace(trace.BackendTrace{
|
||||
Timestamp: time.Now(),
|
||||
Type: trace.BackendTraceLLM,
|
||||
ModelName: "m",
|
||||
Data: map[string]any{
|
||||
"chat_deltas": map[string]any{
|
||||
"content": oversized,
|
||||
"total_deltas": 5,
|
||||
"tool_call_count": 0,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
Eventually(trace.GetBackendTraces).Should(HaveLen(1))
|
||||
got := trace.GetBackendTraces()[0]
|
||||
|
||||
deltas, ok := got.Data["chat_deltas"].(map[string]any)
|
||||
Expect(ok).To(BeTrue(), "nested map structure must be preserved")
|
||||
Expect(deltas["total_deltas"]).To(Equal(5), "non-string siblings must pass through untouched")
|
||||
|
||||
content, ok := deltas["content"].(string)
|
||||
Expect(ok).To(BeTrue())
|
||||
Expect(len(content)).To(BeNumerically("<", smallCap), "nested oversized string must still be capped")
|
||||
Expect(content).To(ContainSubstring("truncated"))
|
||||
})
|
||||
|
||||
It("leaves values within the cap untouched", func() {
|
||||
smallVal := strings.Repeat("z", smallCap-smallCapStep)
|
||||
|
||||
trace.RecordBackendTrace(trace.BackendTrace{
|
||||
Timestamp: time.Now(),
|
||||
Type: trace.BackendTraceEmbedding,
|
||||
ModelName: "m",
|
||||
Data: map[string]any{
|
||||
"input_text": smallVal,
|
||||
},
|
||||
})
|
||||
|
||||
Eventually(trace.GetBackendTraces).Should(HaveLen(1))
|
||||
got := trace.GetBackendTraces()[0]
|
||||
|
||||
Expect(got.Data["input_text"]).To(Equal(smallVal))
|
||||
})
|
||||
|
||||
It("does not re-truncate values that producers already capped with TruncateToBytes", func() {
|
||||
// Producers (LLM messages/response, etc.) prefer head-preserving
|
||||
// truncation so users can still read the start of the conversation.
|
||||
// TruncateToBytes guarantees output <= cap, so the generic safety
|
||||
// net below must leave it alone, otherwise the kept prefix gets
|
||||
// thrown away and replaced with the marker.
|
||||
preTruncated := trace.TruncateToBytes(strings.Repeat("a", smallCap*4), smallCap)
|
||||
Expect(len(preTruncated)).To(BeNumerically("<=", smallCap))
|
||||
|
||||
trace.RecordBackendTrace(trace.BackendTrace{
|
||||
Timestamp: time.Now(),
|
||||
Type: trace.BackendTraceLLM,
|
||||
ModelName: "m",
|
||||
Data: map[string]any{
|
||||
"messages": preTruncated,
|
||||
},
|
||||
})
|
||||
|
||||
Eventually(trace.GetBackendTraces).Should(HaveLen(1))
|
||||
got := trace.GetBackendTraces()[0]
|
||||
Expect(got.Data["messages"]).To(Equal(preTruncated))
|
||||
})
|
||||
})
|
||||
|
||||
var _ = Describe("TruncateToBytes", func() {
|
||||
It("returns the input unchanged when it fits", func() {
|
||||
Expect(trace.TruncateToBytes("hello", 1024)).To(Equal("hello"))
|
||||
})
|
||||
|
||||
It("treats maxBytes <= 0 as unlimited", func() {
|
||||
Expect(trace.TruncateToBytes("hello", 0)).To(Equal("hello"))
|
||||
Expect(trace.TruncateToBytes("hello", -1)).To(Equal("hello"))
|
||||
})
|
||||
|
||||
It("caps oversized input to at most maxBytes and preserves the head", func() {
|
||||
in := strings.Repeat("a", 5000)
|
||||
out := trace.TruncateToBytes(in, 100)
|
||||
Expect(len(out)).To(BeNumerically("<=", 100), "output must never exceed the cap so the generic Record-time safety net doesn't fire")
|
||||
Expect(out).To(HavePrefix("a"), "should keep the leading content readable")
|
||||
Expect(out).To(ContainSubstring("truncated"), "should mark the value as truncated for the UI")
|
||||
})
|
||||
|
||||
It("falls back to plain truncation when the cap is smaller than the suffix", func() {
|
||||
in := strings.Repeat("a", 100)
|
||||
out := trace.TruncateToBytes(in, 4)
|
||||
Expect(len(out)).To(Equal(4))
|
||||
Expect(out).To(Equal("aaaa"))
|
||||
})
|
||||
})
|
||||
13
core/trace/trace_suite_test.go
Normal file
13
core/trace/trace_suite_test.go
Normal file
@@ -0,0 +1,13 @@
|
||||
package trace_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
func TestTrace(t *testing.T) {
|
||||
RegisterFailHandler(Fail)
|
||||
RunSpecs(t, "Trace test suite")
|
||||
}
|
||||
Reference in New Issue
Block a user