mirror of
https://github.com/mudler/LocalAI.git
synced 2026-07-30 09:57:57 -04:00
* feat(backend): add vllm-cpp text-generation backend (vllm.cpp) Wrap https://github.com/mudler/vllm.cpp - the LocalAI-team from-scratch C++20 port of vLLM (paged KV cache, continuous batching, prefix caching, safetensors + GGUF loading, no Python at inference) - as a Go gRPC backend over its stable C ABI (ABI v2) via purego. Backend (backend/go/vllm-cpp): - Load -> vllm_engine_load: accepts a .gguf file or a config.json model dir (anything else is refused, satisfying the greedy-probe rule); context_size maps to max_model_len, options block_size/num_blocks/max_num_seqs size the KV cache and scheduler admission. - Predict -> vllm_complete (blocking); PredictStream -> vllm_complete_stream with the per-delta C callback bridged into the gRPC stream. The backend embeds base.Base (not SingleThread): concurrent requests batch continuously in the engine's shared AsyncLLM scheduler. - PredictOptions.Grammar -> the ABI's structured_grammar (GBNF), giving grammar-constrained tool calling at parity with llama-cpp; the ABI also exposes JSON-schema/regex/choice constraints. - Hand-mirrored POD structs with layout locked by unit tests (unsafe.Offsetof vs the C offsets) and a runtime vllm_abi_version gate. - One portable library per platform (vllm.cpp uses per-file SIMD tiers with runtime dispatch), so no avx/avx2/avx512 variant builds. Wiring: - backend-matrix: CPU amd64+arm64 (per-arch + manifest merge), CUDA 12/13 amd64 (120a;121a Blackwell fat binary), L4T arm64 (121a, GB10/DGX Spark - the runtime-proven GPU target), Vulkan amd64, and Darwin arm64 Metal. - backend/index.yaml meta + 12 image entries (latest/development x cpu, cuda12, cuda13, l4t, vulkan, metal); bump_deps registration for the VLLM_CPP_VERSION pin; root Makefile registration; test-extra runs the unit specs (pure Go, no engine build). - Importers: preference-only swaps - llama-cpp (GGUF) and vllm (safetensors) advertise vllm-cpp via AdditionalBackends and emit backend: vllm-cpp without tokenizer templating (the C ABI takes the FINAL prompt; templating and tool parsing stay LocalAI-side). No auto-detect importer. - Docs: backends list, top-level README maintained-engines table, compatibility table. Verified: 20/20 Ginkgo specs against the real pinned engine and Qwen3.5-2B-UD-Q8_K_XL.gguf on CPU - blocking + streaming parity, greedy determinism, stop words, GBNF-constrained generation, and 4 concurrent streams; plus a dlopen/ABI-gate smoke of the built gRPC server binary. Upstream ABI v2 + production structured-output wiring landed as mudler/vllm.cpp@86013f3. Assisted-by: Claude Code:claude-fable-5 Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * feat(vllm-cpp): ride the autoparser code path - engine-side chat templating and tool engagement (ABI v3) The backend now implements AIModelRich (PredictRich / PredictStreamRich) over vllm.cpp's ABI v3 chat entry points, so chat and tool calling ride the SAME code path as the llama.cpp autoparser: the ENGINE renders the model's chat template, decides when a tool call engages, and parses it - LocalAI receives pre-parsed ChatDelta / ToolCallDelta protos exactly as it does from llama-cpp. - With use_tokenizer_template + structured Messages, PredictOptions lowers to ONE OpenAI chat request JSON (messages, tools, tool_choice, sampling, stream_options.include_usage) for vllm_chat / vllm_chat_stream. tool_choice auto lowers engine-side to a LAZY structural-tag decode constraint - free text until the model emits the tool trigger, then the call is grammar-constrained; required/named force a call. Tool output is parsed by the engine's streaming Hermes-style parser; each chat.completion.chunk maps onto ChatDeltas (content / reasoning_content / tool_calls) which the host already prefers over Go-side tag extraction. Without structured messages the plain path (LocalAI templating + optional GBNF grammar) applies unchanged. - The engine resolves the chat template from the GGUF tokenizer.chat_template metadata (or tokenizer_config.json); templates beyond its minja subset - e.g. the full Qwen3.5 namespace()/macro template - degrade engine-side to a Hermes-aware fallback prompt (tools schemas + <tool_call> instruction) with a stderr witness, so structural-tag engagement keeps working. - Importers now emit the same config shape as llama-cpp for vllm-cpp (use_tokenizer_template: true, no-grammar autoparser flow); only the llama-cpp-specific use_jinja option and the vllm-python parser options are dropped. - Pin bumped to mudler/vllm.cpp@aaed7ec (ABI v3 + chat-prompt resolution). Verified against the real engine and Qwen3.5-2B-UD-Q8_K_XL.gguf on CPU: full suite green - blocking chat, streaming deltas concatenating byte-equal to the blocking answer, a REQUIRED tool call returning schema-valid arguments JSON, and an AUTO run where the engine itself engages get_weather and streams parsed tool deltas; plus unit specs for the request lowering, chunk->ChatDelta mapping, and the C struct mirrors (ABI gate now v3). Assisted-by: Claude Code:claude-fable-5 Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * feat(vllm-cpp): ABI v5 - engine-side parser selection for 30 tool dialects + reasoning Bump the vllm.cpp pin to the autoparser-parity engine: 30 tool-call dialects (every pure-text parser in the pinned vLLM registry, each ported 1:1 with its upstream tests), 7 reasoning parsers, google/minja as the template renderer (the full Qwen3.5 template now renders engine-side), per-family structural tags (tool_choice required/named compiles the model's NATIVE syntax where expressible), and template auto-detection for both parser axes. Backend changes: - cModelParams mirrors ABI v5 (tool_parser + reasoning_parser fields, layout-locked by the offset tests; ABI gate now v5). - New model options tool_parser:<name> / reasoning_parser:<name> pass through to the engine; unset means template auto-detection (18-row tool marker table; [THINK]->mistral, <think>->think_auto for reasoning); "none" disables the reasoning split; unknown names fail the first chat call. - Chat chunks parse the `reasoning` field (the pin renamed reasoning_content), flowing into ChatDelta.ReasoningContent which the host already prefers. Live e2e against Qwen3.5-2B-UD-Q8_K_XL.gguf on CPU, full suite green: the real chat template renders (no more fallback), reasoning auto-detection picks think_auto so markerless answers stay pure content (the live run caught the deepseek_r1 content-swallow upstream and drove the think_auto fix), required tool_choice returns schema-valid arguments, auto tool_choice engages engine-side and streams parsed deltas, and blocking/streaming stay byte-identical. Turn latency also dropped (proper template EOS behavior). Upstream program landed as mudler/vllm.cpp 86013f3..5fffe7e (ABI v2-v5, minja, parser waves B1/B2/B4, reasoning seam, structural-tag registry, think_auto). Assisted-by: Claude Code:claude-fable-5 Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * chore(vllm-cpp): bump the engine pin to the ENG-wave close-out mudler/vllm.cpp@df8909b: the six engine-backed vLLM tool-parser families (qwen3-coder/xml/mimo, kimi_k2, glm45/47, minimax_m2, gemma4, seed_oss) text-reimplemented from their wire formats and held to the upstream test suites - 39 registered dialects; the pinned vLLM registry is now covered except the three Rust/Harmony-backed families, descoped by decision. kimi_k2 also gains a full native structural-tag builder; four new template auto-detection rows land with test-pinned ordering. Full backend e2e re-run green against Qwen3.5-2B-UD-Q8_K_XL.gguf on CPU. Assisted-by: Claude Code:claude-fable-5 Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * fix(vllm-cpp): add the vllm-cpp-development gallery meta The gallery grew the twelve latest/development image entries but was missing the separate vllm-cpp-development meta (own capabilities map targeting the -development image names), which every backend ships so the development gallery resolves per-platform. Validated: all capability targets in both metas resolve to existing entries, and every image URI's tag suffix matches a backend-matrix build. Also full-stack verified in this change's context (single-node local-ai from this branch, locally-built backend under --backends-path, Qwen3.5-2B GGUF): /v1/chat/completions non-stream (clean content + usage), streaming (SSE deltas), tool_choice auto engaging get_weather engine-side with schema-valid arguments and finish_reason=tool_calls, and streamed tool-call deltas in the standard name-first cadence. Assisted-by: Claude Code:claude-fable-5 Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * fix(vllm-cpp): repair the CI backend builds - gcc-14 -Werror + fat-arch Triton Two distinct failures took down all five vllm-cpp backend builds on the PR: 1. gcc-14 (ubuntu:24.04 CI images; the local toolchain is gcc-13) fails the engine build with -Werror=maybe-uninitialized in InputBatch::condense - a false positive through a staging std::optional's raw storage. Fixed upstream (mudler/vllm.cpp@61f3e85) by moving slot-to-slot directly; verified BOTH ways under dockerized g++-14.2 (unfixed reproduces CI's two diagnostics exactly, fixed compiles clean) with the engine's behavior suites green. Pin bumped to that sha. 2. The amd64 CUDA builds died at CMake configure: the vendored Triton-AOT cubin trees are per-arch and the engine refuses -DVLLM_CPP_TRITON=ON on a multi-arch (120a;121a) fat build unless pinned to one tree, which would be unsound for the other arch. Triton is now enabled only on the single-arch arm64/GB10 build (where the cubins matter); the fat amd64 binary uses the engine's non-AOT GDN path. Backend e2e re-run green at the new pin (Qwen3.5-2B on CPU, full suite). Assisted-by: Claude Code:claude-fable-5 Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * fix(vllm-cpp): cuda-12 images cannot compile compute_121a - target 120a only The second CI round surfaced a CUDA-version constraint: the cuda-12 (12.8) image's nvcc rejects 'compute_121a' (GB10 arch support landed with CUDA 13), killing the amd64 cuda-12 build at nvcc. Gate the architecture list on CUDA_MAJOR_VERSION (exported by Dockerfile.golang): cuda-12 builds consumer Blackwell 120a only, cuda-13 keeps the 120a;121a fat binary, arm64/l4t (cuda-13) keeps single-arch 121a with the Triton cubins. GB10 is arm64, so the amd64 cuda-12 image never served it - no capability change. Verified by Makefile dry-run variable dumps for all three combinations (cuda12 -> 120a; cuda13 -> 120a;121a; cpu -> CUDA off). Assisted-by: Claude Code:claude-fable-5 Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * fix(vllm-cpp): drop the cuda-12 variant - the engine needs the CUDA 13 toolchain Third CI round, third layer: with the arch list already narrowed to 120a, the cuda-12 (12.8) build still dies in ptxas compiling the sm_120a NVFP4 MMA kernels ("Vector type too large, exceeds 128 bit limit") - the Blackwell fp4 path genuinely requires the CUDA 13 toolchain, and vllm.cpp supports Blackwell-family GPUs only. Shipping a cuda-12 image without the fp4 kernels would be a crippled build of an engine whose whole GPU story is fp4, so the variant is dropped instead: - backend-matrix: cuda-12 vllm-cpp entry removed (cuda-13 amd64, l4t arm64, cpu, vulkan, metal remain). - gallery: cuda12 image entries removed; the nvidia capability now resolves to the cuda13 image in both metas; the nvidia-cuda-12 key is dropped so older-driver hosts fall back to the CPU image instead of an unrunnable one. - backend Makefile: BUILD_TYPE=cublas under CUDA_MAJOR_VERSION=12 now fails fast with a clear message; cuda-13 keeps the 120a;121a fat binary and arm64/l4t keeps 121a with the Triton cubins. Verified: Makefile branch dumps for all four combinations (cuda12 loud error, cuda13 fat, arm64 121a+Triton, cpu off), YAML parses, matrix filter tests green, gallery capability targets all resolve. Assisted-by: Claude Code:claude-fable-5 Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * fix(vllm-cpp): forward multi-turn tool identity and reasoning to the engine chatRequestJSON dropped Message.ToolCallId and Message.Name on role="tool" replies and Message.ReasoningContent on assistant history, so a second turn after tool execution reached the engine's chat template without the fields that bind a tool result to the call it answers. Forward all three (present-only, matching the OpenAI wire shape) and pin vllm.cpp to 6a0bd3e7, where ChatMessage parses/round-trips tool_calls, tool_call_id, name and reasoning and the minja adapter exposes them to the template context. Adds the round-trip request-lowering spec (user -> assistant tool_call -> tool reply -> lowered request) and re-ran the gated e2e suite against the new engine pin with a real Qwen3.5 GGUF: chat, reasoning split, streaming parity, required-tool and auto-tool cases all green. Assisted-by: Claude Code:claude-fable-5 [Bash] [Edit] [Read] Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * fix(vllm-cpp): bump vllm.cpp for the darwin arm64 i8mm build fix The darwin-metal CI job was the first build to compile the engine's arm CPU-quant files on macOS and hit their Linux-only <asm/hwcap.h> / <sys/auxv.h> includes. vllm.cpp 9e1c9025 detects i8mm per-OS (auxv on Linux, sysctl on Apple Silicon) with kernels untouched. Gated e2e suite re-run green against the new pin with a real Qwen3.5 GGUF. Assisted-by: Claude Code:claude-fable-5 [Bash] [Read] Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * fix(vllm-cpp): darwin build - bound cmake parallelism when nproc is absent The macOS runners have no nproc, so JOBS evaluated empty and `cmake --build -j$(JOBS)` became bare `-j`: unlimited clang jobs on a 3-core/7GB Mac, which swap-thrashed until the 6h GHA timeout (the log shows "nproc: Command not found" and 7+ concurrent clang processes being reaped at the cutoff). Use the same portable fallback chain as the other darwin backends: nproc, then sysctl hw.ncpu, then 4. Assisted-by: Claude Code:claude-fable-5 [Bash] [Edit] [Read] Signed-off-by: Ettore Di Giacinto <mudler@localai.io> --------- Signed-off-by: Ettore Di Giacinto <mudler@localai.io> Co-authored-by: Ettore Di Giacinto <mudler@localai.io>
290 lines
8.4 KiB
Go
290 lines
8.4 KiB
Go
package main
|
|
|
|
// The rich chat path (AIModelRich): rides the ENGINE's serving pipeline via
|
|
// the ABI v3 chat entry points, exactly like the llama-cpp autoparser flow.
|
|
// The engine applies the model's chat template, decides when a tool call
|
|
// engages (tool_choice auto lowers to a LAZY structural-tag decode
|
|
// constraint), parses tool calls with its streaming-stateful Hermes-style
|
|
// parser, and hands back chat.completion.chunk JSON that this file maps 1:1
|
|
// onto pb.Reply ChatDelta / ToolCallDelta.
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"sync"
|
|
"unsafe"
|
|
|
|
"github.com/ebitengine/purego"
|
|
pb "github.com/mudler/LocalAI/pkg/grpc/proto"
|
|
"github.com/mudler/xlog"
|
|
)
|
|
|
|
// useChatPath reports whether the request should go through the engine-side
|
|
// chat pipeline: the model config asked for backend-side templating and the
|
|
// host handed us structured messages.
|
|
func useChatPath(opts *pb.PredictOptions) bool {
|
|
return opts.UseTokenizerTemplate && len(opts.Messages) > 0
|
|
}
|
|
|
|
// chatRequestJSON lowers PredictOptions into one OpenAI chat-completions
|
|
// request object for the ABI (the engine ignores `model`/`stream`).
|
|
func chatRequestJSON(opts *pb.PredictOptions, stream bool) (string, error) {
|
|
messages := make([]map[string]any, 0, len(opts.Messages))
|
|
for _, m := range opts.Messages {
|
|
msg := map[string]any{"role": m.Role, "content": m.Content}
|
|
if m.ToolCalls != "" {
|
|
var toolCalls any
|
|
if err := json.Unmarshal([]byte(m.ToolCalls), &toolCalls); err == nil {
|
|
msg["tool_calls"] = toolCalls
|
|
}
|
|
}
|
|
// Multi-turn tool identity + prior reasoning: a role="tool" reply
|
|
// carries the id (and optionally the name) of the assistant call it
|
|
// answers, and assistant history may carry its reasoning span. The
|
|
// engine's template context needs all three or a second turn after
|
|
// tool execution is malformed.
|
|
if m.ToolCallId != "" {
|
|
msg["tool_call_id"] = m.ToolCallId
|
|
}
|
|
if m.Name != "" {
|
|
msg["name"] = m.Name
|
|
}
|
|
if m.ReasoningContent != "" {
|
|
msg["reasoning"] = m.ReasoningContent
|
|
}
|
|
messages = append(messages, msg)
|
|
}
|
|
req := map[string]any{"messages": messages}
|
|
|
|
if opts.Tools != "" {
|
|
var tools any
|
|
if err := json.Unmarshal([]byte(opts.Tools), &tools); err != nil {
|
|
return "", fmt.Errorf("vllm-cpp: tools is not valid JSON: %w", err)
|
|
}
|
|
req["tools"] = tools
|
|
}
|
|
if opts.ToolChoice != "" {
|
|
var choice any
|
|
// ToolChoice arrives either as a bare string ("auto"/"required"/"none")
|
|
// or as the OpenAI named-function JSON object.
|
|
if err := json.Unmarshal([]byte(opts.ToolChoice), &choice); err == nil {
|
|
req["tool_choice"] = choice
|
|
} else {
|
|
req["tool_choice"] = opts.ToolChoice
|
|
}
|
|
}
|
|
|
|
req["temperature"] = opts.Temperature
|
|
if opts.TopP > 0 {
|
|
req["top_p"] = opts.TopP
|
|
}
|
|
if opts.TopK > 0 {
|
|
req["top_k"] = opts.TopK
|
|
}
|
|
if opts.Tokens > 0 {
|
|
req["max_tokens"] = opts.Tokens
|
|
}
|
|
if opts.Seed > 0 {
|
|
req["seed"] = opts.Seed
|
|
}
|
|
if len(opts.StopPrompts) > 0 {
|
|
req["stop"] = opts.StopPrompts
|
|
}
|
|
if opts.PresencePenalty != 0 {
|
|
req["presence_penalty"] = opts.PresencePenalty
|
|
}
|
|
if opts.FrequencyPenalty != 0 {
|
|
req["frequency_penalty"] = opts.FrequencyPenalty
|
|
}
|
|
if stream {
|
|
// The engine's request parser validates stream_options against the
|
|
// stream flag at parse time (before the ABI entry point forces it),
|
|
// so state the intent explicitly.
|
|
req["stream"] = true
|
|
req["stream_options"] = map[string]any{"include_usage": true}
|
|
}
|
|
|
|
b, err := json.Marshal(req)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
return string(b), nil
|
|
}
|
|
|
|
// chatChunk is the subset of an OpenAI chat.completion(.chunk) object the
|
|
// backend consumes.
|
|
type chatChunk struct {
|
|
Object string `json:"object"`
|
|
Choices []struct {
|
|
Delta *chatDelta `json:"delta"` // streaming chunks
|
|
Message *chatDelta `json:"message"` // non-stream response
|
|
FinishReason string `json:"finish_reason"`
|
|
} `json:"choices"`
|
|
Usage *struct {
|
|
PromptTokens int32 `json:"prompt_tokens"`
|
|
CompletionTokens int32 `json:"completion_tokens"`
|
|
} `json:"usage"`
|
|
}
|
|
|
|
type chatDelta struct {
|
|
Content string `json:"content"`
|
|
ReasoningContent string `json:"reasoning"`
|
|
ToolCalls []struct {
|
|
Index int32 `json:"index"`
|
|
ID string `json:"id"`
|
|
Function struct {
|
|
Name string `json:"name"`
|
|
Arguments string `json:"arguments"`
|
|
} `json:"function"`
|
|
} `json:"tool_calls"`
|
|
}
|
|
|
|
// toReply maps one parsed chunk onto a pb.Reply carrying the content bytes
|
|
// plus the structured ChatDelta (the host prefers ChatDeltas when present).
|
|
func (c *chatChunk) toReply() *pb.Reply {
|
|
reply := &pb.Reply{}
|
|
if c.Usage != nil {
|
|
reply.PromptTokens = c.Usage.PromptTokens
|
|
reply.Tokens = c.Usage.CompletionTokens
|
|
}
|
|
if len(c.Choices) == 0 {
|
|
return reply
|
|
}
|
|
d := c.Choices[0].Delta
|
|
if d == nil {
|
|
d = c.Choices[0].Message
|
|
}
|
|
if d == nil {
|
|
return reply
|
|
}
|
|
delta := &pb.ChatDelta{
|
|
Content: d.Content,
|
|
ReasoningContent: d.ReasoningContent,
|
|
}
|
|
for _, tc := range d.ToolCalls {
|
|
delta.ToolCalls = append(delta.ToolCalls, &pb.ToolCallDelta{
|
|
Index: tc.Index,
|
|
Id: tc.ID,
|
|
Name: tc.Function.Name,
|
|
Arguments: tc.Function.Arguments,
|
|
})
|
|
}
|
|
reply.Message = []byte(d.Content)
|
|
if delta.Content != "" || delta.ReasoningContent != "" ||
|
|
len(delta.ToolCalls) > 0 {
|
|
reply.ChatDeltas = []*pb.ChatDelta{delta}
|
|
}
|
|
return reply
|
|
}
|
|
|
|
// Chat-stream registry: chunk JSON arrives on the engine's delivery thread
|
|
// through one shared C callback; the integer handle in user_data selects the
|
|
// destination channel (never a Go pointer across the ABI).
|
|
var (
|
|
chatStreamsMu sync.Mutex
|
|
chatStreams = map[uintptr]chan<- *pb.Reply{}
|
|
chatStreamNext uintptr
|
|
chatCbOnce sync.Once
|
|
chatCbPtr uintptr
|
|
)
|
|
|
|
func chatCallback(delta uintptr, finished uintptr, userData uintptr) uintptr {
|
|
chatStreamsMu.Lock()
|
|
results := chatStreams[userData]
|
|
chatStreamsMu.Unlock()
|
|
if results == nil {
|
|
return 0
|
|
}
|
|
_ = finished // the terminal call carries an empty delta; nothing to emit.
|
|
payload := goString(delta)
|
|
if payload == "" {
|
|
return 1
|
|
}
|
|
var chunk chatChunk
|
|
if err := json.Unmarshal([]byte(payload), &chunk); err != nil {
|
|
xlog.Error("[vllm-cpp] unparseable chat chunk", "error", err)
|
|
return 1
|
|
}
|
|
results <- chunk.toReply()
|
|
return 1
|
|
}
|
|
|
|
func registerChatStream(results chan<- *pb.Reply) uintptr {
|
|
chatStreamsMu.Lock()
|
|
defer chatStreamsMu.Unlock()
|
|
chatStreamNext++
|
|
chatStreams[chatStreamNext] = results
|
|
return chatStreamNext
|
|
}
|
|
|
|
func unregisterChatStream(h uintptr) {
|
|
chatStreamsMu.Lock()
|
|
defer chatStreamsMu.Unlock()
|
|
delete(chatStreams, h)
|
|
}
|
|
|
|
// PredictRich implements the non-streaming rich path. Without structured
|
|
// messages it falls back to the plain Predict flow (LocalAI-side templating,
|
|
// optional grammar constraint).
|
|
func (v *VllmCpp) PredictRich(opts *pb.PredictOptions) (*pb.Reply, error) {
|
|
if !useChatPath(opts) {
|
|
text, err := v.Predict(opts)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return &pb.Reply{Message: []byte(text)}, nil
|
|
}
|
|
if v.engine == 0 {
|
|
return nil, fmt.Errorf("vllm-cpp: model not loaded")
|
|
}
|
|
request, err := chatRequestJSON(opts, false)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var out uintptr
|
|
rc := vllmChat(v.engine, request, unsafe.Pointer(&out)) // #nosec G103 -- char** out-param
|
|
if rc != vllmOK {
|
|
return nil, fmt.Errorf("vllm-cpp: chat failed: %s", vllmLastError())
|
|
}
|
|
payload := goString(out)
|
|
vllmStringFree(out)
|
|
var response chatChunk
|
|
if err := json.Unmarshal([]byte(payload), &response); err != nil {
|
|
return nil, fmt.Errorf("vllm-cpp: unparseable chat response: %w", err)
|
|
}
|
|
return response.toReply(), nil
|
|
}
|
|
|
|
// PredictStreamRich implements the streaming rich path. Contract: send into
|
|
// the channel and return when finished; the host closes the channel.
|
|
func (v *VllmCpp) PredictStreamRich(opts *pb.PredictOptions, results chan<- *pb.Reply) error {
|
|
if !useChatPath(opts) {
|
|
// Legacy bridge: run the plain stream and wrap deltas.
|
|
plain := make(chan string)
|
|
if err := v.PredictStream(opts, plain); err != nil {
|
|
return err
|
|
}
|
|
for delta := range plain {
|
|
results <- &pb.Reply{Message: []byte(delta)}
|
|
}
|
|
return nil
|
|
}
|
|
if v.engine == 0 {
|
|
return fmt.Errorf("vllm-cpp: model not loaded")
|
|
}
|
|
request, err := chatRequestJSON(opts, true)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
chatCbOnce.Do(func() {
|
|
chatCbPtr = purego.NewCallback(chatCallback)
|
|
})
|
|
handle := registerChatStream(results)
|
|
defer unregisterChatStream(handle)
|
|
rc := vllmChatStream(v.engine, request, chatCbPtr, handle)
|
|
if rc != vllmOK {
|
|
return fmt.Errorf("vllm-cpp: chat stream failed: %s", vllmLastError())
|
|
}
|
|
return nil
|
|
}
|