{"version":3,"file":"moderation.cjs","names":["flattenHeaders","matchesPattern","generateId"],"sources":["../src/moderation.ts"],"sourcesContent":["/**\n * Moderation API support for LLMock.\n *\n * Handles POST /v1/moderations requests (OpenAI-compatible). Matches\n * fixtures by comparing the request `input` field against registered\n * patterns. First match wins; no match returns a default unflagged result.\n */\n\nimport type * as http from \"node:http\";\nimport { flattenHeaders, generateId, matchesPattern } from \"./helpers.js\";\nimport type { Journal } from \"./journal.js\";\nimport type { Logger } from \"./logger.js\";\n\n// ─── Moderation types ─────────────────────────────────────────────────────\n\nexport interface ModerationResult {\n  flagged: boolean;\n  categories: Record<string, boolean>;\n  category_scores?: Record<string, number>;\n}\n\nexport interface ModerationFixture {\n  match: string | RegExp;\n  result: ModerationResult;\n}\n\n// ─── Default unflagged result ─────────────────────────────────────────────\n\nconst DEFAULT_RESULT: ModerationResult = {\n  flagged: false,\n  categories: {\n    sexual: false,\n    hate: false,\n    harassment: false,\n    \"self-harm\": false,\n    \"sexual/minors\": false,\n    \"hate/threatening\": false,\n    \"violence/graphic\": false,\n    \"self-harm/intent\": false,\n    \"self-harm/instructions\": false,\n    \"harassment/threatening\": false,\n    violence: false,\n    illicit: false,\n    \"illicit/violent\": false,\n  },\n  category_scores: {\n    sexual: 0,\n    hate: 0,\n    harassment: 0,\n    \"self-harm\": 0,\n    \"sexual/minors\": 0,\n    \"hate/threatening\": 0,\n    \"violence/graphic\": 0,\n    \"self-harm/intent\": 0,\n    \"self-harm/instructions\": 0,\n    \"harassment/threatening\": 0,\n    violence: 0,\n    illicit: 0,\n    \"illicit/violent\": 0,\n  },\n};\n\n// ─── Request handler ──────────────────────────────────────────────────────\n\nexport async function handleModeration(\n  req: http.IncomingMessage,\n  res: http.ServerResponse,\n  raw: string,\n  fixtures: ModerationFixture[],\n  journal: Journal,\n  defaults: { logger: Logger },\n  setCorsHeaders: (res: http.ServerResponse) => void,\n): Promise<void> {\n  const { logger } = defaults;\n  setCorsHeaders(res);\n\n  let body: { input?: string | string[] };\n  try {\n    body = JSON.parse(raw) as { input?: string | string[] };\n  } catch (parseErr) {\n    const detail = parseErr instanceof Error ? parseErr.message : \"unknown\";\n    journal.add({\n      method: req.method ?? \"POST\",\n      path: req.url ?? \"/v1/moderations\",\n      headers: flattenHeaders(req.headers),\n      body: null,\n      service: \"moderation\",\n      response: { status: 400, fixture: null },\n    });\n    res.writeHead(400, { \"Content-Type\": \"application/json\" });\n    res.end(\n      JSON.stringify({\n        error: {\n          message: `Malformed JSON: ${detail}`,\n          type: \"invalid_request_error\",\n          code: \"invalid_json\",\n        },\n      }),\n    );\n    return;\n  }\n\n  // Normalize input to a single string for matching\n  const rawInput = body.input ?? \"\";\n  const inputText = Array.isArray(rawInput) ? rawInput.join(\" \") : rawInput;\n\n  // Find first matching fixture\n  let matchedResult: ModerationResult = DEFAULT_RESULT;\n  let matchedFixture: ModerationFixture | null = null;\n\n  for (const fixture of fixtures) {\n    if (matchesPattern(inputText, fixture.match)) {\n      matchedFixture = fixture;\n      matchedResult = fixture.result;\n      break;\n    }\n  }\n\n  if (matchedFixture) {\n    logger.debug(`Moderation fixture matched for input \"${inputText.slice(0, 80)}\"`);\n  } else {\n    logger.debug(\n      `No moderation fixture matched for input \"${inputText.slice(0, 80)}\" — returning unflagged`,\n    );\n  }\n\n  journal.add({\n    method: req.method ?? \"POST\",\n    path: req.url ?? \"/v1/moderations\",\n    headers: flattenHeaders(req.headers),\n    body: null,\n    service: \"moderation\",\n    response: { status: 200, fixture: null },\n  });\n\n  res.writeHead(200, { \"Content-Type\": \"application/json\" });\n  res.end(\n    JSON.stringify({\n      id: generateId(\"modr\"),\n      model: \"text-moderation-latest\",\n      results: [matchedResult],\n    }),\n  );\n}\n"],"mappings":";;;AA4BA,MAAM,iBAAmC;CACvC,SAAS;CACT,YAAY;EACV,QAAQ;EACR,MAAM;EACN,YAAY;EACZ,aAAa;EACb,iBAAiB;EACjB,oBAAoB;EACpB,oBAAoB;EACpB,oBAAoB;EACpB,0BAA0B;EAC1B,0BAA0B;EAC1B,UAAU;EACV,SAAS;EACT,mBAAmB;EACpB;CACD,iBAAiB;EACf,QAAQ;EACR,MAAM;EACN,YAAY;EACZ,aAAa;EACb,iBAAiB;EACjB,oBAAoB;EACpB,oBAAoB;EACpB,oBAAoB;EACpB,0BAA0B;EAC1B,0BAA0B;EAC1B,UAAU;EACV,SAAS;EACT,mBAAmB;EACpB;CACF;AAID,eAAsB,iBACpB,KACA,KACA,KACA,UACA,SACA,UACA,gBACe;CACf,MAAM,EAAE,WAAW;AACnB,gBAAe,IAAI;CAEnB,IAAI;AACJ,KAAI;AACF,SAAO,KAAK,MAAM,IAAI;UACf,UAAU;EACjB,MAAM,SAAS,oBAAoB,QAAQ,SAAS,UAAU;AAC9D,UAAQ,IAAI;GACV,QAAQ,IAAI,UAAU;GACtB,MAAM,IAAI,OAAO;GACjB,SAASA,+BAAe,IAAI,QAAQ;GACpC,MAAM;GACN,SAAS;GACT,UAAU;IAAE,QAAQ;IAAK,SAAS;IAAM;GACzC,CAAC;AACF,MAAI,UAAU,KAAK,EAAE,gBAAgB,oBAAoB,CAAC;AAC1D,MAAI,IACF,KAAK,UAAU,EACb,OAAO;GACL,SAAS,mBAAmB;GAC5B,MAAM;GACN,MAAM;GACP,EACF,CAAC,CACH;AACD;;CAIF,MAAM,WAAW,KAAK,SAAS;CAC/B,MAAM,YAAY,MAAM,QAAQ,SAAS,GAAG,SAAS,KAAK,IAAI,GAAG;CAGjE,IAAI,gBAAkC;CACtC,IAAI,iBAA2C;AAE/C,MAAK,MAAM,WAAW,SACpB,KAAIC,+BAAe,WAAW,QAAQ,MAAM,EAAE;AAC5C,mBAAiB;AACjB,kBAAgB,QAAQ;AACxB;;AAIJ,KAAI,eACF,QAAO,MAAM,yCAAyC,UAAU,MAAM,GAAG,GAAG,CAAC,GAAG;KAEhF,QAAO,MACL,4CAA4C,UAAU,MAAM,GAAG,GAAG,CAAC,yBACpE;AAGH,SAAQ,IAAI;EACV,QAAQ,IAAI,UAAU;EACtB,MAAM,IAAI,OAAO;EACjB,SAASD,+BAAe,IAAI,QAAQ;EACpC,MAAM;EACN,SAAS;EACT,UAAU;GAAE,QAAQ;GAAK,SAAS;GAAM;EACzC,CAAC;AAEF,KAAI,UAAU,KAAK,EAAE,gBAAgB,oBAAoB,CAAC;AAC1D,KAAI,IACF,KAAK,UAAU;EACb,IAAIE,2BAAW,OAAO;EACtB,OAAO;EACP,SAAS,CAAC,cAAc;EACzB,CAAC,CACH"}