- Updated .env.example to include new configuration options for the UltiAI branding and API endpoints. - Enhanced Nginx configuration to support new API routes for the MCP and WebSocket connections. - Introduced sub-filters for branding adjustments in Nginx responses. - Added new JavaScript patch for API endpoint adjustments. - Implemented tests for new API functionalities and improved error handling in the AI gateway.
185 lines
5.7 KiB
Go
185 lines
5.7 KiB
Go
package aiapi
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"net/http/httputil"
|
|
"net/url"
|
|
"strings"
|
|
|
|
"github.com/jackc/pgx/v5"
|
|
|
|
"github.com/ultisuite/ulti-backend/internal/ai"
|
|
"github.com/ultisuite/ulti-backend/internal/api/apiresponse"
|
|
"github.com/ultisuite/ulti-backend/internal/apitokens"
|
|
"github.com/ultisuite/ulti-backend/internal/users"
|
|
)
|
|
|
|
func (h *Handler) MCPProxy(w http.ResponseWriter, r *http.Request) {
|
|
if h.cfg == nil || strings.TrimSpace(h.cfg.UltimailMCPURL) == "" {
|
|
apiresponse.WriteError(w, r, http.StatusServiceUnavailable, apiresponse.CodeInternal, "mcp not configured", nil)
|
|
return
|
|
}
|
|
|
|
token, enabledTools, err := h.resolveMCPToken(r)
|
|
if err != nil {
|
|
apiresponse.WriteError(w, r, http.StatusUnauthorized, apiresponse.CodeAuthUnauthorized, err.Error(), nil)
|
|
return
|
|
}
|
|
|
|
target, err := url.Parse(strings.TrimRight(strings.TrimSpace(h.cfg.UltimailMCPURL), "/"))
|
|
if err != nil {
|
|
apiresponse.WriteError(w, r, http.StatusInternalServerError, apiresponse.CodeInternal, err.Error(), nil)
|
|
return
|
|
}
|
|
|
|
proxy := httputil.NewSingleHostReverseProxy(target)
|
|
origDirector := proxy.Director
|
|
upstreamPath := mapMCPUpstreamPath(r.URL.Path)
|
|
proxy.Director = func(req *http.Request) {
|
|
origDirector(req)
|
|
req.Host = target.Host
|
|
req.URL.Scheme = target.Scheme
|
|
req.URL.Host = target.Host
|
|
req.URL.Path = upstreamPath
|
|
req.Header.Set("X-Ulti-Token", token)
|
|
if len(enabledTools) > 0 {
|
|
req.Header.Set("X-Ulti-Enabled-Tools", strings.Join(enabledTools, ","))
|
|
}
|
|
}
|
|
proxy.ModifyResponse = func(resp *http.Response) error {
|
|
resp.Header.Del("Access-Control-Allow-Origin")
|
|
return nil
|
|
}
|
|
proxy.ErrorHandler = func(w http.ResponseWriter, r *http.Request, err error) {
|
|
apiresponse.WriteError(w, r, http.StatusBadGateway, apiresponse.CodeInternal, err.Error(), nil)
|
|
}
|
|
|
|
proxy.ServeHTTP(w, r)
|
|
}
|
|
|
|
func (h *Handler) resolveMCPToken(r *http.Request) (token string, enabledTools []string, err error) {
|
|
ctx := r.Context()
|
|
enabledTools = h.loadEnabledTools(ctx)
|
|
|
|
if headerToken := strings.TrimSpace(r.Header.Get("X-Ulti-Token")); headerToken != "" {
|
|
if _, authErr := apitokens.Authenticate(ctx, h.db, headerToken); authErr == nil {
|
|
return headerToken, enabledTools, nil
|
|
}
|
|
}
|
|
|
|
if bearer := bearerToken(r); strings.HasPrefix(bearer, apitokens.TokenPrefix()) {
|
|
if _, authErr := apitokens.Authenticate(ctx, h.db, bearer); authErr == nil {
|
|
return bearer, enabledTools, nil
|
|
}
|
|
}
|
|
|
|
if bearer := bearerToken(r); h.cfg != nil && h.cfg.AIGatewayAPIKey != "" && bearer == h.cfg.AIGatewayAPIKey {
|
|
email := openWebUIUserEmail(r)
|
|
if email == "" {
|
|
return "", nil, fmt.Errorf("missing openwebui user email")
|
|
}
|
|
created, createErr := h.createMCPSessionForEmail(ctx, email, apitokens.ChatSessionStandalone)
|
|
if createErr != nil {
|
|
return "", nil, createErr
|
|
}
|
|
return created.TokenSecret, enabledTools, nil
|
|
}
|
|
|
|
if claims, ok := h.resolveClaims(r); ok && strings.TrimSpace(claims.Sub) != "" {
|
|
created, createErr := apitokens.CreateChatSession(ctx, h.db, claims.Sub, claims.Email, apitokens.ChatSessionInput{
|
|
Preset: apitokens.ChatSessionStandalone,
|
|
})
|
|
if createErr != nil {
|
|
return "", nil, createErr
|
|
}
|
|
return created.TokenSecret, enabledTools, nil
|
|
}
|
|
|
|
return "", nil, fmt.Errorf("unauthorized")
|
|
}
|
|
|
|
func (h *Handler) createMCPSessionForEmail(ctx context.Context, email string, preset apitokens.ChatSessionPreset) (apitokens.CreatedToken, error) {
|
|
externalID, storedEmail, err := users.LookupIdentityByEmail(ctx, h.db, email)
|
|
if err != nil {
|
|
if err == pgx.ErrNoRows {
|
|
return apitokens.CreatedToken{}, fmt.Errorf("user not found")
|
|
}
|
|
return apitokens.CreatedToken{}, err
|
|
}
|
|
return apitokens.CreateChatSession(ctx, h.db, externalID, storedEmail, apitokens.ChatSessionInput{
|
|
Preset: preset,
|
|
})
|
|
}
|
|
|
|
func openWebUIUserEmail(r *http.Request) string {
|
|
for _, key := range []string{
|
|
"X-OpenWebUI-User-Email",
|
|
"X-Ulti-User-Email",
|
|
} {
|
|
if email := strings.TrimSpace(r.Header.Get(key)); email != "" {
|
|
return email
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func (h *Handler) loadEnabledTools(ctx context.Context) []string {
|
|
deployEnabled := h.cfg != nil && h.cfg.AIAssistantEnabled
|
|
policy, _ := ai.LoadAssistantPolicy(ctx, h.db)
|
|
if !deployEnabled && !policy.Enabled {
|
|
return policy.EnabledTools
|
|
}
|
|
if len(policy.EnabledTools) == 0 {
|
|
return []string{"mail", "drive", "contacts", "agenda", "search", "web_search", "docs"}
|
|
}
|
|
return policy.EnabledTools
|
|
}
|
|
|
|
func mapMCPUpstreamPath(path string) string {
|
|
for _, prefix := range []string{"/api/v1/ai/mcp", "/mcp"} {
|
|
if !strings.HasPrefix(path, prefix) {
|
|
continue
|
|
}
|
|
suffix := strings.TrimPrefix(path, prefix)
|
|
if suffix == "" || suffix == "/" {
|
|
return "/mcp"
|
|
}
|
|
if suffix == "/messages" || suffix == "/sse" {
|
|
return suffix
|
|
}
|
|
return "/mcp" + suffix
|
|
}
|
|
return "/mcp"
|
|
}
|
|
|
|
func (h *Handler) publicMCPPath() string {
|
|
return "/api/v1/ai/mcp"
|
|
}
|
|
|
|
func (h *Handler) writeMCPConfigFields(ctx context.Context, out map[string]any) {
|
|
out["mcp_url"] = h.publicMCPPath()
|
|
out["enabled_tools"] = h.loadEnabledTools(ctx)
|
|
}
|
|
|
|
// MCPHealth proxies ultimail-mcp /health without auth (for compose healthchecks).
|
|
func (h *Handler) MCPHealth(w http.ResponseWriter, r *http.Request) {
|
|
if h.cfg == nil || strings.TrimSpace(h.cfg.UltimailMCPURL) == "" {
|
|
apiresponse.WriteJSON(w, http.StatusOK, map[string]any{"ok": false, "reason": "mcp not configured"})
|
|
return
|
|
}
|
|
target := strings.TrimRight(strings.TrimSpace(h.cfg.UltimailMCPURL), "/") + "/health"
|
|
resp, err := http.Get(target)
|
|
if err != nil {
|
|
apiresponse.WriteJSON(w, http.StatusOK, map[string]any{"ok": false, "reason": err.Error()})
|
|
return
|
|
}
|
|
defer resp.Body.Close()
|
|
body, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(resp.StatusCode)
|
|
_, _ = w.Write(body)
|
|
}
|