Skip to content

Commit 133c546

Browse files
feat(api): add text moderation endpoint (#11316)
* feat(api): add text moderation endpoint Add an OpenAI-compatible /v1/moderations endpoint backed by constrained local text generation. Register its auth and discovery surfaces, document the text-only MVP, and cover response shaping and access control. Assisted-by: Codex:gpt-5 * test(mcp): update assistant client stub Keep the LocalAI Assistant holder test stub aligned with the scheduling methods added to LocalAIClient so repository-wide type checking succeeds.\n\nAssisted-by: Codex:gpt-5 --------- Co-authored-by: localai-org-maint-bot <306269227+localai-org-maint-bot@users.noreply.github.com>
1 parent 8a68f35 commit 133c546

18 files changed

Lines changed: 903 additions & 3 deletions

core/http/auth/features.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,10 @@ var RouteFeatureRegistry = []RouteFeature{
118118
// Rerank
119119
{"POST", "/v1/rerank", FeatureRerank},
120120

121+
// Moderation
122+
{"POST", "/v1/moderations", FeatureModeration},
123+
{"POST", "/moderations", FeatureModeration},
124+
121125
// Stores
122126
{"POST", "/stores/set", FeatureStores},
123127
{"POST", "/stores/delete", FeatureStores},
@@ -193,6 +197,7 @@ func APIFeatureMetas() []FeatureMeta {
193197
{FeatureEmbeddings, "Embeddings", true},
194198
{FeatureSound, "Sound Generation", true},
195199
{FeatureRealtime, "Realtime", true},
200+
{FeatureModeration, "Moderation", true},
196201
{FeatureRerank, "Rerank", true},
197202
{FeatureTokenize, "Tokenize", true},
198203
{FeatureMCP, "MCP", true},
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
package auth_test
2+
3+
import (
4+
. "github.com/mudler/LocalAI/core/http/auth"
5+
. "github.com/onsi/ginkgo/v2"
6+
. "github.com/onsi/gomega"
7+
)
8+
9+
var _ = Describe("Moderation feature registration", func() {
10+
It("registers both moderation routes as default-on API features", func() {
11+
Expect(APIFeatures).To(ContainElement(FeatureModeration))
12+
13+
patterns := []string{}
14+
for _, route := range RouteFeatureRegistry {
15+
if route.Feature == FeatureModeration {
16+
patterns = append(patterns, route.Pattern)
17+
}
18+
}
19+
Expect(patterns).To(ConsistOf("/v1/moderations", "/moderations"))
20+
21+
metas := APIFeatureMetas()
22+
Expect(metas).To(ContainElement(FeatureMeta{Key: FeatureModeration, Label: "Moderation", DefaultValue: true}))
23+
})
24+
})

core/http/auth/helpers_test.go

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,10 +59,14 @@ func ok(c echo.Context) error {
5959
func newAuthTestApp(db *gorm.DB, appConfig *config.ApplicationConfig) *echo.Echo {
6060
e := echo.New()
6161
e.Use(auth.Middleware(db, appConfig))
62+
if db != nil {
63+
e.Use(auth.RequireRouteFeature(db))
64+
}
6265

6366
// API routes (require auth)
6467
e.GET("/v1/models", ok)
6568
e.POST("/v1/chat/completions", ok)
69+
e.POST("/v1/moderations", ok)
6670
e.GET("/api/settings", ok)
6771
e.POST("/api/settings", ok)
6872

@@ -81,10 +85,14 @@ func newAuthTestApp(db *gorm.DB, appConfig *config.ApplicationConfig) *echo.Echo
8185
func newAdminTestApp(db *gorm.DB, appConfig *config.ApplicationConfig) *echo.Echo {
8286
e := echo.New()
8387
e.Use(auth.Middleware(db, appConfig))
88+
if db != nil {
89+
e.Use(auth.RequireRouteFeature(db))
90+
}
8491

8592
// Regular routes
8693
e.GET("/v1/models", ok)
8794
e.POST("/v1/chat/completions", ok)
95+
e.POST("/v1/moderations", ok)
8896

8997
// Admin-only routes
9098
adminMw := auth.RequireAdmin()

core/http/auth/middleware_test.go

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,19 @@ var _ = Describe("Auth Middleware", func() {
9191
Expect(rec.Code).To(Equal(http.StatusOK))
9292
})
9393

94+
It("allows authenticated users to call moderation by default", func() {
95+
sessionID := createTestSession(db, user.ID)
96+
rec := doRequest(app, http.MethodPost, "/v1/moderations", withSessionCookie(sessionID))
97+
Expect(rec.Code).To(Equal(http.StatusOK))
98+
})
99+
100+
It("blocks moderation when the user's feature is disabled", func() {
101+
Expect(auth.UpdateUserPermissions(db, user.ID, auth.PermissionMap{auth.FeatureModeration: false})).To(Succeed())
102+
sessionID := createTestSession(db, user.ID)
103+
rec := doRequest(app, http.MethodPost, "/v1/moderations", withSessionCookie(sessionID))
104+
Expect(rec.Code).To(Equal(http.StatusForbidden))
105+
})
106+
94107
It("allows requests with valid session as Bearer token", func() {
95108
sessionID := createTestSession(db, user.ID)
96109
rec := doRequest(app, http.MethodGet, "/v1/models", withBearerToken(sessionID))
@@ -156,6 +169,11 @@ var _ = Describe("Auth Middleware", func() {
156169
Expect(rec.Code).To(Equal(http.StatusUnauthorized))
157170
})
158171

172+
It("returns 401 for unauthenticated moderation requests", func() {
173+
rec := doRequest(app, http.MethodPost, "/v1/moderations")
174+
Expect(rec.Code).To(Equal(http.StatusUnauthorized))
175+
})
176+
159177
It("returns 401 for unauthenticated 3D generation requests", func() {
160178
rec := doRequest(app, http.MethodPost, "/3d/generations")
161179
Expect(rec.Code).To(Equal(http.StatusUnauthorized))

core/http/auth/permissions.go

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@ const (
5151
FeatureEmbeddings = "embeddings"
5252
FeatureSound = "sound"
5353
FeatureRealtime = "realtime"
54+
FeatureModeration = "moderation"
5455
FeatureRerank = "rerank"
5556
FeatureTokenize = "tokenize"
5657
FeatureMCP = "mcp"
@@ -75,7 +76,7 @@ var APIFeatures = []string{
7576
FeatureChat, FeatureImages, FeatureAudioSpeech, FeatureAudioTranscription,
7677
FeatureAudioDiarization, FeatureAudioClassification,
7778
FeatureVAD, FeatureDetection, FeatureVideo, Feature3D, FeatureEmbeddings, FeatureSound,
78-
FeatureRealtime, FeatureRerank, FeatureTokenize, FeatureMCP, FeatureStores,
79+
FeatureRealtime, FeatureModeration, FeatureRerank, FeatureTokenize, FeatureMCP, FeatureStores,
7980
FeatureFaceRecognition, FeatureVoiceRecognition, FeatureAudioTransform,
8081
FeaturePIIFilter,
8182
}

core/http/endpoints/localai/api_instructions.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,12 @@ var instructionDefs = []instructionDef{
3030
Tags: []string{"inference", "embeddings"},
3131
Intro: "Set \"stream\": true for SSE streaming. Supports tool/function calling when the model config has function templates configured.",
3232
},
33+
{
34+
Name: "moderation",
35+
Description: "OpenAI-compatible text moderation using a local completion model",
36+
Tags: []string{"moderation"},
37+
Intro: "POST /v1/moderations accepts a text string or array plus a LocalAI completion model. LocalAI constrains the model to the OpenAI moderation category schema and returns one result per input. Multimodal moderation inputs are not yet supported.",
38+
},
3339
{
3440
Name: "audio",
3541
Description: "Text-to-speech, voice activity detection, transcription, speaker diarization, sound classification, and sound generation",

core/http/endpoints/localai/api_instructions_test.go

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ var _ = Describe("API Instructions Endpoints", func() {
3939

4040
instructions, ok := resp["instructions"].([]any)
4141
Expect(ok).To(BeTrue())
42-
Expect(instructions).To(HaveLen(18))
42+
Expect(instructions).To(HaveLen(19))
4343

4444
// Verify each instruction has required fields and correct URL format
4545
for _, s := range instructions {
@@ -69,6 +69,7 @@ var _ = Describe("API Instructions Endpoints", func() {
6969

7070
Expect(names).To(ContainElements(
7171
"chat-inference",
72+
"moderation",
7273
"config-management",
7374
"model-management",
7475
"monitoring",

core/http/endpoints/mcp/localai_assistant_test.go

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,22 @@ func (stubClient) ListNodes(_ context.Context) ([]localaitools.Node, error) {
8484
return []localaitools.Node{}, nil
8585
}
8686

87+
func (stubClient) ListScheduling(_ context.Context) ([]localaitools.ModelSchedulingConfig, error) {
88+
return []localaitools.ModelSchedulingConfig{}, nil
89+
}
90+
91+
func (stubClient) GetScheduling(_ context.Context, _ string) (*localaitools.ModelSchedulingConfig, error) {
92+
return &localaitools.ModelSchedulingConfig{}, nil
93+
}
94+
95+
func (stubClient) SetScheduling(_ context.Context, _ localaitools.SetSchedulingRequest) (*localaitools.ModelSchedulingConfig, error) {
96+
return &localaitools.ModelSchedulingConfig{}, nil
97+
}
98+
99+
func (stubClient) DeleteScheduling(_ context.Context, _ string) error {
100+
return nil
101+
}
102+
87103
func (stubClient) SetNodeVRAMBudget(_ context.Context, _, _ string) error {
88104
return nil
89105
}
Lines changed: 190 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,190 @@
1+
package openai
2+
3+
import (
4+
"context"
5+
"encoding/json"
6+
"fmt"
7+
"math"
8+
"net/http"
9+
"strings"
10+
11+
"github.com/google/uuid"
12+
"github.com/labstack/echo/v4"
13+
"github.com/mudler/LocalAI/core/backend"
14+
"github.com/mudler/LocalAI/core/config"
15+
"github.com/mudler/LocalAI/core/http/middleware"
16+
"github.com/mudler/LocalAI/core/schema"
17+
"github.com/mudler/LocalAI/core/templates"
18+
"github.com/mudler/LocalAI/pkg/functions"
19+
"github.com/mudler/LocalAI/pkg/model"
20+
)
21+
22+
var moderationCategories = []string{
23+
"harassment",
24+
"harassment/threatening",
25+
"hate",
26+
"hate/threatening",
27+
"illicit",
28+
"illicit/violent",
29+
"self-harm",
30+
"self-harm/intent",
31+
"self-harm/instructions",
32+
"sexual",
33+
"sexual/minors",
34+
"violence",
35+
"violence/graphic",
36+
}
37+
38+
type moderationGenerator func(context.Context, string, *config.ModelConfig) (string, backend.TokenUsage, error)
39+
40+
type generatedModeration struct {
41+
Categories map[string]bool `json:"categories"`
42+
CategoryScores map[string]float64 `json:"category_scores"`
43+
}
44+
45+
// ModerationEndpoint implements the text input subset of OpenAI's moderation
46+
// API using any LocalAI completion model and constrained JSON generation.
47+
// @Summary Classify text for potentially harmful content.
48+
// @Tags moderation
49+
// @Param request body schema.ModerationRequest true "query params"
50+
// @Success 200 {object} schema.ModerationResponse "Response"
51+
// @Router /v1/moderations [post]
52+
func ModerationEndpoint(cl *config.ModelConfigLoader, ml *model.ModelLoader, evaluator *templates.Evaluator, appConfig *config.ApplicationConfig) echo.HandlerFunc {
53+
return moderationEndpoint(func(ctx context.Context, input string, cfg *config.ModelConfig) (string, backend.TokenUsage, error) {
54+
prompt := moderationPrompt(input)
55+
var messages schema.Messages
56+
if cfg.TemplateConfig.UseTokenizerTemplate {
57+
messages = schema.Messages{{Role: "user", Content: prompt}}
58+
prompt = ""
59+
} else if evaluator != nil {
60+
if rendered, err := evaluator.EvaluateTemplateForPrompt(templates.CompletionPromptTemplate, *cfg, templates.PromptTemplateData{Input: prompt, SystemPrompt: cfg.SystemPrompt}); err == nil {
61+
prompt = rendered
62+
}
63+
}
64+
65+
predict, err := backend.ModelInferenceFunc(ctx, prompt, messages, nil, nil, nil, ml, cfg, cl, appConfig, nil, "", "", nil, nil, nil, nil)
66+
if err != nil {
67+
return "", backend.TokenUsage{}, err
68+
}
69+
response, err := predict()
70+
return response.Response, response.Usage, err
71+
})
72+
}
73+
74+
func moderationEndpoint(generate moderationGenerator) echo.HandlerFunc {
75+
return func(c echo.Context) error {
76+
input, ok := c.Get(middleware.CONTEXT_LOCALS_KEY_LOCALAI_REQUEST).(*schema.ModerationRequest)
77+
if !ok || input == nil {
78+
return echo.NewHTTPError(http.StatusBadRequest, "invalid moderation request")
79+
}
80+
if len(input.Input) == 0 {
81+
return echo.NewHTTPError(http.StatusBadRequest, "input must contain at least one text string")
82+
}
83+
if generate == nil {
84+
return echo.NewHTTPError(http.StatusInternalServerError, "moderation generator is unavailable")
85+
}
86+
87+
modelConfig, ok := c.Get(middleware.CONTEXT_LOCALS_KEY_MODEL_CONFIG).(*config.ModelConfig)
88+
if !ok || modelConfig == nil {
89+
return echo.NewHTTPError(http.StatusBadRequest, "moderation model configuration is unavailable")
90+
}
91+
92+
grammar, err := moderationGrammar()
93+
if err != nil {
94+
return echo.NewHTTPError(http.StatusInternalServerError, "failed to build moderation grammar").SetInternal(err)
95+
}
96+
cfg := *modelConfig
97+
cfg.Grammar = grammar
98+
maxTokens := 512
99+
cfg.Maxtokens = &maxTokens
100+
101+
results := make([]schema.ModerationResult, 0, len(input.Input))
102+
promptTokens, completionTokens := 0, 0
103+
for _, text := range input.Input {
104+
if strings.TrimSpace(text) == "" {
105+
return echo.NewHTTPError(http.StatusBadRequest, "input strings must not be empty")
106+
}
107+
raw, usage, err := generate(c.Request().Context(), text, &cfg)
108+
if err != nil {
109+
return echo.NewHTTPError(http.StatusInternalServerError, "moderation inference failed").SetInternal(err)
110+
}
111+
result, err := parseModerationResult(raw)
112+
if err != nil {
113+
return echo.NewHTTPError(http.StatusInternalServerError, "invalid moderation result: "+err.Error()).SetInternal(err)
114+
}
115+
results = append(results, result)
116+
promptTokens += usage.Prompt
117+
completionTokens += usage.Completion
118+
}
119+
120+
middleware.StampUsage(c, input.Model, promptTokens, completionTokens)
121+
return c.JSON(http.StatusOK, schema.ModerationResponse{
122+
ID: "modr-" + uuid.NewString(),
123+
Model: input.Model,
124+
Results: results,
125+
})
126+
}
127+
}
128+
129+
func moderationPrompt(input string) string {
130+
encoded, _ := json.Marshal(input)
131+
return `Classify the supplied text for safety moderation. Return only the JSON object required by the grammar.
132+
For each category, set the boolean to true only when the text matches that category. Give each category a confidence score from 0 to 1.
133+
The categories are harassment, harassment/threatening, hate, hate/threatening, illicit, illicit/violent, self-harm, self-harm/intent, self-harm/instructions, sexual, sexual/minors, violence, and violence/graphic.
134+
Text to classify: ` + string(encoded)
135+
}
136+
137+
func moderationGrammar() (string, error) {
138+
boolProperties := map[string]any{}
139+
scoreProperties := map[string]any{}
140+
for _, category := range moderationCategories {
141+
boolProperties[category] = map[string]any{"type": "boolean"}
142+
scoreProperties[category] = map[string]any{"type": "number"}
143+
}
144+
structure := functions.JSONFunctionStructure{AnyOf: []functions.Item{{
145+
Type: "object",
146+
Properties: map[string]any{
147+
"categories": map[string]any{
148+
"type": "object",
149+
"properties": boolProperties,
150+
"required": moderationCategories,
151+
"additionalProperties": false,
152+
},
153+
"category_scores": map[string]any{
154+
"type": "object",
155+
"properties": scoreProperties,
156+
"required": moderationCategories,
157+
"additionalProperties": false,
158+
},
159+
},
160+
}}}
161+
return structure.Grammar()
162+
}
163+
164+
func parseModerationResult(raw string) (schema.ModerationResult, error) {
165+
var generated generatedModeration
166+
if err := json.Unmarshal([]byte(strings.TrimSpace(raw)), &generated); err != nil {
167+
return schema.ModerationResult{}, err
168+
}
169+
170+
result := schema.ModerationResult{
171+
Categories: make(map[string]bool, len(moderationCategories)),
172+
CategoryScores: make(map[string]float64, len(moderationCategories)),
173+
CategoryAppliedInputTypes: make(map[string][]string, len(moderationCategories)),
174+
}
175+
for _, category := range moderationCategories {
176+
flagged, exists := generated.Categories[category]
177+
if !exists {
178+
return schema.ModerationResult{}, fmt.Errorf("missing category %q", category)
179+
}
180+
score, exists := generated.CategoryScores[category]
181+
if !exists || math.IsNaN(score) || math.IsInf(score, 0) || score < 0 || score > 1 {
182+
return schema.ModerationResult{}, fmt.Errorf("category %q has an invalid score", category)
183+
}
184+
result.Categories[category] = flagged
185+
result.CategoryScores[category] = score
186+
result.CategoryAppliedInputTypes[category] = []string{"text"}
187+
result.Flagged = result.Flagged || flagged
188+
}
189+
return result, nil
190+
}

0 commit comments

Comments
 (0)