sojorn/go-backend/internal/services/openrouter_service.go

409 lines
15 KiB
Go

package services
import (
"bytes"
"context"
"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"`
UpdatedAt time.Time `json:"updated_at"`
UpdatedBy *string `json:"updated_by,omitempty"`
}
// 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"`
}
// 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, 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.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, 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.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, updatedBy string) error {
_, err := s.pool.Exec(ctx, `
INSERT INTO ai_moderation_config (moderation_type, model_id, model_name, system_prompt, enabled, updated_by, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, NOW())
ON CONFLICT (moderation_type)
DO UPDATE SET model_id = $2, model_name = $3, system_prompt = $4, enabled = $5, updated_by = $6, updated_at = NOW()
`, moderationType, modelID, modelName, systemPrompt, enabled, updatedBy)
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})
}
// 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"`
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"`
}
// 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 — text only or multimodal (text + images)
if len(imageURLs) > 0 {
// Multimodal content array
parts := []map[string]any{}
if textContent != "" {
parts = append(parts, map[string]any{"type": "text", "text": textContent})
}
for _, url := range imageURLs {
parts = append(parts, map[string]any{
"type": "image_url",
"image_url": map[string]string{"url": url},
})
}
messages = append(messages, OpenRouterChatMessage{Role: "user", Content: parts})
} else {
messages = append(messages, OpenRouterChatMessage{Role: "user", Content: textContent})
}
reqBody := OpenRouterChatRequest{
Model: modelID,
Messages: messages,
}
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"`
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.Flagged = parsed.Flagged
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
// Safety: re-derive flagged from scores — if any score > 0.5, it's flagged
// regardless of what the model put in the "flagged" field
scoreFlagged := parsed.Hate > 0.5 || parsed.Greed > 0.5 || parsed.Delusion > 0.5
if scoreFlagged != result.Flagged {
result.Flagged = scoreFlagged
if scoreFlagged && result.Reason == "" {
result.Reason = "Flagged: score exceeded 0.5 threshold"
}
if !scoreFlagged {
result.Reason = ""
}
}
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 for policy violations.
Respond ONLY with a JSON object in this exact format:
{
"flagged": true/false,
"reason": "one-line summary if flagged, empty string if clean",
"explanation": "Detailed paragraph explaining your full analysis. Describe exactly what you observed in the content, what specific elements you checked, why each category scored the way it did, and your overall reasoning for the flagged/clean decision.",
"hate": 0.0-1.0,
"hate_detail": "Explain exactly what you found (or didn't find) related to hate. E.g. 'No hate speech, slurs, threats, violence, sexual content, or discriminatory language detected.' or 'Contains racial slur targeting [group] in aggressive context.'",
"greed": 0.0-1.0,
"greed_detail": "Explain exactly what you found (or didn't find) related to greed. E.g. 'No spam, scam language, or promotional manipulation detected.' or 'Contains crypto pump-and-dump language with fake earnings claims.'",
"delusion": 0.0-1.0,
"delusion_detail": "Explain exactly what you found (or didn't find) related to delusion. E.g. 'No misinformation, self-harm, or conspiracy content detected.' or 'Promotes unverified medical cure with dangerous dosage advice.'"
}
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. Flag if any score > 0.5.
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.`