// Track offset position within the concatenated text for this message
// (matches how extractTextContent joins parts with \n)
let partOffset = 0;
-
+
// Redact only text parts of array content with proper offset tracking
const redactedContent = msg.content.map((part: ContentPart) => {
if (part.type === "text" && typeof part.text === "string") {
const partLength = part.text.length;
-
+
// Find redactions that apply to this specific part
const partRedactions = messageRedactions
.filter((r) => r.start < partOffset + partLength && r.end > partOffset)
start: Math.max(0, r.start - partOffset),
end: Math.min(partLength, r.end - partOffset),
}));
-
+
if (partRedactions.length > 0) {
const { redacted, context: updatedContext } = redactSecrets(
part.text,
partOffset += partLength + 1; // +1 for \n separator
return { ...part, text: redacted };
}
-
+
partOffset += partLength + 1; // +1 for \n separator
return part;
}
import { describe, expect, test } from "bun:test";
-import type { MaskingConfig, SecretsDetectionConfig } from "../config";
+import type { SecretsDetectionConfig } from "../config";
import type { ChatMessage } from "../services/llm-client";
import { maskMessages } from "../services/masking";
import type { PIIEntity } from "../services/pii-detector";
-import { detectSecrets } from "./detect";
-import { redactSecrets } from "./redact";
import type { ContentPart } from "../utils/content";
describe("Multimodal content handling", () => {
- const secretsConfig: SecretsDetectionConfig = {
+ const _secretsConfig: SecretsDetectionConfig = {
enabled: true,
action: "redact",
entities: ["API_KEY_OPENAI"],
const messages: ChatMessage[] = [
{
role: "user",
- content: [
- { type: "text", text: "Contact Alice at alice@secret.com" },
- ],
+ content: [{ type: "text", text: "Contact Alice at alice@secret.com" }],
},
];
expect(Array.isArray(masked[0].content)).toBe(true);
const maskedContent = masked[0].content as ContentPart[];
-
+
// Verify the text is actually masked (not the original)
expect(maskedContent[0].text).not.toContain("Alice");
expect(maskedContent[0].text).not.toContain("alice@secret.com");
// At minimum, verify that the entity is masked somewhere
const fullMasked = maskedContent
- .filter(p => p.type === "text")
- .map(p => p.text)
+ .filter((p) => p.type === "text")
+ .map((p) => p.text)
.join("\n");
-
+
expect(fullMasked).toContain("<EMAIL_ADDRESS_");
expect(fullMasked).not.toContain("email@example.com");
});
import type { MaskingConfig } from "../config";
-import { extractTextContent, type ContentPart } from "../utils/content";
+import { extractTextContent } from "../utils/content";
import type { ChatCompletionResponse, ChatMessage } from "./llm-client";
import type { PIIEntity } from "./pii-detector";
const masked = messages.map((msg, i) => {
const entities = entitiesByMessage[i] || [];
-
+
// Handle array content (multimodal messages)
if (Array.isArray(msg.content)) {
if (entities.length === 0) {
return msg;
}
-
+
// Track offset position within the concatenated text for this message
// (matches how extractTextContent joins parts with \n)
let partOffset = 0;
-
+
// Mask only text parts with proper offset tracking
const maskedContent = msg.content.map((part) => {
if (part.type === "text" && typeof part.text === "string") {
const partLength = part.text.length;
-
+
// Find entities that apply to this specific part
const partEntities = entities
.filter((e) => e.start < partOffset + partLength && e.end > partOffset)
start: Math.max(0, e.start - partOffset),
end: Math.min(partLength, e.end - partOffset),
}));
-
+
if (partEntities.length > 0) {
const { masked: maskedText } = mask(part.text, partEntities, context);
partOffset += partLength + 1; // +1 for \n separator
return { ...part, text: maskedText };
}
-
+
partOffset += partLength + 1; // +1 for \n separator
return part;
}
return part;
});
-
+
return { ...msg, content: maskedContent };
}
-
+
// Handle string content (text-only messages)
const text = extractTextContent(msg.content);
const { masked: maskedContent } = mask(text, entities, context);
import { describe, expect, test } from "bun:test";
-import { extractTextContent, hasTextContent, type ContentPart } from "./content";
+import { type ContentPart, extractTextContent, hasTextContent } from "./content";
describe("extractTextContent", () => {
test("returns empty string for null", () => {