fix: Update type system for multimodal content support
authormkroemer <redacted>
Fri, 9 Jan 2026 20:06:49 +0000 (21:06 +0100)
committermkroemer <redacted>
Fri, 9 Jan 2026 20:06:49 +0000 (21:06 +0100)
- Update ChatMessage interface to accept MessageContent type
- Fix masking.ts to handle multimodal content
- Fix redact.ts to extract text before processing
- Fix import order and formatting per biome linter

docker-compose.yml
src/routes/proxy.ts
src/secrets/redact.ts
src/services/llm-client.ts
src/services/masking.ts
src/services/pii-detector.ts

index 64d538a2fd384fbde14069450b6c214178ca4a85..f891957d1416ecd126d7be9f6f5e01346f99728d 100644 (file)
@@ -3,6 +3,8 @@ services:
     build: .
     ports:
       - "3000:3000"
+    env_file:
+      - .env
     environment:
       - PRESIDIO_URL=http://presidio-analyzer:3000
     volumes:
index d6834052cabccd9f7717fc1c74d115f86afaa5ed..3f23175291cffd567c0805625fb7a8343e6c082d 100644 (file)
@@ -18,20 +18,22 @@ import type {
   ChatMessage,
   LLMResult,
 } from "../services/llm-client";
-import { extractTextContent, type ContentPart } from "../utils/content";
 import { logRequest, type RequestLogData } from "../services/logger";
 import { unmaskResponse } from "../services/masking";
 import { createUnmaskingStream } from "../services/stream-transformer";
+import { type ContentPart, extractTextContent } from "../utils/content";
 
 // Request validation schema
 const ChatCompletionSchema = z
   .object({
     messages: z
       .array(
-        z.object({
-          role: z.enum(["system", "user", "assistant", "tool"]),
-          content: z.union([z.string(), z.array(z.any()), z.null()]).optional(),
-        }).passthrough(), // Allow additional fields like name, tool_calls, etc.
+        z
+          .object({
+            role: z.enum(["system", "user", "assistant", "tool"]),
+            content: z.union([z.string(), z.array(z.any()), z.null()]).optional(),
+          })
+          .passthrough(), // Allow additional fields like name, tool_calls, etc.
       )
       .min(1, "At least one message is required"),
   })
index 3a2c2a32145cc7721959e5fc26698290150acadf..636f2fc3cbf26cba9cfc6792a3356f9321f75cb4 100644 (file)
@@ -1,5 +1,6 @@
 import type { SecretsDetectionConfig } from "../config";
 import type { ChatCompletionResponse, ChatMessage } from "../services/llm-client";
+import { extractTextContent } from "../utils/content";
 import type { SecretsRedaction } from "./detect";
 
 /**
@@ -143,8 +144,12 @@ export function redactMessagesSecrets(
 
   const redacted = messages.map((msg, i) => {
     const redactions = redactionsByMessage[i] || [];
-    const { redacted: redactedContent } = redactSecrets(msg.content, redactions, config, context);
-    return { ...msg, content: redactedContent };
+    const text = extractTextContent(msg.content);
+    const { redacted: redactedContent } = redactSecrets(text, redactions, config, context);
+
+    // If original content was a string, return redacted string
+    // Otherwise return original content (arrays are handled in proxy.ts)
+    return { ...msg, content: typeof msg.content === "string" ? redactedContent : msg.content };
   });
 
   return { redacted, context };
@@ -218,7 +223,10 @@ export function unredactResponse(
       ...choice,
       message: {
         ...choice.message,
-        content: unredactSecrets(choice.message.content, context),
+        content:
+          typeof choice.message.content === "string"
+            ? unredactSecrets(choice.message.content, context)
+            : choice.message.content,
       },
     })),
   };
index 8745899897e1d2e574d168dccb9a93f5c6556a32..451ccfd3e6931dab9fc890276d68e5d81dac3c5c 100644 (file)
@@ -1,11 +1,13 @@
 import type { LocalProvider, UpstreamProvider } from "../config";
+import type { MessageContent } from "../utils/content";
 
 /**
  * OpenAI-compatible message format
+ * Supports both text-only (content: string) and multimodal (content: array) formats
  */
 export interface ChatMessage {
   role: "system" | "user" | "assistant";
-  content: string;
+  content: MessageContent;
 }
 
 /**
index fa5da21bb9afe8209b497801ad53318384edec45..f11cf524f4f3fada3065f81ee48fed62479b6903 100644 (file)
@@ -1,4 +1,5 @@
 import type { MaskingConfig } from "../config";
+import { extractTextContent } from "../utils/content";
 import type { ChatCompletionResponse, ChatMessage } from "./llm-client";
 import type { PIIEntity } from "./pii-detector";
 
@@ -117,8 +118,12 @@ export function maskMessages(
 
   const masked = messages.map((msg, i) => {
     const entities = entitiesByMessage[i] || [];
-    const { masked: maskedContent } = mask(msg.content, entities, context);
-    return { ...msg, content: maskedContent };
+    const text = extractTextContent(msg.content);
+    const { masked: maskedContent } = mask(text, entities, context);
+
+    // If original content was a string, return masked string
+    // Otherwise return original content (arrays are handled elsewhere)
+    return { ...msg, content: typeof msg.content === "string" ? maskedContent : msg.content };
   });
 
   return { masked, context };
@@ -197,7 +202,10 @@ export function unmaskResponse(
       ...choice,
       message: {
         ...choice.message,
-        content: unmask(choice.message.content, context, config),
+        content:
+          typeof choice.message.content === "string"
+            ? unmask(choice.message.content, context, config)
+            : choice.message.content,
       },
     })),
   };
index 5cc9ad357a1bd02b32bd26b7072165ad6a87d517..4d965b8eb1227344ab4942302ecebbf66383a8a4 100644 (file)
@@ -1,10 +1,10 @@
 import { getConfig } from "../config";
+import { extractTextContent, type MessageContent } from "../utils/content";
 import {
   getLanguageDetector,
   type LanguageDetectionResult,
   type SupportedLanguage,
 } from "./language-detector";
-import { extractTextContent, type MessageContent } from "../utils/content";
 
 export interface PIIEntity {
   entity_type: string;
git clone https://git.99rst.org/PROJECT