mirror of
https://github.com/mudler/LocalAI.git
synced 2026-07-03 21:07:33 -04:00
Realtime sessions previously lazy-loaded each pipeline sub-model (VAD,
transcription, LLM, TTS) on first use, so every cold session paid a
per-request model-load stall and load errors only surfaced mid-stream.
Warm the whole pipeline eagerly and blockingly at session start
(including the voice-gate speaker-recognition model, which an enforced
gate blocks each utterance on; compaction's summary_model stays lazy
since it only runs off the response path):
- Add backend.PreloadModel / PreloadModelByName as the single load path
for every modality (no transcription special-case; backend-omitted
configs are deprecated).
- The realtime session blocks on Model.Warmup and returns a
model_load_error to the client if any stage fails to load;
updateSession warms in the background. Opt out per pipeline with
pipeline.disable_warmup, exposed as a UI toggle via the
config-metadata registry.
Add a LocalAI-native POST /backend/load (and /v1/backend/load) that
pre-loads a model -- expanding realtime pipelines into their sub-models
-- as the inverse of /backend/shutdown. There is one preload engine
(backend.PreloadStages): the realtime Warmup methods, /backend/load and
the --load-to-memory startup flag all use it, so --load-to-memory now
also expands pipeline models and records load-failure traces. Pipeline
sub-model alias resolution is likewise shared
(ModelConfigLoader.LoadResolvedModelConfig). Surface the endpoint
everywhere an admin manages models:
- MCP admin tool load_model (httpapi + inproc clients, safety/catalog
prompts, catalog/dispatch tests).
- "Load into memory" action in the React models UI.
- Swagger regenerated; docs moved to the general backend-monitor page
since it is not realtime-specific.
Fix a Traces UI crash ("json: unsupported value: -Inf"): audio-snippet
RMS/peak now floor at a finite dBFS, and backend-trace data is sanitized
to drop non-finite floats before marshaling. The sanitizer is
copy-on-write -- it runs on every RecordBackendTrace, so containers are
only re-allocated on the paths that actually changed.
Migrate core/http/openresponses_test.go onto the prebuilt mock-backend
the rest of the http suite already uses -- it was the last spec still
pointing at a real HuggingFace model, so it 404'd wherever no vision
backend was built -- and fix its item_reference specs to send the
spec's "id" field instead of "item_id", which the handler never
accepted.
Assisted-by: Claude:claude-opus-4-8 Claude Code
Signed-off-by: Richard Palethorpe <io@richiejp.com>
513 lines
16 KiB
Go
513 lines
16 KiB
Go
package backend
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"math/rand/v2"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/mudler/LocalAI/core/config"
|
|
"github.com/mudler/LocalAI/core/trace"
|
|
pb "github.com/mudler/LocalAI/pkg/grpc/proto"
|
|
"github.com/mudler/LocalAI/pkg/downloader"
|
|
"github.com/mudler/LocalAI/pkg/model"
|
|
"github.com/mudler/LocalAI/pkg/vram"
|
|
"github.com/mudler/xlog"
|
|
)
|
|
|
|
// ModelLoadTraceObserver returns the ModelLoader load observer that records
|
|
// a model_load backend trace for every successful real load (backend process
|
|
// spawn + LoadModel RPC; cache hits never reach the observer). Failures are
|
|
// deliberately skipped here: the modality wrappers already record them via
|
|
// recordModelLoadFailure with request context, and the backend auto-discovery
|
|
// scan probes several backends before one succeeds — tracing every probe
|
|
// failure would bury the buffer in noise.
|
|
//
|
|
// The traced data includes the resolved backend runtime (the installed
|
|
// backend's launcher path, which names the variant directory) — that is what
|
|
// identifies WHICH build served the load. A stale installed backend is
|
|
// invisible in the model config but obvious here.
|
|
func ModelLoadTraceObserver(appConfig *config.ApplicationConfig) func(model.BackendLoadEvent) {
|
|
return func(ev model.BackendLoadEvent) {
|
|
if ev.Err != nil || !appConfig.EnableTracing {
|
|
return
|
|
}
|
|
trace.InitBackendTracingIfEnabled(appConfig.TracingMaxItems, appConfig.TracingMaxBodyBytes)
|
|
trace.RecordBackendTrace(trace.BackendTrace{
|
|
Timestamp: time.Now(),
|
|
Duration: ev.Duration,
|
|
Type: trace.BackendTraceModelLoad,
|
|
ModelName: ev.ModelID,
|
|
Backend: ev.Backend,
|
|
Summary: "Model loaded",
|
|
Data: map[string]any{
|
|
"model_file": ev.ModelName,
|
|
"backend_runtime": ev.BackendURI,
|
|
},
|
|
})
|
|
}
|
|
}
|
|
|
|
// PreloadModel warms a model into memory without running any inference, so the
|
|
// first real request doesn't pay the backend's cold-start load cost. It uses
|
|
// the same ModelOptions + ml.Load path the modality functions use, so a
|
|
// subsequent inference call hits the loader cache instead of reloading. Load
|
|
// failures are recorded and returned; callers that warm models opportunistically
|
|
// (e.g. realtime session warm-up) typically log and continue, since the lazy
|
|
// path will retry on first use.
|
|
func PreloadModel(ctx context.Context, ml *model.ModelLoader, modelConfig config.ModelConfig, appConfig *config.ApplicationConfig) error {
|
|
opts := ModelOptions(modelConfig, appConfig, model.WithContext(ctx))
|
|
if _, err := ml.Load(opts...); err != nil {
|
|
recordModelLoadFailure(appConfig, modelConfig.Name, modelConfig.Backend, err, nil)
|
|
return err
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// recordModelLoadFailure records a backend trace when model loading fails.
|
|
func recordModelLoadFailure(appConfig *config.ApplicationConfig, modelName, backend string, err error, data map[string]any) {
|
|
if !appConfig.EnableTracing {
|
|
return
|
|
}
|
|
trace.InitBackendTracingIfEnabled(appConfig.TracingMaxItems, appConfig.TracingMaxBodyBytes)
|
|
trace.RecordBackendTrace(trace.BackendTrace{
|
|
Timestamp: time.Now(),
|
|
Type: trace.BackendTraceModelLoad,
|
|
ModelName: modelName,
|
|
Backend: backend,
|
|
Summary: "Model load failed",
|
|
Error: err.Error(),
|
|
Data: data,
|
|
})
|
|
}
|
|
|
|
// estimateModelSizeBytes uses the unified EstimateModel entry point to compute
|
|
// the total weight-file size for a model config. It collects all weight files
|
|
// from DownloadFiles, Model, and MMProj, and also extracts the HuggingFace
|
|
// repo ID so EstimateModel can fall back to the HF API when local file
|
|
// metadata is unavailable (e.g. not-yet-downloaded models).
|
|
func estimateModelSizeBytes(c config.ModelConfig, modelsPath string) int64 {
|
|
seen := make(map[string]bool)
|
|
input := vram.ModelEstimateInput{}
|
|
|
|
addFile := func(uri string) {
|
|
if !vram.IsWeightFile(uri) {
|
|
return
|
|
}
|
|
resolved := uri
|
|
if !strings.Contains(uri, "://") {
|
|
resolved = "file://" + filepath.Join(modelsPath, uri)
|
|
}
|
|
if seen[resolved] {
|
|
return
|
|
}
|
|
seen[resolved] = true
|
|
input.Files = append(input.Files, vram.FileInput{URI: resolved})
|
|
}
|
|
|
|
// tryHFRepo resolves any huggingface:// or hf:// URI to an HTTPS URL and
|
|
// then extracts the org/model repo ID for use as the HF fallback path.
|
|
tryHFRepo := func(uri string) {
|
|
if input.HFRepo != "" {
|
|
return
|
|
}
|
|
resolved := downloader.URI(uri).ResolveURL()
|
|
if repoID, ok := vram.ExtractHFRepoID(resolved); ok {
|
|
input.HFRepo = repoID
|
|
}
|
|
}
|
|
|
|
for _, f := range c.DownloadFiles {
|
|
uriStr := string(f.URI)
|
|
addFile(uriStr)
|
|
tryHFRepo(uriStr)
|
|
}
|
|
addFile(c.Model)
|
|
tryHFRepo(c.Model)
|
|
if c.MMProj != "" {
|
|
addFile(c.MMProj)
|
|
}
|
|
|
|
if len(input.Files) == 0 && input.HFRepo == "" {
|
|
return 0
|
|
}
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
|
defer cancel()
|
|
|
|
result, err := vram.EstimateModelMultiContext(ctx, input, nil)
|
|
if err != nil || result.SizeBytes == 0 {
|
|
return 0
|
|
}
|
|
return int64(result.SizeBytes)
|
|
}
|
|
|
|
func ModelOptions(c config.ModelConfig, so *config.ApplicationConfig, opts ...model.Option) []model.Option {
|
|
defOpts := []model.Option{
|
|
model.WithBackendString(c.Backend),
|
|
model.WithModel(c.Model),
|
|
model.WithContext(so.Context),
|
|
model.WithModelID(c.ModelID()),
|
|
}
|
|
|
|
threads := 1
|
|
|
|
if c.Threads != nil {
|
|
threads = *c.Threads
|
|
}
|
|
|
|
if so.Threads != 0 {
|
|
threads = so.Threads
|
|
}
|
|
|
|
c.Threads = &threads
|
|
|
|
grpcOpts := grpcModelOpts(c, so.SystemState.Model.ModelsPath)
|
|
defOpts = append(defOpts, model.WithLoadGRPCLoadModelOpts(grpcOpts))
|
|
|
|
defOpts = append(defOpts, model.EnableParallelRequests)
|
|
|
|
if c.GRPC.Attempts != 0 {
|
|
defOpts = append(defOpts, model.WithGRPCAttempts(c.GRPC.Attempts))
|
|
}
|
|
|
|
if c.GRPC.AttemptsSleepTime != 0 {
|
|
defOpts = append(defOpts, model.WithGRPCAttemptsDelay(c.GRPC.AttemptsSleepTime))
|
|
}
|
|
|
|
for k, v := range so.ExternalGRPCBackends {
|
|
defOpts = append(defOpts, model.WithExternalBackend(k, v))
|
|
}
|
|
|
|
if sizeBytes := estimateModelSizeBytes(c, so.SystemState.Model.ModelsPath); sizeBytes > 0 {
|
|
defOpts = append(defOpts, model.WithModelSizeBytes(sizeBytes))
|
|
}
|
|
|
|
return append(defOpts, opts...)
|
|
}
|
|
|
|
func getSeed(c config.ModelConfig) int32 {
|
|
var seed int32 = config.RAND_SEED
|
|
|
|
if c.Seed != nil {
|
|
seed = int32(*c.Seed)
|
|
}
|
|
|
|
if seed == config.RAND_SEED {
|
|
seed = rand.Int32()
|
|
}
|
|
|
|
return seed
|
|
}
|
|
|
|
// DefaultContextSize and DefaultBatchSize are the backend's fallbacks when a
|
|
// model config leaves them unset. Exported so callers that must respect the
|
|
// effective decode window — notably the router's prompt trimmer — resolve the
|
|
// same numbers grpcModelOpts does instead of guessing. The values are owned by
|
|
// core/config (single source of truth shared with the config default tiers).
|
|
const (
|
|
DefaultContextSize = config.DefaultContextSize
|
|
DefaultBatchSize = config.DefaultPhysicalBatch
|
|
)
|
|
|
|
// EffectiveContextSize is the context window the backend will run with: the
|
|
// configured value, or DefaultContextSize when unset.
|
|
func EffectiveContextSize(c config.ModelConfig) int {
|
|
if c.ContextSize != nil {
|
|
return *c.ContextSize
|
|
}
|
|
return DefaultContextSize
|
|
}
|
|
|
|
// EffectiveBatchSize is the single-decode batch the backend will run with.
|
|
// Score, embedding and rerank all process the whole input in one pass: score
|
|
// decodes prompt+candidate (asserts n_tokens <= n_batch), and embedding/rerank
|
|
// pool over the full sequence in one physical batch (n_ubatch). So the batch
|
|
// is sized to the context — anything that fits the context fits one pass,
|
|
// avoiding both the GGML_ASSERT crash and the "input is too large to process"
|
|
// error. Explicit `batch:` always wins.
|
|
func EffectiveBatchSize(c config.ModelConfig) int {
|
|
if c.Batch != 0 {
|
|
return c.Batch
|
|
}
|
|
singlePass := c.HasUsecases(config.FLAG_SCORE) ||
|
|
c.HasUsecases(config.FLAG_EMBEDDINGS) ||
|
|
c.HasUsecases(config.FLAG_RERANK)
|
|
if ctx := EffectiveContextSize(c); singlePass && ctx > DefaultBatchSize {
|
|
return ctx
|
|
}
|
|
return DefaultBatchSize
|
|
}
|
|
|
|
func grpcModelOpts(c config.ModelConfig, modelPath string) *pb.ModelOptions {
|
|
ctxSize := EffectiveContextSize(c)
|
|
b := EffectiveBatchSize(c)
|
|
|
|
flashAttention := config.DefaultFlashAttention
|
|
|
|
if c.FlashAttention != nil {
|
|
flashAttention = *c.FlashAttention
|
|
}
|
|
|
|
f16 := false
|
|
if c.F16 != nil {
|
|
f16 = *c.F16
|
|
}
|
|
|
|
embeddings := false
|
|
if c.Embeddings != nil {
|
|
embeddings = *c.Embeddings
|
|
}
|
|
|
|
lowVRAM := false
|
|
if c.LowVRAM != nil {
|
|
lowVRAM = *c.LowVRAM
|
|
}
|
|
|
|
reranking := false
|
|
if c.Reranking != nil {
|
|
reranking = *c.Reranking
|
|
}
|
|
|
|
mmap := false
|
|
if c.MMap != nil {
|
|
mmap = *c.MMap
|
|
}
|
|
|
|
// Intel SYCL backend has issues with mmap enabled
|
|
// See: https://github.com/mudler/LocalAI/issues/9012
|
|
// Automatically disable mmap for Intel SYCL backends
|
|
if c.Backend != "" {
|
|
if strings.Contains(strings.ToLower(c.Backend), "intel") || strings.Contains(strings.ToLower(c.Backend), "sycl") {
|
|
mmap = false
|
|
xlog.Info("Auto-disabling mmap for Intel SYCL backend", "backend", c.Backend)
|
|
}
|
|
}
|
|
|
|
mmlock := false
|
|
if c.MMlock != nil {
|
|
mmlock = *c.MMlock
|
|
}
|
|
|
|
nGPULayers := config.DefaultNGPULayers
|
|
if c.NGPULayers != nil {
|
|
nGPULayers = *c.NGPULayers
|
|
}
|
|
|
|
triggers := make([]*pb.GrammarTrigger, 0)
|
|
for _, t := range c.FunctionsConfig.GrammarConfig.GrammarTriggers {
|
|
triggers = append(triggers, &pb.GrammarTrigger{
|
|
Word: t.Word,
|
|
})
|
|
}
|
|
|
|
engineArgsJSON := ""
|
|
if len(c.EngineArgs) > 0 {
|
|
buf, err := json.Marshal(c.EngineArgs)
|
|
if err != nil {
|
|
// ModelConfig.Validate() rejects unmarshalable engine_args at
|
|
// config load, so reaching here means the validator was bypassed.
|
|
// Silently dropping user-set options would change runtime behaviour
|
|
// without warning — fail loud instead.
|
|
panic(fmt.Sprintf("engine_args marshal failed for model %q: %v (Validate() should have caught this)", c.Model, err))
|
|
}
|
|
engineArgsJSON = string(buf)
|
|
}
|
|
|
|
opts := &pb.ModelOptions{
|
|
CUDA: c.CUDA || c.Diffusers.CUDA,
|
|
SchedulerType: c.Diffusers.SchedulerType,
|
|
GrammarTriggers: triggers,
|
|
PipelineType: c.Diffusers.PipelineType,
|
|
CFGScale: c.CFGScale,
|
|
LoraAdapter: c.LoraAdapter,
|
|
LoraScale: c.LoraScale,
|
|
LoraAdapters: c.LoraAdapters,
|
|
LoraScales: c.LoraScales,
|
|
F16Memory: f16,
|
|
LoraBase: c.LoraBase,
|
|
IMG2IMG: c.Diffusers.IMG2IMG,
|
|
CLIPModel: c.Diffusers.ClipModel,
|
|
CLIPSubfolder: c.Diffusers.ClipSubFolder,
|
|
Options: c.Options,
|
|
Overrides: c.Overrides,
|
|
EngineArgs: engineArgsJSON,
|
|
CLIPSkip: int32(c.Diffusers.ClipSkip),
|
|
ControlNet: c.Diffusers.ControlNet,
|
|
ContextSize: int32(ctxSize),
|
|
Seed: getSeed(c),
|
|
NBatch: int32(b),
|
|
NoMulMatQ: c.NoMulMatQ,
|
|
DraftModel: c.DraftModel,
|
|
AudioPath: c.AudioPath,
|
|
Quantization: c.Quantization,
|
|
LoadFormat: c.LoadFormat,
|
|
GPUMemoryUtilization: c.GPUMemoryUtilization,
|
|
TrustRemoteCode: c.TrustRemoteCode,
|
|
EnforceEager: c.EnforceEager,
|
|
SwapSpace: int32(c.SwapSpace),
|
|
MaxModelLen: int32(c.MaxModelLen),
|
|
TensorParallelSize: int32(c.TensorParallelSize),
|
|
DisableLogStatus: c.DisableLogStatus,
|
|
DType: c.DType,
|
|
// LimitMMPerPrompt vLLM
|
|
LimitImagePerPrompt: int32(c.LimitMMPerPrompt.LimitImagePerPrompt),
|
|
LimitVideoPerPrompt: int32(c.LimitMMPerPrompt.LimitVideoPerPrompt),
|
|
LimitAudioPerPrompt: int32(c.LimitMMPerPrompt.LimitAudioPerPrompt),
|
|
FlashAttention: flashAttention,
|
|
CacheTypeKey: c.CacheTypeK,
|
|
CacheTypeValue: c.CacheTypeV,
|
|
NoKVOffload: c.NoKVOffloading,
|
|
YarnExtFactor: c.YarnExtFactor,
|
|
YarnAttnFactor: c.YarnAttnFactor,
|
|
YarnBetaFast: c.YarnBetaFast,
|
|
YarnBetaSlow: c.YarnBetaSlow,
|
|
NGQA: c.NGQA,
|
|
RMSNormEps: c.RMSNormEps,
|
|
MLock: mmlock,
|
|
RopeFreqBase: c.RopeFreqBase,
|
|
RopeScaling: c.RopeScaling,
|
|
Type: c.ModelType,
|
|
RopeFreqScale: c.RopeFreqScale,
|
|
NUMA: c.NUMA,
|
|
Embeddings: embeddings,
|
|
Reranking: reranking,
|
|
LowVRAM: lowVRAM,
|
|
NGPULayers: int32(nGPULayers),
|
|
MMap: mmap,
|
|
MainGPU: c.MainGPU,
|
|
Threads: int32(*c.Threads),
|
|
TensorSplit: c.TensorSplit,
|
|
// RWKV
|
|
Tokenizer: c.Tokenizer,
|
|
}
|
|
|
|
if c.Backend == "cloud-proxy" {
|
|
opts.Proxy = &pb.ProxyOptions{
|
|
UpstreamUrl: c.Proxy.UpstreamURL,
|
|
Mode: c.Proxy.Mode,
|
|
Provider: c.Proxy.Provider,
|
|
ApiKeyEnv: c.Proxy.APIKeyEnv,
|
|
ApiKeyFile: c.Proxy.APIKeyFile,
|
|
UpstreamModel: c.Proxy.UpstreamModel,
|
|
RequestTimeoutSeconds: int32(c.Proxy.RequestTimeoutSeconds),
|
|
}
|
|
}
|
|
|
|
if c.MMProj != "" {
|
|
opts.MMProj = filepath.Join(modelPath, c.MMProj)
|
|
}
|
|
|
|
// Resolve draft_model against the models directory, mirroring the
|
|
// handling of parameters.model and mmproj. Always joining (without an
|
|
// IsAbs shortcut) prevents user-supplied configs from pointing the
|
|
// backend at arbitrary host files via an absolute path.
|
|
if c.DraftModel != "" {
|
|
opts.DraftModel = filepath.Join(modelPath, c.DraftModel)
|
|
}
|
|
|
|
return opts
|
|
}
|
|
|
|
func gRPCPredictOpts(c config.ModelConfig, modelPath string) *pb.PredictOptions {
|
|
promptCachePath := ""
|
|
if c.PromptCachePath != "" {
|
|
p := filepath.Join(modelPath, c.PromptCachePath)
|
|
err := os.MkdirAll(filepath.Dir(p), 0750)
|
|
if err == nil {
|
|
promptCachePath = p
|
|
} else {
|
|
xlog.Error("error creating prompt cache folder", "error", err, "promptCachePath", promptCachePath)
|
|
}
|
|
}
|
|
|
|
// TopK may be nil after SetDefaults for backends that don't use llama.cpp's
|
|
// top_k=40 default (issue #6632, e.g. mlx). proto3 int32 can't be unset, so
|
|
// send 0 — the value mlx actually wants (top-k disabled).
|
|
var topK int32
|
|
if c.TopK != nil {
|
|
topK = int32(*c.TopK)
|
|
}
|
|
|
|
pbOpts := &pb.PredictOptions{
|
|
Temperature: float32(*c.Temperature),
|
|
TopP: float32(*c.TopP),
|
|
NDraft: c.NDraft,
|
|
TopK: topK,
|
|
MinP: float32(*c.MinP),
|
|
Tokens: int32(*c.Maxtokens),
|
|
Threads: int32(*c.Threads),
|
|
PromptCacheAll: *c.PromptCacheAll,
|
|
PromptCacheRO: c.PromptCacheRO,
|
|
PromptCachePath: promptCachePath,
|
|
F16KV: *c.F16,
|
|
DebugMode: *c.Debug,
|
|
Grammar: c.Grammar,
|
|
NegativePromptScale: c.NegativePromptScale,
|
|
RopeFreqBase: c.RopeFreqBase,
|
|
RopeFreqScale: c.RopeFreqScale,
|
|
NegativePrompt: c.NegativePrompt,
|
|
Mirostat: int32(*c.LLMConfig.Mirostat),
|
|
MirostatETA: float32(*c.LLMConfig.MirostatETA),
|
|
MirostatTAU: float32(*c.LLMConfig.MirostatTAU),
|
|
Debug: *c.Debug,
|
|
StopPrompts: c.StopWords,
|
|
Repeat: int32(c.RepeatLastN),
|
|
FrequencyPenalty: float32(c.FrequencyPenalty),
|
|
PresencePenalty: float32(c.PresencePenalty),
|
|
Penalty: float32(c.RepeatPenalty),
|
|
NKeep: int32(c.Keep),
|
|
Batch: int32(c.Batch),
|
|
IgnoreEOS: c.IgnoreEOS,
|
|
Seed: getSeed(c),
|
|
MLock: *c.MMlock,
|
|
MMap: *c.MMap,
|
|
MainGPU: c.MainGPU,
|
|
TensorSplit: c.TensorSplit,
|
|
TailFreeSamplingZ: float32(*c.TFZ),
|
|
TypicalP: float32(*c.TypicalP),
|
|
}
|
|
|
|
metadata := map[string]string{}
|
|
if c.ReasoningConfig.DisableReasoning != nil {
|
|
if *c.ReasoningConfig.DisableReasoning {
|
|
metadata["enable_thinking"] = "false"
|
|
} else {
|
|
metadata["enable_thinking"] = "true"
|
|
}
|
|
}
|
|
// Forward the effective reasoning effort so the backend can pass it to the
|
|
// jinja chat template (chat_template_kwargs.reasoning_effort) — the lever
|
|
// models like gpt-oss / LFM2.5 actually read, distinct from enable_thinking.
|
|
if c.ReasoningEffort != "" {
|
|
metadata["reasoning_effort"] = c.ReasoningEffort
|
|
}
|
|
// Client request metadata overrides the server-derived reasoning levers and
|
|
// reaches every backend through these standalone string keys (Python backends
|
|
// read them directly). The reserved blob key is server-owned and skipped.
|
|
for k, v := range c.RequestMetadata {
|
|
if k == "chat_template_kwargs" {
|
|
continue
|
|
}
|
|
metadata[k] = v
|
|
}
|
|
// Build the generic chat_template_kwargs blob (model config map + coerced
|
|
// metadata) for llama.cpp and write it LAST so a client cannot clobber it.
|
|
if blob := c.ResolveChatTemplateKwargs(metadata); len(blob) > 0 {
|
|
b, err := json.Marshal(blob)
|
|
if err != nil {
|
|
xlog.Warn("failed to marshal chat_template_kwargs", "error", err)
|
|
} else {
|
|
metadata["chat_template_kwargs"] = string(b)
|
|
}
|
|
}
|
|
pbOpts.Metadata = metadata
|
|
|
|
// Logprobs and TopLogprobs are set by the caller if provided
|
|
return pbOpts
|
|
}
|