feat(api): add ollama compatibility (#9284)

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
This commit is contained in:
Ettore Di Giacinto
2026-04-09 14:15:14 +02:00
committed by GitHub
parent b0d9ce4905
commit 85be4ff03c
15 changed files with 1495 additions and 10 deletions

View File

@@ -62,6 +62,7 @@ type RunCMD struct {
UploadLimit int `env:"LOCALAI_UPLOAD_LIMIT,UPLOAD_LIMIT" default:"15" help:"Default upload-limit in MB" group:"api"`
APIKeys []string `env:"LOCALAI_API_KEY,API_KEY" help:"List of API Keys to enable API authentication. When this is set, all the requests must be authenticated with one of these API keys" group:"api"`
DisableWebUI bool `env:"LOCALAI_DISABLE_WEBUI,DISABLE_WEBUI" default:"false" help:"Disables the web user interface. When set to true, the server will only expose API endpoints without serving the web interface" group:"api"`
OllamaAPIRootEndpoint bool `env:"LOCALAI_OLLAMA_API_ROOT_ENDPOINT" default:"false" help:"Register Ollama-compatible health check on / (replaces web UI on root path). The /api/* Ollama endpoints are always available regardless of this flag" group:"api"`
DisableRuntimeSettings bool `env:"LOCALAI_DISABLE_RUNTIME_SETTINGS,DISABLE_RUNTIME_SETTINGS" default:"false" help:"Disables the runtime settings. When set to true, the server will not load the runtime settings from the runtime_settings.json file" group:"api"`
DisablePredownloadScan bool `env:"LOCALAI_DISABLE_PREDOWNLOAD_SCAN" help:"If true, disables the best-effort security scanner before downloading any files." group:"hardening" default:"false"`
OpaqueErrors bool `env:"LOCALAI_OPAQUE_ERRORS" default:"false" help:"If true, all error responses are replaced with blank 500 errors. This is intended only for hardening against information leaks and is normally not recommended." group:"hardening"`
@@ -295,6 +296,10 @@ func (r *RunCMD) Run(ctx *cliContext.Context) error {
opts = append(opts, config.DisableWebUI)
}
if r.OllamaAPIRootEndpoint {
opts = append(opts, config.EnableOllamaAPIRootEndpoint)
}
if r.DisableGalleryEndpoint {
opts = append(opts, config.DisableGalleryEndpoint)
}

View File

@@ -40,6 +40,7 @@ type ApplicationConfig struct {
Federated bool
DisableWebUI bool
OllamaAPIRootEndpoint bool
EnforcePredownloadScans bool
OpaqueErrors bool
UseSubtleKeyComparison bool
@@ -263,6 +264,10 @@ var DisableWebUI = func(o *ApplicationConfig) {
o.DisableWebUI = true
}
var EnableOllamaAPIRootEndpoint = func(o *ApplicationConfig) {
o.OllamaAPIRootEndpoint = true
}
var DisableRuntimeSettings = func(o *ApplicationConfig) {
o.DisableRuntimeSettings = true
}

View File

@@ -391,6 +391,10 @@ func API(application *application.Application) (*echo.Echo, error) {
routes.RegisterOpenAIRoutes(e, requestExtractor, application)
routes.RegisterAnthropicRoutes(e, requestExtractor, application)
routes.RegisterOpenResponsesRoutes(e, requestExtractor, application)
routes.RegisterOllamaRoutes(e, requestExtractor, application)
if application.ApplicationConfig().OllamaAPIRootEndpoint {
routes.RegisterOllamaRootEndpoint(e)
}
if !application.ApplicationConfig().DisableWebUI {
routes.RegisterUIAPIRoutes(e, application.ModelConfigLoader(), application.ModelLoader(), application.ApplicationConfig(), application.GalleryService(), opcache, application, adminMiddleware)
routes.RegisterUIRoutes(e, application.ModelConfigLoader(), application.ApplicationConfig(), application.GalleryService(), adminMiddleware)

View File

@@ -0,0 +1,153 @@
package ollama
import (
"fmt"
"time"
"github.com/labstack/echo/v4"
"github.com/mudler/LocalAI/core/backend"
"github.com/mudler/LocalAI/core/config"
openaiEndpoint "github.com/mudler/LocalAI/core/http/endpoints/openai"
"github.com/mudler/LocalAI/core/http/middleware"
"github.com/mudler/LocalAI/core/schema"
"github.com/mudler/LocalAI/core/templates"
"github.com/mudler/LocalAI/pkg/model"
"github.com/mudler/xlog"
)
// ChatEndpoint handles Ollama-compatible /api/chat requests
func ChatEndpoint(cl *config.ModelConfigLoader, ml *model.ModelLoader, evaluator *templates.Evaluator, appConfig *config.ApplicationConfig) echo.HandlerFunc {
return func(c echo.Context) error {
input, ok := c.Get(middleware.CONTEXT_LOCALS_KEY_LOCALAI_REQUEST).(*schema.OllamaChatRequest)
if !ok || input.Model == "" {
return ollamaError(c, 400, "model is required")
}
cfg, ok := c.Get(middleware.CONTEXT_LOCALS_KEY_MODEL_CONFIG).(*config.ModelConfig)
if !ok || cfg == nil {
return ollamaError(c, 400, "model configuration not found")
}
// Apply Ollama options to config
applyOllamaOptions(input.Options, cfg)
// Convert Ollama messages to OpenAI format
openAIMessages := ollamaMessagesToOpenAI(input.Messages)
// Build an OpenAI-compatible request
openAIReq := &schema.OpenAIRequest{
PredictionOptions: schema.PredictionOptions{
BasicModelRequest: schema.BasicModelRequest{Model: input.Model},
},
Messages: openAIMessages,
Stream: input.IsStream(),
Context: input.Context,
Cancel: input.Cancel,
}
if input.Options != nil {
openAIReq.Temperature = input.Options.Temperature
openAIReq.TopP = input.Options.TopP
openAIReq.TopK = input.Options.TopK
openAIReq.RepeatPenalty = input.Options.RepeatPenalty
if input.Options.NumPredict != nil {
openAIReq.Maxtokens = input.Options.NumPredict
}
if len(input.Options.Stop) > 0 {
openAIReq.Stop = input.Options.Stop
}
}
predInput := evaluator.TemplateMessages(*openAIReq, openAIReq.Messages, cfg, nil, false)
xlog.Debug("Ollama Chat - Prompt (after templating)", "prompt_len", len(predInput))
if input.IsStream() {
return handleOllamaChatStream(c, input, cfg, ml, cl, appConfig, predInput, openAIReq)
}
return handleOllamaChatNonStream(c, input, cfg, ml, cl, appConfig, predInput, openAIReq)
}
}
func handleOllamaChatNonStream(c echo.Context, input *schema.OllamaChatRequest, cfg *config.ModelConfig, ml *model.ModelLoader, cl *config.ModelConfigLoader, appConfig *config.ApplicationConfig, predInput string, openAIReq *schema.OpenAIRequest) error {
startTime := time.Now()
var result string
cb := func(s string, choices *[]schema.Choice) {
result = s
}
_, tokenUsage, _, err := openaiEndpoint.ComputeChoices(openAIReq, predInput, cfg, cl, appConfig, ml, cb, nil)
if err != nil {
xlog.Error("Ollama chat inference failed", "error", err)
return ollamaError(c, 500, fmt.Sprintf("model inference failed: %v", err))
}
totalDuration := time.Since(startTime)
resp := schema.OllamaChatResponse{
Model: input.Model,
CreatedAt: time.Now().UTC(),
Message: schema.OllamaMessage{
Role: "assistant",
Content: result,
},
Done: true,
DoneReason: "stop",
TotalDuration: totalDuration.Nanoseconds(),
PromptEvalCount: tokenUsage.Prompt,
EvalCount: tokenUsage.Completion,
}
return c.JSON(200, resp)
}
func handleOllamaChatStream(c echo.Context, input *schema.OllamaChatRequest, cfg *config.ModelConfig, ml *model.ModelLoader, cl *config.ModelConfigLoader, appConfig *config.ApplicationConfig, predInput string, openAIReq *schema.OpenAIRequest) error {
c.Response().Header().Set("Content-Type", "application/x-ndjson")
c.Response().Header().Set("Cache-Control", "no-cache")
c.Response().Header().Set("Connection", "keep-alive")
startTime := time.Now()
tokenCallback := func(token string, usage backend.TokenUsage) bool {
chunk := schema.OllamaChatResponse{
Model: input.Model,
CreatedAt: time.Now().UTC(),
Message: schema.OllamaMessage{
Role: "assistant",
Content: token,
},
Done: false,
}
return writeNDJSON(c, chunk)
}
_, tokenUsage, _, err := openaiEndpoint.ComputeChoices(openAIReq, predInput, cfg, cl, appConfig, ml, func(s string, choices *[]schema.Choice) {}, tokenCallback)
if err != nil {
xlog.Error("Ollama chat stream inference failed", "error", err)
errChunk := schema.OllamaChatResponse{
Model: input.Model,
CreatedAt: time.Now().UTC(),
Done: true,
DoneReason: "error",
}
writeNDJSON(c, errChunk)
return nil
}
// Send final done message
totalDuration := time.Since(startTime)
finalChunk := schema.OllamaChatResponse{
Model: input.Model,
CreatedAt: time.Now().UTC(),
Message: schema.OllamaMessage{Role: "assistant", Content: ""},
Done: true,
DoneReason: "stop",
TotalDuration: totalDuration.Nanoseconds(),
PromptEvalCount: tokenUsage.Prompt,
EvalCount: tokenUsage.Completion,
}
writeNDJSON(c, finalChunk)
return nil
}

View File

@@ -0,0 +1,67 @@
package ollama
import (
"fmt"
"time"
"github.com/labstack/echo/v4"
"github.com/mudler/LocalAI/core/backend"
"github.com/mudler/LocalAI/core/config"
"github.com/mudler/LocalAI/core/http/middleware"
"github.com/mudler/LocalAI/core/schema"
"github.com/mudler/LocalAI/pkg/model"
"github.com/mudler/xlog"
)
// EmbedEndpoint handles Ollama-compatible /api/embed and /api/embeddings requests
func EmbedEndpoint(cl *config.ModelConfigLoader, ml *model.ModelLoader, appConfig *config.ApplicationConfig) echo.HandlerFunc {
return func(c echo.Context) error {
input, ok := c.Get(middleware.CONTEXT_LOCALS_KEY_LOCALAI_REQUEST).(*schema.OllamaEmbedRequest)
if !ok || input.Model == "" {
return ollamaError(c, 400, "model is required")
}
cfg, ok := c.Get(middleware.CONTEXT_LOCALS_KEY_MODEL_CONFIG).(*config.ModelConfig)
if !ok || cfg == nil {
return ollamaError(c, 400, "model configuration not found")
}
startTime := time.Now()
inputStrings := input.GetInputStrings()
if len(inputStrings) == 0 {
return ollamaError(c, 400, "input is required")
}
var allEmbeddings [][]float32
promptEvalCount := 0
for _, s := range inputStrings {
embedFn, err := backend.ModelEmbedding(s, []int{}, ml, *cfg, appConfig)
if err != nil {
xlog.Error("Ollama embed failed", "error", err)
return ollamaError(c, 500, fmt.Sprintf("embedding failed: %v", err))
}
embeddings, err := embedFn()
if err != nil {
xlog.Error("Ollama embed computation failed", "error", err)
return ollamaError(c, 500, fmt.Sprintf("embedding computation failed: %v", err))
}
allEmbeddings = append(allEmbeddings, embeddings)
// Rough token count estimate
promptEvalCount += len(s) / 4
}
totalDuration := time.Since(startTime)
resp := schema.OllamaEmbedResponse{
Model: input.Model,
Embeddings: allEmbeddings,
TotalDuration: totalDuration.Nanoseconds(),
PromptEvalCount: promptEvalCount,
}
return c.JSON(200, resp)
}
}

View File

@@ -0,0 +1,179 @@
package ollama
import (
"fmt"
"time"
"github.com/labstack/echo/v4"
"github.com/mudler/LocalAI/core/backend"
"github.com/mudler/LocalAI/core/config"
openaiEndpoint "github.com/mudler/LocalAI/core/http/endpoints/openai"
"github.com/mudler/LocalAI/core/http/middleware"
"github.com/mudler/LocalAI/core/schema"
"github.com/mudler/LocalAI/core/templates"
"github.com/mudler/LocalAI/pkg/model"
"github.com/mudler/xlog"
)
// GenerateEndpoint handles Ollama-compatible /api/generate requests
func GenerateEndpoint(cl *config.ModelConfigLoader, ml *model.ModelLoader, evaluator *templates.Evaluator, appConfig *config.ApplicationConfig) echo.HandlerFunc {
return func(c echo.Context) error {
input, ok := c.Get(middleware.CONTEXT_LOCALS_KEY_LOCALAI_REQUEST).(*schema.OllamaGenerateRequest)
if !ok || input.Model == "" {
return ollamaError(c, 400, "model is required")
}
cfg, ok := c.Get(middleware.CONTEXT_LOCALS_KEY_MODEL_CONFIG).(*config.ModelConfig)
if !ok || cfg == nil {
return ollamaError(c, 400, "model configuration not found")
}
// Handle empty prompt — return immediately with "load" reason
if input.Prompt == "" {
resp := schema.OllamaGenerateResponse{
Model: input.Model,
CreatedAt: time.Now().UTC(),
Response: "",
Done: true,
DoneReason: "load",
}
if input.IsStream() {
c.Response().Header().Set("Content-Type", "application/x-ndjson")
writeNDJSON(c, resp)
return nil
}
return c.JSON(200, resp)
}
applyOllamaOptions(input.Options, cfg)
// Build messages from prompt
var messages []schema.Message
if input.System != "" {
messages = append(messages, schema.Message{
Role: "system",
StringContent: input.System,
Content: input.System,
})
}
messages = append(messages, schema.Message{
Role: "user",
StringContent: input.Prompt,
Content: input.Prompt,
})
openAIReq := &schema.OpenAIRequest{
PredictionOptions: schema.PredictionOptions{
BasicModelRequest: schema.BasicModelRequest{Model: input.Model},
},
Messages: messages,
Stream: input.IsStream(),
Context: input.Ctx,
Cancel: input.Cancel,
}
if input.Options != nil {
openAIReq.Temperature = input.Options.Temperature
openAIReq.TopP = input.Options.TopP
openAIReq.TopK = input.Options.TopK
openAIReq.RepeatPenalty = input.Options.RepeatPenalty
if input.Options.NumPredict != nil {
openAIReq.Maxtokens = input.Options.NumPredict
}
if len(input.Options.Stop) > 0 {
openAIReq.Stop = input.Options.Stop
}
}
var predInput string
if input.Raw {
// Raw mode: skip chat template, use prompt directly
predInput = input.Prompt
} else {
predInput = evaluator.TemplateMessages(*openAIReq, openAIReq.Messages, cfg, nil, false)
}
xlog.Debug("Ollama Generate - Prompt", "prompt_len", len(predInput), "raw", input.Raw)
if input.IsStream() {
return handleOllamaGenerateStream(c, input, cfg, ml, cl, appConfig, predInput, openAIReq)
}
return handleOllamaGenerateNonStream(c, input, cfg, ml, cl, appConfig, predInput, openAIReq)
}
}
func handleOllamaGenerateNonStream(c echo.Context, input *schema.OllamaGenerateRequest, cfg *config.ModelConfig, ml *model.ModelLoader, cl *config.ModelConfigLoader, appConfig *config.ApplicationConfig, predInput string, openAIReq *schema.OpenAIRequest) error {
startTime := time.Now()
var result string
cb := func(s string, choices *[]schema.Choice) {
result = s
}
_, tokenUsage, _, err := openaiEndpoint.ComputeChoices(openAIReq, predInput, cfg, cl, appConfig, ml, cb, nil)
if err != nil {
xlog.Error("Ollama generate inference failed", "error", err)
return ollamaError(c, 500, fmt.Sprintf("model inference failed: %v", err))
}
totalDuration := time.Since(startTime)
resp := schema.OllamaGenerateResponse{
Model: input.Model,
CreatedAt: time.Now().UTC(),
Response: result,
Done: true,
DoneReason: "stop",
TotalDuration: totalDuration.Nanoseconds(),
PromptEvalCount: tokenUsage.Prompt,
EvalCount: tokenUsage.Completion,
}
return c.JSON(200, resp)
}
func handleOllamaGenerateStream(c echo.Context, input *schema.OllamaGenerateRequest, cfg *config.ModelConfig, ml *model.ModelLoader, cl *config.ModelConfigLoader, appConfig *config.ApplicationConfig, predInput string, openAIReq *schema.OpenAIRequest) error {
c.Response().Header().Set("Content-Type", "application/x-ndjson")
c.Response().Header().Set("Cache-Control", "no-cache")
c.Response().Header().Set("Connection", "keep-alive")
startTime := time.Now()
tokenCallback := func(token string, usage backend.TokenUsage) bool {
chunk := schema.OllamaGenerateResponse{
Model: input.Model,
CreatedAt: time.Now().UTC(),
Response: token,
Done: false,
}
return writeNDJSON(c, chunk)
}
_, tokenUsage, _, err := openaiEndpoint.ComputeChoices(openAIReq, predInput, cfg, cl, appConfig, ml, func(s string, choices *[]schema.Choice) {}, tokenCallback)
if err != nil {
xlog.Error("Ollama generate stream inference failed", "error", err)
errChunk := schema.OllamaGenerateResponse{
Model: input.Model,
CreatedAt: time.Now().UTC(),
Done: true,
DoneReason: "error",
}
writeNDJSON(c, errChunk)
return nil
}
totalDuration := time.Since(startTime)
finalChunk := schema.OllamaGenerateResponse{
Model: input.Model,
CreatedAt: time.Now().UTC(),
Response: "",
Done: true,
DoneReason: "stop",
TotalDuration: totalDuration.Nanoseconds(),
PromptEvalCount: tokenUsage.Prompt,
EvalCount: tokenUsage.Completion,
}
writeNDJSON(c, finalChunk)
return nil
}

View File

@@ -0,0 +1,83 @@
package ollama
import (
"encoding/json"
"fmt"
"github.com/labstack/echo/v4"
"github.com/mudler/LocalAI/core/config"
"github.com/mudler/LocalAI/core/schema"
"github.com/mudler/xlog"
)
// writeNDJSON writes a JSON object followed by a newline to the response (NDJSON format)
func writeNDJSON(c echo.Context, v any) bool {
data, err := json.Marshal(v)
if err != nil {
xlog.Error("Failed to marshal NDJSON", "error", err)
return false
}
_, err = fmt.Fprintf(c.Response().Writer, "%s\n", data)
if err != nil {
return false
}
c.Response().Flush()
return true
}
// ollamaError sends an Ollama-compatible JSON error response
func ollamaError(c echo.Context, statusCode int, message string) error {
return c.JSON(statusCode, map[string]string{"error": message})
}
// applyOllamaOptions applies Ollama options to the model configuration
func applyOllamaOptions(opts *schema.OllamaOptions, cfg *config.ModelConfig) {
if opts == nil {
return
}
if opts.Temperature != nil {
cfg.Temperature = opts.Temperature
}
if opts.TopP != nil {
cfg.TopP = opts.TopP
}
if opts.TopK != nil {
cfg.TopK = opts.TopK
}
if opts.NumPredict != nil {
cfg.Maxtokens = opts.NumPredict
}
if opts.RepeatPenalty != 0 {
cfg.RepeatPenalty = opts.RepeatPenalty
}
if opts.RepeatLastN != 0 {
cfg.RepeatLastN = opts.RepeatLastN
}
if len(opts.Stop) > 0 {
cfg.StopWords = append(cfg.StopWords, opts.Stop...)
}
if opts.NumCtx > 0 {
cfg.ContextSize = &opts.NumCtx
}
}
// ollamaMessagesToOpenAI converts Ollama messages to OpenAI-compatible messages
func ollamaMessagesToOpenAI(messages []schema.OllamaMessage) []schema.Message {
var result []schema.Message
for _, msg := range messages {
openAIMsg := schema.Message{
Role: msg.Role,
StringContent: msg.Content,
Content: msg.Content,
}
// Convert base64 images to data URIs
for _, img := range msg.Images {
dataURI := fmt.Sprintf("data:image/png;base64,%s", img)
openAIMsg.StringImages = append(openAIMsg.StringImages, dataURI)
}
result = append(result, openAIMsg)
}
return result
}

View File

@@ -0,0 +1,142 @@
package ollama
import (
"crypto/sha256"
"fmt"
"strings"
"time"
"github.com/labstack/echo/v4"
"github.com/mudler/LocalAI/core/config"
"github.com/mudler/LocalAI/core/schema"
"github.com/mudler/LocalAI/core/services/galleryop"
"github.com/mudler/LocalAI/pkg/model"
)
const ollamaCompatVersion = "0.9.0"
// ListModelsEndpoint handles Ollama-compatible GET /api/tags
func ListModelsEndpoint(bcl *config.ModelConfigLoader, ml *model.ModelLoader) echo.HandlerFunc {
return func(c echo.Context) error {
modelNames, err := galleryop.ListModels(bcl, ml, nil, galleryop.SKIP_IF_CONFIGURED)
if err != nil {
return ollamaError(c, 500, fmt.Sprintf("failed to list models: %v", err))
}
var models []schema.OllamaModelEntry
for _, name := range modelNames {
ollamaName := name
if !strings.Contains(ollamaName, ":") {
ollamaName += ":latest"
}
digest := fmt.Sprintf("sha256:%x", sha256.Sum256([]byte(name)))
entry := schema.OllamaModelEntry{
Name: ollamaName,
Model: ollamaName,
ModifiedAt: time.Now().UTC(),
Size: 0,
Digest: digest,
Details: modelDetailsFromConfig(bcl, name),
}
models = append(models, entry)
}
return c.JSON(200, schema.OllamaListResponse{Models: models})
}
}
// ShowModelEndpoint handles Ollama-compatible POST /api/show
func ShowModelEndpoint(bcl *config.ModelConfigLoader) echo.HandlerFunc {
return func(c echo.Context) error {
var req schema.OllamaShowRequest
if err := c.Bind(&req); err != nil {
return ollamaError(c, 400, "invalid request body")
}
name := req.Name
if name == "" {
name = req.Model
}
if name == "" {
return ollamaError(c, 400, "name is required")
}
// Strip tag suffix for config lookup
configName := strings.Split(name, ":")[0]
cfg, exists := bcl.GetModelConfig(configName)
if !exists {
return ollamaError(c, 404, fmt.Sprintf("model '%s' not found", name))
}
resp := schema.OllamaShowResponse{
Modelfile: fmt.Sprintf("FROM %s", cfg.Model),
Parameters: "",
Template: cfg.TemplateConfig.Chat,
Details: modelDetailsFromModelConfig(&cfg),
}
return c.JSON(200, resp)
}
}
// ListRunningEndpoint handles Ollama-compatible GET /api/ps
func ListRunningEndpoint(bcl *config.ModelConfigLoader, ml *model.ModelLoader) echo.HandlerFunc {
return func(c echo.Context) error {
loadedModels := ml.ListLoadedModels()
var models []schema.OllamaPsEntry
for _, m := range loadedModels {
name := m.ID
ollamaName := name
if !strings.Contains(ollamaName, ":") {
ollamaName += ":latest"
}
entry := schema.OllamaPsEntry{
Name: ollamaName,
Model: ollamaName,
Size: 0,
Digest: fmt.Sprintf("sha256:%x", sha256.Sum256([]byte(name))),
Details: modelDetailsFromConfig(bcl, name),
ExpiresAt: time.Now().Add(24 * time.Hour).UTC(),
SizeVRAM: 0,
}
models = append(models, entry)
}
return c.JSON(200, schema.OllamaPsResponse{Models: models})
}
}
// VersionEndpoint handles Ollama-compatible GET /api/version
func VersionEndpoint() echo.HandlerFunc {
return func(c echo.Context) error {
return c.JSON(200, schema.OllamaVersionResponse{Version: ollamaCompatVersion})
}
}
// HeartbeatEndpoint handles the Ollama root health check
func HeartbeatEndpoint() echo.HandlerFunc {
return func(c echo.Context) error {
return c.String(200, "Ollama is running")
}
}
func modelDetailsFromConfig(bcl *config.ModelConfigLoader, name string) schema.OllamaModelDetails {
configName := strings.Split(name, ":")[0]
cfg, exists := bcl.GetModelConfig(configName)
if !exists {
return schema.OllamaModelDetails{}
}
return modelDetailsFromModelConfig(&cfg)
}
func modelDetailsFromModelConfig(cfg *config.ModelConfig) schema.OllamaModelDetails {
return schema.OllamaModelDetails{
Format: "gguf",
Family: cfg.Backend,
}
}

View File

@@ -0,0 +1,62 @@
package ollama_test
import (
"net/http"
"net/http/httptest"
"testing"
"github.com/labstack/echo/v4"
"github.com/mudler/LocalAI/core/http/endpoints/ollama"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
func TestOllamaEndpoints(t *testing.T) {
RegisterFailHandler(Fail)
RunSpecs(t, "Ollama Endpoints Suite")
}
var _ = Describe("Ollama endpoint handlers", func() {
var e *echo.Echo
BeforeEach(func() {
e = echo.New()
})
Describe("HeartbeatEndpoint", func() {
It("returns 'Ollama is running' on GET /", func() {
req := httptest.NewRequest(http.MethodGet, "/", nil)
rec := httptest.NewRecorder()
c := e.NewContext(req, rec)
handler := ollama.HeartbeatEndpoint()
Expect(handler(c)).To(Succeed())
Expect(rec.Code).To(Equal(http.StatusOK))
Expect(rec.Body.String()).To(Equal("Ollama is running"))
})
It("returns 200 on HEAD /", func() {
req := httptest.NewRequest(http.MethodHead, "/", nil)
rec := httptest.NewRecorder()
c := e.NewContext(req, rec)
handler := ollama.HeartbeatEndpoint()
Expect(handler(c)).To(Succeed())
Expect(rec.Code).To(Equal(http.StatusOK))
})
})
Describe("VersionEndpoint", func() {
It("returns a JSON object with version field", func() {
req := httptest.NewRequest(http.MethodGet, "/api/version", nil)
rec := httptest.NewRecorder()
c := e.NewContext(req, rec)
handler := ollama.VersionEndpoint()
Expect(handler(c)).To(Succeed())
Expect(rec.Code).To(Equal(http.StatusOK))
Expect(rec.Body.String()).To(ContainSubstring(`"version"`))
Expect(rec.Body.String()).To(MatchRegexp(`\d+\.\d+\.\d+`))
})
})
})

165
core/http/routes/ollama.go Normal file
View File

@@ -0,0 +1,165 @@
package routes
import (
"context"
"github.com/google/uuid"
"github.com/labstack/echo/v4"
"github.com/mudler/LocalAI/core/application"
"github.com/mudler/LocalAI/core/config"
"github.com/mudler/LocalAI/core/http/endpoints/ollama"
"github.com/mudler/LocalAI/core/http/middleware"
"github.com/mudler/LocalAI/core/schema"
)
func RegisterOllamaRoutes(app *echo.Echo,
re *middleware.RequestExtractor,
application *application.Application) {
traceMiddleware := middleware.TraceMiddleware(application)
usageMiddleware := middleware.UsageMiddleware(application.AuthDB())
// Chat endpoint: POST /api/chat
chatHandler := ollama.ChatEndpoint(
application.ModelConfigLoader(),
application.ModelLoader(),
application.TemplatesEvaluator(),
application.ApplicationConfig(),
)
chatMiddleware := []echo.MiddlewareFunc{
usageMiddleware,
traceMiddleware,
re.BuildFilteredFirstAvailableDefaultModel(config.BuildUsecaseFilterFn(config.FLAG_CHAT)),
re.SetModelAndConfig(func() schema.LocalAIRequest { return new(schema.OllamaChatRequest) }),
setOllamaChatRequestContext(application.ApplicationConfig()),
}
app.POST("/api/chat", chatHandler, chatMiddleware...)
// Generate endpoint: POST /api/generate
generateHandler := ollama.GenerateEndpoint(
application.ModelConfigLoader(),
application.ModelLoader(),
application.TemplatesEvaluator(),
application.ApplicationConfig(),
)
generateMiddleware := []echo.MiddlewareFunc{
usageMiddleware,
traceMiddleware,
re.BuildFilteredFirstAvailableDefaultModel(config.BuildUsecaseFilterFn(config.FLAG_CHAT)),
re.SetModelAndConfig(func() schema.LocalAIRequest { return new(schema.OllamaGenerateRequest) }),
setOllamaGenerateRequestContext(application.ApplicationConfig()),
}
app.POST("/api/generate", generateHandler, generateMiddleware...)
// Embed endpoints: POST /api/embed and /api/embeddings
embedHandler := ollama.EmbedEndpoint(
application.ModelConfigLoader(),
application.ModelLoader(),
application.ApplicationConfig(),
)
embedMiddleware := []echo.MiddlewareFunc{
usageMiddleware,
traceMiddleware,
re.BuildFilteredFirstAvailableDefaultModel(config.BuildUsecaseFilterFn(config.FLAG_EMBEDDINGS)),
re.SetModelAndConfig(func() schema.LocalAIRequest { return new(schema.OllamaEmbedRequest) }),
}
app.POST("/api/embed", embedHandler, embedMiddleware...)
app.POST("/api/embeddings", embedHandler, embedMiddleware...)
// Model management endpoints (no model-specific middleware needed)
app.GET("/api/tags", ollama.ListModelsEndpoint(application.ModelConfigLoader(), application.ModelLoader()))
app.HEAD("/api/tags", ollama.ListModelsEndpoint(application.ModelConfigLoader(), application.ModelLoader()))
app.POST("/api/show", ollama.ShowModelEndpoint(application.ModelConfigLoader()))
app.GET("/api/ps", ollama.ListRunningEndpoint(application.ModelConfigLoader(), application.ModelLoader()))
app.GET("/api/version", ollama.VersionEndpoint())
app.HEAD("/api/version", ollama.VersionEndpoint())
}
// RegisterOllamaRootEndpoint registers the Ollama "/" health check.
// This is separate because it conflicts with the web UI and is gated behind a CLI flag.
func RegisterOllamaRootEndpoint(app *echo.Echo) {
app.GET("/", ollama.HeartbeatEndpoint())
app.HEAD("/", ollama.HeartbeatEndpoint())
}
// setOllamaChatRequestContext sets up context and cancellation for Ollama chat requests
func setOllamaChatRequestContext(appConfig *config.ApplicationConfig) echo.MiddlewareFunc {
return func(next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error {
input, ok := c.Get(middleware.CONTEXT_LOCALS_KEY_LOCALAI_REQUEST).(*schema.OllamaChatRequest)
if !ok || input.Model == "" {
return echo.ErrBadRequest
}
cfg, ok := c.Get(middleware.CONTEXT_LOCALS_KEY_MODEL_CONFIG).(*config.ModelConfig)
if !ok || cfg == nil {
return echo.ErrBadRequest
}
correlationID := uuid.New().String()
c.Response().Header().Set("X-Correlation-ID", correlationID)
reqCtx := c.Request().Context()
c1, cancel := context.WithCancel(appConfig.Context)
stop := context.AfterFunc(reqCtx, cancel)
defer func() {
stop()
cancel()
}()
ctxWithCorrelationID := context.WithValue(c1, middleware.CorrelationIDKey, correlationID)
input.Context = ctxWithCorrelationID
input.Cancel = cancel
if cfg.Model == "" {
cfg.Model = input.Model
}
c.Set(middleware.CONTEXT_LOCALS_KEY_LOCALAI_REQUEST, input)
c.Set(middleware.CONTEXT_LOCALS_KEY_MODEL_CONFIG, cfg)
return next(c)
}
}
}
// setOllamaGenerateRequestContext sets up context and cancellation for Ollama generate requests
func setOllamaGenerateRequestContext(appConfig *config.ApplicationConfig) echo.MiddlewareFunc {
return func(next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error {
input, ok := c.Get(middleware.CONTEXT_LOCALS_KEY_LOCALAI_REQUEST).(*schema.OllamaGenerateRequest)
if !ok || input.Model == "" {
return echo.ErrBadRequest
}
cfg, ok := c.Get(middleware.CONTEXT_LOCALS_KEY_MODEL_CONFIG).(*config.ModelConfig)
if !ok || cfg == nil {
return echo.ErrBadRequest
}
correlationID := uuid.New().String()
c.Response().Header().Set("X-Correlation-ID", correlationID)
reqCtx := c.Request().Context()
c1, cancel := context.WithCancel(appConfig.Context)
stop := context.AfterFunc(reqCtx, cancel)
defer func() {
stop()
cancel()
}()
ctxWithCorrelationID := context.WithValue(c1, middleware.CorrelationIDKey, correlationID)
input.Ctx = ctxWithCorrelationID
input.Cancel = cancel
if cfg.Model == "" {
cfg.Model = input.Model
}
c.Set(middleware.CONTEXT_LOCALS_KEY_LOCALAI_REQUEST, input)
c.Set(middleware.CONTEXT_LOCALS_KEY_MODEL_CONFIG, cfg)
return next(c)
}
}
}

257
core/schema/ollama.go Normal file
View File

@@ -0,0 +1,257 @@
package schema
import (
"context"
"time"
)
// OllamaOptions represents runtime parameters for Ollama generation
type OllamaOptions struct {
Temperature *float64 `json:"temperature,omitempty"`
TopP *float64 `json:"top_p,omitempty"`
TopK *int `json:"top_k,omitempty"`
NumPredict *int `json:"num_predict,omitempty"`
RepeatPenalty float64 `json:"repeat_penalty,omitempty"`
RepeatLastN int `json:"repeat_last_n,omitempty"`
Seed *int `json:"seed,omitempty"`
Stop []string `json:"stop,omitempty"`
NumCtx int `json:"num_ctx,omitempty"`
}
// OllamaMessage represents a message in Ollama chat format
type OllamaMessage struct {
Role string `json:"role"`
Content string `json:"content"`
Images []string `json:"images,omitempty"`
ToolCalls []any `json:"tool_calls,omitempty"`
}
// OllamaChatRequest represents a request to the Ollama Chat API
type OllamaChatRequest struct {
Model string `json:"model"`
Messages []OllamaMessage `json:"messages"`
Stream *bool `json:"stream,omitempty"`
Format any `json:"format,omitempty"`
Options *OllamaOptions `json:"options,omitempty"`
Tools []any `json:"tools,omitempty"`
// Internal fields
Context context.Context `json:"-"`
Cancel context.CancelFunc `json:"-"`
}
// ModelName implements the LocalAIRequest interface
func (r *OllamaChatRequest) ModelName(s *string) string {
if s != nil {
r.Model = *s
}
return r.Model
}
// IsStream returns whether streaming is enabled (defaults to true for Ollama)
func (r *OllamaChatRequest) IsStream() bool {
if r.Stream == nil {
return true
}
return *r.Stream
}
// OllamaChatResponse represents a response from the Ollama Chat API
type OllamaChatResponse struct {
Model string `json:"model"`
CreatedAt time.Time `json:"created_at"`
Message OllamaMessage `json:"message"`
Done bool `json:"done"`
DoneReason string `json:"done_reason,omitempty"`
TotalDuration int64 `json:"total_duration,omitempty"`
LoadDuration int64 `json:"load_duration,omitempty"`
PromptEvalCount int `json:"prompt_eval_count,omitempty"`
PromptEvalDuration int64 `json:"prompt_eval_duration,omitempty"`
EvalCount int `json:"eval_count,omitempty"`
EvalDuration int64 `json:"eval_duration,omitempty"`
}
// OllamaGenerateRequest represents a request to the Ollama Generate API
type OllamaGenerateRequest struct {
Model string `json:"model"`
Prompt string `json:"prompt"`
System string `json:"system,omitempty"`
Stream *bool `json:"stream,omitempty"`
Raw bool `json:"raw,omitempty"`
Format any `json:"format,omitempty"`
Options *OllamaOptions `json:"options,omitempty"`
// Context from a previous generate call for continuation
Context []int `json:"context,omitempty"`
// Internal fields
Ctx context.Context `json:"-"`
Cancel context.CancelFunc `json:"-"`
}
// ModelName implements the LocalAIRequest interface
func (r *OllamaGenerateRequest) ModelName(s *string) string {
if s != nil {
r.Model = *s
}
return r.Model
}
// IsStream returns whether streaming is enabled (defaults to true for Ollama)
func (r *OllamaGenerateRequest) IsStream() bool {
if r.Stream == nil {
return true
}
return *r.Stream
}
// OllamaGenerateResponse represents a response from the Ollama Generate API
type OllamaGenerateResponse struct {
Model string `json:"model"`
CreatedAt time.Time `json:"created_at"`
Response string `json:"response"`
Done bool `json:"done"`
DoneReason string `json:"done_reason,omitempty"`
Context []int `json:"context,omitempty"`
TotalDuration int64 `json:"total_duration,omitempty"`
LoadDuration int64 `json:"load_duration,omitempty"`
PromptEvalCount int `json:"prompt_eval_count,omitempty"`
PromptEvalDuration int64 `json:"prompt_eval_duration,omitempty"`
EvalCount int `json:"eval_count,omitempty"`
EvalDuration int64 `json:"eval_duration,omitempty"`
}
// OllamaEmbedRequest represents a request to the Ollama Embed API
type OllamaEmbedRequest struct {
Model string `json:"model"`
Input any `json:"input"` // string or []string
Options *OllamaOptions `json:"options,omitempty"`
}
// ModelName implements the LocalAIRequest interface
func (r *OllamaEmbedRequest) ModelName(s *string) string {
if s != nil {
r.Model = *s
}
return r.Model
}
// GetInputStrings normalizes the Input field to a string slice
func (r *OllamaEmbedRequest) GetInputStrings() []string {
switch v := r.Input.(type) {
case string:
return []string{v}
case []any:
var result []string
for _, item := range v {
if s, ok := item.(string); ok {
result = append(result, s)
}
}
return result
case []string:
return v
}
return nil
}
// OllamaEmbedResponse represents a response from the Ollama Embed API
type OllamaEmbedResponse struct {
Model string `json:"model"`
Embeddings [][]float32 `json:"embeddings"`
TotalDuration int64 `json:"total_duration,omitempty"`
LoadDuration int64 `json:"load_duration,omitempty"`
PromptEvalCount int `json:"prompt_eval_count,omitempty"`
}
// OllamaShowRequest represents a request to the Ollama Show API
type OllamaShowRequest struct {
Name string `json:"name"`
Model string `json:"model"`
Verbose bool `json:"verbose,omitempty"`
}
// ModelName implements the LocalAIRequest interface
func (r *OllamaShowRequest) ModelName(s *string) string {
name := r.Name
if name == "" {
name = r.Model
}
if s != nil {
r.Name = *s
}
return name
}
// OllamaShowResponse represents a response from the Ollama Show API
type OllamaShowResponse struct {
Modelfile string `json:"modelfile"`
Parameters string `json:"parameters"`
Template string `json:"template"`
License string `json:"license,omitempty"`
Details OllamaModelDetails `json:"details"`
}
// OllamaModelDetails contains model metadata
type OllamaModelDetails struct {
ParentModel string `json:"parent_model,omitempty"`
Format string `json:"format,omitempty"`
Family string `json:"family,omitempty"`
Families []string `json:"families,omitempty"`
ParameterSize string `json:"parameter_size,omitempty"`
QuantizationLevel string `json:"quantization_level,omitempty"`
}
// OllamaModelEntry represents a model in the list response
type OllamaModelEntry struct {
Name string `json:"name"`
Model string `json:"model"`
ModifiedAt time.Time `json:"modified_at"`
Size int64 `json:"size"`
Digest string `json:"digest"`
Details OllamaModelDetails `json:"details"`
}
// OllamaListResponse represents a response from the Ollama Tags API
type OllamaListResponse struct {
Models []OllamaModelEntry `json:"models"`
}
// OllamaPsEntry represents a running model in the ps response
type OllamaPsEntry struct {
Name string `json:"name"`
Model string `json:"model"`
Size int64 `json:"size"`
Digest string `json:"digest"`
Details OllamaModelDetails `json:"details"`
ExpiresAt time.Time `json:"expires_at"`
SizeVRAM int64 `json:"size_vram"`
}
// OllamaPsResponse represents a response from the Ollama Ps API
type OllamaPsResponse struct {
Models []OllamaPsEntry `json:"models"`
}
// OllamaVersionResponse represents a response from the Ollama Version API
type OllamaVersionResponse struct {
Version string `json:"version"`
}
// OllamaPullRequest represents a request to pull a model
type OllamaPullRequest struct {
Name string `json:"name"`
Insecure bool `json:"insecure,omitempty"`
Stream *bool `json:"stream,omitempty"`
}
// OllamaDeleteRequest represents a request to delete a model
type OllamaDeleteRequest struct {
Name string `json:"name"`
Model string `json:"model"`
}
// OllamaCopyRequest represents a request to copy a model
type OllamaCopyRequest struct {
Source string `json:"source"`
Destination string `json:"destination"`
}