Files
LocalAI/core/http/auth/middleware.go
Richard Palethorpe 9058a2bb46 feat: Add 3d generation UI/API and trellis2cpp backend (#10979)
* feat(3d): add Generate3D RPC, FLAG_3D capability, and /v1/3d/generations endpoint

Adds the plumbing for image-conditioned 3D asset generation (binary
glTF / GLB output), modeled on the video generation path:

- backend.proto: Generate3D RPC + Generate3DRequest (staged image src,
  glb dst, seed/step/cfg_scale/texture_steps, quality and background
  enums, params map for backend-specific extras)
- pkg/grpc: thread Generate3D through client, server, embed, base and
  the backend interfaces; connection-evicting and distributed-node
  wrappers (in-flight tracking + file staging) included
- core/config: FLAG_3D usecase (guessed only for the trellis2cpp
  backend), '3d' canonical usecase string mapped to the Generate3D
  method, and a '3d' output modality
- REST: POST /v1/3d/generations (+ unversioned alias) returning
  OpenAIResponse with a /generated-3d URL or b64_json; conditioning
  image accepted as URL, base64, or data URI; quality/background
  validated at the edge; .glb served as model/gltf-binary
- auth: '3d' route feature (default ON); /api/instructions entry

Assisted-by: Claude:claude-fable-5 [Claude Code]
Signed-off-by: Richard Palethorpe <io@richiejp.com>

* feat(trellis2cpp): add the trellis2.cpp image-to-3D backend

Wraps localai-org/trellis2cpp (C++/GGML port of Microsoft TRELLIS.2,
pbr-textures branch) as a Go+purego backend, following the
stablediffusion-ggml pattern:

- backend/go/trellis2cpp: purego bindings to the flat C ABI (v9,
  asserted at startup), eager pipeline load with model-set validation
  (refuses non-trellis GGUFs; degrades coarse/geometry-only/textured
  exactly like the upstream demo), Generate3D via t2_generate +
  t2_bake_glb writing a binary glTF to dst. Weight-free unit tests
  cover resolution/validation/param mapping — CI never downloads the
  multi-GB GGUF set or runs inference.
- CPU SIMD variants build into per-variant directories (the shared
  libggml sonames collide across variants, unlike sd-ggml's flat
  renamed-.so scheme); run.sh picks one via /proc/cpuinfo.
- CI wiring: backend-matrix entries (cpu, cuda12/13, vulkan
  amd64+arm64, l4t, l4t-cuda13, darwin metal), index.yaml meta +
  latest/master image entries, bump_deps tracking of the pbr-textures
  branch, changed-backends.js mapping, top-level Makefile targets.
- Importer: auto-detects trellis GGUF repos/URIs (registered before
  llama-cpp so the .gguf match isn't stolen) and expands any trellis
  URI to the full 10-file component set spanning the three LocalAI-io
  HF repos.
- Gallery: trellis2-4b (full PBR + 1024 cascade) and
  trellis2-4b-geometry (512 untextured) with verified sha256s.

Assisted-by: Claude:claude-fable-5 [Claude Code]
Signed-off-by: Richard Palethorpe <io@richiejp.com>

* feat(ui): 3D generation page with native GLB viewer and IndexedDB history

Adds a Studio tab + /app/3d page for the new image-to-3D endpoint:

- GlbViewer ports the trellis2cpp demo's dependency-free WebGL2
  renderer (quaternion trackball, metallic-roughness PBR, ACES,
  hidden-line wireframe with a bounded index budget) and pairs it with
  a minimal GLB parser for the two forms t2_bake_glb emits — dense
  vertex-PBR (linear COLOR_0 + _METALLIC_ROUGHNESS, uploaded as
  normalized integers) and the opt-in UV-atlas textured form. Parsing
  happens before any GL so stats and errors render without WebGL2.
- use3DHistory stores past generations (params, input thumbnail, and
  the GLB blob itself) in IndexedDB with keep-newest-20 eviction —
  GLBs are multi-MB binaries localStorage can't hold — and the page
  offers a download button for the active GLB.
- Wiring: CAP_3D capability constant (FLAG_3D — the exact string
  /api/models/capabilities serves), threeDApi, router entries, Studio
  tab, vite dev proxy, en locale keys.
- e2e: render-smoke entry plus a focused spec that feeds a real
  one-triangle vertex-PBR GLB through the parser/viewer and exercises
  IndexedDB persistence, selection, deletion, and API errors.

Assisted-by: Claude:claude-fable-5 [Claude Code]
Signed-off-by: Richard Palethorpe <io@richiejp.com>

* fix(3d): address API correctness and UX issues

Keep 3D generation on the LocalAI-specific /3d/generations route and ensure authentication and permissions cover it.

Propagate distributed transfer failures, publish a portable ARM64 backend image, honor importer overrides, and align discovery, upload validation, and touch controls.

Assisted-by: Codex:gpt-5
Signed-off-by: Richard Palethorpe <io@richiejp.com>

* feat(3d): add previewable print remeshing

Add a single-detail CGAL Alpha Wrap workflow for existing Trellis GLBs, including PBR reprojection, API documentation, tracing, and an in-browser preview before download.

Allow the remesh route to enforce its 512 MiB upload cap independently of the smaller global default so generated high-resolution meshes can be processed.

Assisted-by: Codex:gpt-5
Signed-off-by: Richard Palethorpe <io@richiejp.com>

* build(trellis2cpp): centralize remesh dependency pins

Assisted-by: Codex:GPT-5 [apply_patch] [exec_command]
Signed-off-by: Richard Palethorpe <io@richiejp.com>

* fix(kokoros): implement Generate3D stub for new proto RPC

The Generate3D RPC added to backend.proto for the trellis2cpp backend
made tonic's generated Backend trait require generate3_d, breaking the
kokoros-grpc build. Return unimplemented like the other unsupported
modalities.

Assisted-by: Claude Code:claude-fable-5
Signed-off-by: Richard Palethorpe <io@richiejp.com>

---------

Signed-off-by: Richard Palethorpe <io@richiejp.com>
Co-authored-by: localai-org-maint-bot <bot-opensource@localaisrl.com>
2026-07-29 16:15:04 +02:00

630 lines
19 KiB
Go

package auth
import (
"bytes"
"crypto/subtle"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"github.com/labstack/echo/v4"
"github.com/mudler/LocalAI/core/config"
"github.com/mudler/LocalAI/core/schema"
"gorm.io/gorm"
)
const (
contextKeyUser = "auth_user"
contextKeyRole = "auth_role"
contextKeyAPIKey = "auth_apikey"
contextKeySource = "auth_source"
)
// Middleware returns an Echo middleware that handles authentication.
//
// Resolution order:
// 1. If auth not enabled AND no legacy API keys → pass through
// 2. Skip auth for exempt paths (PathWithoutAuth + /api/auth/)
// 3. If auth enabled (db != nil):
// a. Try "session" cookie → DB lookup
// b. Try Authorization: Bearer → session ID, then user API key
// c. Try x-api-key / xi-api-key → user API key
// d. Try "token" cookie → legacy API key check
// e. Check all extracted keys against legacy ApiKeys → synthetic admin
// 4. If auth not enabled → delegate to legacy API key validation
// 5. If no auth found for /api/ or /v1/ paths → 401
// 6. Otherwise pass through (static assets, UI pages, etc.)
func Middleware(db *gorm.DB, appConfig *config.ApplicationConfig) echo.MiddlewareFunc {
return func(next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error {
authEnabled := db != nil
hasLegacyKeys := len(appConfig.ApiKeys) > 0
// 1. No auth at all
if !authEnabled && !hasLegacyKeys {
return next(c)
}
path := c.Request().URL.Path
exempt := isExemptPath(path, appConfig)
authenticated := false
// 2. Try to authenticate (populates user in context if possible)
if authEnabled {
user := tryAuthenticate(c, db, appConfig)
if user != nil {
c.Set(contextKeyUser, user)
c.Set(contextKeyRole, user.Role)
authenticated = true
// Session rotation for cookie-based sessions
if session, ok := c.Get("_auth_session").(*Session); ok {
MaybeRotateSession(c, db, session, appConfig.Auth.APIKeyHMACSecret)
}
}
}
// 3. Legacy API key validation (works whether auth is enabled or not)
if !authenticated && hasLegacyKeys {
key := extractKey(c)
if key != "" && isValidLegacyKey(key, appConfig) {
syntheticUser := &User{
ID: "legacy-api-key",
Name: "API Key User",
Role: RoleAdmin,
}
c.Set(contextKeyUser, syntheticUser)
c.Set(contextKeyRole, RoleAdmin)
c.Set(contextKeySource, UsageSourceLegacy)
authenticated = true
}
}
// 4. If authenticated or exempt path, proceed
if authenticated || exempt {
return next(c)
}
// 5. Require auth for API paths
if isAPIPath(path) {
// Check GET exemptions for legacy keys
if hasLegacyKeys && appConfig.DisableApiKeyRequirementForHttpGet && c.Request().Method == http.MethodGet {
for _, rx := range appConfig.HttpGetExemptedEndpoints {
if rx.MatchString(c.Path()) {
return next(c)
}
}
}
return authError(c, appConfig)
}
// 6. Non-API paths (UI, static assets) pass through.
// The React UI handles login redirects client-side.
return next(c)
}
}
}
// RequireAdmin returns middleware that checks the user has admin role.
func RequireAdmin() echo.MiddlewareFunc {
return func(next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error {
user := GetUser(c)
if user == nil {
return c.JSON(http.StatusUnauthorized, schema.ErrorResponse{
Error: &schema.APIError{
Message: "Authentication required",
Code: http.StatusUnauthorized,
Type: "authentication_error",
},
})
}
if user.Role != RoleAdmin {
return c.JSON(http.StatusForbidden, schema.ErrorResponse{
Error: &schema.APIError{
Message: "Admin access required",
Code: http.StatusForbidden,
Type: "authorization_error",
},
})
}
return next(c)
}
}
}
// NoopMiddleware returns a middleware that does nothing (pass-through).
// Used when auth is disabled to satisfy route registration that expects
// an admin middleware parameter.
func NoopMiddleware() echo.MiddlewareFunc {
return func(next echo.HandlerFunc) echo.HandlerFunc {
return next
}
}
// RequireFeature returns middleware that checks the user has access to the given feature.
// If no auth DB is provided, it passes through (backward compat).
// Admins always pass. Regular users must have the feature enabled in their permissions.
func RequireFeature(db *gorm.DB, feature string) echo.MiddlewareFunc {
if db == nil {
return NoopMiddleware()
}
return func(next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error {
user := GetUser(c)
if user == nil {
return c.JSON(http.StatusUnauthorized, schema.ErrorResponse{
Error: &schema.APIError{
Message: "Authentication required",
Code: http.StatusUnauthorized,
Type: "authentication_error",
},
})
}
if user.Role == RoleAdmin {
return next(c)
}
perm, err := GetCachedUserPermissions(c, db, user.ID)
if err != nil {
return c.JSON(http.StatusForbidden, schema.ErrorResponse{
Error: &schema.APIError{
Message: "feature not enabled for your account",
Code: http.StatusForbidden,
Type: "authorization_error",
},
})
}
val, exists := perm.Permissions[feature]
if !exists {
if !isDefaultOnFeature(feature) {
return c.JSON(http.StatusForbidden, schema.ErrorResponse{
Error: &schema.APIError{
Message: "feature not enabled for your account",
Code: http.StatusForbidden,
Type: "authorization_error",
},
})
}
} else if !val {
return c.JSON(http.StatusForbidden, schema.ErrorResponse{
Error: &schema.APIError{
Message: "feature not enabled for your account",
Code: http.StatusForbidden,
Type: "authorization_error",
},
})
}
return next(c)
}
}
}
// GetUser returns the authenticated user from the echo context, or nil.
func GetUser(c echo.Context) *User {
u, ok := c.Get(contextKeyUser).(*User)
if !ok {
return nil
}
return u
}
// GetUserRole returns the role of the authenticated user, or empty string.
func GetUserRole(c echo.Context) string {
role, _ := c.Get(contextKeyRole).(string)
return role
}
// GetAPIKey returns the resolved API key from the echo context, or nil.
// Nil for session-cookie and legacy-env-key authentication.
func GetAPIKey(c echo.Context) *UserAPIKey {
k, _ := c.Get(contextKeyAPIKey).(*UserAPIKey)
return k
}
// GetSource returns the request's authentication source: UsageSourceAPIKey,
// UsageSourceWeb, UsageSourceLegacy, or empty if no authentication was performed.
func GetSource(c echo.Context) string {
s, _ := c.Get(contextKeySource).(string)
return s
}
// RequireRouteFeature returns a global middleware that checks the user has access
// to the feature required by the matched route. It uses the RouteFeatureRegistry
// to look up the required feature for each route pattern + HTTP method.
// If no entry matches, the request passes through (no restriction).
func RequireRouteFeature(db *gorm.DB) echo.MiddlewareFunc {
if db == nil {
return NoopMiddleware()
}
// Pre-build lookup map: "METHOD:pattern" -> feature
lookup := map[string]string{}
for _, rf := range RouteFeatureRegistry {
lookup[rf.Method+":"+rf.Pattern] = rf.Feature
}
return func(next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error {
path := c.Path() // Echo route pattern (e.g. "/v1/engines/:model/completions")
method := c.Request().Method
feature := lookup[method+":"+path]
if feature == "" {
feature = lookup["*:"+path]
}
if feature == "" {
return next(c) // no restriction for this route
}
user := GetUser(c)
if user == nil {
return next(c) // auth middleware handles unauthenticated
}
if user.Role == RoleAdmin {
return next(c)
}
perm, err := GetCachedUserPermissions(c, db, user.ID)
if err != nil {
return c.JSON(http.StatusInternalServerError, schema.ErrorResponse{
Error: &schema.APIError{
Message: "failed to check permissions",
Code: http.StatusInternalServerError,
Type: "server_error",
},
})
}
val, exists := perm.Permissions[feature]
if !exists {
if !isDefaultOnFeature(feature) {
return c.JSON(http.StatusForbidden, schema.ErrorResponse{
Error: &schema.APIError{
Message: "feature not enabled for your account: " + feature,
Code: http.StatusForbidden,
Type: "authorization_error",
},
})
}
} else if !val {
return c.JSON(http.StatusForbidden, schema.ErrorResponse{
Error: &schema.APIError{
Message: "feature not enabled for your account: " + feature,
Code: http.StatusForbidden,
Type: "authorization_error",
},
})
}
return next(c)
}
}
}
// RequireModelAccess returns a global middleware that checks the user is allowed
// to use the resolved model. It extracts the model name directly from the request
// (path param, query param, JSON body, or form value) rather than relying on a
// context key set by downstream route-specific middleware.
func RequireModelAccess(db *gorm.DB) echo.MiddlewareFunc {
if db == nil {
return NoopMiddleware()
}
return func(next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error {
user := GetUser(c)
if user == nil {
return next(c)
}
if user.Role == RoleAdmin {
return next(c)
}
// Check if this user even has a model allowlist enabled before
// doing the expensive body read. Most users won't have restrictions.
// Uses request-scoped cache to avoid duplicate DB hit when
// RequireRouteFeature already fetched permissions.
perm, err := GetCachedUserPermissions(c, db, user.ID)
if err != nil {
return c.JSON(http.StatusInternalServerError, schema.ErrorResponse{
Error: &schema.APIError{
Message: "failed to check permissions",
Code: http.StatusInternalServerError,
Type: "server_error",
},
})
}
allowlist := perm.AllowedModels
if !allowlist.Enabled {
return next(c)
}
modelName := extractModelFromRequest(c)
if modelName == "" {
return next(c)
}
for _, m := range allowlist.Models {
if m == modelName {
return next(c)
}
}
return c.JSON(http.StatusForbidden, schema.ErrorResponse{
Error: &schema.APIError{
Message: "access denied to model: " + modelName,
Code: http.StatusForbidden,
Type: "authorization_error",
},
})
}
}
}
// extractModelFromRequest extracts the model name from various request sources.
// It checks URL path params, query params, JSON body, and form values.
// For JSON bodies, it peeks at the body and resets it so downstream handlers
// can still read it.
func extractModelFromRequest(c echo.Context) string {
// 1. URL path param (e.g. /v1/engines/:model/completions)
if model := c.Param("model"); model != "" {
return model
}
// 2. Query param
if model := c.QueryParam("model"); model != "" {
return model
}
// 3. Peek at JSON body
if strings.HasPrefix(c.Request().Header.Get("Content-Type"), "application/json") {
body, err := io.ReadAll(c.Request().Body)
c.Request().Body = io.NopCloser(bytes.NewReader(body)) // always reset
if err == nil && len(body) > 0 {
var m struct {
Model string `json:"model"`
}
if json.Unmarshal(body, &m) == nil && m.Model != "" {
return m.Model
}
}
}
// 4. Form value (multipart/form-data)
if model := c.FormValue("model"); model != "" {
return model
}
return ""
}
// RequireQuota returns a global middleware that enforces per-user quota rules.
// If no auth DB is provided, it's a no-op. Admin users always bypass quotas.
// Only inference routes (those listed in RouteFeatureRegistry) count toward quota.
func RequireQuota(db *gorm.DB) echo.MiddlewareFunc {
if db == nil {
return NoopMiddleware()
}
// Pre-build lookup set from RouteFeatureRegistry — only these routes
// should count toward quota. Mirrors RequireRouteFeature's approach.
inferenceRoutes := map[string]bool{}
for _, rf := range RouteFeatureRegistry {
inferenceRoutes[rf.Method+":"+rf.Pattern] = true
}
return func(next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error {
// Only enforce quotas on inference routes
path := c.Path()
method := c.Request().Method
if !inferenceRoutes[method+":"+path] && !inferenceRoutes["*:"+path] {
return next(c)
}
user := GetUser(c)
if user == nil {
return next(c)
}
if user.Role == RoleAdmin {
return next(c)
}
model := extractModelFromRequest(c)
exceeded, retryAfter, msg := QuotaExceeded(db, user.ID, model)
if exceeded {
c.Response().Header().Set("Retry-After", fmt.Sprintf("%d", retryAfter))
return c.JSON(http.StatusTooManyRequests, schema.ErrorResponse{
Error: &schema.APIError{
Message: msg,
Code: http.StatusTooManyRequests,
Type: "quota_exceeded",
},
})
}
return next(c)
}
}
}
// tryAuthenticate attempts to authenticate the request using the database.
//
// On success it returns the user and, as a side effect, sets the following
// values on the Echo context:
// - contextKeySource ("auth_source"): always set, one of UsageSourceWeb /
// UsageSourceAPIKey. UsageSourceLegacy is set elsewhere by the parent
// Middleware when a legacy env key matches.
// - contextKeyAPIKey ("auth_apikey"): set to the resolved *UserAPIKey for
// named-key branches (Bearer, x-api-key, xi-api-key, token cookie).
// - "_auth_session": session record, used by Middleware to drive cookie
// rotation. Only set on the session-cookie branch.
//
// contextKeyUser and contextKeyRole are populated by the parent Middleware
// after this function returns.
func tryAuthenticate(c echo.Context, db *gorm.DB, appConfig *config.ApplicationConfig) *User {
hmacSecret := appConfig.Auth.APIKeyHMACSecret
// a. Session cookie -> web UI
if cookie, err := c.Cookie(sessionCookie); err == nil && cookie.Value != "" {
if user, session := ValidateSession(db, cookie.Value, hmacSecret); user != nil {
// Store session for rotation check in middleware
c.Set("_auth_session", session)
c.Set(contextKeySource, UsageSourceWeb)
return user
}
}
// b. Authorization: Bearer
authHeader := c.Request().Header.Get("Authorization")
if strings.HasPrefix(authHeader, "Bearer ") {
token := strings.TrimPrefix(authHeader, "Bearer ")
// b1. Session token via Bearer -> still web UI
if user, _ := ValidateSession(db, token, hmacSecret); user != nil {
c.Set(contextKeySource, UsageSourceWeb)
return user
}
// b2. Named API key
if key, err := ValidateAPIKey(db, token, hmacSecret); err == nil {
c.Set(contextKeySource, UsageSourceAPIKey)
c.Set(contextKeyAPIKey, key)
return &key.User
}
}
// c. x-api-key / xi-api-key -> named API key
for _, header := range []string{"x-api-key", "xi-api-key"} {
if k := c.Request().Header.Get(header); k != "" {
if apiKey, err := ValidateAPIKey(db, k, hmacSecret); err == nil {
c.Set(contextKeySource, UsageSourceAPIKey)
c.Set(contextKeyAPIKey, apiKey)
return &apiKey.User
}
}
}
// d. token cookie -> named API key
if cookie, err := c.Cookie("token"); err == nil && cookie.Value != "" {
if key, err := ValidateAPIKey(db, cookie.Value, hmacSecret); err == nil {
c.Set(contextKeySource, UsageSourceAPIKey)
c.Set(contextKeyAPIKey, key)
return &key.User
}
}
return nil
}
// extractKey extracts an API key from the request (all sources).
func extractKey(c echo.Context) string {
// Authorization header
auth := c.Request().Header.Get("Authorization")
if strings.HasPrefix(auth, "Bearer ") {
return strings.TrimPrefix(auth, "Bearer ")
}
if auth != "" {
return auth
}
// x-api-key
if key := c.Request().Header.Get("x-api-key"); key != "" {
return key
}
// xi-api-key
if key := c.Request().Header.Get("xi-api-key"); key != "" {
return key
}
// token cookie
if cookie, err := c.Cookie("token"); err == nil && cookie.Value != "" {
return cookie.Value
}
return ""
}
// isValidLegacyKey checks if the key matches any configured API key
// using constant-time comparison to prevent timing attacks.
func isValidLegacyKey(key string, appConfig *config.ApplicationConfig) bool {
for _, validKey := range appConfig.ApiKeys {
if subtle.ConstantTimeCompare([]byte(key), []byte(validKey)) == 1 {
return true
}
}
return false
}
// isExemptPath returns true if the path should skip authentication.
func isExemptPath(path string, appConfig *config.ApplicationConfig) bool {
// Auth endpoints are always public
if strings.HasPrefix(path, "/api/auth/") {
return true
}
// Node self-service endpoints — authenticated via registration token, not global auth.
// Only exempt the specific known endpoints, not the entire prefix.
if strings.HasPrefix(path, "/api/node/") {
if path == "/api/node/register" ||
strings.HasSuffix(path, "/heartbeat") ||
strings.HasSuffix(path, "/drain") ||
strings.HasSuffix(path, "/deregister") {
return true
}
}
// Check configured exempt paths
for _, p := range appConfig.PathWithoutAuth {
if strings.HasPrefix(path, p) {
return true
}
}
return false
}
// isAPIPath returns true for paths that always require authentication.
func isAPIPath(path string) bool {
return strings.HasPrefix(path, "/api/") ||
strings.HasPrefix(path, "/v1/") ||
strings.HasPrefix(path, "/models/") ||
strings.HasPrefix(path, "/backends/") ||
strings.HasPrefix(path, "/backend/") ||
strings.HasPrefix(path, "/tts") ||
strings.HasPrefix(path, "/vad") ||
strings.HasPrefix(path, "/video") ||
strings.HasPrefix(path, "/3d/") ||
strings.HasPrefix(path, "/stores/") ||
strings.HasPrefix(path, "/system") ||
strings.HasPrefix(path, "/ws/") ||
strings.HasPrefix(path, "/generated-") ||
strings.HasPrefix(path, "/chat/") ||
strings.HasPrefix(path, "/completions") ||
strings.HasPrefix(path, "/edits") ||
strings.HasPrefix(path, "/embeddings") ||
strings.HasPrefix(path, "/audio/") ||
strings.HasPrefix(path, "/images/") ||
strings.HasPrefix(path, "/messages") ||
strings.HasPrefix(path, "/responses") ||
path == "/metrics"
}
// authError returns an appropriate error response.
func authError(c echo.Context, appConfig *config.ApplicationConfig) error {
c.Response().Header().Set("WWW-Authenticate", "Bearer")
if appConfig.OpaqueErrors {
return c.NoContent(http.StatusUnauthorized)
}
contentType := c.Request().Header.Get("Content-Type")
if strings.Contains(contentType, "application/json") {
return c.JSON(http.StatusUnauthorized, schema.ErrorResponse{
Error: &schema.APIError{
Message: "An authentication key is required",
Code: http.StatusUnauthorized,
Type: "invalid_request_error",
},
})
}
return c.JSON(http.StatusUnauthorized, schema.ErrorResponse{
Error: &schema.APIError{
Message: "An authentication key is required",
Code: http.StatusUnauthorized,
Type: "invalid_request_error",
},
})
}