Files
LocalAI/core/http/app_test.go
LocalAI [bot] b00422e45f feat(backends): add LongCat video and avatar generation (#10792)
* feat(backends): add LongCat video and avatar generation

Assisted-by: Codex:GPT-5 [apply_patch] [exec_command] [web]

* refactor(config): declare model I/O modalities

Make model configs declare input and output modalities so capability discovery no longer branches on backend or checkpoint names. Complete the LongCat gallery and user documentation, make the SDPA patch apply to the pinned upstream revision, and stabilize the Agent Jobs race exposed by the required hook.

Assisted-by: Codex:GPT-5 [web]

---------

Co-authored-by: Ettore Di Giacinto <mudler@localai.io>
2026-07-12 23:58:46 +02:00

1138 lines
36 KiB
Go

package http_test
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"time"
"github.com/mudler/LocalAI/core/application"
"github.com/mudler/LocalAI/core/config"
. "github.com/mudler/LocalAI/core/http"
"github.com/mudler/LocalAI/core/schema"
"github.com/labstack/echo/v4"
"github.com/mudler/LocalAI/core/gallery"
"github.com/mudler/LocalAI/pkg/downloader"
"github.com/mudler/LocalAI/pkg/system"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
"gopkg.in/yaml.v3"
"github.com/mudler/xlog"
openaigo "github.com/otiai10/openaigo"
"github.com/sashabaranov/go-openai"
)
const apiKey = "joshua"
const bearerKey = "Bearer " + apiKey
const testPrompt = `### System:
You are an AI assistant that follows instruction extremely well. Help as much as you can.
### Instruction:
Say hello.
### Response:`
type modelApplyRequest struct {
ID string `json:"id"`
URL string `json:"url"`
ConfigURL string `json:"config_url"`
Name string `json:"name"`
Overrides map[string]any `json:"overrides"`
}
func getModelStatus(url string) (response map[string]any) {
// Create the HTTP request
req, err := http.NewRequest("GET", url, nil)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", bearerKey)
if err != nil {
fmt.Println("Error creating request:", err)
return
}
client := &http.Client{}
resp, err := client.Do(req)
if err != nil {
fmt.Println("Error sending request:", err)
return
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
fmt.Println("Error reading response body:", err)
return
}
// Unmarshal the response into a map[string]interface{}
err = json.Unmarshal(body, &response)
if err != nil {
fmt.Println("Error unmarshaling JSON response:", err)
return
}
return
}
func getModels(url string) ([]gallery.GalleryModel, error) {
response := []gallery.GalleryModel{}
uri := downloader.URI(url)
// TODO: No tests currently seem to exercise file:// urls. Fix?
err := uri.ReadWithAuthorizationAndCallback(context.TODO(), "", bearerKey, func(url string, i []byte) error {
// Unmarshal YAML data into a struct
return json.Unmarshal(i, &response)
})
return response, err
}
func postModelApplyRequest(url string, request modelApplyRequest) (response map[string]any) {
//url := "http://localhost:AI/models/apply"
// Create the request payload
payload, err := json.Marshal(request)
if err != nil {
fmt.Println("Error marshaling JSON:", err)
return
}
// Create the HTTP request
req, err := http.NewRequest("POST", url, bytes.NewBuffer(payload))
if err != nil {
fmt.Println("Error creating request:", err)
return
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", bearerKey)
// Make the request
client := &http.Client{}
resp, err := client.Do(req)
if err != nil {
fmt.Println("Error making request:", err)
return
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
fmt.Println("Error reading response body:", err)
return
}
// Unmarshal the response into a map[string]interface{}
err = json.Unmarshal(body, &response)
if err != nil {
fmt.Println("Error unmarshaling JSON response:", err)
return
}
return
}
func postRequestJSON[B any](url string, bodyJson *B) error {
payload, err := json.Marshal(bodyJson)
if err != nil {
return err
}
GinkgoWriter.Printf("POST %s: %s\n", url, string(payload))
req, err := http.NewRequest("POST", url, bytes.NewBuffer(payload))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", bearerKey)
client := &http.Client{}
resp, err := client.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return err
}
if resp.StatusCode < 200 || resp.StatusCode >= 400 {
return fmt.Errorf("unexpected status code: %d, body: %s", resp.StatusCode, string(body))
}
return nil
}
func postRequestResponseJSON[B1 any, B2 any](url string, reqJson *B1, respJson *B2) error {
payload, err := json.Marshal(reqJson)
if err != nil {
return err
}
GinkgoWriter.Printf("POST %s: %s\n", url, string(payload))
req, err := http.NewRequest("POST", url, bytes.NewBuffer(payload))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", bearerKey)
client := &http.Client{}
resp, err := client.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return err
}
if resp.StatusCode < 200 || resp.StatusCode >= 400 {
return fmt.Errorf("unexpected status code: %d, body: %s", resp.StatusCode, string(body))
}
return json.Unmarshal(body, respJson)
}
func putRequestJSON[B any](url string, bodyJson *B) error {
payload, err := json.Marshal(bodyJson)
if err != nil {
return err
}
GinkgoWriter.Printf("PUT %s: %s\n", url, string(payload))
req, err := http.NewRequest("PUT", url, bytes.NewBuffer(payload))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", bearerKey)
client := &http.Client{}
resp, err := client.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return err
}
if resp.StatusCode < 200 || resp.StatusCode >= 400 {
return fmt.Errorf("unexpected status code: %d, body: %s", resp.StatusCode, string(body))
}
return nil
}
func postInvalidRequest(url string) (error, int) {
req, err := http.NewRequest("POST", url, bytes.NewBufferString("invalid request"))
if err != nil {
return err, -1
}
req.Header.Set("Content-Type", "application/json")
client := &http.Client{}
resp, err := client.Do(req)
if err != nil {
return err, -1
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return err, -1
}
if resp.StatusCode < 200 || resp.StatusCode >= 400 {
return fmt.Errorf("unexpected status code: %d, body: %s", resp.StatusCode, string(body)), resp.StatusCode
}
return nil, resp.StatusCode
}
func getRequest(url string, header http.Header) (error, int, []byte) {
req, err := http.NewRequest("GET", url, nil)
if err != nil {
return err, -1, nil
}
req.Header = header
client := &http.Client{}
resp, err := client.Do(req)
if err != nil {
return err, -1, nil
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return err, -1, nil
}
return nil, resp.StatusCode, body
}
const bertEmbeddingsURL = `https://gist.githubusercontent.com/mudler/0a080b166b87640e8644b09c2aee6e3b/raw/f0e8c26bb72edc16d9fbafbfd6638072126ff225/bert-embeddings-gallery.yaml`
var _ = Describe("API test", func() {
var app *echo.Echo
var client *openai.Client
var client2 *openaigo.Client
var c context.Context
var cancel context.CancelFunc
var tmpdir string
var modelDir string
// localAIApp captures the Application so AfterEach can synchronously
// stop the spawned gRPC backend processes. application.New cancels
// them asynchronously on context cancel, which races with test-binary
// exit and leaks mock-backend children to init.
var localAIApp *application.Application
commonOpts := []config.AppOption{
config.WithDebug(true),
}
Context("API with ephemeral models", func() {
BeforeEach(func(sc SpecContext) {
var err error
tmpdir, err = os.MkdirTemp("", "")
Expect(err).ToNot(HaveOccurred())
// No real backends needed — these specs cover gallery API, auth,
// routing, and file:// import. Use the suite-level empty backend dir.
backendPath := backendDir
modelDir = filepath.Join(tmpdir, "models")
err = os.Mkdir(modelDir, 0750)
Expect(err).ToNot(HaveOccurred())
c, cancel = context.WithCancel(context.Background())
g := []gallery.GalleryModel{
{
Metadata: gallery.Metadata{
Name: "bert",
URL: bertEmbeddingsURL,
},
Overrides: map[string]any{"backend": "llama-cpp"},
},
{
Metadata: gallery.Metadata{
Name: "bert2",
URL: bertEmbeddingsURL,
AdditionalFiles: []gallery.File{{Filename: "foo.yaml", URI: bertEmbeddingsURL}},
},
Overrides: map[string]any{"foo": "bar"},
},
}
out, err := yaml.Marshal(g)
Expect(err).ToNot(HaveOccurred())
err = os.WriteFile(filepath.Join(modelDir, "gallery_simple.yaml"), out, 0600)
Expect(err).ToNot(HaveOccurred())
galleries := []config.Gallery{
{
Name: "test",
URL: "file://" + filepath.Join(modelDir, "gallery_simple.yaml"),
},
}
systemState, err := system.GetSystemState(
system.WithBackendPath(backendPath),
system.WithModelPath(modelDir),
)
Expect(err).ToNot(HaveOccurred())
application, err := application.New(
append(commonOpts,
config.WithContext(c),
config.WithSystemState(systemState),
config.WithGalleries(galleries),
config.WithApiKeys([]string{apiKey}),
)...)
Expect(err).ToNot(HaveOccurred())
app, err = API(application)
Expect(err).ToNot(HaveOccurred())
go func() {
if err := app.Start("127.0.0.1:9090"); err != nil && err != http.ErrServerClosed {
xlog.Error("server error", "error", err)
}
}()
defaultConfig := openai.DefaultConfig(apiKey)
defaultConfig.BaseURL = "http://127.0.0.1:9090/v1"
client2 = openaigo.NewClient("")
client2.BaseURL = defaultConfig.BaseURL
// Wait for API to be ready
client = openai.NewClientWithConfig(defaultConfig)
Eventually(func() error {
_, err := client.ListModels(context.TODO())
return err
}, "2m").ShouldNot(HaveOccurred())
})
AfterEach(func(sc SpecContext) {
cancel()
if app != nil {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
err := app.Shutdown(ctx)
Expect(err).ToNot(HaveOccurred())
}
err := os.RemoveAll(tmpdir)
Expect(err).ToNot(HaveOccurred())
_, err = os.ReadDir(tmpdir)
Expect(err).To(HaveOccurred())
})
Context("Auth Tests", func() {
It("Should fail if the api key is missing", func() {
err, sc := postInvalidRequest("http://127.0.0.1:9090/models/available")
Expect(err).ToNot(BeNil())
Expect(sc).To(Equal(401))
})
})
Context("URL routing Tests", func() {
It("Should support reverse-proxy when unauthenticated", func() {
err, sc, body := getRequest("http://127.0.0.1:9090/myprefix/", http.Header{
"X-Forwarded-Proto": {"https"},
"X-Forwarded-Host": {"example.org"},
"X-Forwarded-Prefix": {"/myprefix/"},
})
Expect(err).To(BeNil(), "error")
Expect(sc).To(Equal(200), "status code")
// Non-API paths pass through to the React SPA (which handles login client-side)
Expect(string(body)).To(ContainSubstring(`<base href="https://example.org/myprefix/" />`), "body")
Expect(string(body)).To(ContainSubstring(`<div id="root">`), "should serve React SPA")
})
It("Should support reverse-proxy when authenticated", func() {
err, sc, body := getRequest("http://127.0.0.1:9090/myprefix/", http.Header{
"Authorization": {bearerKey},
"X-Forwarded-Proto": {"https"},
"X-Forwarded-Host": {"example.org"},
"X-Forwarded-Prefix": {"/myprefix/"},
})
Expect(err).To(BeNil(), "error")
Expect(sc).To(Equal(200), "status code")
Expect(string(body)).To(ContainSubstring(`<base href="https://example.org/myprefix/" />`), "body")
})
// Caddy's `handle_path` (and similar directives) strip the matched
// prefix before forwarding upstream, so LocalAI receives the
// already-stripped path together with X-Forwarded-Prefix. The base
// href and asset URLs must still include the prefix so the browser
// requests them through the proxy.
It("Should support reverse-proxy when prefix is stripped by the proxy", func() {
err, sc, body := getRequest("http://127.0.0.1:9090/app", http.Header{
"X-Forwarded-Proto": {"https"},
"X-Forwarded-Host": {"example.org"},
"X-Forwarded-Prefix": {"/myprefix"},
})
Expect(err).To(BeNil(), "error")
Expect(sc).To(Equal(200), "status code")
Expect(string(body)).To(ContainSubstring(`<base href="https://example.org/myprefix/" />`), "body")
Expect(string(body)).ToNot(ContainSubstring(`="/assets/`), "asset URLs must include the prefix")
Expect(string(body)).ToNot(ContainSubstring(`="/favicon.svg"`), "favicon URL must include the prefix")
})
// X-Forwarded-Prefix is attacker controllable on misconfigured
// proxy chains. A value like "//evil.com" would otherwise turn the
// asset URL rewrite into a protocol-relative URL that loads JS
// from a foreign origin. BasePathPrefix must reject these via
// SafeForwardedPrefix and fall back to "/".
It("Should ignore an unsafe X-Forwarded-Prefix and not poison asset URLs", func() {
err, sc, body := getRequest("http://127.0.0.1:9090/app", http.Header{
"X-Forwarded-Proto": {"https"},
"X-Forwarded-Host": {"example.org"},
"X-Forwarded-Prefix": {"//evil.com"},
})
Expect(err).To(BeNil(), "error")
Expect(sc).To(Equal(200), "status code")
Expect(string(body)).ToNot(ContainSubstring("evil.com"), "unsafe prefix must not leak into the response")
Expect(string(body)).ToNot(ContainSubstring(`="//`), "asset URLs must not become protocol-relative")
})
})
Context("Applying models", func() {
It("applies models from a gallery", func() {
models, err := getModels("http://127.0.0.1:9090/models/available")
Expect(err).To(BeNil())
Expect(len(models)).To(Equal(2), fmt.Sprint(models))
Expect(models[0].Installed).To(BeFalse(), fmt.Sprint(models))
Expect(models[1].Installed).To(BeFalse(), fmt.Sprint(models))
response := postModelApplyRequest("http://127.0.0.1:9090/models/apply", modelApplyRequest{
ID: "test@bert2",
})
Expect(response["uuid"]).ToNot(BeEmpty(), fmt.Sprint(response))
uuid := response["uuid"].(string)
resp := map[string]any{}
Eventually(func() bool {
response := getModelStatus("http://127.0.0.1:9090/models/jobs/" + uuid)
fmt.Println(response)
resp = response
return response["processed"].(bool)
}, "360s", "10s").Should(Equal(true))
Expect(resp["message"]).ToNot(ContainSubstring("error"))
dat, err := os.ReadFile(filepath.Join(modelDir, "bert2.yaml"))
Expect(err).ToNot(HaveOccurred())
_, err = os.ReadFile(filepath.Join(modelDir, "foo.yaml"))
Expect(err).ToNot(HaveOccurred())
content := map[string]any{}
err = yaml.Unmarshal(dat, &content)
Expect(err).ToNot(HaveOccurred())
Expect(content["usage"]).To(ContainSubstring("You can test this model with curl like this"))
Expect(content["foo"]).To(Equal("bar"))
models, err = getModels("http://127.0.0.1:9090/models/available")
Expect(err).To(BeNil())
Expect(len(models)).To(Equal(2), fmt.Sprint(models))
Expect(models[0].Name).To(Or(Equal("bert"), Equal("bert2")))
Expect(models[1].Name).To(Or(Equal("bert"), Equal("bert2")))
for _, m := range models {
if m.Name == "bert2" {
Expect(m.Installed).To(BeTrue())
} else {
Expect(m.Installed).To(BeFalse())
}
}
})
It("overrides models", func() {
response := postModelApplyRequest("http://127.0.0.1:9090/models/apply", modelApplyRequest{
URL: bertEmbeddingsURL,
Name: "bert",
Overrides: map[string]any{
"backend": "llama",
},
})
Expect(response["uuid"]).ToNot(BeEmpty(), fmt.Sprint(response))
uuid := response["uuid"].(string)
Eventually(func() bool {
response := getModelStatus("http://127.0.0.1:9090/models/jobs/" + uuid)
return response["processed"].(bool)
}, "360s", "10s").Should(Equal(true))
dat, err := os.ReadFile(filepath.Join(modelDir, "bert.yaml"))
Expect(err).ToNot(HaveOccurred())
content := map[string]any{}
err = yaml.Unmarshal(dat, &content)
Expect(err).ToNot(HaveOccurred())
Expect(content["backend"]).To(Equal("llama"))
})
It("apply models without overrides", func() {
response := postModelApplyRequest("http://127.0.0.1:9090/models/apply", modelApplyRequest{
URL: bertEmbeddingsURL,
Name: "bert",
Overrides: map[string]any{},
})
Expect(response["uuid"]).ToNot(BeEmpty(), fmt.Sprint(response))
uuid := response["uuid"].(string)
Eventually(func() bool {
response := getModelStatus("http://127.0.0.1:9090/models/jobs/" + uuid)
return response["processed"].(bool)
}, "360s", "10s").Should(Equal(true))
dat, err := os.ReadFile(filepath.Join(modelDir, "bert.yaml"))
Expect(err).ToNot(HaveOccurred())
content := map[string]any{}
err = yaml.Unmarshal(dat, &content)
Expect(err).ToNot(HaveOccurred())
Expect(content["usage"]).To(ContainSubstring("You can test this model with curl like this"))
})
})
Context("Importing models from URI", func() {
var testYamlFile string
BeforeEach(func() {
// Create a test YAML config file
yamlContent := `name: test-import-model
backend: llama-cpp
description: Test model imported from file URI
parameters:
model: path/to/model.gguf
temperature: 0.7
`
testYamlFile = filepath.Join(tmpdir, "test-import.yaml")
err := os.WriteFile(testYamlFile, []byte(yamlContent), 0644)
Expect(err).ToNot(HaveOccurred())
})
AfterEach(func() {
err := os.Remove(testYamlFile)
Expect(err).ToNot(HaveOccurred())
})
It("should import model from file:// URI pointing to local YAML config", func() {
importReq := schema.ImportModelRequest{
URI: "file://" + testYamlFile,
Preferences: json.RawMessage(`{}`),
}
var response schema.GalleryResponse
err := postRequestResponseJSON("http://127.0.0.1:9090/models/import-uri", &importReq, &response)
Expect(err).ToNot(HaveOccurred())
Expect(response.ID).ToNot(BeEmpty())
uuid := response.ID
resp := map[string]any{}
Eventually(func() bool {
response := getModelStatus("http://127.0.0.1:9090/models/jobs/" + uuid)
resp = response
return response["processed"].(bool)
}, "360s", "10s").Should(Equal(true))
// Check that the model was imported successfully
Expect(resp["message"]).ToNot(ContainSubstring("error"))
Expect(resp["error"]).To(BeNil())
// Verify the model config file was created
dat, err := os.ReadFile(filepath.Join(modelDir, "test-import-model.yaml"))
Expect(err).ToNot(HaveOccurred())
content := map[string]any{}
err = yaml.Unmarshal(dat, &content)
Expect(err).ToNot(HaveOccurred())
Expect(content["name"]).To(Equal("test-import-model"))
Expect(content["backend"]).To(Equal("llama-cpp"))
})
It("should return error when file:// URI points to non-existent file", func() {
nonExistentFile := filepath.Join(tmpdir, "nonexistent.yaml")
importReq := schema.ImportModelRequest{
URI: "file://" + nonExistentFile,
Preferences: json.RawMessage(`{}`),
}
var response schema.GalleryResponse
err := postRequestResponseJSON("http://127.0.0.1:9090/models/import-uri", &importReq, &response)
// The endpoint should return an error immediately
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("failed to discover model config"))
})
})
Context("Importing models from URI can't point to absolute paths", func() {
var testYamlFile string
BeforeEach(func() {
// Create a test YAML config file
yamlContent := `name: test-import-model
backend: llama-cpp
description: Test model imported from file URI
parameters:
model: /path/to/model.gguf
temperature: 0.7
`
testYamlFile = filepath.Join(tmpdir, "test-import.yaml")
err := os.WriteFile(testYamlFile, []byte(yamlContent), 0644)
Expect(err).ToNot(HaveOccurred())
})
AfterEach(func() {
err := os.Remove(testYamlFile)
Expect(err).ToNot(HaveOccurred())
})
It("should fail to import model from file:// URI pointing to local YAML config", func() {
importReq := schema.ImportModelRequest{
URI: "file://" + testYamlFile,
Preferences: json.RawMessage(`{}`),
}
var response schema.GalleryResponse
err := postRequestResponseJSON("http://127.0.0.1:9090/models/import-uri", &importReq, &response)
Expect(err).ToNot(HaveOccurred())
Expect(response.ID).ToNot(BeEmpty())
uuid := response.ID
resp := map[string]any{}
Eventually(func() bool {
response := getModelStatus("http://127.0.0.1:9090/models/jobs/" + uuid)
resp = response
return response["processed"].(bool)
}, "360s", "10s").Should(Equal(true))
// Check that the model was imported successfully
Expect(resp["message"]).To(ContainSubstring("error"))
Expect(resp["error"]).ToNot(BeNil())
})
})
})
Context("API query", func() {
BeforeEach(func() {
if mockBackendPath == "" {
Skip("mock-backend binary not built; run 'make build-mock-backend'")
}
c, cancel = context.WithCancel(context.Background())
// Stand up an isolated model dir for this Context so the suite can
// register a mock-model config (read by /v1/models, /system, and the
// agent-jobs flow) without depending on real backend builds.
var err error
tmpdir, err = os.MkdirTemp("", "")
Expect(err).ToNot(HaveOccurred())
modelDir = filepath.Join(tmpdir, "models")
Expect(os.Mkdir(modelDir, 0750)).To(Succeed())
mockModelYAML := `name: mock-model
backend: mock-backend
parameters:
model: mock-model.bin
`
Expect(os.WriteFile(filepath.Join(modelDir, "mock-model.yaml"), []byte(mockModelYAML), 0644)).To(Succeed())
// A second model carrying chat_template_kwargs so the REST->gRPC
// metadata-forwarding spec below can assert the model-YAML kwarg is
// merged with the per-request override.
mockCTKModelYAML := `name: mock-ctk-model
backend: mock-backend
parameters:
model: mock-model.bin
chat_template_kwargs:
preserve_thinking: true
`
Expect(os.WriteFile(filepath.Join(modelDir, "mock-ctk-model.yaml"), []byte(mockCTKModelYAML), 0644)).To(Succeed())
systemState, err := system.GetSystemState(
system.WithBackendPath(backendDir),
system.WithModelPath(modelDir),
)
Expect(err).ToNot(HaveOccurred())
localAIApp, err = application.New(
append(commonOpts,
config.WithContext(c),
config.WithSystemState(systemState),
)...)
Expect(err).ToNot(HaveOccurred())
localAIApp.ModelLoader().SetExternalBackend("mock-backend", mockBackendPath)
app, err = API(localAIApp)
Expect(err).ToNot(HaveOccurred())
go func() {
if err := app.Start("127.0.0.1:9090"); err != nil && err != http.ErrServerClosed {
xlog.Error("server error", "error", err)
}
}()
defaultConfig := openai.DefaultConfig("")
defaultConfig.BaseURL = "http://127.0.0.1:9090/v1"
client2 = openaigo.NewClient("")
client2.BaseURL = defaultConfig.BaseURL
// Wait for API to be ready
client = openai.NewClientWithConfig(defaultConfig)
Eventually(func() error {
_, err := client.ListModels(context.TODO())
return err
}, "2m").ShouldNot(HaveOccurred())
})
AfterEach(func() {
// Synchronous shutdown — context-cancel cleanup is async and races
// test-binary exit, orphaning mock-backend children to init.
if localAIApp != nil {
_ = localAIApp.Shutdown()
}
cancel()
if app != nil {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
err := app.Shutdown(ctx)
Expect(err).ToNot(HaveOccurred())
}
Expect(os.RemoveAll(tmpdir)).To(Succeed())
})
It("returns the models list", func() {
models, err := client.ListModels(context.TODO())
Expect(err).ToNot(HaveOccurred())
Expect(len(models.Models)).To(BeNumerically(">=", 1))
})
It("returns errors", func() {
_, err := client.CreateCompletion(context.TODO(), openai.CompletionRequest{Model: "foomodel", Prompt: testPrompt})
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("error, status code: 404, status: 404 Not Found"))
})
It("shows the external backend on /system", func() {
// /system reports the backends available to the application.
// Mock-backend is registered via SetExternalBackend so it appears
// alongside any built-in entries; verifying that string proves the
// endpoint is wired up regardless of which real backends exist.
resp, err := http.Get("http://127.0.0.1:9090/system")
Expect(err).ToNot(HaveOccurred())
Expect(resp.StatusCode).To(Equal(200))
dat, err := io.ReadAll(resp.Body)
Expect(err).ToNot(HaveOccurred())
Expect(string(dat)).To(ContainSubstring("mock-backend"))
})
It("forwards chat_template_kwargs and reasoning levers to gRPC PredictOptions.Metadata", func() {
// True HTTP->gRPC contract guard: drive a real /v1/chat/completions
// request and assert the exact metadata the REST layer forwarded to
// the backend. The mock-backend echoes PredictOptions.Metadata as JSON
// when it sees the ECHO_PREDICT_METADATA marker in the prompt, so this
// pins the request->gRPC mapping (model-YAML chat_template_kwargs +
// per-request metadata override + type coercion + standalone keys)
// without adding a new RPC. The marker rides in the user content and
// must survive into the backend prompt; if a future default chat
// template drops raw user content, move the marker to /v1/completions.
reqBody := map[string]any{
"model": "mock-ctk-model",
"messages": []map[string]any{
{"role": "user", "content": "ECHO_PREDICT_METADATA"},
},
// per-request override: overrides the standalone enable_thinking key
// and exercises coercion ("false" -> bool, "low" -> string) in the blob
"metadata": map[string]string{
"enable_thinking": "false",
"reasoning_effort": "low",
},
}
var chatResp struct {
Choices []struct {
Message struct {
Content string `json:"content"`
} `json:"message"`
} `json:"choices"`
}
err := postRequestResponseJSON("http://127.0.0.1:9090/v1/chat/completions", &reqBody, &chatResp)
Expect(err).ToNot(HaveOccurred())
Expect(chatResp.Choices).ToNot(BeEmpty())
// The assistant content is the JSON snapshot of PredictOptions.Metadata.
var meta map[string]string
Expect(json.Unmarshal([]byte(chatResp.Choices[0].Message.Content), &meta)).To(Succeed(), "echoed metadata: %s", chatResp.Choices[0].Message.Content)
// Standalone keys reflect the per-request override (consumed by Python
// backends; consistent across backends).
Expect(meta).To(HaveKeyWithValue("enable_thinking", "false"))
Expect(meta).To(HaveKeyWithValue("reasoning_effort", "low"))
// The chat_template_kwargs blob (consumed by llama.cpp) merges the
// model-YAML kwarg with the coerced request metadata override.
Expect(meta).To(HaveKey("chat_template_kwargs"))
var ctk map[string]any
Expect(json.Unmarshal([]byte(meta["chat_template_kwargs"]), &ctk)).To(Succeed(), "chat_template_kwargs blob: %s", meta["chat_template_kwargs"])
Expect(ctk).To(HaveKeyWithValue("preserve_thinking", true)) // bool from model YAML
Expect(ctk).To(HaveKeyWithValue("enable_thinking", false)) // coerced "false" -> bool
Expect(ctk).To(HaveKeyWithValue("reasoning_effort", "low")) // non-bool stays string
})
// Agent Jobs: HTTP API for task/job scheduling. The underlying AgentPool
// service is exercised in core/services/agentpool/agent_jobs_test.go;
// these specs cover the /api/agent/* HTTP plumbing on top.
Context("Agent Jobs", func() {
It("creates and manages tasks", func() {
// Create a task
taskBody := map[string]any{
"name": "Test Task",
"description": "Test Description",
"model": "mock-model",
"prompt": "Hello {{.name}}",
"enabled": true,
}
var createResp map[string]any
err := postRequestResponseJSON("http://127.0.0.1:9090/api/agent/tasks", &taskBody, &createResp)
Expect(err).ToNot(HaveOccurred())
Expect(createResp["id"]).ToNot(BeEmpty())
taskID := createResp["id"].(string)
// Get the task
var task schema.Task
resp, err := http.Get("http://127.0.0.1:9090/api/agent/tasks/" + taskID)
Expect(err).ToNot(HaveOccurred())
Expect(resp.StatusCode).To(Equal(200))
body, _ := io.ReadAll(resp.Body)
json.Unmarshal(body, &task)
Expect(task.Name).To(Equal("Test Task"))
// List tasks
resp, err = http.Get("http://127.0.0.1:9090/api/agent/tasks")
Expect(err).ToNot(HaveOccurred())
Expect(resp.StatusCode).To(Equal(200))
var tasks []schema.Task
body, _ = io.ReadAll(resp.Body)
json.Unmarshal(body, &tasks)
Expect(len(tasks)).To(BeNumerically(">=", 1))
// Update task
taskBody["name"] = "Updated Task"
err = putRequestJSON("http://127.0.0.1:9090/api/agent/tasks/"+taskID, &taskBody)
Expect(err).ToNot(HaveOccurred())
// Verify update
resp, err = http.Get("http://127.0.0.1:9090/api/agent/tasks/" + taskID)
Expect(err).ToNot(HaveOccurred())
body, _ = io.ReadAll(resp.Body)
json.Unmarshal(body, &task)
Expect(task.Name).To(Equal("Updated Task"))
// Delete task
req, _ := http.NewRequest("DELETE", "http://127.0.0.1:9090/api/agent/tasks/"+taskID, nil)
req.Header.Set("Authorization", bearerKey)
resp, err = http.DefaultClient.Do(req)
Expect(err).ToNot(HaveOccurred())
Expect(resp.StatusCode).To(Equal(200))
})
It("executes and monitors jobs", func() {
// Create a task first
taskBody := map[string]any{
"name": "Job Test Task",
"model": "mock-model",
"prompt": "Say hello",
"enabled": true,
}
var createResp map[string]any
err := postRequestResponseJSON("http://127.0.0.1:9090/api/agent/tasks", &taskBody, &createResp)
Expect(err).ToNot(HaveOccurred())
taskID := createResp["id"].(string)
// Execute a job
jobBody := map[string]any{
"task_id": taskID,
"parameters": map[string]string{},
}
var jobResp schema.JobExecutionResponse
err = postRequestResponseJSON("http://127.0.0.1:9090/api/agent/jobs/execute", &jobBody, &jobResp)
Expect(err).ToNot(HaveOccurred())
Expect(jobResp.JobID).ToNot(BeEmpty())
jobID := jobResp.JobID
// Get job status
var job schema.Job
resp, err := http.Get("http://127.0.0.1:9090/api/agent/jobs/" + jobID)
Expect(err).ToNot(HaveOccurred())
Expect(resp.StatusCode).To(Equal(200))
body, _ := io.ReadAll(resp.Body)
json.Unmarshal(body, &job)
Expect(job.ID).To(Equal(jobID))
Expect(job.TaskID).To(Equal(taskID))
// List jobs
resp, err = http.Get("http://127.0.0.1:9090/api/agent/jobs")
Expect(err).ToNot(HaveOccurred())
Expect(resp.StatusCode).To(Equal(200))
var jobs []schema.Job
body, _ = io.ReadAll(resp.Body)
json.Unmarshal(body, &jobs)
Expect(len(jobs)).To(BeNumerically(">=", 1))
// Cancel job (if still pending/running)
if job.Status == schema.JobStatusPending || job.Status == schema.JobStatusRunning {
req, _ := http.NewRequest("POST", "http://127.0.0.1:9090/api/agent/jobs/"+jobID+"/cancel", nil)
req.Header.Set("Authorization", bearerKey)
resp, err = http.DefaultClient.Do(req)
Expect(err).ToNot(HaveOccurred())
if resp.StatusCode == http.StatusBadRequest {
// The worker can finish between the status read and cancellation request.
resp, err = http.Get("http://127.0.0.1:9090/api/agent/jobs/" + jobID)
Expect(err).ToNot(HaveOccurred())
Expect(resp.StatusCode).To(Equal(http.StatusOK))
body, _ = io.ReadAll(resp.Body)
err = json.Unmarshal(body, &job)
Expect(err).ToNot(HaveOccurred())
Expect(job.Status).To(Or(
Equal(schema.JobStatusCompleted),
Equal(schema.JobStatusFailed),
Equal(schema.JobStatusCancelled),
))
} else {
Expect(resp.StatusCode).To(Equal(http.StatusOK))
}
}
})
It("executes task by name", func() {
// Create a task with a specific name
taskBody := map[string]any{
"name": "Named Task",
"model": "mock-model",
"prompt": "Hello",
"enabled": true,
}
var createResp map[string]any
err := postRequestResponseJSON("http://127.0.0.1:9090/api/agent/tasks", &taskBody, &createResp)
Expect(err).ToNot(HaveOccurred())
// Execute by name
paramsBody := map[string]string{"param1": "value1"}
var jobResp schema.JobExecutionResponse
err = postRequestResponseJSON("http://127.0.0.1:9090/api/agent/tasks/Named Task/execute", &paramsBody, &jobResp)
Expect(err).ToNot(HaveOccurred())
Expect(jobResp.JobID).ToNot(BeEmpty())
})
})
})
// Config file Context: exercises the path where models are loaded from a
// single multi-entry YAML (config_file option) rather than per-model YAMLs
// in the model dir. The fixtures point at mock-backend so this is a
// plumbing test for config-file loading and routing, not a real-inference
// test.
Context("Config file", func() {
BeforeEach(func() {
if mockBackendPath == "" {
Skip("mock-backend binary not built; run 'make build-mock-backend'")
}
c, cancel = context.WithCancel(context.Background())
var err error
tmpdir, err = os.MkdirTemp("", "")
Expect(err).ToNot(HaveOccurred())
modelDir = filepath.Join(tmpdir, "models")
Expect(os.Mkdir(modelDir, 0750)).To(Succeed())
// Inline config file with two list entries that both resolve to mock-backend.
// Mirrors the legacy testmodel.ggml shape so the test still proves that
// config-file loading registers each entry as a routable model.
configContent := `- name: list1
parameters:
model: mock-model.bin
backend: mock-backend
context_size: 200
- name: list2
parameters:
model: mock-model.bin
backend: mock-backend
context_size: 200
`
configFile := filepath.Join(tmpdir, "config.yaml")
Expect(os.WriteFile(configFile, []byte(configContent), 0644)).To(Succeed())
systemState, err := system.GetSystemState(
system.WithBackendPath(backendDir),
system.WithModelPath(modelDir),
)
Expect(err).ToNot(HaveOccurred())
localAIApp, err = application.New(
append(commonOpts,
config.WithContext(c),
config.WithSystemState(systemState),
config.WithConfigFile(configFile))...,
)
Expect(err).ToNot(HaveOccurred())
localAIApp.ModelLoader().SetExternalBackend("mock-backend", mockBackendPath)
app, err = API(localAIApp)
Expect(err).ToNot(HaveOccurred())
go func() {
if err := app.Start("127.0.0.1:9090"); err != nil && err != http.ErrServerClosed {
xlog.Error("server error", "error", err)
}
}()
defaultConfig := openai.DefaultConfig("")
defaultConfig.BaseURL = "http://127.0.0.1:9090/v1"
client2 = openaigo.NewClient("")
client2.BaseURL = defaultConfig.BaseURL
// Wait for API to be ready
client = openai.NewClientWithConfig(defaultConfig)
Eventually(func() error {
_, err := client.ListModels(context.TODO())
return err
}, "2m").ShouldNot(HaveOccurred())
})
AfterEach(func() {
// Synchronous shutdown — context-cancel cleanup is async and races
// test-binary exit, orphaning mock-backend children to init.
if localAIApp != nil {
_ = localAIApp.Shutdown()
}
cancel()
if app != nil {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
err := app.Shutdown(ctx)
Expect(err).ToNot(HaveOccurred())
}
Expect(os.RemoveAll(tmpdir)).To(Succeed())
})
It("can generate chat completions from config file (list1)", func() {
resp, err := client.CreateChatCompletion(context.TODO(), openai.ChatCompletionRequest{Model: "list1", Messages: []openai.ChatCompletionMessage{{Role: "user", Content: testPrompt}}})
Expect(err).ToNot(HaveOccurred())
Expect(len(resp.Choices)).To(Equal(1))
Expect(resp.Choices[0].Message.Content).ToNot(BeEmpty())
})
It("can generate chat completions from config file (list2)", func() {
resp, err := client.CreateChatCompletion(context.TODO(), openai.ChatCompletionRequest{Model: "list2", Messages: []openai.ChatCompletionMessage{{Role: "user", Content: testPrompt}}})
Expect(err).ToNot(HaveOccurred())
Expect(len(resp.Choices)).To(Equal(1))
Expect(resp.Choices[0].Message.Content).ToNot(BeEmpty())
})
It("can generate edit completions from config file", func() {
request := openaigo.EditCreateRequestBody{
Model: "list2",
Instruction: "foo",
Input: "bar",
}
resp, err := client2.CreateEdit(context.Background(), request)
Expect(err).ToNot(HaveOccurred())
Expect(len(resp.Choices)).To(Equal(1))
Expect(resp.Choices[0].Text).ToNot(BeEmpty())
})
})
})