Skip to content

Commit 85be4ff

Browse files
authored
feat(api): add ollama compatibility (#9284)
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
1 parent b0d9ce4 commit 85be4ff

15 files changed

Lines changed: 1495 additions & 10 deletions

File tree

core/cli/run.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,7 @@ type RunCMD struct {
6262
UploadLimit int `env:"LOCALAI_UPLOAD_LIMIT,UPLOAD_LIMIT" default:"15" help:"Default upload-limit in MB" group:"api"`
6363
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"`
6464
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"`
65+
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"`
6566
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"`
6667
DisablePredownloadScan bool `env:"LOCALAI_DISABLE_PREDOWNLOAD_SCAN" help:"If true, disables the best-effort security scanner before downloading any files." group:"hardening" default:"false"`
6768
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 {
295296
opts = append(opts, config.DisableWebUI)
296297
}
297298

299+
if r.OllamaAPIRootEndpoint {
300+
opts = append(opts, config.EnableOllamaAPIRootEndpoint)
301+
}
302+
298303
if r.DisableGalleryEndpoint {
299304
opts = append(opts, config.DisableGalleryEndpoint)
300305
}

core/config/application_config.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ type ApplicationConfig struct {
4040
Federated bool
4141

4242
DisableWebUI bool
43+
OllamaAPIRootEndpoint bool
4344
EnforcePredownloadScans bool
4445
OpaqueErrors bool
4546
UseSubtleKeyComparison bool
@@ -263,6 +264,10 @@ var DisableWebUI = func(o *ApplicationConfig) {
263264
o.DisableWebUI = true
264265
}
265266

267+
var EnableOllamaAPIRootEndpoint = func(o *ApplicationConfig) {
268+
o.OllamaAPIRootEndpoint = true
269+
}
270+
266271
var DisableRuntimeSettings = func(o *ApplicationConfig) {
267272
o.DisableRuntimeSettings = true
268273
}

core/http/app.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -391,6 +391,10 @@ func API(application *application.Application) (*echo.Echo, error) {
391391
routes.RegisterOpenAIRoutes(e, requestExtractor, application)
392392
routes.RegisterAnthropicRoutes(e, requestExtractor, application)
393393
routes.RegisterOpenResponsesRoutes(e, requestExtractor, application)
394+
routes.RegisterOllamaRoutes(e, requestExtractor, application)
395+
if application.ApplicationConfig().OllamaAPIRootEndpoint {
396+
routes.RegisterOllamaRootEndpoint(e)
397+
}
394398
if !application.ApplicationConfig().DisableWebUI {
395399
routes.RegisterUIAPIRoutes(e, application.ModelConfigLoader(), application.ModelLoader(), application.ApplicationConfig(), application.GalleryService(), opcache, application, adminMiddleware)
396400
routes.RegisterUIRoutes(e, application.ModelConfigLoader(), application.ApplicationConfig(), application.GalleryService(), adminMiddleware)

core/http/endpoints/ollama/chat.go

Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,153 @@
1+
package ollama
2+
3+
import (
4+
"fmt"
5+
"time"
6+
7+
"github.com/labstack/echo/v4"
8+
"github.com/mudler/LocalAI/core/backend"
9+
"github.com/mudler/LocalAI/core/config"
10+
openaiEndpoint "github.com/mudler/LocalAI/core/http/endpoints/openai"
11+
"github.com/mudler/LocalAI/core/http/middleware"
12+
"github.com/mudler/LocalAI/core/schema"
13+
"github.com/mudler/LocalAI/core/templates"
14+
"github.com/mudler/LocalAI/pkg/model"
15+
"github.com/mudler/xlog"
16+
)
17+
18+
// ChatEndpoint handles Ollama-compatible /api/chat requests
19+
func ChatEndpoint(cl *config.ModelConfigLoader, ml *model.ModelLoader, evaluator *templates.Evaluator, appConfig *config.ApplicationConfig) echo.HandlerFunc {
20+
return func(c echo.Context) error {
21+
input, ok := c.Get(middleware.CONTEXT_LOCALS_KEY_LOCALAI_REQUEST).(*schema.OllamaChatRequest)
22+
if !ok || input.Model == "" {
23+
return ollamaError(c, 400, "model is required")
24+
}
25+
26+
cfg, ok := c.Get(middleware.CONTEXT_LOCALS_KEY_MODEL_CONFIG).(*config.ModelConfig)
27+
if !ok || cfg == nil {
28+
return ollamaError(c, 400, "model configuration not found")
29+
}
30+
31+
// Apply Ollama options to config
32+
applyOllamaOptions(input.Options, cfg)
33+
34+
// Convert Ollama messages to OpenAI format
35+
openAIMessages := ollamaMessagesToOpenAI(input.Messages)
36+
37+
// Build an OpenAI-compatible request
38+
openAIReq := &schema.OpenAIRequest{
39+
PredictionOptions: schema.PredictionOptions{
40+
BasicModelRequest: schema.BasicModelRequest{Model: input.Model},
41+
},
42+
Messages: openAIMessages,
43+
Stream: input.IsStream(),
44+
Context: input.Context,
45+
Cancel: input.Cancel,
46+
}
47+
48+
if input.Options != nil {
49+
openAIReq.Temperature = input.Options.Temperature
50+
openAIReq.TopP = input.Options.TopP
51+
openAIReq.TopK = input.Options.TopK
52+
openAIReq.RepeatPenalty = input.Options.RepeatPenalty
53+
if input.Options.NumPredict != nil {
54+
openAIReq.Maxtokens = input.Options.NumPredict
55+
}
56+
if len(input.Options.Stop) > 0 {
57+
openAIReq.Stop = input.Options.Stop
58+
}
59+
}
60+
61+
predInput := evaluator.TemplateMessages(*openAIReq, openAIReq.Messages, cfg, nil, false)
62+
xlog.Debug("Ollama Chat - Prompt (after templating)", "prompt_len", len(predInput))
63+
64+
if input.IsStream() {
65+
return handleOllamaChatStream(c, input, cfg, ml, cl, appConfig, predInput, openAIReq)
66+
}
67+
68+
return handleOllamaChatNonStream(c, input, cfg, ml, cl, appConfig, predInput, openAIReq)
69+
}
70+
}
71+
72+
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 {
73+
startTime := time.Now()
74+
var result string
75+
76+
cb := func(s string, choices *[]schema.Choice) {
77+
result = s
78+
}
79+
80+
_, tokenUsage, _, err := openaiEndpoint.ComputeChoices(openAIReq, predInput, cfg, cl, appConfig, ml, cb, nil)
81+
if err != nil {
82+
xlog.Error("Ollama chat inference failed", "error", err)
83+
return ollamaError(c, 500, fmt.Sprintf("model inference failed: %v", err))
84+
}
85+
86+
totalDuration := time.Since(startTime)
87+
88+
resp := schema.OllamaChatResponse{
89+
Model: input.Model,
90+
CreatedAt: time.Now().UTC(),
91+
Message: schema.OllamaMessage{
92+
Role: "assistant",
93+
Content: result,
94+
},
95+
Done: true,
96+
DoneReason: "stop",
97+
TotalDuration: totalDuration.Nanoseconds(),
98+
PromptEvalCount: tokenUsage.Prompt,
99+
EvalCount: tokenUsage.Completion,
100+
}
101+
102+
return c.JSON(200, resp)
103+
}
104+
105+
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 {
106+
c.Response().Header().Set("Content-Type", "application/x-ndjson")
107+
c.Response().Header().Set("Cache-Control", "no-cache")
108+
c.Response().Header().Set("Connection", "keep-alive")
109+
110+
startTime := time.Now()
111+
112+
tokenCallback := func(token string, usage backend.TokenUsage) bool {
113+
chunk := schema.OllamaChatResponse{
114+
Model: input.Model,
115+
CreatedAt: time.Now().UTC(),
116+
Message: schema.OllamaMessage{
117+
Role: "assistant",
118+
Content: token,
119+
},
120+
Done: false,
121+
}
122+
return writeNDJSON(c, chunk)
123+
}
124+
125+
_, tokenUsage, _, err := openaiEndpoint.ComputeChoices(openAIReq, predInput, cfg, cl, appConfig, ml, func(s string, choices *[]schema.Choice) {}, tokenCallback)
126+
if err != nil {
127+
xlog.Error("Ollama chat stream inference failed", "error", err)
128+
errChunk := schema.OllamaChatResponse{
129+
Model: input.Model,
130+
CreatedAt: time.Now().UTC(),
131+
Done: true,
132+
DoneReason: "error",
133+
}
134+
writeNDJSON(c, errChunk)
135+
return nil
136+
}
137+
138+
// Send final done message
139+
totalDuration := time.Since(startTime)
140+
finalChunk := schema.OllamaChatResponse{
141+
Model: input.Model,
142+
CreatedAt: time.Now().UTC(),
143+
Message: schema.OllamaMessage{Role: "assistant", Content: ""},
144+
Done: true,
145+
DoneReason: "stop",
146+
TotalDuration: totalDuration.Nanoseconds(),
147+
PromptEvalCount: tokenUsage.Prompt,
148+
EvalCount: tokenUsage.Completion,
149+
}
150+
writeNDJSON(c, finalChunk)
151+
152+
return nil
153+
}
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
package ollama
2+
3+
import (
4+
"fmt"
5+
"time"
6+
7+
"github.com/labstack/echo/v4"
8+
"github.com/mudler/LocalAI/core/backend"
9+
"github.com/mudler/LocalAI/core/config"
10+
"github.com/mudler/LocalAI/core/http/middleware"
11+
"github.com/mudler/LocalAI/core/schema"
12+
"github.com/mudler/LocalAI/pkg/model"
13+
"github.com/mudler/xlog"
14+
)
15+
16+
// EmbedEndpoint handles Ollama-compatible /api/embed and /api/embeddings requests
17+
func EmbedEndpoint(cl *config.ModelConfigLoader, ml *model.ModelLoader, appConfig *config.ApplicationConfig) echo.HandlerFunc {
18+
return func(c echo.Context) error {
19+
input, ok := c.Get(middleware.CONTEXT_LOCALS_KEY_LOCALAI_REQUEST).(*schema.OllamaEmbedRequest)
20+
if !ok || input.Model == "" {
21+
return ollamaError(c, 400, "model is required")
22+
}
23+
24+
cfg, ok := c.Get(middleware.CONTEXT_LOCALS_KEY_MODEL_CONFIG).(*config.ModelConfig)
25+
if !ok || cfg == nil {
26+
return ollamaError(c, 400, "model configuration not found")
27+
}
28+
29+
startTime := time.Now()
30+
inputStrings := input.GetInputStrings()
31+
if len(inputStrings) == 0 {
32+
return ollamaError(c, 400, "input is required")
33+
}
34+
35+
var allEmbeddings [][]float32
36+
promptEvalCount := 0
37+
38+
for _, s := range inputStrings {
39+
embedFn, err := backend.ModelEmbedding(s, []int{}, ml, *cfg, appConfig)
40+
if err != nil {
41+
xlog.Error("Ollama embed failed", "error", err)
42+
return ollamaError(c, 500, fmt.Sprintf("embedding failed: %v", err))
43+
}
44+
45+
embeddings, err := embedFn()
46+
if err != nil {
47+
xlog.Error("Ollama embed computation failed", "error", err)
48+
return ollamaError(c, 500, fmt.Sprintf("embedding computation failed: %v", err))
49+
}
50+
51+
allEmbeddings = append(allEmbeddings, embeddings)
52+
// Rough token count estimate
53+
promptEvalCount += len(s) / 4
54+
}
55+
56+
totalDuration := time.Since(startTime)
57+
58+
resp := schema.OllamaEmbedResponse{
59+
Model: input.Model,
60+
Embeddings: allEmbeddings,
61+
TotalDuration: totalDuration.Nanoseconds(),
62+
PromptEvalCount: promptEvalCount,
63+
}
64+
65+
return c.JSON(200, resp)
66+
}
67+
}

0 commit comments

Comments
 (0)