mirror of
https://github.com/mudler/LocalAI.git
synced 2026-09-25 07:34:58 -04:00
* fix(capabilities): report the per-request context with split KV slots With parallel slots and kv_unified:false, llama.cpp gives each slot n_ctx/n_parallel, padded up to a multiple of 256. /models/capabilities still reported the full n_ctx. A client that budgets a request against context_size then overflows at a fraction of it. EffectiveRequestContextSize returns the per-slot size in that case and the full context otherwise. With the unified KV cache, the grpc-server default, one request may use all of n_ctx. The capabilities endpoint and the router's prompt trimmer now use it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * fix(openai): return an HTTP error when a stream fails before any chunk A streamed chat request set the SSE headers, then waited for the backend. When the backend failed before the first token, LocalAI sent a 200 with a `data: {"error":...}` chunk and [DONE]. Clients that do not parse error chunks saw an empty reply. cogito's LocalAI client was one of them: nib users got "streaming decision produced no content" instead of the context overflow that caused it. Nothing has been written at that point, so the handler now returns the error as a normal HTTP response. A failure after the first chunk keeps the in-stream error chunk. A prompt that exceeds the context is now a 400 on both paths, as in the OpenAI API and llama-server, and no longer a 500. The message is kept whole, because clients read the token counts from it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * test(e2e): check the error from closing the response body golangci-lint's errcheck flags the unchecked resp.Body.Close in the new pre-stream error helper. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Signed-off-by: Ettore Di Giacinto <mudler@localai.io> --------- Signed-off-by: Ettore Di Giacinto <mudler@localai.io> Co-authored-by: Ettore Di Giacinto <mudler@localai.io> Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
921 lines
35 KiB
Go
921 lines
35 KiB
Go
package e2e_test
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"io"
|
|
"mime/multipart"
|
|
"net/http"
|
|
"strings"
|
|
"time"
|
|
|
|
. "github.com/onsi/ginkgo/v2"
|
|
. "github.com/onsi/gomega"
|
|
"github.com/openai/openai-go/v3"
|
|
)
|
|
|
|
var _ = Describe("Mock Backend E2E Tests", Label("MockBackend"), func() {
|
|
Describe("Text Generation APIs", func() {
|
|
Context("Predict (Chat Completions)", func() {
|
|
It("should return mocked response", func() {
|
|
resp, err := client.Chat.Completions.New(
|
|
context.TODO(),
|
|
openai.ChatCompletionNewParams{
|
|
Model: "mock-model",
|
|
Messages: []openai.ChatCompletionMessageParamUnion{
|
|
openai.UserMessage("Hello"),
|
|
},
|
|
},
|
|
)
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(len(resp.Choices)).To(Equal(1))
|
|
Expect(resp.Choices[0].Message.Content).To(ContainSubstring("mocked response"))
|
|
})
|
|
})
|
|
|
|
Context("PredictStream (Streaming Chat Completions)", func() {
|
|
It("should stream mocked tokens", func() {
|
|
stream := client.Chat.Completions.NewStreaming(
|
|
context.TODO(),
|
|
openai.ChatCompletionNewParams{
|
|
Model: "mock-model",
|
|
Messages: []openai.ChatCompletionMessageParamUnion{
|
|
openai.UserMessage("Hello"),
|
|
},
|
|
},
|
|
)
|
|
hasContent := false
|
|
for stream.Next() {
|
|
response := stream.Current()
|
|
if len(response.Choices) > 0 && response.Choices[0].Delta.Content != "" {
|
|
hasContent = true
|
|
}
|
|
}
|
|
Expect(stream.Err()).ToNot(HaveOccurred())
|
|
Expect(hasContent).To(BeTrue())
|
|
})
|
|
})
|
|
})
|
|
|
|
Describe("Error Handling", func() {
|
|
Context("Non-streaming errors", func() {
|
|
It("should return error for request with error trigger", func() {
|
|
_, err := client.Chat.Completions.New(
|
|
context.TODO(),
|
|
openai.ChatCompletionNewParams{
|
|
Model: "mock-model",
|
|
Messages: []openai.ChatCompletionMessageParamUnion{
|
|
openai.UserMessage("MOCK_ERROR"),
|
|
},
|
|
},
|
|
)
|
|
Expect(err).To(HaveOccurred())
|
|
Expect(err.Error()).To(ContainSubstring("simulated failure"))
|
|
})
|
|
})
|
|
|
|
Context("Context overflow", func() {
|
|
It("should return HTTP 400 for a non-streaming request that exceeds the context", func() {
|
|
_, err := client.Chat.Completions.New(
|
|
context.TODO(),
|
|
openai.ChatCompletionNewParams{
|
|
Model: "mock-model",
|
|
Messages: []openai.ChatCompletionMessageParamUnion{
|
|
openai.UserMessage("MOCK_ERROR_CONTEXT_OVERFLOW"),
|
|
},
|
|
},
|
|
)
|
|
Expect(err).To(HaveOccurred())
|
|
var apiErr *openai.Error
|
|
Expect(errors.As(err, &apiErr)).To(BeTrue(), err.Error())
|
|
Expect(apiErr.StatusCode).To(Equal(http.StatusBadRequest))
|
|
Expect(err.Error()).To(ContainSubstring("exceeds the available context size"))
|
|
})
|
|
})
|
|
|
|
Context("Streaming errors", func() {
|
|
It("should return error for streaming request with immediate error trigger", func() {
|
|
stream := client.Chat.Completions.NewStreaming(
|
|
context.TODO(),
|
|
openai.ChatCompletionNewParams{
|
|
Model: "mock-model",
|
|
Messages: []openai.ChatCompletionMessageParamUnion{
|
|
openai.UserMessage("MOCK_ERROR_IMMEDIATE"),
|
|
},
|
|
},
|
|
)
|
|
for stream.Next() {
|
|
// drain
|
|
}
|
|
Expect(stream.Err()).To(HaveOccurred())
|
|
})
|
|
|
|
// A backend that fails before it streams anything leaves the
|
|
// response unwritten, so LocalAI can still answer with a real
|
|
// status. Clients (the OpenAI SDKs, cogito) then see an HTTP error
|
|
// instead of an in-stream chunk they may not parse.
|
|
postStream := func(content string) (*http.Response, string) {
|
|
body := `{"model":"mock-model","messages":[{"role":"user","content":"` + content + `"}],"stream":true}`
|
|
req, err := http.NewRequest("POST", apiURL+"/chat/completions", strings.NewReader(body))
|
|
Expect(err).ToNot(HaveOccurred())
|
|
req.Header.Set("Content-Type", "application/json")
|
|
resp, err := http.DefaultClient.Do(req)
|
|
Expect(err).ToNot(HaveOccurred())
|
|
defer func() { _ = resp.Body.Close() }()
|
|
data, err := io.ReadAll(resp.Body)
|
|
Expect(err).ToNot(HaveOccurred())
|
|
return resp, string(data)
|
|
}
|
|
|
|
It("should return HTTP 400 when the prompt exceeds the context before streaming", func() {
|
|
resp, body := postStream("MOCK_ERROR_CONTEXT_OVERFLOW")
|
|
Expect(resp.StatusCode).To(Equal(http.StatusBadRequest), body)
|
|
Expect(resp.Header.Get("Content-Type")).To(ContainSubstring("application/json"))
|
|
Expect(body).To(ContainSubstring("exceeds the available context size (8192 tokens)"))
|
|
Expect(body).ToNot(ContainSubstring("[DONE]"))
|
|
})
|
|
|
|
It("should return an HTTP error when the backend fails before streaming", func() {
|
|
resp, body := postStream("MOCK_ERROR_IMMEDIATE")
|
|
Expect(resp.StatusCode).To(Equal(http.StatusInternalServerError), body)
|
|
Expect(resp.Header.Get("Content-Type")).To(ContainSubstring("application/json"))
|
|
Expect(body).To(ContainSubstring("simulated failure"))
|
|
})
|
|
|
|
It("should return structured error for mid-stream failure", func() {
|
|
body := `{"model":"mock-model","messages":[{"role":"user","content":"MOCK_ERROR_MIDSTREAM"}],"stream":true}`
|
|
req, err := http.NewRequest("POST", apiURL+"/chat/completions", strings.NewReader(body))
|
|
Expect(err).ToNot(HaveOccurred())
|
|
req.Header.Set("Content-Type", "application/json")
|
|
|
|
resp, err := http.DefaultClient.Do(req)
|
|
Expect(err).ToNot(HaveOccurred())
|
|
defer resp.Body.Close()
|
|
Expect(resp.StatusCode).To(Equal(200))
|
|
|
|
data, err := io.ReadAll(resp.Body)
|
|
Expect(err).ToNot(HaveOccurred())
|
|
bodyStr := string(data)
|
|
|
|
// Should contain a structured error event
|
|
Expect(bodyStr).To(ContainSubstring(`"error"`))
|
|
Expect(bodyStr).To(ContainSubstring(`"message"`))
|
|
Expect(bodyStr).To(ContainSubstring("simulated mid-stream failure"))
|
|
// Should also contain [DONE]
|
|
Expect(bodyStr).To(ContainSubstring("[DONE]"))
|
|
})
|
|
})
|
|
})
|
|
|
|
Describe("Embeddings API", func() {
|
|
It("should return mocked embeddings", func() {
|
|
resp, err := client.Embeddings.New(
|
|
context.TODO(),
|
|
openai.EmbeddingNewParams{
|
|
Model: "mock-model",
|
|
Input: openai.EmbeddingNewParamsInputUnion{
|
|
OfArrayOfStrings: []string{"test"},
|
|
},
|
|
},
|
|
)
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(len(resp.Data)).To(Equal(1))
|
|
Expect(len(resp.Data[0].Embedding)).To(Equal(768))
|
|
})
|
|
|
|
// LocalAI extension: a chat conversation can be embedded by sending
|
|
// messages[] instead of input — raw http.Post because the OpenAI SDK
|
|
// has no such parameter.
|
|
It("should embed a chat conversation sent via messages[]", func() {
|
|
body := `{"model":"mock-model","messages":[{"role":"system","content":"be brief"},{"role":"user","content":"hello"}]}`
|
|
resp, err := http.Post(apiURL+"/embeddings", "application/json", strings.NewReader(body))
|
|
Expect(err).ToNot(HaveOccurred())
|
|
defer func() { _ = resp.Body.Close() }()
|
|
Expect(resp.StatusCode).To(Equal(200))
|
|
var decoded struct {
|
|
Data []struct {
|
|
Embedding []float64 `json:"embedding"`
|
|
} `json:"data"`
|
|
}
|
|
payload, err := io.ReadAll(resp.Body)
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(json.Unmarshal(payload, &decoded)).To(Succeed())
|
|
// One conversation per request -> exactly one data item.
|
|
Expect(decoded.Data).To(HaveLen(1))
|
|
Expect(decoded.Data[0].Embedding).To(HaveLen(768))
|
|
})
|
|
|
|
It("should reject input combined with messages", func() {
|
|
body := `{"model":"mock-model","input":"x","messages":[{"role":"user","content":"hello"}]}`
|
|
resp, err := http.Post(apiURL+"/embeddings", "application/json", strings.NewReader(body))
|
|
Expect(err).ToNot(HaveOccurred())
|
|
defer func() { _ = resp.Body.Close() }()
|
|
Expect(resp.StatusCode).To(Equal(400))
|
|
})
|
|
})
|
|
|
|
Describe("TTS APIs", func() {
|
|
Context("TTS", func() {
|
|
It("should generate mocked audio", func() {
|
|
body := `{"model":"mock-model","input":"Hello world","voice":"default"}`
|
|
req, err := http.NewRequest("POST", apiURL+"/audio/speech", io.NopCloser(strings.NewReader(body)))
|
|
Expect(err).ToNot(HaveOccurred())
|
|
req.Header.Set("Content-Type", "application/json")
|
|
|
|
httpClient := &http.Client{Timeout: 30 * time.Second}
|
|
resp, err := httpClient.Do(req)
|
|
Expect(err).ToNot(HaveOccurred())
|
|
defer resp.Body.Close()
|
|
Expect(resp.StatusCode).To(Equal(200))
|
|
Expect(resp.Header.Get("Content-Type")).To(HavePrefix("audio/"), "TTS response should set an audio Content-Type")
|
|
data, err := io.ReadAll(resp.Body)
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(len(data)).To(BeNumerically(">", 0), "TTS response body should be non-empty")
|
|
})
|
|
})
|
|
})
|
|
|
|
Describe("Sound Generation API", func() {
|
|
It("should generate mocked sound (simple mode)", func() {
|
|
body := `{"model_id":"mock-model","text":"a soft Bengali love song for a quiet evening","instrumental":false,"vocal_language":"bn"}`
|
|
req, err := http.NewRequest("POST", apiURL+"/sound-generation", io.NopCloser(strings.NewReader(body)))
|
|
Expect(err).ToNot(HaveOccurred())
|
|
req.Header.Set("Content-Type", "application/json")
|
|
|
|
httpClient := &http.Client{Timeout: 30 * time.Second}
|
|
resp, err := httpClient.Do(req)
|
|
Expect(err).ToNot(HaveOccurred())
|
|
defer resp.Body.Close()
|
|
Expect(resp.StatusCode).To(Equal(200))
|
|
Expect(resp.Header.Get("Content-Type")).To(HavePrefix("audio/"), "sound-generation response should set an audio Content-Type (pkg/audio normalization)")
|
|
data, err := io.ReadAll(resp.Body)
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(len(data)).To(BeNumerically(">", 0), "sound-generation response body should be non-empty")
|
|
})
|
|
|
|
It("should generate mocked sound (advanced mode)", func() {
|
|
body := `{"model_id":"mock-model","text":"upbeat pop","caption":"A funky Japanese disco track","lyrics":"[Verse 1]\nTest lyrics","think":true,"bpm":120,"duration_seconds":225,"keyscale":"Ab major","language":"ja","timesignature":"4"}`
|
|
req, err := http.NewRequest("POST", apiURL+"/sound-generation", io.NopCloser(strings.NewReader(body)))
|
|
Expect(err).ToNot(HaveOccurred())
|
|
req.Header.Set("Content-Type", "application/json")
|
|
|
|
httpClient := &http.Client{Timeout: 30 * time.Second}
|
|
resp, err := httpClient.Do(req)
|
|
Expect(err).ToNot(HaveOccurred())
|
|
defer resp.Body.Close()
|
|
Expect(resp.StatusCode).To(Equal(200))
|
|
Expect(resp.Header.Get("Content-Type")).To(HavePrefix("audio/"), "sound-generation response should set an audio Content-Type (pkg/audio normalization)")
|
|
data, err := io.ReadAll(resp.Body)
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(len(data)).To(BeNumerically(">", 0), "sound-generation response body should be non-empty")
|
|
})
|
|
})
|
|
|
|
Describe("Image Generation API", func() {
|
|
It("should generate mocked image", func() {
|
|
req, err := http.NewRequest("POST", apiURL+"/images/generations", nil)
|
|
Expect(err).ToNot(HaveOccurred())
|
|
req.Header.Set("Content-Type", "application/json")
|
|
|
|
body := `{"model":"mock-model","prompt":"a cat"}`
|
|
req.Body = http.NoBody
|
|
req.GetBody = func() (io.ReadCloser, error) {
|
|
return io.NopCloser(strings.NewReader(body)), nil
|
|
}
|
|
|
|
httpClient := &http.Client{Timeout: 30 * time.Second}
|
|
resp, err := httpClient.Do(req)
|
|
if err == nil {
|
|
defer resp.Body.Close()
|
|
Expect(resp.StatusCode).To(BeNumerically("<", 500))
|
|
}
|
|
})
|
|
})
|
|
|
|
Describe("Audio Transcription API", func() {
|
|
It("should return mocked transcription", func() {
|
|
req, err := http.NewRequest("POST", apiURL+"/audio/transcriptions", nil)
|
|
Expect(err).ToNot(HaveOccurred())
|
|
req.Header.Set("Content-Type", "multipart/form-data")
|
|
|
|
httpClient := &http.Client{Timeout: 30 * time.Second}
|
|
resp, err := httpClient.Do(req)
|
|
if err == nil {
|
|
defer resp.Body.Close()
|
|
Expect(resp.StatusCode).To(BeNumerically("<", 500))
|
|
}
|
|
})
|
|
})
|
|
|
|
Describe("Audio Diarization API", func() {
|
|
// Helper: build a multipart/form-data request to /v1/audio/diarization
|
|
// with a tiny stub WAV. The backend ignores the audio payload
|
|
// (it returns a deterministic three-segment layout), so a 4-byte
|
|
// stub is enough to exercise the HTTP layer.
|
|
postDiarize := func(extraFields map[string]string) (*http.Response, []byte) {
|
|
body := &bytes.Buffer{}
|
|
mw := multipart.NewWriter(body)
|
|
|
|
Expect(mw.WriteField("model", "mock-diarize")).To(Succeed())
|
|
for k, v := range extraFields {
|
|
Expect(mw.WriteField(k, v)).To(Succeed())
|
|
}
|
|
|
|
part, err := mw.CreateFormFile("file", "stub.wav")
|
|
Expect(err).ToNot(HaveOccurred())
|
|
_, err = part.Write([]byte{0, 0, 0, 0})
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(mw.Close()).To(Succeed())
|
|
|
|
req, err := http.NewRequest("POST", apiURL+"/audio/diarization", body)
|
|
Expect(err).ToNot(HaveOccurred())
|
|
req.Header.Set("Content-Type", mw.FormDataContentType())
|
|
|
|
httpClient := &http.Client{Timeout: 30 * time.Second}
|
|
resp, err := httpClient.Do(req)
|
|
Expect(err).ToNot(HaveOccurred())
|
|
defer func() { _ = resp.Body.Close() }()
|
|
data, err := io.ReadAll(resp.Body)
|
|
Expect(err).ToNot(HaveOccurred())
|
|
return resp, data
|
|
}
|
|
|
|
It("normalizes raw backend speaker labels to SPEAKER_NN in first-seen order", func() {
|
|
resp, data := postDiarize(nil)
|
|
Expect(resp.StatusCode).To(Equal(http.StatusOK))
|
|
|
|
var got map[string]any
|
|
Expect(json.Unmarshal(data, &got)).To(Succeed())
|
|
|
|
Expect(got["task"]).To(Equal("diarize"))
|
|
Expect(got["num_speakers"]).To(BeEquivalentTo(2))
|
|
// json (default) drops the heavy speakers summary
|
|
Expect(got).ToNot(HaveKey("speakers"))
|
|
|
|
segs, ok := got["segments"].([]any)
|
|
Expect(ok).To(BeTrue())
|
|
Expect(segs).To(HaveLen(3))
|
|
|
|
// Mock emits raw labels "5", "2", "5" — first-seen order maps:
|
|
// 5 → SPEAKER_00, 2 → SPEAKER_01.
|
|
seg0 := segs[0].(map[string]any)
|
|
seg1 := segs[1].(map[string]any)
|
|
seg2 := segs[2].(map[string]any)
|
|
Expect(seg0["speaker"]).To(Equal("SPEAKER_00"))
|
|
Expect(seg0["label"]).To(Equal("5"))
|
|
Expect(seg1["speaker"]).To(Equal("SPEAKER_01"))
|
|
Expect(seg2["speaker"]).To(Equal("SPEAKER_00"))
|
|
|
|
// json default suppresses per-segment text even when the backend
|
|
// happened to emit some (here, IncludeText was not set so the
|
|
// backend already stripped — but the HTTP layer also gates).
|
|
_, hasText := seg0["text"].(string)
|
|
if hasText {
|
|
Expect(seg0["text"]).To(Equal(""))
|
|
}
|
|
})
|
|
|
|
It("verbose_json emits speakers summary and per-segment transcripts when include_text is set", func() {
|
|
resp, data := postDiarize(map[string]string{
|
|
"response_format": "verbose_json",
|
|
"include_text": "true",
|
|
})
|
|
Expect(resp.StatusCode).To(Equal(http.StatusOK))
|
|
|
|
var got map[string]any
|
|
Expect(json.Unmarshal(data, &got)).To(Succeed())
|
|
|
|
speakers, ok := got["speakers"].([]any)
|
|
Expect(ok).To(BeTrue(), "verbose_json must include speakers summary")
|
|
Expect(speakers).To(HaveLen(2))
|
|
|
|
// SPEAKER_00 should reflect both 1.0s segments (1.0 + 1.5 = 2.5s, 2 segments)
|
|
byID := map[string]map[string]any{}
|
|
for _, sp := range speakers {
|
|
m := sp.(map[string]any)
|
|
byID[m["id"].(string)] = m
|
|
}
|
|
Expect(byID).To(HaveKey("SPEAKER_00"))
|
|
Expect(byID["SPEAKER_00"]["total_speech_duration"]).To(BeNumerically("~", 2.5, 0.001))
|
|
Expect(byID["SPEAKER_00"]["segment_count"]).To(BeEquivalentTo(2))
|
|
|
|
segs := got["segments"].([]any)
|
|
Expect(segs[0].(map[string]any)["text"]).To(Equal("hello there"))
|
|
Expect(segs[1].(map[string]any)["text"]).To(Equal("general kenobi"))
|
|
})
|
|
|
|
It("rttm response_format returns NIST RTTM rows", func() {
|
|
resp, data := postDiarize(map[string]string{"response_format": "rttm"})
|
|
Expect(resp.StatusCode).To(Equal(http.StatusOK))
|
|
Expect(resp.Header.Get("Content-Type")).To(HavePrefix("text/plain"))
|
|
|
|
body := string(data)
|
|
lines := strings.Split(strings.TrimSpace(body), "\n")
|
|
Expect(lines).To(HaveLen(3))
|
|
// "SPEAKER stub 1 0.000 1.000 <NA> <NA> SPEAKER_00 <NA> <NA>"
|
|
Expect(lines[0]).To(HavePrefix("SPEAKER stub 1 "))
|
|
Expect(lines[0]).To(ContainSubstring(" SPEAKER_00 "))
|
|
Expect(lines[1]).To(ContainSubstring(" SPEAKER_01 "))
|
|
Expect(lines[2]).To(ContainSubstring(" SPEAKER_00 "))
|
|
})
|
|
|
|
It("rejects unknown response_format with 4xx/5xx", func() {
|
|
resp, _ := postDiarize(map[string]string{"response_format": "csv"})
|
|
Expect(resp.StatusCode).To(BeNumerically(">=", 400))
|
|
})
|
|
})
|
|
|
|
Describe("Rerank API", func() {
|
|
It("should return mocked reranking results", func() {
|
|
req, err := http.NewRequest("POST", apiURL+"/rerank", nil)
|
|
Expect(err).ToNot(HaveOccurred())
|
|
req.Header.Set("Content-Type", "application/json")
|
|
|
|
body := `{"model":"mock-model","query":"test","documents":["doc1","doc2"]}`
|
|
req.Body = http.NoBody
|
|
req.GetBody = func() (io.ReadCloser, error) {
|
|
return io.NopCloser(strings.NewReader(body)), nil
|
|
}
|
|
|
|
httpClient := &http.Client{Timeout: 30 * time.Second}
|
|
resp, err := httpClient.Do(req)
|
|
if err == nil {
|
|
defer resp.Body.Close()
|
|
Expect(resp.StatusCode).To(BeNumerically("<", 500))
|
|
}
|
|
})
|
|
})
|
|
|
|
Describe("Tokenization API", func() {
|
|
It("should return mocked tokens", func() {
|
|
req, err := http.NewRequest("POST", apiURL+"/tokenize", nil)
|
|
Expect(err).ToNot(HaveOccurred())
|
|
req.Header.Set("Content-Type", "application/json")
|
|
|
|
body := `{"model":"mock-model","text":"Hello world"}`
|
|
req.Body = http.NoBody
|
|
req.GetBody = func() (io.ReadCloser, error) {
|
|
return io.NopCloser(strings.NewReader(body)), nil
|
|
}
|
|
|
|
httpClient := &http.Client{Timeout: 30 * time.Second}
|
|
resp, err := httpClient.Do(req)
|
|
if err == nil {
|
|
defer resp.Body.Close()
|
|
Expect(resp.StatusCode).To(BeNumerically("<", 500))
|
|
}
|
|
})
|
|
})
|
|
|
|
Describe("Detokenization API", func() {
|
|
It("should return content for known token IDs", func() {
|
|
body := `{"model":"mock-model","tokens":[101,2023,2003,1037,3231,1012]}`
|
|
req, err := http.NewRequest("POST", apiURL+"/detokenize", strings.NewReader(body))
|
|
Expect(err).ToNot(HaveOccurred())
|
|
req.Header.Set("Content-Type", "application/json")
|
|
|
|
httpClient := &http.Client{Timeout: 30 * time.Second}
|
|
resp, err := httpClient.Do(req)
|
|
Expect(err).ToNot(HaveOccurred())
|
|
defer func() { _ = resp.Body.Close() }()
|
|
Expect(resp.StatusCode).To(Equal(200))
|
|
|
|
data, err := io.ReadAll(resp.Body)
|
|
Expect(err).ToNot(HaveOccurred())
|
|
var result map[string]any
|
|
Expect(json.Unmarshal(data, &result)).To(Succeed())
|
|
content, ok := result["content"].(string)
|
|
Expect(ok).To(BeTrue(), "response missing 'content' field: %s", string(data))
|
|
Expect(content).ToNot(BeEmpty())
|
|
})
|
|
|
|
It("should round-trip tokenize then detokenize", func() {
|
|
httpClient := &http.Client{Timeout: 30 * time.Second}
|
|
|
|
// Step 1: tokenize
|
|
tokenizeReq, err := http.NewRequest("POST", apiURL+"/tokenize",
|
|
strings.NewReader(`{"model":"mock-model","content":"Hello world"}`))
|
|
Expect(err).ToNot(HaveOccurred())
|
|
tokenizeReq.Header.Set("Content-Type", "application/json")
|
|
|
|
tokenizeResp, err := httpClient.Do(tokenizeReq)
|
|
Expect(err).ToNot(HaveOccurred())
|
|
defer func() { _ = tokenizeResp.Body.Close() }()
|
|
Expect(tokenizeResp.StatusCode).To(Equal(200))
|
|
|
|
tokenizeData, err := io.ReadAll(tokenizeResp.Body)
|
|
Expect(err).ToNot(HaveOccurred())
|
|
var tokenizeResult map[string]any
|
|
Expect(json.Unmarshal(tokenizeData, &tokenizeResult)).To(Succeed())
|
|
|
|
tokensRaw, ok := tokenizeResult["tokens"].([]any)
|
|
Expect(ok).To(BeTrue(), "tokenize response missing 'tokens': %s", string(tokenizeData))
|
|
Expect(tokensRaw).ToNot(BeEmpty())
|
|
|
|
// Step 2: detokenize the returned token IDs
|
|
tokens := make([]int, len(tokensRaw))
|
|
for i, t := range tokensRaw {
|
|
tokens[i] = int(t.(float64))
|
|
}
|
|
tokenJSON, err := json.Marshal(map[string]any{"model": "mock-model", "tokens": tokens})
|
|
Expect(err).ToNot(HaveOccurred())
|
|
|
|
detokenizeReq, err := http.NewRequest("POST", apiURL+"/detokenize", strings.NewReader(string(tokenJSON)))
|
|
Expect(err).ToNot(HaveOccurred())
|
|
detokenizeReq.Header.Set("Content-Type", "application/json")
|
|
|
|
detokenizeResp, err := httpClient.Do(detokenizeReq)
|
|
Expect(err).ToNot(HaveOccurred())
|
|
defer func() { _ = detokenizeResp.Body.Close() }()
|
|
Expect(detokenizeResp.StatusCode).To(Equal(200))
|
|
|
|
detokenizeData, err := io.ReadAll(detokenizeResp.Body)
|
|
Expect(err).ToNot(HaveOccurred())
|
|
var detokenizeResult map[string]any
|
|
Expect(json.Unmarshal(detokenizeData, &detokenizeResult)).To(Succeed())
|
|
content, ok := detokenizeResult["content"].(string)
|
|
Expect(ok).To(BeTrue(), "detokenize response missing 'content': %s", string(detokenizeData))
|
|
Expect(content).ToNot(BeEmpty())
|
|
})
|
|
})
|
|
|
|
Describe("Autoparser ChatDelta Streaming", Label("Autoparser"), func() {
|
|
// These tests verify that when the C++ autoparser handles tool calls
|
|
// and content via ChatDeltas (with empty raw message), the streaming
|
|
// endpoint does NOT unnecessarily retry. This is a regression test for
|
|
// the bug where the retry logic only checked Go-side parsing, ignoring
|
|
// ChatDelta results, causing up to 6 retries and concatenated output.
|
|
|
|
Context("Streaming with tools and ChatDelta tool calls", func() {
|
|
It("should return tool calls without unnecessary retries", func() {
|
|
body := `{
|
|
"model": "mock-model-autoparser",
|
|
"messages": [{"role": "user", "content": "AUTOPARSER_TOOL_CALL"}],
|
|
"tools": [{"type": "function", "function": {"name": "search_collections", "description": "Search documents", "parameters": {"type": "object", "properties": {"query": {"type": "string"}}, "required": ["query"]}}}],
|
|
"stream": true
|
|
}`
|
|
req, err := http.NewRequest("POST", apiURL+"/chat/completions", strings.NewReader(body))
|
|
Expect(err).ToNot(HaveOccurred())
|
|
req.Header.Set("Content-Type", "application/json")
|
|
|
|
httpClient := &http.Client{Timeout: 60 * time.Second}
|
|
resp, err := httpClient.Do(req)
|
|
Expect(err).ToNot(HaveOccurred())
|
|
defer resp.Body.Close()
|
|
Expect(resp.StatusCode).To(Equal(200))
|
|
|
|
data, err := io.ReadAll(resp.Body)
|
|
Expect(err).ToNot(HaveOccurred())
|
|
bodyStr := string(data)
|
|
|
|
// Parse all SSE events
|
|
lines := strings.Split(bodyStr, "\n")
|
|
var toolCallChunks int
|
|
var reasoningChunks int
|
|
hasFinishReason := false
|
|
|
|
for _, line := range lines {
|
|
line = strings.TrimSpace(line)
|
|
if !strings.HasPrefix(line, "data: ") || line == "data: [DONE]" {
|
|
continue
|
|
}
|
|
jsonData := strings.TrimPrefix(line, "data: ")
|
|
var chunk map[string]any
|
|
if err := json.Unmarshal([]byte(jsonData), &chunk); err != nil {
|
|
continue
|
|
}
|
|
choices, ok := chunk["choices"].([]any)
|
|
if !ok || len(choices) == 0 {
|
|
continue
|
|
}
|
|
choice := choices[0].(map[string]any)
|
|
delta, _ := choice["delta"].(map[string]any)
|
|
if delta == nil {
|
|
continue
|
|
}
|
|
if _, ok := delta["tool_calls"]; ok {
|
|
toolCallChunks++
|
|
}
|
|
if _, ok := delta["reasoning"]; ok {
|
|
reasoningChunks++
|
|
}
|
|
if fr, ok := choice["finish_reason"].(string); ok && fr != "" {
|
|
hasFinishReason = true
|
|
}
|
|
}
|
|
|
|
// The key assertion: tool calls from ChatDeltas should be present
|
|
Expect(toolCallChunks).To(BeNumerically(">", 0),
|
|
"Expected tool_calls in streaming response from ChatDeltas, but got none. "+
|
|
"This likely means the retry logic discarded ChatDelta tool calls.")
|
|
|
|
// Should have a finish reason
|
|
Expect(hasFinishReason).To(BeTrue(), "Expected a finish_reason in the streaming response")
|
|
|
|
// Reasoning should be present (from ChatDelta reasoning)
|
|
Expect(reasoningChunks).To(BeNumerically(">", 0),
|
|
"Expected reasoning deltas from ChatDeltas")
|
|
})
|
|
})
|
|
|
|
Context("Streaming with tools and ChatDelta content (no tool calls)", func() {
|
|
It("should return content without retrying and without concatenation", func() {
|
|
body := `{
|
|
"model": "mock-model-autoparser",
|
|
"messages": [{"role": "user", "content": "AUTOPARSER_CONTENT"}],
|
|
"tools": [{"type": "function", "function": {"name": "search_collections", "description": "Search documents", "parameters": {"type": "object", "properties": {"query": {"type": "string"}}, "required": ["query"]}}}],
|
|
"stream": true
|
|
}`
|
|
req, err := http.NewRequest("POST", apiURL+"/chat/completions", strings.NewReader(body))
|
|
Expect(err).ToNot(HaveOccurred())
|
|
req.Header.Set("Content-Type", "application/json")
|
|
|
|
httpClient := &http.Client{Timeout: 60 * time.Second}
|
|
resp, err := httpClient.Do(req)
|
|
Expect(err).ToNot(HaveOccurred())
|
|
defer resp.Body.Close()
|
|
Expect(resp.StatusCode).To(Equal(200))
|
|
|
|
data, err := io.ReadAll(resp.Body)
|
|
Expect(err).ToNot(HaveOccurred())
|
|
bodyStr := string(data)
|
|
|
|
// Parse all SSE events and collect content
|
|
lines := strings.Split(bodyStr, "\n")
|
|
var contentParts []string
|
|
var reasoningParts []string
|
|
|
|
for _, line := range lines {
|
|
line = strings.TrimSpace(line)
|
|
if !strings.HasPrefix(line, "data: ") || line == "data: [DONE]" {
|
|
continue
|
|
}
|
|
jsonData := strings.TrimPrefix(line, "data: ")
|
|
var chunk map[string]any
|
|
if err := json.Unmarshal([]byte(jsonData), &chunk); err != nil {
|
|
continue
|
|
}
|
|
choices, ok := chunk["choices"].([]any)
|
|
if !ok || len(choices) == 0 {
|
|
continue
|
|
}
|
|
choice := choices[0].(map[string]any)
|
|
delta, _ := choice["delta"].(map[string]any)
|
|
if delta == nil {
|
|
continue
|
|
}
|
|
if content, ok := delta["content"].(string); ok && content != "" {
|
|
contentParts = append(contentParts, content)
|
|
}
|
|
if reasoning, ok := delta["reasoning"].(string); ok && reasoning != "" {
|
|
reasoningParts = append(reasoningParts, reasoning)
|
|
}
|
|
}
|
|
|
|
fullContent := strings.Join(contentParts, "")
|
|
fullReasoning := strings.Join(reasoningParts, "")
|
|
|
|
// Content should be present and match the expected answer
|
|
Expect(fullContent).To(ContainSubstring("LocalAI"),
|
|
"Expected content from ChatDeltas to contain 'LocalAI'. "+
|
|
"The retry logic may have discarded ChatDelta content.")
|
|
|
|
// Content should NOT be duplicated (no retry concatenation)
|
|
occurrences := strings.Count(fullContent, "LocalAI is an open-source AI platform.")
|
|
Expect(occurrences).To(Equal(1),
|
|
"Expected content to appear exactly once, but found %d occurrences. "+
|
|
"This indicates unnecessary retries are concatenating output.", occurrences)
|
|
|
|
// Reasoning should be present
|
|
Expect(fullReasoning).To(ContainSubstring("compose"),
|
|
"Expected reasoning content from ChatDeltas")
|
|
})
|
|
})
|
|
|
|
Context("Non-streaming with tools and ChatDelta tool calls", func() {
|
|
It("should return tool calls from ChatDeltas", func() {
|
|
body := `{
|
|
"model": "mock-model-autoparser",
|
|
"messages": [{"role": "user", "content": "AUTOPARSER_TOOL_CALL"}],
|
|
"tools": [{"type": "function", "function": {"name": "search_collections", "description": "Search documents", "parameters": {"type": "object", "properties": {"query": {"type": "string"}}, "required": ["query"]}}}]
|
|
}`
|
|
req, err := http.NewRequest("POST", apiURL+"/chat/completions", strings.NewReader(body))
|
|
Expect(err).ToNot(HaveOccurred())
|
|
req.Header.Set("Content-Type", "application/json")
|
|
|
|
httpClient := &http.Client{Timeout: 60 * time.Second}
|
|
resp, err := httpClient.Do(req)
|
|
Expect(err).ToNot(HaveOccurred())
|
|
defer resp.Body.Close()
|
|
Expect(resp.StatusCode).To(Equal(200))
|
|
|
|
data, err := io.ReadAll(resp.Body)
|
|
Expect(err).ToNot(HaveOccurred())
|
|
|
|
var result map[string]any
|
|
Expect(json.Unmarshal(data, &result)).To(Succeed())
|
|
|
|
choices, ok := result["choices"].([]any)
|
|
Expect(ok).To(BeTrue())
|
|
Expect(choices).To(HaveLen(1))
|
|
|
|
choice := choices[0].(map[string]any)
|
|
msg, _ := choice["message"].(map[string]any)
|
|
Expect(msg).ToNot(BeNil())
|
|
|
|
toolCalls, ok := msg["tool_calls"].([]any)
|
|
Expect(ok).To(BeTrue(),
|
|
"Expected tool_calls in non-streaming response from ChatDeltas, "+
|
|
"but got: %s", string(data))
|
|
Expect(toolCalls).To(HaveLen(1))
|
|
|
|
tc := toolCalls[0].(map[string]any)
|
|
fn, _ := tc["function"].(map[string]any)
|
|
Expect(fn["name"]).To(Equal("search_collections"))
|
|
})
|
|
})
|
|
|
|
// Regression test: thinking model (Gemma 4-style) with tools, where the
|
|
// model responds with content only (no tool calls). The C++ autoparser
|
|
// puts clean content in Message AND reasoning+content in ChatDeltas.
|
|
// Bug: Go-side PrependThinkingTokenIfNeeded prepends <|channel>thought
|
|
// to the clean content, causing it to be classified as unclosed reasoning,
|
|
// leading to "Backend produced reasoning without actionable content, retrying".
|
|
Context("Non-streaming thinking model with tools and ChatDelta content (no tool calls)", func() {
|
|
It("should return content without retrying", func() {
|
|
body := `{
|
|
"model": "mock-model-thinking-autoparser",
|
|
"messages": [{"role": "user", "content": "AUTOPARSER_THINKING_CONTENT"}],
|
|
"tools": [{"type": "function", "function": {"name": "search_collections", "description": "Search documents", "parameters": {"type": "object", "properties": {"query": {"type": "string"}}, "required": ["query"]}}}]
|
|
}`
|
|
req, err := http.NewRequest("POST", apiURL+"/chat/completions", strings.NewReader(body))
|
|
Expect(err).ToNot(HaveOccurred())
|
|
req.Header.Set("Content-Type", "application/json")
|
|
|
|
httpClient := &http.Client{Timeout: 60 * time.Second}
|
|
resp, err := httpClient.Do(req)
|
|
Expect(err).ToNot(HaveOccurred())
|
|
defer resp.Body.Close()
|
|
Expect(resp.StatusCode).To(Equal(200))
|
|
|
|
data, err := io.ReadAll(resp.Body)
|
|
Expect(err).ToNot(HaveOccurred())
|
|
|
|
var result map[string]any
|
|
Expect(json.Unmarshal(data, &result)).To(Succeed())
|
|
|
|
choices, ok := result["choices"].([]any)
|
|
Expect(ok).To(BeTrue(), "Expected choices array, got: %s", string(data))
|
|
Expect(choices).To(HaveLen(1))
|
|
|
|
choice := choices[0].(map[string]any)
|
|
msg, _ := choice["message"].(map[string]any)
|
|
Expect(msg).ToNot(BeNil())
|
|
|
|
content, _ := msg["content"].(string)
|
|
Expect(content).ToNot(BeEmpty(),
|
|
"Expected non-empty content in thinking model response with tools, "+
|
|
"but got empty content. Full response: %s", string(data))
|
|
Expect(content).To(ContainSubstring("helpful AI assistant"),
|
|
"Expected content to contain the model's response text, got: %s", content)
|
|
})
|
|
})
|
|
})
|
|
|
|
// Tests for duplicate tool call emissions during streaming.
|
|
// The Go-side incremental JSON parser was emitting the same tool call on
|
|
// every streaming token, and the post-streaming default: case re-emitted
|
|
// all tool calls again, producing massive duplication.
|
|
Describe("Streaming Tool Call Deduplication", Label("ToolDedup"), func() {
|
|
// Helper: parse SSE lines and count tool call name/arguments chunks
|
|
parseToolCallChunks := func(data []byte) (nameChunks int, argChunks int) {
|
|
for _, line := range strings.Split(string(data), "\n") {
|
|
line = strings.TrimSpace(line)
|
|
if !strings.HasPrefix(line, "data: ") || line == "data: [DONE]" {
|
|
continue
|
|
}
|
|
var chunk map[string]any
|
|
if err := json.Unmarshal([]byte(strings.TrimPrefix(line, "data: ")), &chunk); err != nil {
|
|
continue
|
|
}
|
|
choices, _ := chunk["choices"].([]any)
|
|
if len(choices) == 0 {
|
|
continue
|
|
}
|
|
delta, _ := choices[0].(map[string]any)["delta"].(map[string]any)
|
|
if delta == nil {
|
|
continue
|
|
}
|
|
toolCalls, _ := delta["tool_calls"].([]any)
|
|
for _, tc := range toolCalls {
|
|
tcMap, _ := tc.(map[string]any)
|
|
fn, _ := tcMap["function"].(map[string]any)
|
|
if fn == nil {
|
|
continue
|
|
}
|
|
if name, _ := fn["name"].(string); name != "" {
|
|
nameChunks++
|
|
}
|
|
if args, _ := fn["arguments"].(string); args != "" {
|
|
argChunks++
|
|
}
|
|
}
|
|
}
|
|
return
|
|
}
|
|
|
|
Context("Single tool call via Go-side JSON parser", func() {
|
|
It("should emit exactly one tool call name without duplicates", func() {
|
|
body := `{
|
|
"model": "mock-model-autoparser",
|
|
"messages": [{"role": "user", "content": "SINGLE_TOOL_CALL"}],
|
|
"tools": [{"type": "function", "function": {"name": "get_weather", "description": "Get weather", "parameters": {"type": "object", "properties": {"location": {"type": "string"}}, "required": ["location"]}}}],
|
|
"stream": true
|
|
}`
|
|
req, err := http.NewRequest("POST", apiURL+"/chat/completions", strings.NewReader(body))
|
|
Expect(err).ToNot(HaveOccurred())
|
|
req.Header.Set("Content-Type", "application/json")
|
|
|
|
httpClient := &http.Client{Timeout: 60 * time.Second}
|
|
resp, err := httpClient.Do(req)
|
|
Expect(err).ToNot(HaveOccurred())
|
|
defer resp.Body.Close()
|
|
Expect(resp.StatusCode).To(Equal(200))
|
|
|
|
data, err := io.ReadAll(resp.Body)
|
|
Expect(err).ToNot(HaveOccurred())
|
|
|
|
nameChunks, argChunks := parseToolCallChunks(data)
|
|
|
|
Expect(nameChunks).To(Equal(1),
|
|
"Expected exactly 1 tool call name chunk, got %d. Full SSE:\n%s",
|
|
nameChunks, string(data))
|
|
Expect(argChunks).To(BeNumerically(">=", 1),
|
|
"Expected at least 1 arguments chunk. Full SSE:\n%s", string(data))
|
|
})
|
|
})
|
|
|
|
Context("ChatDelta tool calls (regression guard)", func() {
|
|
It("should emit exactly one tool call name per tool", func() {
|
|
body := `{
|
|
"model": "mock-model-autoparser",
|
|
"messages": [{"role": "user", "content": "AUTOPARSER_TOOL_CALL"}],
|
|
"tools": [{"type": "function", "function": {"name": "search_collections", "description": "Search documents", "parameters": {"type": "object", "properties": {"query": {"type": "string"}}, "required": ["query"]}}}],
|
|
"stream": true
|
|
}`
|
|
req, err := http.NewRequest("POST", apiURL+"/chat/completions", strings.NewReader(body))
|
|
Expect(err).ToNot(HaveOccurred())
|
|
req.Header.Set("Content-Type", "application/json")
|
|
|
|
httpClient := &http.Client{Timeout: 60 * time.Second}
|
|
resp, err := httpClient.Do(req)
|
|
Expect(err).ToNot(HaveOccurred())
|
|
defer resp.Body.Close()
|
|
Expect(resp.StatusCode).To(Equal(200))
|
|
|
|
data, err := io.ReadAll(resp.Body)
|
|
Expect(err).ToNot(HaveOccurred())
|
|
|
|
nameChunks, _ := parseToolCallChunks(data)
|
|
|
|
Expect(nameChunks).To(Equal(1),
|
|
"Expected exactly 1 tool call name chunk from ChatDeltas, got %d. Full SSE:\n%s",
|
|
nameChunks, string(data))
|
|
})
|
|
})
|
|
|
|
Context("Multiple tool calls via Go-side JSON parser", func() {
|
|
It("should emit exactly two tool call names without duplicates", func() {
|
|
body := `{
|
|
"model": "mock-model-autoparser",
|
|
"messages": [{"role": "user", "content": "MULTI_TOOL_CALL"}],
|
|
"tools": [{"type": "function", "function": {"name": "get_weather", "description": "Get weather", "parameters": {"type": "object", "properties": {"location": {"type": "string"}}, "required": ["location"]}}}],
|
|
"stream": true
|
|
}`
|
|
req, err := http.NewRequest("POST", apiURL+"/chat/completions", strings.NewReader(body))
|
|
Expect(err).ToNot(HaveOccurred())
|
|
req.Header.Set("Content-Type", "application/json")
|
|
|
|
httpClient := &http.Client{Timeout: 60 * time.Second}
|
|
resp, err := httpClient.Do(req)
|
|
Expect(err).ToNot(HaveOccurred())
|
|
defer resp.Body.Close()
|
|
Expect(resp.StatusCode).To(Equal(200))
|
|
|
|
data, err := io.ReadAll(resp.Body)
|
|
Expect(err).ToNot(HaveOccurred())
|
|
|
|
nameChunks, argChunks := parseToolCallChunks(data)
|
|
|
|
Expect(nameChunks).To(Equal(2),
|
|
"Expected exactly 2 tool call name chunks (one per tool), got %d. Full SSE:\n%s",
|
|
nameChunks, string(data))
|
|
Expect(argChunks).To(BeNumerically(">=", 2),
|
|
"Expected at least 2 arguments chunks. Full SSE:\n%s", string(data))
|
|
})
|
|
})
|
|
})
|
|
})
|