mirror of
https://github.com/mudler/LocalAI.git
synced 2026-06-10 17:56:49 -04:00
Compare commits
1 Commits
v4.4.0
...
dependabot
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e90d2f42f2 |
2
Makefile
2
Makefile
@@ -180,7 +180,7 @@ osx-signed: build
|
||||
|
||||
## Run
|
||||
run: ## run local-ai
|
||||
CGO_LDFLAGS="$(CGO_LDFLAGS)" $(GOCMD) run ./cmd/local-ai
|
||||
CGO_LDFLAGS="$(CGO_LDFLAGS)" $(GOCMD) run ./
|
||||
|
||||
prepare-test: protogen-go build-mock-backend
|
||||
|
||||
|
||||
10
README.md
10
README.md
@@ -149,16 +149,6 @@ local-ai run https://gist.githubusercontent.com/.../phi-2.yaml
|
||||
local-ai run oci://localai/phi-2:latest
|
||||
```
|
||||
|
||||
To test a running LocalAI server from the terminal, open an interactive chat session from another shell. Inside the prompt, `/models` lists installed models and `/model <name>` switches between them.
|
||||
|
||||
```bash
|
||||
# Terminal 1
|
||||
local-ai run llama-3.2-1b-instruct:q4_k_m
|
||||
|
||||
# Terminal 2
|
||||
local-ai chat --model llama-3.2-1b-instruct:q4_k_m
|
||||
```
|
||||
|
||||
> **Automatic Backend Detection**: LocalAI automatically detects your GPU capabilities and downloads the appropriate backend. For advanced options, see [GPU Acceleration](https://localai.io/features/gpu-acceleration/).
|
||||
|
||||
For more details, see the [Getting Started guide](https://localai.io/basics/getting_started/).
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
# ds4 backend Makefile.
|
||||
#
|
||||
# Upstream pin lives below as DS4_VERSION?=91bafb5acd5a6cf00b1e55ef68bf40ddd207bee7
|
||||
# Upstream pin lives below as DS4_VERSION?=c463029c205c2ec8d7ab6c0df4a3f52979091286
|
||||
# (.github/bump_deps.sh) can find and update it - matches the
|
||||
# llama-cpp / ik-llama-cpp / turboquant convention.
|
||||
|
||||
DS4_VERSION?=91bafb5acd5a6cf00b1e55ef68bf40ddd207bee7
|
||||
DS4_VERSION?=c463029c205c2ec8d7ab6c0df4a3f52979091286
|
||||
DS4_REPO?=https://github.com/antirez/ds4
|
||||
|
||||
CURRENT_MAKEFILE_DIR := $(dir $(abspath $(lastword $(MAKEFILE_LIST))))
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
|
||||
IK_LLAMA_VERSION?=e6f8112f3ba126eed3ff5b30cdd08085414a7516
|
||||
IK_LLAMA_VERSION?=6b9de3dbaa21ae95ea80638e5ee836795cc48c93
|
||||
LLAMA_REPO?=https://github.com/ikawrakow/ik_llama.cpp
|
||||
|
||||
CMAKE_ARGS?=
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
|
||||
LLAMA_VERSION?=039e20a2db9e87b2477c76cc04905f3e1acad77f
|
||||
LLAMA_VERSION?=9e3b928fd8c9d14dbf15a8768b9fdd7e5c721d66
|
||||
LLAMA_REPO?=https://github.com/ggerganov/llama.cpp
|
||||
|
||||
CMAKE_ARGS?=
|
||||
|
||||
@@ -381,15 +381,6 @@ json parse_options(bool streaming, const backend::PredictOptions* predict, const
|
||||
});
|
||||
}
|
||||
|
||||
// for each video in the request, add the video data
|
||||
for (int i = 0; i < predict->videos_size(); i++) {
|
||||
data["video_data"].push_back(json
|
||||
{
|
||||
{"id", i},
|
||||
{"data", predict->videos(i)},
|
||||
});
|
||||
}
|
||||
|
||||
data["stop"] = predict->stopprompts();
|
||||
// data["n_probs"] = predict->nprobs();
|
||||
//TODO: images,
|
||||
@@ -1512,7 +1503,7 @@ public:
|
||||
msg_json["role"] = msg.role();
|
||||
|
||||
bool is_last_user_msg = (i == last_user_msg_idx);
|
||||
bool has_images_or_audio = (request->images_size() > 0 || request->audios_size() > 0 || request->videos_size() > 0);
|
||||
bool has_images_or_audio = (request->images_size() > 0 || request->audios_size() > 0);
|
||||
|
||||
// Handle content - can be string, null, or array
|
||||
// For multimodal content, we'll embed images/audio from separate fields
|
||||
@@ -1563,16 +1554,6 @@ public:
|
||||
content_array.push_back(audio_chunk);
|
||||
}
|
||||
}
|
||||
if (request->videos_size() > 0) {
|
||||
for (int j = 0; j < request->videos_size(); j++) {
|
||||
json video_chunk;
|
||||
video_chunk["type"] = "input_video";
|
||||
json input_video;
|
||||
input_video["data"] = request->videos(j);
|
||||
video_chunk["input_video"] = input_video;
|
||||
content_array.push_back(video_chunk);
|
||||
}
|
||||
}
|
||||
msg_json["content"] = content_array;
|
||||
} else {
|
||||
// Use content as-is (already array or not last user message)
|
||||
@@ -1607,16 +1588,6 @@ public:
|
||||
content_array.push_back(audio_chunk);
|
||||
}
|
||||
}
|
||||
if (request->videos_size() > 0) {
|
||||
for (int j = 0; j < request->videos_size(); j++) {
|
||||
json video_chunk;
|
||||
video_chunk["type"] = "input_video";
|
||||
json input_video;
|
||||
input_video["data"] = request->videos(j);
|
||||
video_chunk["input_video"] = input_video;
|
||||
content_array.push_back(video_chunk);
|
||||
}
|
||||
}
|
||||
msg_json["content"] = content_array;
|
||||
} else if (msg.role() == "tool") {
|
||||
// Tool role messages must have content field set, even if empty
|
||||
@@ -2068,16 +2039,6 @@ public:
|
||||
files.push_back(decoded_data);
|
||||
}
|
||||
}
|
||||
|
||||
const auto &video_data = data.find("video_data");
|
||||
if (video_data != data.end() && video_data->is_array())
|
||||
{
|
||||
for (const auto &video : *video_data)
|
||||
{
|
||||
auto decoded_data = base64_decode(video["data"].get<std::string>());
|
||||
files.push_back(decoded_data);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const bool has_mtmd = ctx_server.impl->mctx != nullptr;
|
||||
@@ -2330,7 +2291,7 @@ public:
|
||||
}
|
||||
|
||||
bool is_last_user_msg = (i == last_user_msg_idx);
|
||||
bool has_images_or_audio = (request->images_size() > 0 || request->audios_size() > 0 || request->videos_size() > 0);
|
||||
bool has_images_or_audio = (request->images_size() > 0 || request->audios_size() > 0);
|
||||
|
||||
// Handle content - can be string, null, or array
|
||||
// For multimodal content, we'll embed images/audio from separate fields
|
||||
@@ -2383,16 +2344,6 @@ public:
|
||||
content_array.push_back(audio_chunk);
|
||||
}
|
||||
}
|
||||
if (request->videos_size() > 0) {
|
||||
for (int j = 0; j < request->videos_size(); j++) {
|
||||
json video_chunk;
|
||||
video_chunk["type"] = "input_video";
|
||||
json input_video;
|
||||
input_video["data"] = request->videos(j);
|
||||
video_chunk["input_video"] = input_video;
|
||||
content_array.push_back(video_chunk);
|
||||
}
|
||||
}
|
||||
msg_json["content"] = content_array;
|
||||
} else {
|
||||
// Use content as-is (already array or not last user message)
|
||||
@@ -2432,16 +2383,6 @@ public:
|
||||
content_array.push_back(audio_chunk);
|
||||
}
|
||||
}
|
||||
if (request->videos_size() > 0) {
|
||||
for (int j = 0; j < request->videos_size(); j++) {
|
||||
json video_chunk;
|
||||
video_chunk["type"] = "input_video";
|
||||
json input_video;
|
||||
input_video["data"] = request->videos(j);
|
||||
video_chunk["input_video"] = input_video;
|
||||
content_array.push_back(video_chunk);
|
||||
}
|
||||
}
|
||||
msg_json["content"] = content_array;
|
||||
SRV_INF("[CONTENT DEBUG] Predict: Message %d created content array with media\n", i);
|
||||
} else if (!msg.tool_calls().empty()) {
|
||||
@@ -2904,16 +2845,6 @@ public:
|
||||
files.push_back(decoded_data);
|
||||
}
|
||||
}
|
||||
|
||||
const auto &video_data = data.find("video_data");
|
||||
if (video_data != data.end() && video_data->is_array())
|
||||
{
|
||||
for (const auto &video : *video_data)
|
||||
{
|
||||
auto decoded_data = base64_decode(video["data"].get<std::string>());
|
||||
files.push_back(decoded_data);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// process files
|
||||
|
||||
@@ -8,7 +8,7 @@ JOBS?=$(shell nproc --ignore=1)
|
||||
|
||||
# CrispASR version (release tag)
|
||||
CRISPASR_REPO?=https://github.com/CrispStrobe/CrispASR
|
||||
CRISPASR_VERSION?=c29f6653a516a3001d923944dad8892072cc7334
|
||||
CRISPASR_VERSION?=f7838a306687f22c281d29c250f879a4ab3df2d7
|
||||
SO_TARGET?=libgocrispasr.so
|
||||
|
||||
CMAKE_ARGS+=-DBUILD_SHARED_LIBS=OFF
|
||||
|
||||
@@ -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?=19bdfe22d255d5b4dff39d449318b9bc5ea2317f
|
||||
STABLEDIFFUSION_GGML_VERSION?=b3d56d0ba1bd437886079e339118e8e75bb79ee7
|
||||
|
||||
CMAKE_ARGS+=-DGGML_MAX_NAME=128
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ JOBS?=$(shell nproc --ignore=1)
|
||||
|
||||
# whisper.cpp version
|
||||
WHISPER_REPO?=https://github.com/ggml-org/whisper.cpp
|
||||
WHISPER_CPP_VERSION?=df7638d8229a243af8a4b5a8ae557e0d74e0a0ae
|
||||
WHISPER_CPP_VERSION?=a8ec021f2750a473ff4a8f3883bc9fdf5feafa84
|
||||
SO_TARGET?=libgowhisper.so
|
||||
|
||||
CMAKE_ARGS+=-DBUILD_SHARED_LIBS=OFF
|
||||
|
||||
@@ -2,7 +2,7 @@ torch==2.7.1
|
||||
llvmlite==0.43.0
|
||||
numba==0.60.0
|
||||
accelerate
|
||||
transformers>=5.9.0
|
||||
transformers>=5.10.2
|
||||
bitsandbytes
|
||||
sentence-transformers==5.5.1
|
||||
diffusers
|
||||
|
||||
@@ -2,7 +2,7 @@ torch==2.7.1
|
||||
accelerate
|
||||
llvmlite==0.43.0
|
||||
numba==0.60.0
|
||||
transformers>=5.9.0
|
||||
transformers>=5.10.2
|
||||
bitsandbytes
|
||||
sentence-transformers==5.5.1
|
||||
diffusers
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
torch==2.9.0
|
||||
llvmlite==0.43.0
|
||||
numba==0.60.0
|
||||
transformers>=5.9.0
|
||||
transformers>=5.10.2
|
||||
bitsandbytes
|
||||
sentence-transformers==5.5.1
|
||||
diffusers
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
--extra-index-url https://download.pytorch.org/whl/rocm7.0
|
||||
torch==2.10.0+rocm7.0
|
||||
accelerate
|
||||
transformers>=5.9.0
|
||||
transformers>=5.10.2
|
||||
llvmlite==0.43.0
|
||||
numba==0.60.0
|
||||
bitsandbytes
|
||||
|
||||
@@ -3,7 +3,7 @@ torch
|
||||
optimum[openvino]
|
||||
llvmlite==0.43.0
|
||||
numba==0.60.0
|
||||
transformers>=5.9.0
|
||||
transformers>=5.10.2
|
||||
bitsandbytes
|
||||
sentence-transformers==5.5.1
|
||||
diffusers
|
||||
|
||||
@@ -2,7 +2,7 @@ torch==2.7.1
|
||||
llvmlite==0.43.0
|
||||
numba==0.60.0
|
||||
accelerate
|
||||
transformers>=5.9.0
|
||||
transformers>=5.10.2
|
||||
bitsandbytes
|
||||
sentence-transformers==5.5.1
|
||||
diffusers
|
||||
|
||||
@@ -1,30 +0,0 @@
|
||||
package chat
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type Options struct {
|
||||
Model string
|
||||
BaseURL string
|
||||
APIKey string
|
||||
In io.Reader
|
||||
Out io.Writer
|
||||
}
|
||||
|
||||
func Run(ctx context.Context, opts Options) error {
|
||||
if opts.In == nil {
|
||||
opts.In = strings.NewReader("")
|
||||
}
|
||||
if opts.Out == nil {
|
||||
opts.Out = io.Discard
|
||||
}
|
||||
|
||||
session, err := newChatSession(ctx, newLocalAIChatClient(opts.BaseURL, opts.APIKey), opts.Model)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return runTerminalChat(ctx, session, opts.In, opts.Out)
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
package chat
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
func TestChat(t *testing.T) {
|
||||
RegisterFailHandler(Fail)
|
||||
RunSpecs(t, "Chat Suite")
|
||||
}
|
||||
@@ -1,172 +0,0 @@
|
||||
package chat
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
var _ = Describe("Run chat", func() {
|
||||
It("streams a single chat response", func() {
|
||||
var capturedModel string
|
||||
var capturedAuth string
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path == "/v1/models" {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
writeResponse(w, `{"object":"list","data":[{"id":"test-model","object":"model"}]}`)
|
||||
return
|
||||
}
|
||||
|
||||
Expect(r.URL.Path).To(Equal("/v1/chat/completions"))
|
||||
capturedAuth = r.Header.Get("Authorization")
|
||||
|
||||
var body struct {
|
||||
Model string `json:"model"`
|
||||
Messages []struct {
|
||||
Role string `json:"role"`
|
||||
Content string `json:"content"`
|
||||
} `json:"messages"`
|
||||
}
|
||||
Expect(json.NewDecoder(r.Body).Decode(&body)).To(Succeed())
|
||||
capturedModel = body.Model
|
||||
Expect(body.Messages).To(HaveLen(1))
|
||||
Expect(body.Messages[0].Role).To(Equal("user"))
|
||||
Expect(body.Messages[0].Content).To(Equal("hello"))
|
||||
|
||||
w.Header().Set("Content-Type", "text/event-stream")
|
||||
writeResponse(w, "data: {\"choices\":[{\"index\":0,\"delta\":{\"content\":\"hi\"}}]}\n\n")
|
||||
writeResponse(w, "data: {\"choices\":[{\"index\":0,\"delta\":{\"content\":\"!\"}}]}\n\n")
|
||||
writeResponse(w, "data: [DONE]\n\n")
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
var out bytes.Buffer
|
||||
err := Run(GinkgoT().Context(), Options{
|
||||
Model: "test-model",
|
||||
BaseURL: server.URL + "/v1",
|
||||
APIKey: "secret",
|
||||
In: strings.NewReader("hello\n/exit\n"),
|
||||
Out: &out,
|
||||
})
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(capturedModel).To(Equal("test-model"))
|
||||
Expect(capturedAuth).To(Equal("Bearer secret"))
|
||||
Expect(out.String()).To(ContainSubstring("assistant: hi!"))
|
||||
Expect(out.String()).To(ContainSubstring("bye"))
|
||||
})
|
||||
|
||||
It("auto-selects the only available model", func() {
|
||||
server := chatTestServer([]string{"solo"}, nil)
|
||||
defer server.Close()
|
||||
|
||||
var out bytes.Buffer
|
||||
err := Run(GinkgoT().Context(), Options{
|
||||
BaseURL: server.URL + "/v1",
|
||||
In: strings.NewReader("/exit\n"),
|
||||
Out: &out,
|
||||
})
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(out.String()).To(ContainSubstring("LocalAI chat (solo)"))
|
||||
})
|
||||
|
||||
It("returns an actionable error when no models are installed", func() {
|
||||
server := chatTestServer(nil, nil)
|
||||
defer server.Close()
|
||||
|
||||
err := Run(GinkgoT().Context(), Options{
|
||||
BaseURL: server.URL + "/v1",
|
||||
In: strings.NewReader(""),
|
||||
})
|
||||
|
||||
Expect(err).To(HaveOccurred())
|
||||
Expect(err.Error()).To(ContainSubstring("no chat models are installed"))
|
||||
Expect(err.Error()).To(ContainSubstring("local-ai models install <model>"))
|
||||
})
|
||||
|
||||
It("returns an actionable error when multiple models are available without a selection", func() {
|
||||
server := chatTestServer([]string{"alpha", "beta"}, nil)
|
||||
defer server.Close()
|
||||
|
||||
err := Run(GinkgoT().Context(), Options{
|
||||
BaseURL: server.URL + "/v1",
|
||||
In: strings.NewReader(""),
|
||||
})
|
||||
|
||||
Expect(err).To(HaveOccurred())
|
||||
Expect(err.Error()).To(ContainSubstring("multiple models are available"))
|
||||
Expect(err.Error()).To(ContainSubstring("--model"))
|
||||
Expect(err.Error()).To(ContainSubstring("alpha"))
|
||||
Expect(err.Error()).To(ContainSubstring("beta"))
|
||||
})
|
||||
|
||||
It("lists and switches models inside the chat", func() {
|
||||
requestedModels := []string{}
|
||||
server := chatTestServer([]string{"alpha", "beta"}, func(model string) {
|
||||
requestedModels = append(requestedModels, model)
|
||||
})
|
||||
defer server.Close()
|
||||
|
||||
var out bytes.Buffer
|
||||
err := Run(GinkgoT().Context(), Options{
|
||||
Model: "alpha",
|
||||
BaseURL: server.URL + "/v1",
|
||||
In: strings.NewReader("/models\n/model beta\nhello\n/exit\n"),
|
||||
Out: &out,
|
||||
})
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(out.String()).To(ContainSubstring("* alpha"))
|
||||
Expect(out.String()).To(ContainSubstring(" beta"))
|
||||
Expect(out.String()).To(ContainSubstring("switched to beta; conversation cleared"))
|
||||
Expect(requestedModels).To(Equal([]string{"beta"}))
|
||||
})
|
||||
})
|
||||
|
||||
func chatTestServer(models []string, onChat func(model string)) *httptest.Server {
|
||||
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case "/v1/models":
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
writeResponse(w, `{"object":"list","data":[`)
|
||||
for i, model := range models {
|
||||
if i > 0 {
|
||||
writeResponse(w, ",")
|
||||
}
|
||||
writeResponsef(w, `{"id":%q,"object":"model"}`, model)
|
||||
}
|
||||
writeResponse(w, `]}`)
|
||||
case "/v1/chat/completions":
|
||||
var body struct {
|
||||
Model string `json:"model"`
|
||||
}
|
||||
Expect(json.NewDecoder(r.Body).Decode(&body)).To(Succeed())
|
||||
if onChat != nil {
|
||||
onChat(body.Model)
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/event-stream")
|
||||
writeResponse(w, "data: {\"choices\":[{\"index\":0,\"delta\":{\"content\":\"ok\"}}]}\n\n")
|
||||
writeResponse(w, "data: [DONE]\n\n")
|
||||
default:
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
}
|
||||
}))
|
||||
}
|
||||
|
||||
func writeResponse(w io.Writer, text string) {
|
||||
_, err := fmt.Fprint(w, text)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
}
|
||||
|
||||
func writeResponsef(w io.Writer, format string, args ...any) {
|
||||
_, err := fmt.Fprintf(w, format, args...)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
}
|
||||
@@ -1,114 +0,0 @@
|
||||
package chat
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
openai "github.com/sashabaranov/go-openai"
|
||||
)
|
||||
|
||||
type chatClient interface {
|
||||
ListModels(ctx context.Context) ([]string, error)
|
||||
StreamChat(ctx context.Context, model string, messages []chatMessage, out io.Writer) (string, error)
|
||||
}
|
||||
|
||||
type localAIChatClient struct {
|
||||
client *openai.Client
|
||||
}
|
||||
|
||||
func newLocalAIChatClient(baseURL string, apiKey string) *localAIChatClient {
|
||||
cfg := openai.DefaultConfig(apiKey)
|
||||
cfg.BaseURL = baseURL
|
||||
return &localAIChatClient{client: openai.NewClientWithConfig(cfg)}
|
||||
}
|
||||
|
||||
func (c *localAIChatClient) ListModels(ctx context.Context) ([]string, error) {
|
||||
resp, err := c.client.ListModels(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
models := make([]string, 0, len(resp.Models))
|
||||
for _, model := range resp.Models {
|
||||
if model.ID != "" {
|
||||
models = append(models, model.ID)
|
||||
}
|
||||
}
|
||||
sort.Strings(models)
|
||||
return models, nil
|
||||
}
|
||||
|
||||
func (c *localAIChatClient) StreamChat(ctx context.Context, model string, messages []chatMessage, out io.Writer) (string, error) {
|
||||
stream, err := c.client.CreateChatCompletionStream(ctx, openai.ChatCompletionRequest{
|
||||
Model: model,
|
||||
Messages: openAIChatMessages(messages),
|
||||
})
|
||||
if err != nil {
|
||||
return "", friendlyChatError(err, model)
|
||||
}
|
||||
defer func() {
|
||||
_ = stream.Close()
|
||||
}()
|
||||
|
||||
var answer strings.Builder
|
||||
for {
|
||||
resp, err := stream.Recv()
|
||||
if errors.Is(err, io.EOF) {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
return answer.String(), friendlyChatError(err, model)
|
||||
}
|
||||
if len(resp.Choices) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
token := resp.Choices[0].Delta.Content
|
||||
if token == "" {
|
||||
continue
|
||||
}
|
||||
answer.WriteString(token)
|
||||
if _, err := fmt.Fprint(out, token); err != nil {
|
||||
return answer.String(), err
|
||||
}
|
||||
}
|
||||
|
||||
return answer.String(), nil
|
||||
}
|
||||
|
||||
func openAIChatMessages(messages []chatMessage) []openai.ChatCompletionMessage {
|
||||
converted := make([]openai.ChatCompletionMessage, len(messages))
|
||||
for i, message := range messages {
|
||||
converted[i] = openai.ChatCompletionMessage{
|
||||
Role: message.Role,
|
||||
Content: message.Content,
|
||||
}
|
||||
}
|
||||
return converted
|
||||
}
|
||||
|
||||
func friendlyChatError(err error, model string) error {
|
||||
var apiErr *openai.APIError
|
||||
if errors.As(err, &apiErr) {
|
||||
switch apiErr.HTTPStatusCode {
|
||||
case 404:
|
||||
return fmt.Errorf("model %q is not available. Run `local-ai models list`, install a model with `local-ai models install <model>`, or switch with `/model <name>`", model)
|
||||
case 403:
|
||||
return fmt.Errorf("model %q is disabled. Enable it from LocalAI settings or choose another model with `/model <name>`", model)
|
||||
}
|
||||
if apiErr.Message != "" {
|
||||
return errors.New(apiErr.Message)
|
||||
}
|
||||
}
|
||||
|
||||
msg := err.Error()
|
||||
if strings.Contains(msg, "model") && strings.Contains(msg, "not found") {
|
||||
return fmt.Errorf("model %q is not available. Run `local-ai models list`, install a model with `local-ai models install <model>`, or switch with `/model <name>`", model)
|
||||
}
|
||||
|
||||
return err
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
package chat
|
||||
|
||||
import "strings"
|
||||
|
||||
func formatChatModelList(models []string, current string) string {
|
||||
var b strings.Builder
|
||||
for _, model := range models {
|
||||
prefix := " "
|
||||
if model == current {
|
||||
prefix = "* "
|
||||
}
|
||||
b.WriteString(prefix)
|
||||
b.WriteString(model)
|
||||
b.WriteByte('\n')
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
@@ -1,120 +0,0 @@
|
||||
package chat
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const (
|
||||
chatRoleUser = "user"
|
||||
chatRoleAssistant = "assistant"
|
||||
)
|
||||
|
||||
type chatMessage struct {
|
||||
Role string
|
||||
Content string
|
||||
}
|
||||
|
||||
type chatSession struct {
|
||||
client chatClient
|
||||
model string
|
||||
models []string
|
||||
messages []chatMessage
|
||||
}
|
||||
|
||||
func newChatSession(ctx context.Context, client chatClient, requestedModel string) (*chatSession, error) {
|
||||
models, err := client.ListModels(ctx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list models: %w", err)
|
||||
}
|
||||
|
||||
model, err := resolveChatModel(requestedModel, models)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &chatSession{
|
||||
client: client,
|
||||
model: model,
|
||||
models: models,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *chatSession) CurrentModel() string {
|
||||
return s.model
|
||||
}
|
||||
|
||||
func (s *chatSession) Models() []string {
|
||||
models := make([]string, len(s.models))
|
||||
copy(models, s.models)
|
||||
return models
|
||||
}
|
||||
|
||||
func (s *chatSession) Clear() {
|
||||
s.messages = nil
|
||||
}
|
||||
|
||||
func (s *chatSession) SwitchModel(model string) error {
|
||||
if !modelExists(s.models, model) {
|
||||
return fmt.Errorf("model %q is not available. Use /models to see installed models", model)
|
||||
}
|
||||
s.model = model
|
||||
s.Clear()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *chatSession) Send(ctx context.Context, prompt string, out io.Writer) error {
|
||||
s.messages = append(s.messages, chatMessage{
|
||||
Role: chatRoleUser,
|
||||
Content: prompt,
|
||||
})
|
||||
|
||||
answer, err := s.client.StreamChat(ctx, s.model, s.messages, out)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
s.messages = append(s.messages, chatMessage{
|
||||
Role: chatRoleAssistant,
|
||||
Content: answer,
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
func resolveChatModel(requested string, models []string) (string, error) {
|
||||
switch {
|
||||
case requested == "" && len(models) == 0:
|
||||
return "", errors.New(`no chat models are installed.
|
||||
|
||||
Install a model first, for example:
|
||||
local-ai models list
|
||||
local-ai models install <model>
|
||||
local-ai run
|
||||
|
||||
Then start a chat session:
|
||||
local-ai chat --model <model>`)
|
||||
case requested == "" && len(models) == 1:
|
||||
return models[0], nil
|
||||
case requested == "" && len(models) > 1:
|
||||
var b strings.Builder
|
||||
b.WriteString("multiple models are available; choose one with --model:\n")
|
||||
b.WriteString(formatChatModelList(models, ""))
|
||||
return "", errors.New(b.String())
|
||||
case !modelExists(models, requested):
|
||||
return "", fmt.Errorf("model %q is not available. Use `local-ai models list` and `local-ai models install <model>`, or pass an installed model with --model", requested)
|
||||
default:
|
||||
return requested, nil
|
||||
}
|
||||
}
|
||||
|
||||
func modelExists(models []string, name string) bool {
|
||||
for _, model := range models {
|
||||
if model == name {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -1,56 +0,0 @@
|
||||
package chat
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
var _ = Describe("Chat session", func() {
|
||||
It("keeps model switching and message history out of the terminal adapter", func() {
|
||||
client := &fakeChatClient{
|
||||
models: []string{"alpha", "beta"},
|
||||
answer: "pong",
|
||||
}
|
||||
|
||||
session, err := newChatSession(context.Background(), client, "alpha")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(session.CurrentModel()).To(Equal("alpha"))
|
||||
|
||||
Expect(session.SwitchModel("beta")).To(Succeed())
|
||||
Expect(session.CurrentModel()).To(Equal("beta"))
|
||||
Expect(session.Send(context.Background(), "ping", io.Discard)).To(Succeed())
|
||||
|
||||
Expect(client.requests).To(HaveLen(1))
|
||||
Expect(client.requests[0].model).To(Equal("beta"))
|
||||
Expect(client.requests[0].messages).To(HaveLen(1))
|
||||
Expect(client.requests[0].messages[0].Content).To(Equal("ping"))
|
||||
})
|
||||
})
|
||||
|
||||
type fakeChatClient struct {
|
||||
models []string
|
||||
answer string
|
||||
requests []fakeChatRequest
|
||||
}
|
||||
|
||||
type fakeChatRequest struct {
|
||||
model string
|
||||
messages []chatMessage
|
||||
}
|
||||
|
||||
func (c *fakeChatClient) ListModels(context.Context) ([]string, error) {
|
||||
return c.models, nil
|
||||
}
|
||||
|
||||
func (c *fakeChatClient) StreamChat(_ context.Context, model string, messages []chatMessage, out io.Writer) (string, error) {
|
||||
copied := make([]chatMessage, len(messages))
|
||||
copy(copied, messages)
|
||||
c.requests = append(c.requests, fakeChatRequest{model: model, messages: copied})
|
||||
if _, err := io.WriteString(out, c.answer); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return c.answer, nil
|
||||
}
|
||||
@@ -1,93 +0,0 @@
|
||||
package chat
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func runTerminalChat(ctx context.Context, session *chatSession, in io.Reader, out io.Writer) error {
|
||||
scanner := bufio.NewScanner(in)
|
||||
scanner.Buffer(make([]byte, 0, 64*1024), 4*1024*1024)
|
||||
|
||||
if err := writeChat(out, "LocalAI chat (%s)\n", session.CurrentModel()); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := writeChat(out, "Type /exit to quit, /clear to reset the conversation, /models to list models.\n"); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for {
|
||||
if err := writeChat(out, "\n> "); err != nil {
|
||||
return err
|
||||
}
|
||||
if !scanner.Scan() {
|
||||
break
|
||||
}
|
||||
|
||||
prompt := strings.TrimSpace(scanner.Text())
|
||||
switch prompt {
|
||||
case "":
|
||||
continue
|
||||
case "/bye", "/exit", "/quit":
|
||||
return writeChat(out, "bye\n")
|
||||
case "/clear":
|
||||
session.Clear()
|
||||
if err := writeChat(out, "conversation cleared\n"); err != nil {
|
||||
return err
|
||||
}
|
||||
continue
|
||||
case "/models":
|
||||
if err := printChatModels(out, session.Models(), session.CurrentModel()); err != nil {
|
||||
return err
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if nextModel, ok := strings.CutPrefix(prompt, "/model "); ok {
|
||||
nextModel = strings.TrimSpace(nextModel)
|
||||
if nextModel == "" {
|
||||
if err := writeChat(out, "usage: /model <name>\n"); err != nil {
|
||||
return err
|
||||
}
|
||||
continue
|
||||
}
|
||||
if err := session.SwitchModel(nextModel); err != nil {
|
||||
if writeErr := writeChat(out, "%s\n", err); writeErr != nil {
|
||||
return writeErr
|
||||
}
|
||||
continue
|
||||
}
|
||||
if err := writeChat(out, "switched to %s; conversation cleared\n", session.CurrentModel()); err != nil {
|
||||
return err
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if err := writeChat(out, "assistant: "); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := session.Send(ctx, prompt, out); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := writeChat(out, "\n"); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return scanner.Err()
|
||||
}
|
||||
|
||||
func printChatModels(out io.Writer, models []string, current string) error {
|
||||
if len(models) == 0 {
|
||||
return writeChat(out, "no models installed\n")
|
||||
}
|
||||
return writeChat(out, "%s", formatChatModelList(models, current))
|
||||
}
|
||||
|
||||
func writeChat(out io.Writer, format string, args ...any) error {
|
||||
_, err := fmt.Fprintf(out, format, args...)
|
||||
return err
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
|
||||
chatcli "github.com/mudler/LocalAI/core/cli/chat"
|
||||
cliContext "github.com/mudler/LocalAI/core/cli/context"
|
||||
)
|
||||
|
||||
type ChatCMD struct {
|
||||
Model string `short:"m" help:"Model name to use. Defaults to the only model returned by the server when exactly one is available"`
|
||||
Endpoint string `env:"LOCALAI_CHAT_ENDPOINT" default:"http://127.0.0.1:8080" help:"LocalAI server endpoint. The /v1 path is added automatically when omitted"`
|
||||
APIKey string `env:"LOCALAI_API_KEY,API_KEY" help:"API key to use when the LocalAI server requires authentication"`
|
||||
}
|
||||
|
||||
func (c *ChatCMD) Run(ctx *cliContext.Context) error {
|
||||
return chatcli.Run(context.Background(), chatcli.Options{
|
||||
Model: c.Model,
|
||||
BaseURL: chatAPIBaseURL(c.Endpoint),
|
||||
APIKey: c.APIKey,
|
||||
In: os.Stdin,
|
||||
Out: os.Stdout,
|
||||
})
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
var _ = Describe("Chat command wiring", func() {
|
||||
Describe("chatAPIBaseURL", func() {
|
||||
It("adds /v1 to a root endpoint", func() {
|
||||
Expect(chatAPIBaseURL("http://127.0.0.1:8080")).To(Equal("http://127.0.0.1:8080/v1"))
|
||||
})
|
||||
|
||||
It("keeps endpoints that already include /v1", func() {
|
||||
Expect(chatAPIBaseURL("http://127.0.0.1:8080/v1")).To(Equal("http://127.0.0.1:8080/v1"))
|
||||
Expect(chatAPIBaseURL("http://127.0.0.1:8080/v1/")).To(Equal("http://127.0.0.1:8080/v1"))
|
||||
})
|
||||
|
||||
It("adds a default http scheme", func() {
|
||||
Expect(chatAPIBaseURL("127.0.0.1:8080")).To(Equal("http://127.0.0.1:8080/v1"))
|
||||
})
|
||||
|
||||
It("preserves non-root paths before /v1", func() {
|
||||
Expect(chatAPIBaseURL("http://127.0.0.1:8080/localai")).To(Equal("http://127.0.0.1:8080/localai/v1"))
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,29 +0,0 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"net/url"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func chatAPIBaseURL(endpoint string) string {
|
||||
if !strings.Contains(endpoint, "://") {
|
||||
endpoint = "http://" + endpoint
|
||||
}
|
||||
|
||||
u, err := url.Parse(endpoint)
|
||||
if err != nil {
|
||||
return strings.TrimRight(endpoint, "/") + "/v1"
|
||||
}
|
||||
|
||||
path := strings.TrimRight(u.Path, "/")
|
||||
if path == "" {
|
||||
u.Path = "/v1"
|
||||
} else if path != "/v1" && !strings.HasSuffix(path, "/v1") {
|
||||
u.Path = path + "/v1"
|
||||
} else {
|
||||
u.Path = path
|
||||
}
|
||||
u.RawQuery = ""
|
||||
u.Fragment = ""
|
||||
return u.String()
|
||||
}
|
||||
@@ -9,7 +9,6 @@ var CLI struct {
|
||||
cliContext.Context `embed:""`
|
||||
|
||||
Run RunCMD `cmd:"" help:"Run LocalAI, this the default command if no other command is specified. Run 'local-ai run --help' for more information" default:"withargs"`
|
||||
Chat ChatCMD `cmd:"" help:"Open an interactive chat session against a running LocalAI server"`
|
||||
Federated FederatedCLI `cmd:"" help:"Run LocalAI in federated mode"`
|
||||
Models ModelsCMD `cmd:"" help:"Manage LocalAI models and definitions"`
|
||||
Backends BackendsCMD `cmd:"" help:"Manage LocalAI backends and definitions"`
|
||||
|
||||
@@ -30,8 +30,6 @@ type RunCMD struct {
|
||||
ModelArgs []string `arg:"" optional:"" name:"models" help:"Model configuration URLs to load"`
|
||||
|
||||
ExternalBackends []string `env:"LOCALAI_EXTERNAL_BACKENDS,EXTERNAL_BACKENDS" help:"A list of external backends to load from gallery on boot" group:"backends"`
|
||||
WebRTCNAT1To1IPs []string `env:"LOCALAI_WEBRTC_NAT_1TO1_IPS,WEBRTC_NAT_1TO1_IPS" help:"IPs advertised as the host ICE candidates for /v1/realtime WebRTC instead of every local interface. Set to the reachable host/LAN IP when running under Docker host networking or NAT, where pion otherwise offers unreachable bridge addresses and the connection drops after ICE consent checks fail." group:"api"`
|
||||
WebRTCICEInterfaces []string `env:"LOCALAI_WEBRTC_ICE_INTERFACES,WEBRTC_ICE_INTERFACES" help:"Restrict /v1/realtime WebRTC ICE candidate gathering to these network interfaces (e.g. eth0), filtering out docker0/veth noise." group:"api"`
|
||||
BackendsPath string `env:"LOCALAI_BACKENDS_PATH,BACKENDS_PATH" type:"path" default:"${basepath}/backends" help:"Path containing backends used for inferencing" group:"backends"`
|
||||
BackendsSystemPath string `env:"LOCALAI_BACKENDS_SYSTEM_PATH,BACKEND_SYSTEM_PATH" type:"path" default:"/var/lib/local-ai/backends" help:"Path containing system backends used for inferencing" group:"backends"`
|
||||
ModelsPath string `env:"LOCALAI_MODELS_PATH,MODELS_PATH" type:"path" default:"${basepath}/models" help:"Path containing models used for inferencing" group:"storage"`
|
||||
@@ -227,8 +225,6 @@ func (r *RunCMD) Run(ctx *cliContext.Context) error {
|
||||
config.WithApiKeys(r.APIKeys),
|
||||
config.WithModelsURL(append(r.Models, r.ModelArgs...)...),
|
||||
config.WithExternalBackends(r.ExternalBackends...),
|
||||
config.WithWebRTCNAT1To1IPs(r.WebRTCNAT1To1IPs...),
|
||||
config.WithWebRTCICEInterfaces(r.WebRTCICEInterfaces...),
|
||||
config.WithOpaqueErrors(r.OpaqueErrors),
|
||||
config.WithEnforcedPredownloadScans(!r.DisablePredownloadScan),
|
||||
config.WithSubtleKeyComparison(r.UseSubtleKeyComparison),
|
||||
@@ -656,12 +652,12 @@ func (r *RunCMD) Run(ctx *cliContext.Context) error {
|
||||
// waitForServerReady polls the given address until the HTTP server is
|
||||
// accepting connections or the context is cancelled.
|
||||
func waitForServerReady(address string, ctx context.Context) {
|
||||
// Ensure the address has a host component for dialing.
|
||||
// Echo accepts ":8080" but net.Dial needs a resolvable host.
|
||||
host, port, err := net.SplitHostPort(address)
|
||||
if err == nil && host == "" {
|
||||
address = "127.0.0.1:" + port
|
||||
}
|
||||
ticker := time.NewTicker(250 * time.Millisecond)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
@@ -669,17 +665,11 @@ func waitForServerReady(address string, ctx context.Context) {
|
||||
return
|
||||
default:
|
||||
}
|
||||
|
||||
conn, err := net.DialTimeout("tcp", address, 500*time.Millisecond)
|
||||
if err == nil {
|
||||
conn.Close()
|
||||
return
|
||||
}
|
||||
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
}
|
||||
time.Sleep(250 * time.Millisecond)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,19 +12,10 @@ import (
|
||||
)
|
||||
|
||||
type ApplicationConfig struct {
|
||||
Context context.Context
|
||||
ConfigFile string
|
||||
SystemState *system.SystemState
|
||||
ExternalBackends []string
|
||||
|
||||
// WebRTCNAT1To1IPs, when set, are advertised as the host ICE candidates for
|
||||
// /v1/realtime WebRTC instead of every local interface address. Needed when
|
||||
// the routable address differs from what pion gathers — e.g. Docker host
|
||||
// networking (where pion also offers unreachable bridge IPs) or NAT.
|
||||
WebRTCNAT1To1IPs []string
|
||||
// WebRTCICEInterfaces, when set, restricts ICE candidate gathering to these
|
||||
// network interfaces (e.g. eth0), filtering out docker0/veth noise.
|
||||
WebRTCICEInterfaces []string
|
||||
Context context.Context
|
||||
ConfigFile string
|
||||
SystemState *system.SystemState
|
||||
ExternalBackends []string
|
||||
UploadLimitMB, Threads, ContextSize int
|
||||
F16 bool
|
||||
Debug bool
|
||||
@@ -90,6 +81,7 @@ type ApplicationConfig struct {
|
||||
// file is mode 0600.
|
||||
MITMCADir string
|
||||
|
||||
|
||||
// PIIPatternOverrides applies persisted per-id deltas (action,
|
||||
// disabled) to the live redactor at startup. Loaded from
|
||||
// runtime_settings.json and applied right after pii.NewRedactor.
|
||||
@@ -124,11 +116,11 @@ type ApplicationConfig struct {
|
||||
// --require-backend-integrity / LOCALAI_REQUIRE_BACKEND_INTEGRITY.
|
||||
RequireBackendIntegrity bool
|
||||
|
||||
SingleBackend bool // Deprecated: use MaxActiveBackends = 1 instead
|
||||
MaxActiveBackends int // Maximum number of active backends (0 = unlimited, 1 = single backend mode)
|
||||
WatchDogIdle bool
|
||||
WatchDogBusy bool
|
||||
WatchDog bool
|
||||
SingleBackend bool // Deprecated: use MaxActiveBackends = 1 instead
|
||||
MaxActiveBackends int // Maximum number of active backends (0 = unlimited, 1 = single backend mode)
|
||||
WatchDogIdle bool
|
||||
WatchDogBusy bool
|
||||
WatchDog bool
|
||||
|
||||
// Memory Reclaimer settings (works with GPU if available, otherwise RAM)
|
||||
MemoryReclaimerEnabled bool // Enable memory threshold monitoring
|
||||
@@ -319,18 +311,6 @@ func WithExternalBackends(backends ...string) AppOption {
|
||||
}
|
||||
}
|
||||
|
||||
func WithWebRTCNAT1To1IPs(ips ...string) AppOption {
|
||||
return func(o *ApplicationConfig) {
|
||||
o.WebRTCNAT1To1IPs = ips
|
||||
}
|
||||
}
|
||||
|
||||
func WithWebRTCICEInterfaces(interfaces ...string) AppOption {
|
||||
return func(o *ApplicationConfig) {
|
||||
o.WebRTCICEInterfaces = interfaces
|
||||
}
|
||||
}
|
||||
|
||||
func WithMachineTag(tag string) AppOption {
|
||||
return func(o *ApplicationConfig) {
|
||||
o.MachineTag = tag
|
||||
@@ -722,6 +702,7 @@ func WithMITMCADir(dir string) AppOption {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
func WithDynamicConfigDir(dynamicConfigsDir string) AppOption {
|
||||
return func(o *ApplicationConfig) {
|
||||
o.DynamicConfigsDir = dynamicConfigsDir
|
||||
|
||||
@@ -103,12 +103,7 @@ func applyAutoparserOverride(
|
||||
// blocks like "<think></think>" that some models emit when reasoning
|
||||
// is disabled.
|
||||
if deltaReasoning == "" && deltaContent != "" {
|
||||
// Complete-response extraction: only honor a prefilled <think> start
|
||||
// token when deltaContent actually closes the reasoning block. Without
|
||||
// it the model answered directly and the whole answer must stay in
|
||||
// content rather than be swallowed as unclosed reasoning. See
|
||||
// reason.ExtractReasoningComplete.
|
||||
deltaReasoning, deltaContent = reason.ExtractReasoningComplete(deltaContent, thinkingStartToken, reasoningConfig)
|
||||
deltaReasoning, deltaContent = reason.ExtractReasoningWithConfig(deltaContent, thinkingStartToken, reasoningConfig)
|
||||
}
|
||||
xlog.Debug("[ChatDeltas] non-SSE no-tools: overriding result with C++ autoparser deltas",
|
||||
"content_len", len(deltaContent), "reasoning_len", len(deltaReasoning))
|
||||
|
||||
@@ -186,114 +186,6 @@ var _ = Describe("applyAutoparserOverride", func() {
|
||||
Expect(result).To(Equal(existing))
|
||||
})
|
||||
})
|
||||
|
||||
// Regression tests for the prefilled-thinking-token path (thinkingStartToken
|
||||
// != ""). This is the configuration the gallery qwen3 family runs in: the
|
||||
// chat template injects <think> into the prompt, so DetectThinkingStartToken
|
||||
// returns "<think>" and the model's output begins *inside* a reasoning block
|
||||
// — it emits a closing </think> but no opening tag.
|
||||
//
|
||||
// The defensive Go-side fallback prepends the start token so the standard
|
||||
// extractor can pair it with the model's </think>. But on a *complete*
|
||||
// response that contains NO closing tag (the model answered directly with no
|
||||
// reasoning at all), prepending <think> manufactures an unclosed block that
|
||||
// swallows the entire answer into reasoning, leaving content empty. That is
|
||||
// the bug: short/direct answers (session names, JSON summaries) come back
|
||||
// with an empty content field.
|
||||
Context("autoparser delivered content with empty reasoning and a prefilled thinking token", func() {
|
||||
const startToken = "<think>"
|
||||
|
||||
It("keeps a tag-less direct answer as content instead of swallowing it as reasoning", func() {
|
||||
// Model answered directly: no <think>, no </think> anywhere.
|
||||
chatDeltas := []*pb.ChatDelta{
|
||||
{Content: "hello", ReasoningContent: ""},
|
||||
}
|
||||
|
||||
result := applyAutoparserOverride(chatDeltas, startToken, reason.Config{}, nil)
|
||||
|
||||
Expect(result).To(HaveLen(1))
|
||||
Expect(result[0].Message.Content).ToNot(BeNil())
|
||||
Expect(*(result[0].Message.Content.(*string))).To(Equal("hello"),
|
||||
"a complete answer with no closing reasoning tag must stay in content")
|
||||
Expect(result[0].Message.Reasoning).To(BeNil(),
|
||||
"no reasoning block was emitted, so Reasoning must not be set")
|
||||
})
|
||||
|
||||
It("keeps a tag-less JSON answer as content (the summary case)", func() {
|
||||
raw := `{"short":"Tests pass","long":"go test ./... succeeded."}`
|
||||
chatDeltas := []*pb.ChatDelta{
|
||||
{Content: raw, ReasoningContent: ""},
|
||||
}
|
||||
|
||||
result := applyAutoparserOverride(chatDeltas, startToken, reason.Config{}, nil)
|
||||
|
||||
Expect(result).To(HaveLen(1))
|
||||
Expect(*(result[0].Message.Content.(*string))).To(Equal(raw))
|
||||
Expect(result[0].Message.Reasoning).To(BeNil())
|
||||
})
|
||||
|
||||
It("still splits reasoning when the model emits the closing tag (prefill paired with </think>)", func() {
|
||||
// The legitimate prefill case: <think> was in the prompt, so the
|
||||
// output carries only the closing tag. The closing tag is the proof
|
||||
// that a reasoning block exists, so extraction must run.
|
||||
raw := "The user wants a greeting.\n</think>\n\nHello there!"
|
||||
chatDeltas := []*pb.ChatDelta{
|
||||
{Content: raw, ReasoningContent: ""},
|
||||
}
|
||||
|
||||
result := applyAutoparserOverride(chatDeltas, startToken, reason.Config{}, nil)
|
||||
|
||||
Expect(result).To(HaveLen(1))
|
||||
content := *(result[0].Message.Content.(*string))
|
||||
Expect(content).To(ContainSubstring("Hello there!"))
|
||||
Expect(content).ToNot(ContainSubstring("</think>"))
|
||||
Expect(content).ToNot(ContainSubstring("The user wants a greeting"))
|
||||
Expect(result[0].Message.Reasoning).ToNot(BeNil())
|
||||
Expect(*result[0].Message.Reasoning).To(ContainSubstring("The user wants a greeting"))
|
||||
})
|
||||
|
||||
It("still splits a fully-tagged <think>…</think> block with a prefill token set", func() {
|
||||
raw := "<think>Reasoning here.</think>Final answer."
|
||||
chatDeltas := []*pb.ChatDelta{
|
||||
{Content: raw, ReasoningContent: ""},
|
||||
}
|
||||
|
||||
result := applyAutoparserOverride(chatDeltas, startToken, reason.Config{}, nil)
|
||||
|
||||
Expect(result).To(HaveLen(1))
|
||||
Expect(*(result[0].Message.Content.(*string))).To(Equal("Final answer."))
|
||||
Expect(result[0].Message.Reasoning).ToNot(BeNil())
|
||||
Expect(*result[0].Message.Reasoning).To(ContainSubstring("Reasoning here"))
|
||||
})
|
||||
|
||||
// End-to-end regression for the real production failure: a request with
|
||||
// enable_thinking=false against a <think>-capable model (qwen3 family).
|
||||
//
|
||||
// In non-thinking mode the model emits no reasoning block, so llama.cpp's
|
||||
// autoparser correctly returns ChatDeltas with Content set and
|
||||
// ReasoningContent EMPTY (verified against stock llama-server: the same
|
||||
// model with chat_template_kwargs.enable_thinking=false returns
|
||||
// reasoning_content=null and content="hello"). But thinkingStartToken is
|
||||
// detected per-model from the enable_thinking=TRUE render
|
||||
// (grpc-server renders with enable_thinking=true; DetectThinkingStartToken
|
||||
// does not evaluate the jinja {% if enable_thinking %} conditional), so it
|
||||
// is "<think>" even for this non-thinking request. The old code prepended
|
||||
// it and swallowed the answer. This is the case that broke session
|
||||
// summaries and auto-titles and was NOT covered before.
|
||||
It("preserves content for a non-thinking-mode request (enable_thinking=false, empty reasoning_content)", func() {
|
||||
// What llama.cpp's autoparser actually returns in non-thinking mode.
|
||||
chatDeltas := []*pb.ChatDelta{
|
||||
{Content: `{"short":"Go tests passed for internal/session"}`, ReasoningContent: ""},
|
||||
}
|
||||
|
||||
result := applyAutoparserOverride(chatDeltas, startToken, reason.Config{}, nil)
|
||||
|
||||
Expect(result).To(HaveLen(1))
|
||||
Expect(*(result[0].Message.Content.(*string))).To(Equal(`{"short":"Go tests passed for internal/session"}`),
|
||||
"non-thinking-mode answers must reach the client intact, not be swallowed as reasoning")
|
||||
Expect(result[0].Message.Reasoning).To(BeNil())
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
var _ = Describe("mergeToolCallDeltas", func() {
|
||||
|
||||
@@ -1579,7 +1579,7 @@ func triggerResponseAtTurn(ctx context.Context, session *Session, conv *Conversa
|
||||
// ExtractReasoningWithConfig is a no-op when no tag pair matches,
|
||||
// so it's safe to apply unconditionally in the no-reasoning branch.
|
||||
if deltaReasoning == "" && deltaContent != "" {
|
||||
deltaReasoning, deltaContent = reasoning.ExtractReasoningComplete(deltaContent, thinkingStartToken, config.ReasoningConfig)
|
||||
deltaReasoning, deltaContent = reasoning.ExtractReasoningWithConfig(deltaContent, thinkingStartToken, config.ReasoningConfig)
|
||||
}
|
||||
reasoningText = deltaReasoning
|
||||
responseWithoutReasoning = deltaContent
|
||||
@@ -1587,7 +1587,7 @@ func triggerResponseAtTurn(ctx context.Context, session *Session, conv *Conversa
|
||||
cleanedResponse = deltaContent
|
||||
toolCalls = deltaToolCalls
|
||||
} else {
|
||||
reasoningText, responseWithoutReasoning = reasoning.ExtractReasoningComplete(rawResponse, thinkingStartToken, config.ReasoningConfig)
|
||||
reasoningText, responseWithoutReasoning = reasoning.ExtractReasoningWithConfig(rawResponse, thinkingStartToken, config.ReasoningConfig)
|
||||
textContent = functions.ParseTextContent(responseWithoutReasoning, config.FunctionsConfig)
|
||||
cleanedResponse = functions.CleanupLLMResult(responseWithoutReasoning, config.FunctionsConfig)
|
||||
toolCalls = functions.ParseFunctionCall(cleanedResponse, config.FunctionsConfig)
|
||||
|
||||
@@ -48,8 +48,7 @@ func RealtimeCalls(application *application.Application) echo.HandlerFunc {
|
||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "codec registration failed"})
|
||||
}
|
||||
|
||||
se := webRTCSettingEngine(application.ApplicationConfig())
|
||||
api := webrtc.NewAPI(webrtc.WithMediaEngine(m), webrtc.WithSettingEngine(se))
|
||||
api := webrtc.NewAPI(webrtc.WithMediaEngine(m))
|
||||
|
||||
pc, err := api.NewPeerConnection(webrtc.Configuration{})
|
||||
if err != nil {
|
||||
|
||||
@@ -1,47 +0,0 @@
|
||||
package openai
|
||||
|
||||
import (
|
||||
"github.com/mudler/LocalAI/core/config"
|
||||
"github.com/mudler/xlog"
|
||||
"github.com/pion/webrtc/v4"
|
||||
)
|
||||
|
||||
// webRTCSettingEngine builds the pion SettingEngine for /v1/realtime WebRTC.
|
||||
//
|
||||
// With a default (empty) SettingEngine, pion gathers a host ICE candidate for
|
||||
// every local interface. Under Docker host networking that includes bridge
|
||||
// addresses (docker0/veth, 172.x) that a remote browser cannot route to; the
|
||||
// connection often establishes on a good pair and then drops once ICE consent
|
||||
// checks fail on the unreachable ones. The two opt-in knobs below let an
|
||||
// operator advertise only the reachable address.
|
||||
func webRTCSettingEngine(cfg *config.ApplicationConfig) webrtc.SettingEngine {
|
||||
s := webrtc.SettingEngine{}
|
||||
if cfg == nil {
|
||||
return s
|
||||
}
|
||||
if len(cfg.WebRTCNAT1To1IPs) > 0 {
|
||||
s.SetNAT1To1IPs(cfg.WebRTCNAT1To1IPs, webrtc.ICECandidateTypeHost)
|
||||
xlog.Debug("realtime webrtc: advertising NAT 1:1 host IPs", "ips", cfg.WebRTCNAT1To1IPs)
|
||||
}
|
||||
if filter := iceInterfaceFilter(cfg.WebRTCICEInterfaces); filter != nil {
|
||||
s.SetInterfaceFilter(filter)
|
||||
xlog.Debug("realtime webrtc: restricting ICE interfaces", "interfaces", cfg.WebRTCICEInterfaces)
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// iceInterfaceFilter returns an interface allow-list predicate for pion, or nil
|
||||
// when no interfaces are configured (pion's default: gather from all).
|
||||
func iceInterfaceFilter(allowed []string) func(string) bool {
|
||||
if len(allowed) == 0 {
|
||||
return nil
|
||||
}
|
||||
set := make(map[string]struct{}, len(allowed))
|
||||
for _, name := range allowed {
|
||||
set[name] = struct{}{}
|
||||
}
|
||||
return func(iface string) bool {
|
||||
_, ok := set[iface]
|
||||
return ok
|
||||
}
|
||||
}
|
||||
@@ -1,39 +0,0 @@
|
||||
package openai
|
||||
|
||||
import (
|
||||
"github.com/mudler/LocalAI/core/config"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
var _ = Describe("webRTC ICE settings", func() {
|
||||
Describe("iceInterfaceFilter", func() {
|
||||
It("returns nil when no interfaces are configured", func() {
|
||||
Expect(iceInterfaceFilter(nil)).To(BeNil())
|
||||
Expect(iceInterfaceFilter([]string{})).To(BeNil())
|
||||
})
|
||||
|
||||
It("admits only the configured interfaces", func() {
|
||||
f := iceInterfaceFilter([]string{"eth0", "wlan0"})
|
||||
Expect(f).NotTo(BeNil())
|
||||
Expect(f("eth0")).To(BeTrue())
|
||||
Expect(f("wlan0")).To(BeTrue())
|
||||
Expect(f("docker0")).To(BeFalse())
|
||||
Expect(f("veth123")).To(BeFalse())
|
||||
})
|
||||
})
|
||||
|
||||
Describe("webRTCSettingEngine", func() {
|
||||
It("does not panic on a nil config", func() {
|
||||
Expect(func() { webRTCSettingEngine(nil) }).NotTo(Panic())
|
||||
})
|
||||
|
||||
It("builds an engine with NAT 1:1 IPs and an interface filter configured", func() {
|
||||
cfg := &config.ApplicationConfig{
|
||||
WebRTCNAT1To1IPs: []string{"192.168.1.10"},
|
||||
WebRTCICEInterfaces: []string{"eth0"},
|
||||
}
|
||||
Expect(func() { webRTCSettingEngine(cfg) }).NotTo(Panic())
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1356,7 +1356,7 @@ func handleOpenResponsesNonStream(c echo.Context, responseID string, createdAt i
|
||||
thinkingStartToken := reason.DetectThinkingStartToken(template, &cfg.ReasoningConfig)
|
||||
|
||||
// Extract reasoning from result before cleaning
|
||||
reasoningContent, cleanedResult := reason.ExtractReasoningComplete(result, thinkingStartToken, cfg.ReasoningConfig)
|
||||
reasoningContent, cleanedResult := reason.ExtractReasoningWithConfig(result, thinkingStartToken, cfg.ReasoningConfig)
|
||||
|
||||
// Parse tool calls if using functions
|
||||
var outputItems []schema.ORItemField
|
||||
@@ -1996,7 +1996,7 @@ func handleOpenResponsesStream(c echo.Context, responseID string, createdAt int6
|
||||
finalCleanedResult = extractor.CleanedContent()
|
||||
}
|
||||
if finalReasoning == "" && finalCleanedResult == "" {
|
||||
finalReasoning, finalCleanedResult = reason.ExtractReasoningComplete(result, thinkingStartToken, cfg.ReasoningConfig)
|
||||
finalReasoning, finalCleanedResult = reason.ExtractReasoningWithConfig(result, thinkingStartToken, cfg.ReasoningConfig)
|
||||
}
|
||||
|
||||
// Close reasoning item if it exists and wasn't closed yet
|
||||
@@ -2493,7 +2493,7 @@ func handleOpenResponsesStream(c echo.Context, responseID string, createdAt int6
|
||||
finalCleanedResult = extractor.CleanedContent()
|
||||
}
|
||||
if finalReasoning == "" && finalCleanedResult == "" {
|
||||
finalReasoning, finalCleanedResult = reason.ExtractReasoningComplete(result, thinkingStartToken, cfg.ReasoningConfig)
|
||||
finalReasoning, finalCleanedResult = reason.ExtractReasoningWithConfig(result, thinkingStartToken, cfg.ReasoningConfig)
|
||||
}
|
||||
|
||||
// Close reasoning item if it exists and wasn't closed yet
|
||||
|
||||
6
core/http/react-ui/src/hooks/useChat.js
vendored
6
core/http/react-ui/src/hooks/useChat.js
vendored
@@ -216,12 +216,6 @@ export function useChat(initialModel = '') {
|
||||
audio_url: { url: `data:${file.type};base64,${file.base64}` },
|
||||
})
|
||||
userFiles.push({ name: file.name, type: 'audio' })
|
||||
} else if (file.type?.startsWith('video/')) {
|
||||
messageContent.push({
|
||||
type: 'video_url',
|
||||
video_url: { url: `data:${file.type};base64,${file.base64}` },
|
||||
})
|
||||
userFiles.push({ name: file.name, type: 'video' })
|
||||
} else {
|
||||
// Text/PDF files - append to content
|
||||
if (file.textContent) {
|
||||
|
||||
@@ -265,7 +265,7 @@ function UserMessageContent({ content, files }) {
|
||||
<div className="chat-message-files">
|
||||
{files.map((f, i) => (
|
||||
<span key={i} className="chat-file-inline">
|
||||
<i className={`fas ${f.type === 'image' ? 'fa-image' : f.type === 'audio' ? 'fa-headphones' : f.type === 'video' ? 'fa-film' : 'fa-file'}`} />
|
||||
<i className={`fas ${f.type === 'image' ? 'fa-image' : f.type === 'audio' ? 'fa-headphones' : 'fa-file'}`} />
|
||||
{f.name}
|
||||
</span>
|
||||
))}
|
||||
@@ -274,9 +274,6 @@ function UserMessageContent({ content, files }) {
|
||||
{Array.isArray(content) && content.filter(c => c.type === 'image_url').map((img, i) => (
|
||||
<img key={i} src={img.image_url.url} alt="attached" className="chat-inline-image" />
|
||||
))}
|
||||
{Array.isArray(content) && content.filter(c => c.type === 'video_url').map((vid, i) => (
|
||||
<video key={i} src={vid.video_url.url} controls className="chat-inline-video" />
|
||||
))}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -714,7 +711,7 @@ export default function Chat() {
|
||||
for (const file of e.target.files) {
|
||||
const base64 = await fileToBase64(file)
|
||||
const entry = { name: file.name, type: file.type, base64 }
|
||||
if (!file.type.startsWith('image/') && !file.type.startsWith('audio/') && !file.type.startsWith('video/')) {
|
||||
if (!file.type.startsWith('image/') && !file.type.startsWith('audio/')) {
|
||||
entry.textContent = await file.text().catch(() => '')
|
||||
}
|
||||
newFiles.push(entry)
|
||||
@@ -1247,7 +1244,7 @@ export default function Chat() {
|
||||
<div className="chat-files">
|
||||
{files.map((f, i) => (
|
||||
<span key={i} className="chat-file-badge">
|
||||
<i className={`fas ${f.type?.startsWith('image/') ? 'fa-image' : f.type?.startsWith('audio/') ? 'fa-headphones' : f.type?.startsWith('video/') ? 'fa-film' : 'fa-file'}`} />
|
||||
<i className={`fas ${f.type?.startsWith('image/') ? 'fa-image' : f.type?.startsWith('audio/') ? 'fa-headphones' : 'fa-file'}`} />
|
||||
{f.name}
|
||||
<button onClick={() => setFiles(prev => prev.filter((_, idx) => idx !== i))}>
|
||||
<i className="fas fa-xmark" />
|
||||
@@ -1346,7 +1343,7 @@ export default function Chat() {
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
multiple
|
||||
accept="image/*,audio/*,video/*,application/pdf,.txt,.md,.csv,.json"
|
||||
accept="image/*,audio/*,application/pdf,.txt,.md,.csv,.json"
|
||||
style={{ display: 'none' }}
|
||||
onChange={handleFileChange}
|
||||
/>
|
||||
|
||||
@@ -466,11 +466,10 @@ func (s *AgentPoolService) Chat(name, message string) (string, error) {
|
||||
s.collectAndCopyMetadata(metadata, chatUserID)
|
||||
}
|
||||
|
||||
content := s.appendLocalAGIKBCitations(response.Response, name, message, response.State)
|
||||
msg := map[string]any{
|
||||
"id": messageID + "-agent",
|
||||
"sender": "agent",
|
||||
"content": content,
|
||||
"content": response.Response,
|
||||
"timestamp": time.Now().Format(time.RFC3339),
|
||||
}
|
||||
if len(metadata) > 0 {
|
||||
@@ -490,79 +489,6 @@ func (s *AgentPoolService) Chat(name, message string) (string, error) {
|
||||
return messageID, nil
|
||||
}
|
||||
|
||||
func (s *AgentPoolService) appendLocalAGIKBCitations(response, agentKey, message string, states []coreTypes.ActionState) string {
|
||||
if strings.TrimSpace(response) == "" {
|
||||
return response
|
||||
}
|
||||
|
||||
userID, collection := splitAgentKey(agentKey)
|
||||
cfg := s.localAGI.pool.GetConfig(agentKey)
|
||||
if cfg == nil || !cfg.EnableKnowledgeBase {
|
||||
return response
|
||||
}
|
||||
|
||||
citations := kbCitationsFromActionStates(states)
|
||||
if len(citations) == 0 && cfg.KBAutoSearch {
|
||||
maxResults := cfg.KnowledgeBaseResults
|
||||
if maxResults <= 0 {
|
||||
maxResults = 5
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
kbResult := agents.KBAutoSearchPrompt(ctx, s.apiURL, s.apiKey, collection, message, maxResults, userID)
|
||||
citations = kbResult.Citations
|
||||
}
|
||||
|
||||
return agents.AppendKBCitations(response, collection, userID, citations)
|
||||
}
|
||||
|
||||
func splitAgentKey(agentKey string) (userID, name string) {
|
||||
if uid, n, ok := strings.Cut(agentKey, ":"); ok {
|
||||
return uid, n
|
||||
}
|
||||
return "", agentKey
|
||||
}
|
||||
|
||||
func kbCitationsFromActionStates(states []coreTypes.ActionState) []agents.KBCitation {
|
||||
var citations []agents.KBCitation
|
||||
for _, state := range states {
|
||||
citations = append(citations, kbCitationsFromMetadata(state.Metadata)...)
|
||||
}
|
||||
return citations
|
||||
}
|
||||
|
||||
func kbCitationsFromMetadata(metadata map[string]any) []agents.KBCitation {
|
||||
if len(metadata) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
fileName := metadata["file_name"]
|
||||
source := metadata["source"]
|
||||
if fileName == nil && source == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
citation := agents.KBCitation{
|
||||
FileName: metadataString(fileName),
|
||||
EntryKey: metadataString(source),
|
||||
}
|
||||
if citation.FileName == "" && citation.EntryKey == "" {
|
||||
return nil
|
||||
}
|
||||
return []agents.KBCitation{citation}
|
||||
}
|
||||
|
||||
func metadataString(value any) string {
|
||||
switch v := value.(type) {
|
||||
case string:
|
||||
return v
|
||||
case fmt.Stringer:
|
||||
return v.String()
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
// userOutputsDir returns the per-user outputs directory, creating it if needed.
|
||||
// If userID is empty, falls back to the shared outputs directory.
|
||||
func (s *AgentPoolService) userOutputsDir(userID string) string {
|
||||
|
||||
@@ -1,127 +0,0 @@
|
||||
package agents
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
|
||||
type kbCitationList struct {
|
||||
mu sync.Mutex
|
||||
citations []KBCitation
|
||||
}
|
||||
|
||||
func (l *kbCitationList) AddKBCitations(citations []KBCitation) {
|
||||
if len(citations) == 0 {
|
||||
return
|
||||
}
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
l.citations = append(l.citations, citations...)
|
||||
}
|
||||
|
||||
func (l *kbCitationList) Citations() []KBCitation {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
out := make([]KBCitation, len(l.citations))
|
||||
copy(out, l.citations)
|
||||
return out
|
||||
}
|
||||
|
||||
// AppendKBCitations appends a markdown Sources block for KB citations.
|
||||
func AppendKBCitations(response, collection, userID string, citations []KBCitation) string {
|
||||
if strings.TrimSpace(response) == "" || len(citations) == 0 {
|
||||
return response
|
||||
}
|
||||
|
||||
var lines []string
|
||||
seen := make(map[string]struct{})
|
||||
for _, citation := range citations {
|
||||
key := strings.TrimSpace(citation.EntryKey)
|
||||
if key == "" {
|
||||
key = strings.TrimSpace(citation.FileName)
|
||||
}
|
||||
if key == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[key]; ok {
|
||||
continue
|
||||
}
|
||||
seen[key] = struct{}{}
|
||||
|
||||
displayName := kbCitationDisplayName(citation)
|
||||
if displayName == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
sourceURL := kbCitationRawFileURL(collection, citation.EntryKey, userID)
|
||||
number := len(lines) + 1
|
||||
if sourceURL == "" {
|
||||
lines = append(lines, fmt.Sprintf("[%d] %s", number, displayName))
|
||||
continue
|
||||
}
|
||||
lines = append(lines, fmt.Sprintf("[%d] [%s](%s)", number, escapeMarkdownLinkText(displayName), sourceURL))
|
||||
}
|
||||
|
||||
if len(lines) == 0 {
|
||||
return response
|
||||
}
|
||||
|
||||
var sb strings.Builder
|
||||
sb.WriteString(strings.TrimRight(response, "\n"))
|
||||
sb.WriteString("\n\nSources:\n")
|
||||
for _, line := range lines {
|
||||
sb.WriteString(line)
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
return strings.TrimRight(sb.String(), "\n")
|
||||
}
|
||||
|
||||
func kbCitationDisplayName(citation KBCitation) string {
|
||||
if fileName := strings.TrimSpace(citation.FileName); fileName != "" {
|
||||
return fileName
|
||||
}
|
||||
|
||||
segments := strings.Split(strings.Trim(strings.TrimSpace(citation.EntryKey), "/"), "/")
|
||||
for i := len(segments) - 1; i >= 0; i-- {
|
||||
if segment := strings.TrimSpace(segments[i]); segment != "" {
|
||||
return segment
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func kbCitationRawFileURL(collection, entryKey, userID string) string {
|
||||
collection = strings.TrimSpace(collection)
|
||||
entryKey = strings.Trim(strings.TrimSpace(entryKey), "/")
|
||||
if collection == "" || entryKey == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
var escapedEntrySegments []string
|
||||
for _, segment := range strings.Split(entryKey, "/") {
|
||||
if segment == "" {
|
||||
continue
|
||||
}
|
||||
escapedEntrySegments = append(escapedEntrySegments, url.PathEscape(segment))
|
||||
}
|
||||
if len(escapedEntrySegments) == 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
sourceURL := "/api/agents/collections/" + url.PathEscape(collection) + "/entries-raw/" + strings.Join(escapedEntrySegments, "/")
|
||||
if userID != "" {
|
||||
query := url.Values{}
|
||||
query.Set("user_id", userID)
|
||||
sourceURL += "?" + query.Encode()
|
||||
}
|
||||
return sourceURL
|
||||
}
|
||||
|
||||
func escapeMarkdownLinkText(text string) string {
|
||||
text = strings.ReplaceAll(text, `\`, `\\`)
|
||||
text = strings.ReplaceAll(text, "[", `\[`)
|
||||
text = strings.ReplaceAll(text, "]", `\]`)
|
||||
return text
|
||||
}
|
||||
@@ -167,12 +167,10 @@ func ExecuteChatWithLLM(ctx context.Context, llm cogito.LLM, cfg *AgentConfig, m
|
||||
}
|
||||
}
|
||||
|
||||
kbCitations := &kbCitationList{}
|
||||
if cfg.EnableKnowledgeBase && (kbMode == KBModeAutoSearch || kbMode == KBModeBoth) {
|
||||
kbResult := KBAutoSearchPrompt(ctx, effectiveURL, effectiveKey, cfg.Name, message, cfg.KnowledgeBaseResults, userID)
|
||||
if kbResult.Prompt != "" {
|
||||
fragment = fragment.AddMessage(cogito.SystemMessageRole, kbResult.Prompt)
|
||||
kbCitations.AddKBCitations(kbResult.Citations)
|
||||
kbResults := KBAutoSearchPrompt(ctx, effectiveURL, effectiveKey, cfg.Name, message, cfg.KnowledgeBaseResults, userID)
|
||||
if kbResults != "" {
|
||||
fragment = fragment.AddMessage(cogito.SystemMessageRole, kbResults)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -199,7 +197,7 @@ func ExecuteChatWithLLM(ctx context.Context, llm cogito.LLM, cfg *AgentConfig, m
|
||||
}
|
||||
cogitoOpts = append(cogitoOpts, cogito.WithTools(
|
||||
cogito.NewToolDefinition(
|
||||
KBSearchMemoryTool{APIURL: effectiveURL, APIKey: effectiveKey, Collection: cfg.Name, MaxResults: kbResults, UserID: userID, CitationCollector: kbCitations},
|
||||
KBSearchMemoryTool{APIURL: effectiveURL, APIKey: effectiveKey, Collection: cfg.Name, MaxResults: kbResults, UserID: userID},
|
||||
KBSearchMemoryArgs{},
|
||||
"search_memory",
|
||||
"Search the knowledge base for relevant information",
|
||||
@@ -338,8 +336,6 @@ func ExecuteChatWithLLM(ctx context.Context, llm cogito.LLM, cfg *AgentConfig, m
|
||||
if cfg.StripThinkingTags && response != "" {
|
||||
response = stripThinkingTags(response)
|
||||
}
|
||||
responseForMemory := response
|
||||
response = AppendKBCitations(response, cfg.Name, userID, kbCitations.Citations())
|
||||
|
||||
// Save conversation to KB when long-term memory is enabled.
|
||||
// Use a detached context: the parent ctx may be cancelled (e.g. in distributed
|
||||
@@ -348,7 +344,7 @@ func ExecuteChatWithLLM(ctx context.Context, llm cogito.LLM, cfg *AgentConfig, m
|
||||
go func() {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
saveConversationToKB(ctx, llm, effectiveURL, effectiveKey, cfg, message, responseForMemory, userID)
|
||||
saveConversationToKB(ctx, llm, effectiveURL, effectiveKey, cfg, message, response, userID)
|
||||
}()
|
||||
}
|
||||
|
||||
|
||||
@@ -2,8 +2,6 @@ package agents
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
|
||||
@@ -38,34 +36,6 @@ func (m *mockLLM) CreateChatCompletion(ctx context.Context, req openai.ChatCompl
|
||||
}, cogito.LLMUsage{}, nil
|
||||
}
|
||||
|
||||
type toolCallingMockLLM struct {
|
||||
createResponses []openai.ChatCompletionResponse
|
||||
askResponse string
|
||||
callCount atomic.Int32
|
||||
}
|
||||
|
||||
func (m *toolCallingMockLLM) Ask(ctx context.Context, f cogito.Fragment) (cogito.Fragment, error) {
|
||||
m.callCount.Add(1)
|
||||
return f.AddMessage(cogito.AssistantMessageRole, m.askResponse), nil
|
||||
}
|
||||
|
||||
func (m *toolCallingMockLLM) CreateChatCompletion(ctx context.Context, req openai.ChatCompletionRequest) (cogito.LLMReply, cogito.LLMUsage, error) {
|
||||
idx := int(m.callCount.Add(1)) - 1
|
||||
if idx >= len(m.createResponses) {
|
||||
return cogito.LLMReply{
|
||||
ChatCompletionResponse: openai.ChatCompletionResponse{
|
||||
Choices: []openai.ChatCompletionChoice{{
|
||||
Message: openai.ChatCompletionMessage{
|
||||
Role: "assistant",
|
||||
Content: "No more tools needed.",
|
||||
},
|
||||
}},
|
||||
},
|
||||
}, cogito.LLMUsage{}, nil
|
||||
}
|
||||
return cogito.LLMReply{ChatCompletionResponse: m.createResponses[idx]}, cogito.LLMUsage{}, nil
|
||||
}
|
||||
|
||||
// statusCollector records status callbacks in a thread-safe way.
|
||||
type statusCollector struct {
|
||||
mu sync.Mutex
|
||||
@@ -103,74 +73,6 @@ var _ = DescribeTable("stripThinkingTags",
|
||||
Entry("adjacent tag pairs", "<thinking>a</thinking><thinking>b</thinking>", ""),
|
||||
)
|
||||
|
||||
var _ = DescribeTable("appendKBCitations",
|
||||
func(response, collection, userID string, citations []KBCitation, want string) {
|
||||
Expect(AppendKBCitations(response, collection, userID, citations)).To(Equal(want))
|
||||
},
|
||||
Entry("leaves responses without citations unchanged",
|
||||
"answer",
|
||||
"agent",
|
||||
"",
|
||||
nil,
|
||||
"answer",
|
||||
),
|
||||
Entry("leaves blank responses unchanged",
|
||||
"",
|
||||
"agent",
|
||||
"",
|
||||
[]KBCitation{{FileName: "source.pdf", EntryKey: "uuid/source.pdf"}},
|
||||
"",
|
||||
),
|
||||
Entry("appends clickable source links",
|
||||
"answer",
|
||||
"my-agent",
|
||||
"",
|
||||
[]KBCitation{{FileName: "new feature.pdf", EntryKey: "uuid/new feature.pdf"}},
|
||||
"answer\n\nSources:\n[1] [new feature.pdf](/api/agents/collections/my-agent/entries-raw/uuid/new%20feature.pdf)",
|
||||
),
|
||||
Entry("deduplicates citations by entry key",
|
||||
"answer",
|
||||
"agent",
|
||||
"",
|
||||
[]KBCitation{
|
||||
{FileName: "first.pdf", EntryKey: "uuid/shared.pdf"},
|
||||
{FileName: "second.pdf", EntryKey: "uuid/shared.pdf"},
|
||||
},
|
||||
"answer\n\nSources:\n[1] [first.pdf](/api/agents/collections/agent/entries-raw/uuid/shared.pdf)",
|
||||
),
|
||||
Entry("uses plain text when entry key is missing",
|
||||
"answer",
|
||||
"agent",
|
||||
"",
|
||||
[]KBCitation{{FileName: "source.pdf"}},
|
||||
"answer\n\nSources:\n[1] source.pdf",
|
||||
),
|
||||
Entry("uses entry basename when filename is missing",
|
||||
"answer",
|
||||
"agent",
|
||||
"",
|
||||
[]KBCitation{{EntryKey: "uuid/source.pdf"}},
|
||||
"answer\n\nSources:\n[1] [source.pdf](/api/agents/collections/agent/entries-raw/uuid/source.pdf)",
|
||||
),
|
||||
Entry("adds user id query when present",
|
||||
"answer",
|
||||
"agent",
|
||||
"user 1",
|
||||
[]KBCitation{{FileName: "source.pdf", EntryKey: "uuid/source.pdf"}},
|
||||
"answer\n\nSources:\n[1] [source.pdf](/api/agents/collections/agent/entries-raw/uuid/source.pdf?user_id=user+1)",
|
||||
),
|
||||
Entry("escapes collection, path segments, and markdown link text",
|
||||
"answer",
|
||||
"agent one",
|
||||
"",
|
||||
[]KBCitation{{FileName: "source [draft].pdf", EntryKey: "uuid/source [draft].pdf"}},
|
||||
`answer
|
||||
|
||||
Sources:
|
||||
[1] [source \[draft\].pdf](/api/agents/collections/agent%20one/entries-raw/uuid/source%20%5Bdraft%5D.pdf)`,
|
||||
),
|
||||
)
|
||||
|
||||
var _ = Describe("ExecuteChatWithLLM", func() {
|
||||
var (
|
||||
ctx context.Context
|
||||
@@ -282,150 +184,6 @@ var _ = Describe("ExecuteChatWithLLM", func() {
|
||||
})
|
||||
})
|
||||
|
||||
Context("knowledge base citations", func() {
|
||||
It("appends KB sources to the returned response and callback message", func() {
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/api/agents/collections/kb-agent/search", func(w http.ResponseWriter, r *http.Request) {
|
||||
Expect(r.URL.Query().Get("user_id")).To(Equal("user-1"))
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{
|
||||
"results": [
|
||||
{
|
||||
"content": "KB content",
|
||||
"id": "result-1",
|
||||
"similarity": 0.99,
|
||||
"metadata": {
|
||||
"file_name": "new feature.pdf",
|
||||
"source": "uuid/new feature.pdf"
|
||||
}
|
||||
}
|
||||
],
|
||||
"count": 1
|
||||
}`))
|
||||
})
|
||||
server := httptest.NewServer(mux)
|
||||
defer server.Close()
|
||||
|
||||
var msgContent string
|
||||
cb.OnMessage = func(sender, content, messageID string) {
|
||||
msgContent = content
|
||||
}
|
||||
|
||||
llm := &mockLLM{response: "agent reply"}
|
||||
cfg := &AgentConfig{
|
||||
Name: "kb-agent",
|
||||
Model: "test-model",
|
||||
EnableKnowledgeBase: true,
|
||||
KBMode: KBModeAutoSearch,
|
||||
}
|
||||
|
||||
result, err := ExecuteChatWithLLM(ctx, llm, cfg, "hello", cb, ExecuteChatOpts{
|
||||
APIURL: server.URL,
|
||||
UserID: "user-1",
|
||||
})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(result).To(Equal("agent reply\n\nSources:\n[1] [new feature.pdf](/api/agents/collections/kb-agent/entries-raw/uuid/new%20feature.pdf?user_id=user-1)"))
|
||||
Expect(msgContent).To(Equal(result))
|
||||
})
|
||||
|
||||
It("collects citations from the search_memory tool", func() {
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/api/agents/collections/kb-agent/search", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{
|
||||
"results": [
|
||||
{
|
||||
"content": "Tool KB content",
|
||||
"id": "result-1",
|
||||
"similarity": 0.99,
|
||||
"metadata": {
|
||||
"file_name": "tool source.pdf",
|
||||
"source": "uuid/tool source.pdf"
|
||||
}
|
||||
}
|
||||
],
|
||||
"count": 1
|
||||
}`))
|
||||
})
|
||||
server := httptest.NewServer(mux)
|
||||
defer server.Close()
|
||||
|
||||
collector := &kbCitationList{}
|
||||
tool := KBSearchMemoryTool{
|
||||
APIURL: server.URL,
|
||||
Collection: "kb-agent",
|
||||
CitationCollector: collector,
|
||||
}
|
||||
|
||||
result, _, err := tool.Run(KBSearchMemoryArgs{Query: "hello"})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(result).To(ContainSubstring("Tool KB content"))
|
||||
Expect(collector.Citations()).To(Equal([]KBCitation{{FileName: "tool source.pdf", EntryKey: "uuid/tool source.pdf"}}))
|
||||
})
|
||||
|
||||
It("appends KB sources found through tools-only search_memory calls", func() {
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/api/agents/collections/kb-agent/search", func(w http.ResponseWriter, r *http.Request) {
|
||||
Expect(r.URL.Query().Get("user_id")).To(Equal("user-1"))
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{
|
||||
"results": [
|
||||
{
|
||||
"content": "Tool KB content",
|
||||
"id": "result-1",
|
||||
"similarity": 0.99,
|
||||
"metadata": {
|
||||
"file_name": "tool source.pdf",
|
||||
"source": "uuid/tool source.pdf"
|
||||
}
|
||||
}
|
||||
],
|
||||
"count": 1
|
||||
}`))
|
||||
})
|
||||
server := httptest.NewServer(mux)
|
||||
defer server.Close()
|
||||
|
||||
llm := &toolCallingMockLLM{
|
||||
askResponse: "agent reply from tool context",
|
||||
createResponses: []openai.ChatCompletionResponse{
|
||||
{
|
||||
Choices: []openai.ChatCompletionChoice{
|
||||
{
|
||||
Message: openai.ChatCompletionMessage{
|
||||
Role: "assistant",
|
||||
ToolCalls: []openai.ToolCall{
|
||||
{
|
||||
ID: "call-1",
|
||||
Type: openai.ToolTypeFunction,
|
||||
Function: openai.FunctionCall{
|
||||
Name: "search_memory",
|
||||
Arguments: `{"query":"hello"}`,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
cfg := &AgentConfig{
|
||||
Name: "kb-agent",
|
||||
Model: "test-model",
|
||||
EnableKnowledgeBase: true,
|
||||
KBMode: KBModeTools,
|
||||
}
|
||||
|
||||
result, err := ExecuteChatWithLLM(ctx, llm, cfg, "hello", cb, ExecuteChatOpts{
|
||||
APIURL: server.URL,
|
||||
UserID: "user-1",
|
||||
})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(result).To(Equal("agent reply from tool context\n\nSources:\n[1] [tool source.pdf](/api/agents/collections/kb-agent/entries-raw/uuid/tool%20source.pdf?user_id=user-1)"))
|
||||
})
|
||||
})
|
||||
|
||||
Context("context cancellation", func() {
|
||||
It("returns an error when context is already cancelled", func() {
|
||||
cancelledCtx, cancel := context.WithCancel(ctx)
|
||||
|
||||
@@ -8,7 +8,6 @@ import (
|
||||
"io"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -18,19 +17,10 @@ import (
|
||||
"github.com/mudler/LocalAI/pkg/httpclient"
|
||||
)
|
||||
|
||||
// Metadata keys populated by localrecall for every stored chunk. The original
|
||||
// upload file name lives under file_name (used for display); source holds the
|
||||
// collection entry key ("<uuid>/<filename>") used to build the raw-file URL.
|
||||
const (
|
||||
kbMetadataFileName = "file_name"
|
||||
kbMetadataSource = "source"
|
||||
)
|
||||
|
||||
// KBSearchResult represents a search result from the knowledge base.
|
||||
// Field names mirror the collection search endpoint's JSON response.
|
||||
type KBSearchResult struct {
|
||||
Content string `json:"content"`
|
||||
ID string `json:"id"`
|
||||
Score float64 `json:"score"`
|
||||
Similarity float64 `json:"similarity"`
|
||||
Metadata map[string]string `json:"metadata"`
|
||||
}
|
||||
@@ -41,48 +31,22 @@ type kbSearchResponse struct {
|
||||
Count int `json:"count"`
|
||||
}
|
||||
|
||||
// KBCitation is a single source document that a KB search drew from. Citations
|
||||
// travel alongside the prompt as structured data so the consumer (and UI) can
|
||||
// render clickable source links, independent of what the model writes inline.
|
||||
type KBCitation struct {
|
||||
// FileName is the original uploaded file name, for display (e.g. "report.pdf").
|
||||
FileName string `json:"file_name"`
|
||||
// EntryKey is the collection entry identifier ("<uuid>/<filename>"), used to
|
||||
// build the raw-file URL and as the de-duplication key.
|
||||
EntryKey string `json:"entry_key"`
|
||||
}
|
||||
|
||||
// KBSearchContext is the result of an auto-search against the knowledge base:
|
||||
// the system-prompt block to feed the model, plus the de-duplicated list of
|
||||
// source documents the results were drawn from.
|
||||
type KBSearchContext struct {
|
||||
Prompt string `json:"prompt"`
|
||||
Citations []KBCitation `json:"citations"`
|
||||
}
|
||||
|
||||
// KBCitationCollector receives source citations found during KB searches.
|
||||
type KBCitationCollector interface {
|
||||
AddKBCitations([]KBCitation)
|
||||
}
|
||||
|
||||
// KBAutoSearchPrompt queries the knowledge base with the user's message and
|
||||
// returns a KBSearchContext: a system prompt block with the relevant results
|
||||
// plus the de-duplicated source citations those results came from.
|
||||
// KBAutoSearchPrompt queries the knowledge base with the user's message
|
||||
// and returns a system prompt block with relevant results.
|
||||
// Uses LocalAI's collection search endpoint via the API.
|
||||
func KBAutoSearchPrompt(ctx context.Context, apiURL, apiKey, collection, query string, maxResults int, userID string) KBSearchContext {
|
||||
func KBAutoSearchPrompt(ctx context.Context, apiURL, apiKey, collection, query string, maxResults int, userID string) string {
|
||||
if collection == "" || query == "" {
|
||||
return KBSearchContext{}
|
||||
return ""
|
||||
}
|
||||
|
||||
if maxResults <= 0 {
|
||||
maxResults = 5
|
||||
}
|
||||
|
||||
searchURL := strings.TrimRight(apiURL, "/") + "/api/agents/collections/" + url.PathEscape(collection) + "/search"
|
||||
// Call LocalAI's collection search API
|
||||
searchURL := strings.TrimRight(apiURL, "/") + "/api/agents/collections/" + collection + "/search"
|
||||
if userID != "" {
|
||||
query := url.Values{}
|
||||
query.Set("user_id", userID)
|
||||
searchURL += "?" + query.Encode()
|
||||
searchURL += "?user_id=" + userID
|
||||
}
|
||||
reqBody, _ := json.Marshal(map[string]any{
|
||||
"query": query,
|
||||
@@ -92,7 +56,7 @@ func KBAutoSearchPrompt(ctx context.Context, apiURL, apiKey, collection, query s
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, searchURL, strings.NewReader(string(reqBody)))
|
||||
if err != nil {
|
||||
xlog.Warn("KB auto-search: failed to create request", "error", err)
|
||||
return KBSearchContext{}
|
||||
return ""
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
if apiKey != "" {
|
||||
@@ -102,70 +66,41 @@ func KBAutoSearchPrompt(ctx context.Context, apiURL, apiKey, collection, query s
|
||||
resp, err := httpclient.New().Do(req)
|
||||
if err != nil {
|
||||
xlog.Warn("KB auto-search: request failed", "error", err)
|
||||
return KBSearchContext{}
|
||||
return ""
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
xlog.Warn("KB auto-search: non-200 response", "status", resp.StatusCode, "body", string(body))
|
||||
return KBSearchContext{}
|
||||
return ""
|
||||
}
|
||||
|
||||
var searchResp kbSearchResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&searchResp); err != nil {
|
||||
xlog.Warn("KB auto-search: failed to decode response", "error", err)
|
||||
return KBSearchContext{}
|
||||
return ""
|
||||
}
|
||||
|
||||
if len(searchResp.Results) == 0 {
|
||||
return KBSearchContext{}
|
||||
return ""
|
||||
}
|
||||
|
||||
// Build the system prompt block, labelling each chunk with its source file
|
||||
// so the model can attribute inline, and collect the structured citations.
|
||||
// Format results as a system prompt block (same format as LocalAGI)
|
||||
var sb strings.Builder
|
||||
sb.WriteString("Given the user input you have the following in memory:\n")
|
||||
|
||||
var citations []KBCitation
|
||||
seen := make(map[string]struct{})
|
||||
|
||||
for _, r := range searchResp.Results {
|
||||
fileName := r.Metadata[kbMetadataFileName]
|
||||
source := r.Metadata[kbMetadataSource]
|
||||
|
||||
label := fileName
|
||||
if label == "" {
|
||||
label = "unknown"
|
||||
for i, r := range searchResp.Results {
|
||||
sb.WriteString(fmt.Sprintf("- %s", r.Content))
|
||||
if len(r.Metadata) > 0 {
|
||||
meta, _ := json.Marshal(r.Metadata)
|
||||
sb.WriteString(fmt.Sprintf(" (%s)", string(meta)))
|
||||
}
|
||||
sb.WriteString(fmt.Sprintf("[Source: %s]\n%s\n", label, r.Content))
|
||||
|
||||
// Citations are de-duplicated per source document: many chunks from the
|
||||
// same file share one source key, so a file is listed only once. Skip
|
||||
// results with no source key — they cannot be linked back to a document.
|
||||
dedupKey := source
|
||||
if dedupKey == "" {
|
||||
dedupKey = fileName
|
||||
if i < len(searchResp.Results)-1 {
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
if dedupKey == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[dedupKey]; ok {
|
||||
continue
|
||||
}
|
||||
seen[dedupKey] = struct{}{}
|
||||
citations = append(citations, KBCitation{
|
||||
FileName: fileName,
|
||||
EntryKey: source,
|
||||
})
|
||||
}
|
||||
|
||||
sb.WriteString("When answering, cite sources using [Source: filename].")
|
||||
|
||||
return KBSearchContext{
|
||||
Prompt: sb.String(),
|
||||
Citations: citations,
|
||||
}
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
// KBSearchMemoryArgs defines the arguments for the search_memory tool.
|
||||
@@ -175,25 +110,21 @@ type KBSearchMemoryArgs struct {
|
||||
|
||||
// KBSearchMemoryTool implements the search_memory MCP tool.
|
||||
type KBSearchMemoryTool struct {
|
||||
APIURL string
|
||||
APIKey string
|
||||
Collection string
|
||||
MaxResults int
|
||||
UserID string
|
||||
CitationCollector KBCitationCollector
|
||||
APIURL string
|
||||
APIKey string
|
||||
Collection string
|
||||
MaxResults int
|
||||
UserID string
|
||||
}
|
||||
|
||||
func (t KBSearchMemoryTool) Run(args KBSearchMemoryArgs) (string, any, error) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
result := KBAutoSearchPrompt(ctx, t.APIURL, t.APIKey, t.Collection, args.Query, t.MaxResults, t.UserID)
|
||||
if result.Prompt == "" {
|
||||
if result == "" {
|
||||
return "No results found.", nil, nil
|
||||
}
|
||||
if t.CitationCollector != nil {
|
||||
t.CitationCollector.AddKBCitations(result.Citations)
|
||||
}
|
||||
return result.Prompt, nil, nil
|
||||
return result, nil, nil
|
||||
}
|
||||
|
||||
// KBAddMemoryArgs defines the arguments for the add_memory tool.
|
||||
@@ -225,11 +156,9 @@ func (t KBAddMemoryTool) Run(args KBAddMemoryArgs) (string, any, error) {
|
||||
|
||||
// KBStoreContent uploads text content to a collection via the multipart upload API.
|
||||
func KBStoreContent(ctx context.Context, apiURL, apiKey, collection, content, userID string) error {
|
||||
uploadURL := strings.TrimRight(apiURL, "/") + "/api/agents/collections/" + url.PathEscape(collection) + "/upload"
|
||||
uploadURL := strings.TrimRight(apiURL, "/") + "/api/agents/collections/" + collection + "/upload"
|
||||
if userID != "" {
|
||||
query := url.Values{}
|
||||
query.Set("user_id", userID)
|
||||
uploadURL += "?" + query.Encode()
|
||||
uploadURL += "?user_id=" + userID
|
||||
}
|
||||
|
||||
// Build multipart form with the text content as a file
|
||||
|
||||
@@ -157,82 +157,3 @@ func (c *InFlightTrackingClient) Rerank(ctx context.Context, in *pb.RerankReques
|
||||
res, err := c.Backend.Rerank(ctx, in, opts...)
|
||||
return res, c.reconcile(err)
|
||||
}
|
||||
|
||||
func (c *InFlightTrackingClient) VAD(ctx context.Context, in *pb.VADRequest, opts ...ggrpc.CallOption) (*pb.VADResponse, error) {
|
||||
defer c.track(ctx)()
|
||||
res, err := c.Backend.VAD(ctx, in, opts...)
|
||||
return res, c.reconcile(err)
|
||||
}
|
||||
|
||||
func (c *InFlightTrackingClient) Diarize(ctx context.Context, in *pb.DiarizeRequest, opts ...ggrpc.CallOption) (*pb.DiarizeResponse, error) {
|
||||
defer c.track(ctx)()
|
||||
res, err := c.Backend.Diarize(ctx, in, opts...)
|
||||
return res, c.reconcile(err)
|
||||
}
|
||||
|
||||
func (c *InFlightTrackingClient) FaceVerify(ctx context.Context, in *pb.FaceVerifyRequest, opts ...ggrpc.CallOption) (*pb.FaceVerifyResponse, error) {
|
||||
defer c.track(ctx)()
|
||||
res, err := c.Backend.FaceVerify(ctx, in, opts...)
|
||||
return res, c.reconcile(err)
|
||||
}
|
||||
|
||||
func (c *InFlightTrackingClient) FaceAnalyze(ctx context.Context, in *pb.FaceAnalyzeRequest, opts ...ggrpc.CallOption) (*pb.FaceAnalyzeResponse, error) {
|
||||
defer c.track(ctx)()
|
||||
res, err := c.Backend.FaceAnalyze(ctx, in, opts...)
|
||||
return res, c.reconcile(err)
|
||||
}
|
||||
|
||||
func (c *InFlightTrackingClient) VoiceVerify(ctx context.Context, in *pb.VoiceVerifyRequest, opts ...ggrpc.CallOption) (*pb.VoiceVerifyResponse, error) {
|
||||
defer c.track(ctx)()
|
||||
res, err := c.Backend.VoiceVerify(ctx, in, opts...)
|
||||
return res, c.reconcile(err)
|
||||
}
|
||||
|
||||
func (c *InFlightTrackingClient) VoiceAnalyze(ctx context.Context, in *pb.VoiceAnalyzeRequest, opts ...ggrpc.CallOption) (*pb.VoiceAnalyzeResponse, error) {
|
||||
defer c.track(ctx)()
|
||||
res, err := c.Backend.VoiceAnalyze(ctx, in, opts...)
|
||||
return res, c.reconcile(err)
|
||||
}
|
||||
|
||||
func (c *InFlightTrackingClient) VoiceEmbed(ctx context.Context, in *pb.VoiceEmbedRequest, opts ...ggrpc.CallOption) (*pb.VoiceEmbedResponse, error) {
|
||||
defer c.track(ctx)()
|
||||
res, err := c.Backend.VoiceEmbed(ctx, in, opts...)
|
||||
return res, c.reconcile(err)
|
||||
}
|
||||
|
||||
func (c *InFlightTrackingClient) TokenClassify(ctx context.Context, in *pb.TokenClassifyRequest, opts ...ggrpc.CallOption) (*pb.TokenClassifyResponse, error) {
|
||||
defer c.track(ctx)()
|
||||
res, err := c.Backend.TokenClassify(ctx, in, opts...)
|
||||
return res, c.reconcile(err)
|
||||
}
|
||||
|
||||
func (c *InFlightTrackingClient) Score(ctx context.Context, in *pb.ScoreRequest, opts ...ggrpc.CallOption) (*pb.ScoreResponse, error) {
|
||||
defer c.track(ctx)()
|
||||
res, err := c.Backend.Score(ctx, in, opts...)
|
||||
return res, c.reconcile(err)
|
||||
}
|
||||
|
||||
func (c *InFlightTrackingClient) AudioEncode(ctx context.Context, in *pb.AudioEncodeRequest, opts ...ggrpc.CallOption) (*pb.AudioEncodeResult, error) {
|
||||
defer c.track(ctx)()
|
||||
res, err := c.Backend.AudioEncode(ctx, in, opts...)
|
||||
return res, c.reconcile(err)
|
||||
}
|
||||
|
||||
func (c *InFlightTrackingClient) AudioDecode(ctx context.Context, in *pb.AudioDecodeRequest, opts ...ggrpc.CallOption) (*pb.AudioDecodeResult, error) {
|
||||
defer c.track(ctx)()
|
||||
res, err := c.Backend.AudioDecode(ctx, in, opts...)
|
||||
return res, c.reconcile(err)
|
||||
}
|
||||
|
||||
func (c *InFlightTrackingClient) AudioTransform(ctx context.Context, in *pb.AudioTransformRequest, opts ...ggrpc.CallOption) (*pb.AudioTransformResult, error) {
|
||||
defer c.track(ctx)()
|
||||
res, err := c.Backend.AudioTransform(ctx, in, opts...)
|
||||
return res, c.reconcile(err)
|
||||
}
|
||||
|
||||
// AudioTransformStream, AudioToAudioStream and Forward are deliberately left as
|
||||
// embedded passthrough: they return a stream client and the inference spans the
|
||||
// stream's lifetime, not the constructor call. Wrapping the constructor with
|
||||
// track() would increment and immediately decrement (and fire onFirstComplete)
|
||||
// before any audio flows. Tracking those correctly needs the done() func tied to
|
||||
// stream close, which the current Backend interface doesn't surface here.
|
||||
|
||||
@@ -304,105 +304,6 @@ var _ = Describe("InFlightTrackingClient", func() {
|
||||
})
|
||||
})
|
||||
|
||||
Describe("non-LLM inference methods track in-flight", func() {
|
||||
// silero-vad and friends only ever expose a single non-Predict method.
|
||||
// If that method isn't wrapped, the load-time reservation released by
|
||||
// onFirstComplete never fires and in-flight is stuck at 1 forever.
|
||||
assertTracked := func(call func() error) {
|
||||
var firstFired int
|
||||
client.OnFirstComplete(func() { firstFired++ })
|
||||
err := call()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(tracker.increments).To(Equal(1), "method must increment in-flight")
|
||||
Expect(tracker.decrements).To(Equal(1), "method must decrement in-flight")
|
||||
Expect(firstFired).To(Equal(1), "method must release the load-time reservation")
|
||||
}
|
||||
|
||||
It("VAD", func() {
|
||||
assertTracked(func() error {
|
||||
_, err := client.VAD(context.Background(), &pb.VADRequest{})
|
||||
return err
|
||||
})
|
||||
})
|
||||
|
||||
It("Diarize", func() {
|
||||
assertTracked(func() error {
|
||||
_, err := client.Diarize(context.Background(), &pb.DiarizeRequest{})
|
||||
return err
|
||||
})
|
||||
})
|
||||
|
||||
It("VoiceVerify", func() {
|
||||
assertTracked(func() error {
|
||||
_, err := client.VoiceVerify(context.Background(), &pb.VoiceVerifyRequest{})
|
||||
return err
|
||||
})
|
||||
})
|
||||
|
||||
It("VoiceAnalyze", func() {
|
||||
assertTracked(func() error {
|
||||
_, err := client.VoiceAnalyze(context.Background(), &pb.VoiceAnalyzeRequest{})
|
||||
return err
|
||||
})
|
||||
})
|
||||
|
||||
It("VoiceEmbed", func() {
|
||||
assertTracked(func() error {
|
||||
_, err := client.VoiceEmbed(context.Background(), &pb.VoiceEmbedRequest{})
|
||||
return err
|
||||
})
|
||||
})
|
||||
|
||||
It("FaceVerify", func() {
|
||||
assertTracked(func() error {
|
||||
_, err := client.FaceVerify(context.Background(), &pb.FaceVerifyRequest{})
|
||||
return err
|
||||
})
|
||||
})
|
||||
|
||||
It("FaceAnalyze", func() {
|
||||
assertTracked(func() error {
|
||||
_, err := client.FaceAnalyze(context.Background(), &pb.FaceAnalyzeRequest{})
|
||||
return err
|
||||
})
|
||||
})
|
||||
|
||||
It("TokenClassify", func() {
|
||||
assertTracked(func() error {
|
||||
_, err := client.TokenClassify(context.Background(), &pb.TokenClassifyRequest{})
|
||||
return err
|
||||
})
|
||||
})
|
||||
|
||||
It("Score", func() {
|
||||
assertTracked(func() error {
|
||||
_, err := client.Score(context.Background(), &pb.ScoreRequest{})
|
||||
return err
|
||||
})
|
||||
})
|
||||
|
||||
It("AudioEncode", func() {
|
||||
assertTracked(func() error {
|
||||
_, err := client.AudioEncode(context.Background(), &pb.AudioEncodeRequest{})
|
||||
return err
|
||||
})
|
||||
})
|
||||
|
||||
It("AudioDecode", func() {
|
||||
assertTracked(func() error {
|
||||
_, err := client.AudioDecode(context.Background(), &pb.AudioDecodeRequest{})
|
||||
return err
|
||||
})
|
||||
})
|
||||
|
||||
It("AudioTransform", func() {
|
||||
assertTracked(func() error {
|
||||
_, err := client.AudioTransform(context.Background(), &pb.AudioTransformRequest{})
|
||||
return err
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Describe("stale model reload (self-heal)", func() {
|
||||
It("removes the replica when the backend reports the model is not loaded", func() {
|
||||
backend.predictErr = fmt.Errorf("parakeet-cpp: model not loaded")
|
||||
|
||||
@@ -74,28 +74,6 @@ EXTERNAL_GRPC_BACKENDS=opus:/path/to/backend/go/opus/opus
|
||||
|
||||
The opus backend is loaded automatically when a WebRTC session starts. It does not require any model configuration file — just the backend binary.
|
||||
|
||||
#### WebRTC behind Docker host networking or NAT
|
||||
|
||||
By default pion gathers a host ICE candidate for every local interface. Under
|
||||
Docker **host networking** that includes bridge addresses (`docker0`/`veth`,
|
||||
`172.x`) that a remote browser cannot route to: the call typically connects on a
|
||||
good candidate and then drops a few seconds later when ICE consent checks fail on
|
||||
the unreachable ones. Two settings let you advertise only the reachable address:
|
||||
|
||||
```bash
|
||||
# Advertise these IPs as the host ICE candidates (e.g. the host's LAN IP)
|
||||
LOCALAI_WEBRTC_NAT_1TO1_IPS=192.168.1.10
|
||||
|
||||
# ...or restrict ICE gathering to specific interfaces
|
||||
LOCALAI_WEBRTC_ICE_INTERFACES=eth0
|
||||
```
|
||||
|
||||
{{% notice tip %}}
|
||||
For a browser on another LAN machine talking to LocalAI in a host-networked
|
||||
container, set `LOCALAI_WEBRTC_NAT_1TO1_IPS` to the host's LAN IP. This is the
|
||||
most reliable fix for WebRTC connections that establish and then drop.
|
||||
{{% /notice %}}
|
||||
|
||||
## Protocol
|
||||
|
||||
The API follows the OpenAI Realtime API protocol for handling sessions, audio buffers, and conversation items.
|
||||
|
||||
@@ -20,29 +20,7 @@ With the CLI you can list the models with `local-ai models list` and install the
|
||||
You can also [run models manually]({{%relref "getting-started/models" %}}) by copying files into the `models` directory.
|
||||
{{% /notice %}}
|
||||
|
||||
You can test chat models from the CLI without keeping a separate `curl` command around:
|
||||
|
||||
```bash
|
||||
# Terminal 1
|
||||
local-ai run
|
||||
|
||||
# Terminal 2
|
||||
local-ai chat --model gpt-4
|
||||
```
|
||||
|
||||
`local-ai chat` connects to a running LocalAI server, opens an interactive chat prompt, and exits when you type `/exit`, `/quit`, or `/bye`. Use `/models` to list installed models, `/model <name>` to switch models, and `/clear` to reset the current conversation. If the server exposes exactly one model, LocalAI uses that model automatically:
|
||||
|
||||
```bash
|
||||
# Terminal 1
|
||||
local-ai run llama-3.2-1b-instruct:q4_k_m
|
||||
|
||||
# Terminal 2
|
||||
local-ai chat
|
||||
```
|
||||
|
||||
When more than one model is configured, pass `--model` with the installed model name to avoid ambiguity. Use `--endpoint` to connect to a non-default server, for example `local-ai chat --endpoint http://127.0.0.1:8081 --model gpt-4`.
|
||||
|
||||
You can also test out the API endpoints using `curl`, few examples are listed below. The models we are referring here (`gpt-4`, `gpt-4-vision-preview`, `tts-1`, `whisper-1`) are examples - replace them with the model names you have installed.
|
||||
You can test out the API endpoints using `curl`, few examples are listed below. The models we are referring here (`gpt-4`, `gpt-4-vision-preview`, `tts-1`, `whisper-1`) are examples - replace them with the model names you have installed.
|
||||
|
||||
### Text Generation
|
||||
|
||||
|
||||
@@ -118,21 +118,6 @@ For more information on VRAM management, see [VRAM and Memory Management]({{%rel
|
||||
|
||||
See [Authentication & Authorization]({{%relref "features/authentication" %}}) for full documentation.
|
||||
|
||||
## Chat Flags
|
||||
|
||||
Use `local-ai chat` to open an interactive terminal chat session against a running LocalAI server.
|
||||
|
||||
| Parameter | Default | Description | Environment Variable |
|
||||
|-----------|---------|-------------|----------------------|
|
||||
| `--endpoint` | `http://127.0.0.1:8080` | LocalAI server endpoint. The `/v1` path is added automatically when omitted. | `$LOCALAI_CHAT_ENDPOINT` |
|
||||
| `--model` | | Model name to use. If omitted, LocalAI uses the only model returned by the server when exactly one is available. | |
|
||||
| `--api-key` | | API key to use when the LocalAI server requires authentication. | `$LOCALAI_API_KEY`, `$API_KEY` |
|
||||
|
||||
- Inside the chat prompt:
|
||||
- Use `/models` to list installed models.
|
||||
- Use `/model <name>` to switch to a different model and clear the conversation.
|
||||
- Use `/clear` to reset the current conversation.
|
||||
|
||||
## P2P Flags
|
||||
|
||||
| Parameter | Default | Description | Environment Variable |
|
||||
@@ -196,3 +181,4 @@ export LOCALAI_F16=true
|
||||
|
||||
- See [Advanced Usage]({{%relref "advanced/advanced-usage" %}}) for configuration examples
|
||||
- See [VRAM and Memory Management]({{%relref "advanced/vram-management" %}}) for memory management options
|
||||
|
||||
|
||||
@@ -89,35 +89,6 @@ func ExtractReasoningWithConfig(content, thinkingStartToken string, config Confi
|
||||
return reasoning, cleanedContent
|
||||
}
|
||||
|
||||
// ExtractReasoningComplete extracts reasoning from a COMPLETE (non-streaming)
|
||||
// model response. It behaves like ExtractReasoningWithConfig except that it only
|
||||
// honors a prefilled thinking start token when the response actually contains
|
||||
// the matching closing tag.
|
||||
//
|
||||
// Rationale: when a chat template injects the start token into the prompt (so
|
||||
// DetectThinkingStartToken returns e.g. "<think>"), the model's output begins
|
||||
// inside a reasoning block and carries only the closing tag. The defensive
|
||||
// fallback prepends the start token so the extractor can pair it with that
|
||||
// close tag. But on a COMPLETE response with no closing tag, the model answered
|
||||
// directly with no reasoning at all — prepending the start token would
|
||||
// manufacture an unclosed block that swallows the entire answer into reasoning,
|
||||
// leaving content empty (breaking short/direct answers such as session names or
|
||||
// JSON summaries). Genuine reasoning tags already present in the content still
|
||||
// extract, because dropping the synthetic prefill does not affect them.
|
||||
//
|
||||
// Streaming callers must keep using ExtractReasoningWithConfig: mid-stream an
|
||||
// as-yet-unclosed block is legitimate and its tokens should surface as
|
||||
// reasoning deltas as they arrive.
|
||||
func ExtractReasoningComplete(content, thinkingStartToken string, config Config) (reasoning string, cleanedContent string) {
|
||||
startToken := thinkingStartToken
|
||||
if startToken != "" {
|
||||
if end := ClosingTokenForStart(startToken, &config); end == "" || !strings.Contains(content, end) {
|
||||
startToken = ""
|
||||
}
|
||||
}
|
||||
return ExtractReasoningWithConfig(content, startToken, config)
|
||||
}
|
||||
|
||||
// PrependThinkingTokenIfNeeded prepends the thinking start token to content if it was
|
||||
// detected in the prompt. This allows the standard extraction logic to work correctly
|
||||
// for models where the thinking token is already in the prompt.
|
||||
@@ -160,48 +131,6 @@ func PrependThinkingTokenIfNeeded(content string, startToken string) string {
|
||||
return startToken + content
|
||||
}
|
||||
|
||||
// defaultReasoningTagPairs are the built-in start/end reasoning tag pairs,
|
||||
// matching llama.cpp's chat-parser.cpp. Kept at package scope so that
|
||||
// ExtractReasoning and ClosingTokenForStart share a single source of truth.
|
||||
var defaultReasoningTagPairs = []TagPair{
|
||||
{Start: "<|START_THINKING|>", End: "<|END_THINKING|>"}, // Command-R models
|
||||
{Start: "<|inner_prefix|>", End: "<|inner_suffix|>"}, // Apertus models
|
||||
{Start: "<seed:think>", End: "</seed:think>"}, // Seed models
|
||||
{Start: "<think>", End: "</think>"}, // DeepSeek, Granite, ExaOne models
|
||||
{Start: "<|think|>", End: "<|end|><|begin|>assistant<|content|>"}, // Solar Open models (complex end)
|
||||
{Start: "<|channel>thought", End: "<channel|>"}, // Gemma 4 models
|
||||
{Start: "<thinking>", End: "</thinking>"}, // General thinking tag
|
||||
{Start: "[THINK]", End: "[/THINK]"}, // Magistral models
|
||||
}
|
||||
|
||||
// ClosingTokenForStart returns the closing reasoning tag that pairs with the
|
||||
// given start token, searching custom config TagPairs first then the built-in
|
||||
// defaults. Returns "" when startToken is empty or unrecognized.
|
||||
//
|
||||
// Used by the non-streaming autoparser fallback to decide whether a complete
|
||||
// response that began with a prefilled thinking token actually closed its
|
||||
// reasoning block: only then is synthesizing the start token (so the standard
|
||||
// extractor can pair it with the model's close tag) safe. A complete response
|
||||
// with no closing tag is a direct answer, not unclosed reasoning.
|
||||
func ClosingTokenForStart(startToken string, config *Config) string {
|
||||
if startToken == "" {
|
||||
return ""
|
||||
}
|
||||
if config != nil {
|
||||
for _, pair := range config.TagPairs {
|
||||
if pair.Start == startToken {
|
||||
return pair.End
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, pair := range defaultReasoningTagPairs {
|
||||
if pair.Start == startToken {
|
||||
return pair.End
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// ExtractReasoning extracts reasoning content from thinking tags and returns
|
||||
// both the extracted reasoning and the cleaned content (with tags removed).
|
||||
// It handles <thinking>...</thinking> and <think>...</think> tags.
|
||||
@@ -216,7 +145,22 @@ func ExtractReasoning(content string, config *Config) (reasoning string, cleaned
|
||||
var cleanedParts []string
|
||||
remaining := content
|
||||
|
||||
// Merge custom tag pairs (highest priority) with the built-in defaults.
|
||||
// Define default tag pairs to look for (matching llama.cpp's chat-parser.cpp)
|
||||
defaultTagPairs := []struct {
|
||||
start string
|
||||
end string
|
||||
}{
|
||||
{"<|START_THINKING|>", "<|END_THINKING|>"}, // Command-R models
|
||||
{"<|inner_prefix|>", "<|inner_suffix|>"}, // Apertus models
|
||||
{"<seed:think>", "</seed:think>"}, // Seed models
|
||||
{"<think>", "</think>"}, // DeepSeek, Granite, ExaOne models
|
||||
{"<|think|>", "<|end|><|begin|>assistant<|content|>"}, // Solar Open models (complex end)
|
||||
{"<|channel>thought", "<channel|>"}, // Gemma 4 models
|
||||
{"<thinking>", "</thinking>"}, // General thinking tag
|
||||
{"[THINK]", "[/THINK]"}, // Magistral models
|
||||
}
|
||||
|
||||
// Merge custom tag pairs with default tag pairs (custom pairs first for priority)
|
||||
var tagPairs []struct {
|
||||
start string
|
||||
end string
|
||||
@@ -231,11 +175,9 @@ func ExtractReasoning(content string, config *Config) (reasoning string, cleaned
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, pair := range defaultReasoningTagPairs {
|
||||
tagPairs = append(tagPairs, struct {
|
||||
start string
|
||||
end string
|
||||
}{pair.Start, pair.End})
|
||||
// Add default tag pairs
|
||||
for _, pair := range defaultTagPairs {
|
||||
tagPairs = append(tagPairs, pair)
|
||||
}
|
||||
|
||||
// Track the last position we've processed
|
||||
|
||||
@@ -1175,55 +1175,6 @@ var _ = Describe("Custom Tokens and Tag Pairs Integration", func() {
|
||||
})
|
||||
})
|
||||
|
||||
var _ = Describe("ClosingTokenForStart", func() {
|
||||
It("returns the default closing tag for a known start token", func() {
|
||||
Expect(ClosingTokenForStart("<think>", nil)).To(Equal("</think>"))
|
||||
Expect(ClosingTokenForStart("<thinking>", nil)).To(Equal("</thinking>"))
|
||||
Expect(ClosingTokenForStart("[THINK]", nil)).To(Equal("[/THINK]"))
|
||||
})
|
||||
|
||||
It("returns empty for an empty or unknown start token", func() {
|
||||
Expect(ClosingTokenForStart("", nil)).To(BeEmpty())
|
||||
Expect(ClosingTokenForStart("<nope>", nil)).To(BeEmpty())
|
||||
})
|
||||
|
||||
It("prefers custom config tag pairs over the defaults", func() {
|
||||
cfg := &Config{TagPairs: []TagPair{{Start: "<think>", End: "<<END>>"}}}
|
||||
Expect(ClosingTokenForStart("<think>", cfg)).To(Equal("<<END>>"))
|
||||
})
|
||||
})
|
||||
|
||||
var _ = Describe("ExtractReasoningComplete", func() {
|
||||
const startToken = "<think>"
|
||||
|
||||
It("keeps a tag-less answer as content when a start token is prefilled but no close tag is present", func() {
|
||||
// The bug guard: prompt-prefilled <think>, model answered directly with
|
||||
// no reasoning. The synthetic prefill must not swallow it as reasoning.
|
||||
reasoning, content := ExtractReasoningComplete("hello", startToken, Config{})
|
||||
Expect(reasoning).To(BeEmpty())
|
||||
Expect(content).To(Equal("hello"))
|
||||
})
|
||||
|
||||
It("extracts reasoning when the model emits only the closing tag (legitimate prefill)", func() {
|
||||
reasoning, content := ExtractReasoningComplete("the rationale\n</think>\n\nthe answer", startToken, Config{})
|
||||
Expect(reasoning).To(ContainSubstring("the rationale"))
|
||||
Expect(content).To(ContainSubstring("the answer"))
|
||||
Expect(content).ToNot(ContainSubstring("</think>"))
|
||||
})
|
||||
|
||||
It("extracts a fully-tagged block regardless of the prefill token", func() {
|
||||
reasoning, content := ExtractReasoningComplete("<think>r</think>answer", startToken, Config{})
|
||||
Expect(reasoning).To(Equal("r"))
|
||||
Expect(content).To(Equal("answer"))
|
||||
})
|
||||
|
||||
It("behaves like ExtractReasoningWithConfig when no start token is prefilled", func() {
|
||||
reasoning, content := ExtractReasoningComplete("<think>r</think>answer", "", Config{})
|
||||
Expect(reasoning).To(Equal("r"))
|
||||
Expect(content).To(Equal("answer"))
|
||||
})
|
||||
})
|
||||
|
||||
// Helper function to create bool pointers for test configs
|
||||
func boolPtr(b bool) *bool {
|
||||
return &b
|
||||
|
||||
Reference in New Issue
Block a user