mirror of
https://github.com/mudler/LocalAI.git
synced 2026-06-11 02:07:27 -04:00
Propagate terminal write errors from the chat prompt and explicitly ignore stream close errors during cleanup. Update chat tests to assert response writer errors so errcheck passes without hiding failed writes. Tests: - go test -count=1 ./core/cli/chat - go test -count=1 ./core/cli Assisted-by: Codex:GPT-5 Signed-off-by: Ching Kao <0980124jim@gmail.com>
173 lines
5.1 KiB
Go
173 lines
5.1 KiB
Go
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())
|
|
}
|