package services import ( "bytes" "context" "encoding/base64" "encoding/json" "fmt" "io" "net/http" "strings" "sync" "time" "github.com/jackc/pgx/v5/pgxpool" ) // OpenRouterService handles interactions with the OpenRouter API type OpenRouterService struct { pool *pgxpool.Pool httpClient *http.Client apiKey string // Cached model list modelCache []OpenRouterModel modelCacheMu sync.RWMutex modelCacheTime time.Time } // OpenRouterModel represents a model available on OpenRouter type OpenRouterModel struct { ID string `json:"id"` Name string `json:"name"` Description string `json:"description,omitempty"` Pricing OpenRouterPricing `json:"pricing"` ContextLength int `json:"context_length"` Architecture map[string]any `json:"architecture,omitempty"` TopProvider map[string]any `json:"top_provider,omitempty"` PerRequestLimits map[string]any `json:"per_request_limits,omitempty"` } type OpenRouterPricing struct { Prompt string `json:"prompt"` Completion string `json:"completion"` Image string `json:"image,omitempty"` Request string `json:"request,omitempty"` } // ModerationConfigEntry represents a row in ai_moderation_config type ModerationConfigEntry struct { ID string `json:"id"` ModerationType string `json:"moderation_type"` ModelID string `json:"model_id"` ModelName string `json:"model_name"` SystemPrompt string `json:"system_prompt"` Enabled bool `json:"enabled"` Engines []string `json:"engines"` UpdatedAt time.Time `json:"updated_at"` UpdatedBy *string `json:"updated_by,omitempty"` } // HasEngine returns true if the given engine is in the config's engines list. func (c *ModerationConfigEntry) HasEngine(engine string) bool { for _, e := range c.Engines { if e == engine { return true } } return false } // OpenRouterChatMessage represents a message in a chat completion request type OpenRouterChatMessage struct { Role string `json:"role"` Content any `json:"content"` } // OpenRouterChatRequest represents a chat completion request type OpenRouterChatRequest struct { Model string `json:"model"` Messages []OpenRouterChatMessage `json:"messages"` Temperature *float64 `json:"temperature,omitempty"` MaxTokens *int `json:"max_tokens,omitempty"` } func floatPtr(f float64) *float64 { return &f } func intPtr(i int) *int { return &i } // downloadImage downloads an image from URL and returns base64 encoded data func (s *OpenRouterService) downloadImage(ctx context.Context, url string) (string, error) { req, err := http.NewRequestWithContext(ctx, "GET", url, nil) if err != nil { return "", fmt.Errorf("failed to create image request: %w", err) } resp, err := s.httpClient.Do(req) if err != nil { return "", fmt.Errorf("failed to download image: %w", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return "", fmt.Errorf("image download failed: %d", resp.StatusCode) } // Limit image size to 5MB const maxImageSize = 5 * 1024 * 1024 limitedReader := io.LimitReader(resp.Body, maxImageSize) imageData, err := io.ReadAll(limitedReader) if err != nil { return "", fmt.Errorf("failed to read image data: %w", err) } // Detect content type contentType := resp.Header.Get("Content-Type") if contentType == "" { contentType = http.DetectContentType(imageData) } // Only allow image formats if !strings.HasPrefix(contentType, "image/") { return "", fmt.Errorf("unsupported content type: %s", contentType) } // Convert to base64 base64Data := base64.StdEncoding.EncodeToString(imageData) return fmt.Sprintf("data:%s;base64,%s", contentType, base64Data), nil } // OpenRouterChatResponse represents a chat completion response type OpenRouterChatResponse struct { ID string `json:"id"` Choices []struct { Message struct { Role string `json:"role"` Content string `json:"content"` } `json:"message"` FinishReason string `json:"finish_reason"` } `json:"choices"` Usage struct { PromptTokens int `json:"prompt_tokens"` CompletionTokens int `json:"completion_tokens"` TotalTokens int `json:"total_tokens"` } `json:"usage"` } func NewOpenRouterService(pool *pgxpool.Pool, apiKey string) *OpenRouterService { return &OpenRouterService{ pool: pool, apiKey: apiKey, httpClient: &http.Client{ Timeout: 60 * time.Second, }, } } // ListModels fetches available models from OpenRouter, with 1-hour cache func (s *OpenRouterService) ListModels(ctx context.Context) ([]OpenRouterModel, error) { s.modelCacheMu.RLock() if len(s.modelCache) > 0 && time.Since(s.modelCacheTime) < time.Hour { cached := s.modelCache s.modelCacheMu.RUnlock() return cached, nil } s.modelCacheMu.RUnlock() req, err := http.NewRequestWithContext(ctx, "GET", "https://openrouter.ai/api/v1/models", nil) if err != nil { return nil, fmt.Errorf("failed to create request: %w", err) } if s.apiKey != "" { req.Header.Set("Authorization", "Bearer "+s.apiKey) } resp, err := s.httpClient.Do(req) if err != nil { return nil, fmt.Errorf("failed to fetch models: %w", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { body, _ := io.ReadAll(resp.Body) return nil, fmt.Errorf("OpenRouter API error %d: %s", resp.StatusCode, string(body)) } var result struct { Data []OpenRouterModel `json:"data"` } if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { return nil, fmt.Errorf("failed to decode models: %w", err) } s.modelCacheMu.Lock() s.modelCache = result.Data s.modelCacheTime = time.Now() s.modelCacheMu.Unlock() return result.Data, nil } // GetModerationConfigs returns all moderation type configurations func (s *OpenRouterService) GetModerationConfigs(ctx context.Context) ([]ModerationConfigEntry, error) { rows, err := s.pool.Query(ctx, ` SELECT id, moderation_type, model_id, model_name, system_prompt, enabled, engines, updated_at, updated_by FROM ai_moderation_config ORDER BY moderation_type `) if err != nil { return nil, fmt.Errorf("failed to query configs: %w", err) } defer rows.Close() var configs []ModerationConfigEntry for rows.Next() { var c ModerationConfigEntry if err := rows.Scan(&c.ID, &c.ModerationType, &c.ModelID, &c.ModelName, &c.SystemPrompt, &c.Enabled, &c.Engines, &c.UpdatedAt, &c.UpdatedBy); err != nil { return nil, err } configs = append(configs, c) } return configs, nil } // GetModerationConfig returns config for a specific moderation type func (s *OpenRouterService) GetModerationConfig(ctx context.Context, moderationType string) (*ModerationConfigEntry, error) { var c ModerationConfigEntry err := s.pool.QueryRow(ctx, ` SELECT id, moderation_type, model_id, model_name, system_prompt, enabled, engines, updated_at, updated_by FROM ai_moderation_config WHERE moderation_type = $1 `, moderationType).Scan(&c.ID, &c.ModerationType, &c.ModelID, &c.ModelName, &c.SystemPrompt, &c.Enabled, &c.Engines, &c.UpdatedAt, &c.UpdatedBy) if err != nil { return nil, err } return &c, nil } // SetModerationConfig upserts a moderation config func (s *OpenRouterService) SetModerationConfig(ctx context.Context, moderationType, modelID, modelName, systemPrompt string, enabled bool, engines []string, updatedBy string) error { if len(engines) == 0 { engines = []string{"local_ai", "openrouter", "openai"} } _, err := s.pool.Exec(ctx, ` INSERT INTO ai_moderation_config (moderation_type, model_id, model_name, system_prompt, enabled, engines, updated_by, updated_at) VALUES ($1, $2, $3, $4, $5, $7, $6, NOW()) ON CONFLICT (moderation_type) DO UPDATE SET model_id = $2, model_name = $3, system_prompt = $4, enabled = $5, engines = $7, updated_by = $6, updated_at = NOW() `, moderationType, modelID, modelName, systemPrompt, enabled, updatedBy, engines) return err } // ModerateText sends text content to the configured model for moderation func (s *OpenRouterService) ModerateText(ctx context.Context, content string) (*ModerationResult, error) { config, err := s.GetModerationConfig(ctx, "text") if err != nil || !config.Enabled || config.ModelID == "" { return nil, fmt.Errorf("text moderation not configured") } return s.callModel(ctx, config.ModelID, config.SystemPrompt, content, nil) } // ModerateImage sends an image URL to a vision model for moderation func (s *OpenRouterService) ModerateImage(ctx context.Context, imageURL string) (*ModerationResult, error) { config, err := s.GetModerationConfig(ctx, "image") if err != nil || !config.Enabled || config.ModelID == "" { return nil, fmt.Errorf("image moderation not configured") } return s.callModel(ctx, config.ModelID, config.SystemPrompt, "", []string{imageURL}) } // ModerateWithType sends content to a specific moderation type config (e.g. "group_text", "beacon_image"). // Returns nil if the config doesn't exist or isn't enabled — caller should fall back to generic. func (s *OpenRouterService) ModerateWithType(ctx context.Context, moderationType string, textContent string, imageURLs []string) (*ModerationResult, error) { config, err := s.GetModerationConfig(ctx, moderationType) if err != nil || !config.Enabled || config.ModelID == "" { return nil, fmt.Errorf("%s moderation not configured", moderationType) } return s.callModel(ctx, config.ModelID, config.SystemPrompt, textContent, imageURLs) } // ModerateVideo sends video frame URLs to a vision model for moderation func (s *OpenRouterService) ModerateVideo(ctx context.Context, frameURLs []string) (*ModerationResult, error) { config, err := s.GetModerationConfig(ctx, "video") if err != nil || !config.Enabled || config.ModelID == "" { return nil, fmt.Errorf("video moderation not configured") } return s.callModel(ctx, config.ModelID, config.SystemPrompt, "These are 3 frames extracted from a short video. Analyze all frames for policy violations.", frameURLs) } // ModerationResult is the parsed response from OpenRouter moderation type ModerationResult struct { Flagged bool `json:"flagged"` Action string `json:"action"` // "clean", "nsfw", "flag" NSFWReason string `json:"nsfw_reason"` // e.g. "violence", "nudity", "18+ content" Reason string `json:"reason"` Explanation string `json:"explanation"` Hate float64 `json:"hate"` HateDetail string `json:"hate_detail"` Greed float64 `json:"greed"` GreedDetail string `json:"greed_detail"` Delusion float64 `json:"delusion"` DelusionDetail string `json:"delusion_detail"` RawContent string `json:"raw_content"` } // GenerateText sends a general-purpose chat completion request and returns the raw text response. // Used for AI content generation (not moderation). func (s *OpenRouterService) GenerateText(ctx context.Context, modelID, systemPrompt, userPrompt string, temperature float64, maxTokens int) (string, error) { if s.apiKey == "" { return "", fmt.Errorf("OpenRouter API key not configured") } messages := []OpenRouterChatMessage{ {Role: "system", Content: systemPrompt}, {Role: "user", Content: userPrompt}, } reqBody := OpenRouterChatRequest{ Model: modelID, Messages: messages, Temperature: floatPtr(temperature), MaxTokens: intPtr(maxTokens), } jsonBody, err := json.Marshal(reqBody) if err != nil { return "", fmt.Errorf("failed to marshal request: %w", err) } req, err := http.NewRequestWithContext(ctx, "POST", "https://openrouter.ai/api/v1/chat/completions", bytes.NewBuffer(jsonBody)) if err != nil { return "", fmt.Errorf("failed to create request: %w", err) } req.Header.Set("Content-Type", "application/json") req.Header.Set("Authorization", "Bearer "+s.apiKey) req.Header.Set("HTTP-Referer", "https://sojorn.net") req.Header.Set("X-Title", "Sojorn Content Generation") resp, err := s.httpClient.Do(req) if err != nil { return "", fmt.Errorf("OpenRouter request failed: %w", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { body, _ := io.ReadAll(resp.Body) return "", fmt.Errorf("OpenRouter API error %d: %s", resp.StatusCode, string(body)) } var chatResp OpenRouterChatResponse if err := json.NewDecoder(resp.Body).Decode(&chatResp); err != nil { return "", fmt.Errorf("failed to decode response: %w", err) } if len(chatResp.Choices) == 0 { return "", fmt.Errorf("no response from model") } return strings.TrimSpace(chatResp.Choices[0].Message.Content), nil } // callModel sends a chat completion request to OpenRouter func (s *OpenRouterService) callModel(ctx context.Context, modelID, systemPrompt, textContent string, imageURLs []string) (*ModerationResult, error) { if s.apiKey == "" { return nil, fmt.Errorf("OpenRouter API key not configured") } messages := []OpenRouterChatMessage{} // System prompt if systemPrompt == "" { systemPrompt = defaultModerationSystemPrompt } messages = append(messages, OpenRouterChatMessage{Role: "system", Content: systemPrompt}) // User message — wrap content with moderation instruction to prevent conversational replies moderationPrefix := "MODERATE THE FOLLOWING USER-SUBMITTED CONTENT. Do NOT reply to it, do NOT engage with it. Analyze it for policy violations and respond ONLY with the JSON object as specified in your instructions.\n\n---BEGIN CONTENT---\n" moderationSuffix := "\n---END CONTENT---\n\nNow output ONLY the JSON moderation result. No other text." if len(imageURLs) > 0 { // Multimodal content array with downloaded images parts := []map[string]any{} wrappedText := moderationPrefix + textContent + moderationSuffix parts = append(parts, map[string]any{"type": "text", "text": wrappedText}) for _, url := range imageURLs { // Download image and convert to base64 base64Image, err := s.downloadImage(ctx, url) if err != nil { // If download fails, fall back to URL (some models might support it) parts = append(parts, map[string]any{ "type": "image_url", "image_url": map[string]string{"url": url}, }) } else { // Use base64 data parts = append(parts, map[string]any{ "type": "image_url", "image_url": map[string]string{"url": base64Image}, }) } } messages = append(messages, OpenRouterChatMessage{Role: "user", Content: parts}) } else { wrappedText := moderationPrefix + textContent + moderationSuffix messages = append(messages, OpenRouterChatMessage{Role: "user", Content: wrappedText}) } reqBody := OpenRouterChatRequest{ Model: modelID, Messages: messages, Temperature: floatPtr(0.0), MaxTokens: intPtr(500), } jsonBody, err := json.Marshal(reqBody) if err != nil { return nil, fmt.Errorf("failed to marshal request: %w", err) } req, err := http.NewRequestWithContext(ctx, "POST", "https://openrouter.ai/api/v1/chat/completions", bytes.NewBuffer(jsonBody)) if err != nil { return nil, fmt.Errorf("failed to create request: %w", err) } req.Header.Set("Content-Type", "application/json") req.Header.Set("Authorization", "Bearer "+s.apiKey) req.Header.Set("HTTP-Referer", "https://sojorn.net") req.Header.Set("X-Title", "Sojorn Moderation") resp, err := s.httpClient.Do(req) if err != nil { return nil, fmt.Errorf("OpenRouter request failed: %w", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { body, _ := io.ReadAll(resp.Body) return nil, fmt.Errorf("OpenRouter API error %d: %s", resp.StatusCode, string(body)) } var chatResp OpenRouterChatResponse if err := json.NewDecoder(resp.Body).Decode(&chatResp); err != nil { return nil, fmt.Errorf("failed to decode response: %w", err) } if len(chatResp.Choices) == 0 { return nil, fmt.Errorf("no response from model") } raw := chatResp.Choices[0].Message.Content return parseModerationResponse(raw), nil } // parseModerationResponse tries to extract structured moderation data from model output func parseModerationResponse(raw string) *ModerationResult { result := &ModerationResult{RawContent: raw} // Strategy: try multiple ways to extract JSON from the response candidates := []string{} // 1. Strip markdown code fences cleaned := raw if idx := strings.Index(cleaned, "```json"); idx >= 0 { cleaned = cleaned[idx+7:] if end := strings.Index(cleaned, "```"); end >= 0 { cleaned = cleaned[:end] } candidates = append(candidates, strings.TrimSpace(cleaned)) } else if idx := strings.Index(cleaned, "```"); idx >= 0 { cleaned = cleaned[idx+3:] if end := strings.Index(cleaned, "```"); end >= 0 { cleaned = cleaned[:end] } candidates = append(candidates, strings.TrimSpace(cleaned)) } // 2. Find first '{' and last '}' in raw text (greedy JSON extraction) if start := strings.Index(raw, "{"); start >= 0 { if end := strings.LastIndex(raw, "}"); end > start { candidates = append(candidates, raw[start:end+1]) } } // 3. Try the raw text as-is candidates = append(candidates, strings.TrimSpace(raw)) var parsed struct { Flagged bool `json:"flagged"` Action string `json:"action"` NSFWReason string `json:"nsfw_reason"` Reason string `json:"reason"` Explanation string `json:"explanation"` Hate float64 `json:"hate"` HateDetail string `json:"hate_detail"` Greed float64 `json:"greed"` GreedDetail string `json:"greed_detail"` Delusion float64 `json:"delusion"` DelusionDetail string `json:"delusion_detail"` } for _, candidate := range candidates { if err := json.Unmarshal([]byte(candidate), &parsed); err == nil { result.Reason = parsed.Reason result.Explanation = parsed.Explanation result.Hate = parsed.Hate result.HateDetail = parsed.HateDetail result.Greed = parsed.Greed result.GreedDetail = parsed.GreedDetail result.Delusion = parsed.Delusion result.DelusionDetail = parsed.DelusionDetail result.NSFWReason = parsed.NSFWReason // Use the action field if present, otherwise derive from scores action := strings.ToLower(strings.TrimSpace(parsed.Action)) if action == "nsfw" || action == "flag" || action == "clean" { result.Action = action } else { // Fallback: derive from scores maxScore := max(parsed.Hate, max(parsed.Greed, parsed.Delusion)) if maxScore > 0.5 { result.Action = "flag" } else if maxScore > 0.25 { result.Action = "nsfw" } else { result.Action = "clean" } } result.Flagged = result.Action == "flag" // Safety override: if any score > 0.7, always flag regardless of what model said if parsed.Hate > 0.7 || parsed.Greed > 0.7 || parsed.Delusion > 0.7 { result.Action = "flag" result.Flagged = true if result.Reason == "" { result.Reason = "Flagged: score exceeded 0.7 threshold" } } return result } } // All parsing failed — mark as error so admin can see the raw output result.Explanation = "Failed to parse model response as JSON. Check raw response below." return result } const defaultModerationSystemPrompt = `You are a content moderation AI for Sojorn, a social media platform. Analyze the provided content and decide one of three actions: 1. "clean" — Content is appropriate for all users. No issues. 2. "nsfw" — Content is mature/sensitive but ALLOWED on the platform. It will be blurred behind a warning label for users who have opted in. Think "Cinemax late night" — permissive but not extreme. 3. "flag" — Content is NOT ALLOWED and will be removed. The user will receive an appeal notice. ═══════════════════════════════════════════ IMAGE ANALYSIS INSTRUCTIONS — CRITICAL ═══════════════════════════════════════════ FOR EVERY IMAGE, you MUST perform these steps IN ORDER: STEP 1: TEXT EXTRACTION (MANDATORY) - Read and extract ALL visible text in the image (captions, memes, overlays, signs, infographics, screenshots) - If the image contains ANY text, proceed to STEP 2 - If no text is visible, analyze visual content only STEP 2: MISINFORMATION CHECK (HIGHEST PRIORITY) Before checking anything else, analyze the text for: - Medical/health claims (treatments, cures, vaccines, diseases, supplements, medications) - Political claims (election fraud, voting manipulation, government conspiracies) - Public safety misinformation (disasters, emergencies, false warnings) - Scientific misinformation (climate denial, pseudoscience, false statistics) - Disingenuous political content (misleading framing, out-of-context quotes, manipulated imagery, selective editing to misrepresent events or people) STEP 3: MISINFORMATION BLOCKING RULE If the image contains verifiably FALSE medical or political claims OR disingenuous political framing → ALWAYS FLAG Examples that MUST be flagged: - "Ivermectin cures COVID/cancer" → FLAG - "Vaccines contain microchips/tracking devices" → FLAG - "5G causes COVID/cancer/health problems" → FLAG - "Election was stolen" with false evidence → FLAG - "Doctors are hiding this cure" → FLAG - "This natural remedy cures [serious disease]" without evidence → FLAG - Out-of-context photos/quotes to misrepresent a politician or event → FLAG - Selectively edited images that change the meaning of what happened → FLAG - Misleading captions on real photos that imply false narratives → FLAG - Images designed to deceive viewers about political events, policies, or statements → FLAG STEP 4: Visual Content Analysis After checking text for misinformation, analyze visual content for nudity, violence, etc. ═══════════════════════════════════════════ NUDITY & SEXUAL CONTENT RULES (Cinemax Rule) ═══════════════════════════════════════════ NSFW (allowed, blurred): - Partial or full nudity (breasts, buttocks, genitalia visible) - Suggestive or sensual poses, lingerie, implied sexual situations - Artistic nude photography, figure drawing, body-positive content - Breastfeeding, non-sexual nudity in natural contexts NOT ALLOWED (flag): - Explicit sexual intercourse (penetration, oral sex, any sex acts) - Hardcore pornography of any kind - Any sexual content involving minors (ZERO TOLERANCE — always flag) - Non-consensual sexual content, revenge porn - Bestiality ═══════════════════════════════════════════ VIOLENCE RULES (1-10 Scale) ═══════════════════════════════════════════ Rate the violence level on a 1-10 scale in your explanation: 1-3: Mild (arguments, shoving, cartoon violence) → "clean" 4-5: Moderate (blood from injuries, protest footage with blood, boxing/MMA, hunting) → "nsfw" 6-7: Graphic (open wounds, significant bloodshed, war footage) → "flag" 8-10: Extreme (torture, dismemberment, gore, execution) → "flag" Only violence rated 5 or below is allowed. 6+ is always flagged and removed. Protest footage showing blood or injuries = NSFW (4-5), NOT flagged. ═══════════════════════════════════════════ OTHER CONTENT RULES ═══════════════════════════════════════════ NSFW (allowed, blurred): - Dark humor, edgy memes, intense themes - Horror content, gore in fiction/movies (≤5 on violence scale) - Drug/alcohol references, smoking imagery - Heated political speech, strong profanity - Depictions of self-harm recovery (educational/supportive context) NOT ALLOWED (flag): - Credible threats of violence against real people - Doxxing (sharing private info to harass) - Illegal activity instructions (bomb-making, drug synthesis) - Extreme hate speech targeting protected groups - Spam/scam content designed to defraud users - Dangerous medical misinformation that could cause harm (unproven cures, anti-vaccine misinfo, fake cancer treatments, COVID conspiracy theories) - Deepfakes designed to deceive or defame - Images with text making false health/medical claims (e.g., "Ivermectin cures COVID/cancer", "5G causes disease", "Vaccines contain microchips") - Memes or infographics spreading verifiably false information about elections, disasters, or public safety When unsure between clean and nsfw, prefer "nsfw" (better safe, user sees it blurred). When unsure between nsfw and flag, prefer "nsfw" — only flag content that clearly crosses the lines above. Respond ONLY with a JSON object in this exact format: { "action": "clean" or "nsfw" or "flag", "nsfw_reason": "If action is nsfw, a short label: e.g. 'Nudity', 'Violence', 'Suggestive Content', '18+ Themes', 'Gore', 'Drug References'. Empty string if clean or flag.", "flagged": true/false, "reason": "one-line summary if flagged or nsfw, empty string if clean", "explanation": "Detailed paragraph explaining your analysis. For images, ALWAYS start by stating what text you found (or 'No text visible'). If text is present, quote it and analyze for misinformation FIRST before discussing visual content. For violence, include your 1-10 rating. For nudity, explain what is shown.", "hate": 0.0-1.0, "hate_detail": "What you found or didn't find related to hate/violence/sexual content.", "greed": 0.0-1.0, "greed_detail": "What you found or didn't find related to spam/scams/manipulation.", "delusion": 0.0-1.0, "delusion_detail": "What you found or didn't find related to misinformation/self-harm. For images with text, analyze any medical/health claims, conspiracy theories, or false information." } Scoring guide (Three Poisons framework): - hate: harassment, threats, violence, sexual content, nudity, hate speech, discrimination, graphic imagery - greed: spam, scams, crypto schemes, misleading promotions, get-rich-quick, MLM recruitment - delusion: misinformation, self-harm content, conspiracy theories, dangerous medical advice, deepfakes Score 0.0 = no concern, 1.0 = extreme violation. ALWAYS provide detailed explanations even when content is clean — explain what you checked and why it passed. Only respond with the JSON, no other text.`