From 58834203c196e663c0ec2abc9654d5a612caa0f8 Mon Sep 17 00:00:00 2001 From: Rashin Arab Date: Tue, 28 May 2024 15:43:42 -0700 Subject: [PATCH] Add Azure OpenAI Content Filter Support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Azure enforces content filtering on all completion requests. To reduce the overhead of content filtering, they’ve added asychronous mode, which basically outputs specialized bodies at the end of streaming output. https://learn.microsoft.com/en-us/azure/ai-services/openai/concepts/content-filter?tabs=warning%2Cpython-new#annotation-message The basic structure is this: data: {"choices":[{"content_filter_offsets":{"check_offset":33188,"start_offset":33188,"end_offset":33546},"content_filter_results":{"hate":{"filtered":false,"severity":"safe"},"self_harm":{"filtered":false,"severity":"safe"},"sexual":{"filtered":false,"severity":"safe"},"violence":{"filtered":false,"severity":"safe"}},"finish_reason":null,"index":0}],"created":0,"id":"","model":"","object":""} --- .../extension/{ChatChuck.kt => ChatChunk.kt} | 0 .../internal/ChatMessageAssembler.kt | 12 +++- .../com/aallam/openai/client/TestChatChunk.kt | 62 +++++++++++++++++++ .../openai/client/TestChatCompletionChunk.kt | 25 ++++++++ .../json/azureContentFilterChunk.json | 35 +++++++++++ .../commonTest/resources/json/chatChunk.json | 16 +++++ .../com.aallam.openai.api/chat/ChatChunk.kt | 13 +++- .../com.aallam.openai.api/chat/ChatMessage.kt | 30 +++++++++ .../chat/ContentFilterOffsets.kt | 11 ++++ .../chat/ContentFilterResults.kt | 20 ++++++ .../core/FinishReason.kt | 1 + .../com/aallam/openai/sample/jvm/Chat.kt | 2 +- sample/native/src/nativeMain/kotlin/main.kt | 2 +- 13 files changed, 224 insertions(+), 5 deletions(-) rename openai-client/src/commonMain/kotlin/com.aallam.openai.client/extension/{ChatChuck.kt => ChatChunk.kt} (100%) create mode 100644 openai-client/src/commonTest/kotlin/com/aallam/openai/client/TestChatCompletionChunk.kt create mode 100644 openai-client/src/commonTest/resources/json/azureContentFilterChunk.json create mode 100644 openai-client/src/commonTest/resources/json/chatChunk.json create mode 100644 openai-core/src/commonMain/kotlin/com.aallam.openai.api/chat/ContentFilterOffsets.kt create mode 100644 openai-core/src/commonMain/kotlin/com.aallam.openai.api/chat/ContentFilterResults.kt diff --git a/openai-client/src/commonMain/kotlin/com.aallam.openai.client/extension/ChatChuck.kt b/openai-client/src/commonMain/kotlin/com.aallam.openai.client/extension/ChatChunk.kt similarity index 100% rename from openai-client/src/commonMain/kotlin/com.aallam.openai.client/extension/ChatChuck.kt rename to openai-client/src/commonMain/kotlin/com.aallam.openai.client/extension/ChatChunk.kt diff --git a/openai-client/src/commonMain/kotlin/com.aallam.openai.client/extension/internal/ChatMessageAssembler.kt b/openai-client/src/commonMain/kotlin/com.aallam.openai.client/extension/internal/ChatMessageAssembler.kt index 7f8f521b..6d4d843d 100644 --- a/openai-client/src/commonMain/kotlin/com.aallam.openai.client/extension/internal/ChatMessageAssembler.kt +++ b/openai-client/src/commonMain/kotlin/com.aallam.openai.client/extension/internal/ChatMessageAssembler.kt @@ -11,12 +11,14 @@ internal class ChatMessageAssembler { private val chatContent = StringBuilder() private var chatRole: ChatRole? = null private val toolCallsAssemblers = mutableMapOf() + private var chatContentFilterOffsets = mutableListOf() + private var chatContentFilterResults = mutableListOf() /** * Merges a chat chunk into the chat message being assembled. */ fun merge(chunk: ChatChunk): ChatMessageAssembler { - chunk.delta.run { + chunk.delta?.run { role?.let { chatRole = it } content?.let { chatContent.append(it) } functionCall?.let { call -> @@ -30,6 +32,12 @@ internal class ChatMessageAssembler { assembler.merge(toolCall) } } + chunk.contentFilterOffsets?.also { + chatContentFilterOffsets.add(it) + } + chunk.contentFilterResults?.also { + chatContentFilterResults.add(it) + } return this } @@ -39,6 +47,8 @@ internal class ChatMessageAssembler { fun build(): ChatMessage = chatMessage { this.role = chatRole this.content = chatContent.toString() + this.contentFilterOffsets = chatContentFilterOffsets + this.contentFilterResults = chatContentFilterResults if (chatFuncName.isNotEmpty() || chatFuncArgs.isNotEmpty()) { this.functionCall = FunctionCall(chatFuncName.toString(), chatFuncArgs.toString()) this.name = chatFuncName.toString() diff --git a/openai-client/src/commonTest/kotlin/com/aallam/openai/client/TestChatChunk.kt b/openai-client/src/commonTest/kotlin/com/aallam/openai/client/TestChatChunk.kt index ab51ce21..5fcd812b 100644 --- a/openai-client/src/commonTest/kotlin/com/aallam/openai/client/TestChatChunk.kt +++ b/openai-client/src/commonTest/kotlin/com/aallam/openai/client/TestChatChunk.kt @@ -4,6 +4,9 @@ import com.aallam.openai.api.chat.ChatChunk import com.aallam.openai.api.chat.ChatDelta import com.aallam.openai.api.chat.ChatMessage import com.aallam.openai.api.chat.ChatRole +import com.aallam.openai.api.chat.ContentFilterOffsets +import com.aallam.openai.api.chat.ContentFilterResult +import com.aallam.openai.api.chat.ContentFilterResults import com.aallam.openai.api.core.FinishReason import com.aallam.openai.client.extension.mergeToChatMessage import kotlin.test.Test @@ -20,6 +23,8 @@ class TestChatChunk { role = ChatRole(role = "assistant"), content = "" ), + contentFilterOffsets = null, + contentFilterResults = null, finishReason = null ), ChatChunk( @@ -28,6 +33,8 @@ class TestChatChunk { role = null, content = "The" ), + contentFilterOffsets = null, + contentFilterResults = null, finishReason = null ), ChatChunk( @@ -36,6 +43,8 @@ class TestChatChunk { role = null, content = " World" ), + contentFilterOffsets = null, + contentFilterResults = null, finishReason = null ), ChatChunk( @@ -44,6 +53,8 @@ class TestChatChunk { role = null, content = " Series" ), + contentFilterOffsets = null, + contentFilterResults = null, finishReason = null ), ChatChunk( @@ -52,6 +63,8 @@ class TestChatChunk { role = null, content = " in" ), + contentFilterOffsets = null, + contentFilterResults = null, finishReason = null ), ChatChunk( @@ -60,6 +73,8 @@ class TestChatChunk { role = null, content = " " ), + contentFilterOffsets = null, + contentFilterResults = null, finishReason = null ), ChatChunk( @@ -68,6 +83,8 @@ class TestChatChunk { role = null, content = "202" ), + contentFilterOffsets = null, + contentFilterResults = null, finishReason = null ), ChatChunk( @@ -76,6 +93,8 @@ class TestChatChunk { role = null, content = "0" ), + contentFilterOffsets = null, + contentFilterResults = null, finishReason = null ), ChatChunk( @@ -84,6 +103,8 @@ class TestChatChunk { role = null, content = " is" ), + contentFilterOffsets = null, + contentFilterResults = null, finishReason = null ), ChatChunk( @@ -92,6 +113,8 @@ class TestChatChunk { role = null, content = " being held" ), + contentFilterOffsets = null, + contentFilterResults = null, finishReason = null ), ChatChunk( @@ -100,6 +123,8 @@ class TestChatChunk { role = null, content = " in" ), + contentFilterOffsets = null, + contentFilterResults = null, finishReason = null ), ChatChunk( @@ -108,6 +133,8 @@ class TestChatChunk { role = null, content = " Texas" ), + contentFilterOffsets = null, + contentFilterResults = null, finishReason = null ), ChatChunk( @@ -116,6 +143,8 @@ class TestChatChunk { role = null, content = "." ), + contentFilterOffsets = null, + contentFilterResults = null, finishReason = null ), ChatChunk( @@ -124,6 +153,24 @@ class TestChatChunk { role = null, content = null ), + contentFilterOffsets = null, + contentFilterResults = null, + finishReason = FinishReason(value = "stop") + ), + ChatChunk( + index = 0, + delta = null, + contentFilterOffsets = ContentFilterOffsets( + checkOffset = 1, + startOffset = 1, + endOffset = 1, + ), + contentFilterResults = ContentFilterResults( + hate = ContentFilterResult( + filtered = false, + severity = "high", + ) + ), finishReason = FinishReason(value = "stop") ) ) @@ -132,6 +179,21 @@ class TestChatChunk { role = ChatRole.Assistant, content = "The World Series in 2020 is being held in Texas.", name = null, + contentFilterResults = listOf( + ContentFilterResults( + hate = ContentFilterResult( + filtered = false, + severity = "high", + ) + ) + ), + contentFilterOffsets = listOf( + ContentFilterOffsets( + checkOffset = 1, + startOffset = 1, + endOffset = 1, + ) + ), ) assertEquals(chatMessage, message) } diff --git a/openai-client/src/commonTest/kotlin/com/aallam/openai/client/TestChatCompletionChunk.kt b/openai-client/src/commonTest/kotlin/com/aallam/openai/client/TestChatCompletionChunk.kt new file mode 100644 index 00000000..90ff4d81 --- /dev/null +++ b/openai-client/src/commonTest/kotlin/com/aallam/openai/client/TestChatCompletionChunk.kt @@ -0,0 +1,25 @@ +package com.aallam.openai.client + +import com.aallam.openai.api.chat.ChatCompletionChunk +import com.aallam.openai.api.file.FileSource +import com.aallam.openai.client.internal.JsonLenient +import com.aallam.openai.client.internal.TestFileSystem +import com.aallam.openai.client.internal.testFilePath +import kotlin.test.Test +import okio.buffer + +class TestChatCompletionChunk { + @Test + fun testContentFilterDeserialization() { + val json = FileSource(path = testFilePath("json/azureContentFilterChunk.json"), fileSystem = TestFileSystem) + val actualJson = json.source.buffer().readByteArray().decodeToString() + JsonLenient.decodeFromString(actualJson) + } + + @Test + fun testDeserialization() { + val json = FileSource(path = testFilePath("json/chatChunk.json"), fileSystem = TestFileSystem) + val actualJson = json.source.buffer().readByteArray().decodeToString() + JsonLenient.decodeFromString(actualJson) + } +} diff --git a/openai-client/src/commonTest/resources/json/azureContentFilterChunk.json b/openai-client/src/commonTest/resources/json/azureContentFilterChunk.json new file mode 100644 index 00000000..db250571 --- /dev/null +++ b/openai-client/src/commonTest/resources/json/azureContentFilterChunk.json @@ -0,0 +1,35 @@ +{ + "choices": [ + { + "content_filter_offsets": { + "check_offset": 33188, + "start_offset": 33188, + "end_offset": 33557 + }, + "content_filter_results": { + "hate": { + "filtered": false, + "severity": "safe" + }, + "self_harm": { + "filtered": false, + "severity": "safe" + }, + "sexual": { + "filtered": false, + "severity": "safe" + }, + "violence": { + "filtered": false, + "severity": "safe" + } + }, + "finish_reason": null, + "index": 0 + } + ], + "created": 0, + "id": "", + "model": "", + "object": "" +} diff --git a/openai-client/src/commonTest/resources/json/chatChunk.json b/openai-client/src/commonTest/resources/json/chatChunk.json new file mode 100644 index 00000000..0bd182e7 --- /dev/null +++ b/openai-client/src/commonTest/resources/json/chatChunk.json @@ -0,0 +1,16 @@ +{ + "choices": [ + { + "delta": { + "content": " engineering" + }, + "finish_reason": null, + "index": 0 + } + ], + "created": 1716855566, + "id": "chatcmpl-9TeqkT3BJs5zXQq12b204deXcY5nj", + "model": "gpt-4o-2024-05-13", + "object": "chat.completion.chunk", + "system_fingerprint": "fp_5f4bad809a" +} \ No newline at end of file diff --git a/openai-core/src/commonMain/kotlin/com.aallam.openai.api/chat/ChatChunk.kt b/openai-core/src/commonMain/kotlin/com.aallam.openai.api/chat/ChatChunk.kt index 6066987c..41769872 100644 --- a/openai-core/src/commonMain/kotlin/com.aallam.openai.api/chat/ChatChunk.kt +++ b/openai-core/src/commonMain/kotlin/com.aallam.openai.api/chat/ChatChunk.kt @@ -1,6 +1,5 @@ package com.aallam.openai.api.chat; -import com.aallam.openai.api.BetaOpenAI import com.aallam.openai.api.core.FinishReason import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable @@ -19,7 +18,17 @@ public data class ChatChunk( /** * The generated chat message. */ - @SerialName("delta") public val delta: ChatDelta, + @SerialName("delta") public val delta: ChatDelta? = null, + + /** + * Azure content filter offsets + */ + @SerialName("content_filter_offsets") public val contentFilterOffsets: ContentFilterOffsets? = null, + + /** + * Azure content filter results + */ + @SerialName("content_filter_results") public val contentFilterResults: ContentFilterResults? = null, /** * The reason why OpenAI stopped generating. diff --git a/openai-core/src/commonMain/kotlin/com.aallam.openai.api/chat/ChatMessage.kt b/openai-core/src/commonMain/kotlin/com.aallam.openai.api/chat/ChatMessage.kt index 8a6ad832..1a76e031 100644 --- a/openai-core/src/commonMain/kotlin/com.aallam.openai.api/chat/ChatMessage.kt +++ b/openai-core/src/commonMain/kotlin/com.aallam.openai.api/chat/ChatMessage.kt @@ -45,6 +45,16 @@ public data class ChatMessage( * Tool call ID. */ @SerialName("tool_call_id") public val toolCallId: ToolId? = null, + + /** + * Azure Content Filter Results + */ + @SerialName("content_filter_results") public val contentFilterResults: List? = null, + + /** + * Azure Content Filter Offsets + */ + @SerialName("content_filter_offsets") public val contentFilterOffsets: List? = null, ) { public constructor( @@ -54,6 +64,8 @@ public data class ChatMessage( functionCall: FunctionCall? = null, toolCalls: List? = null, toolCallId: ToolId? = null, + contentFilterResults: List? = null, + contentFilterOffsets: List? = null, ) : this( role = role, messageContent = content?.let { TextContent(it) }, @@ -61,6 +73,8 @@ public data class ChatMessage( functionCall = functionCall, toolCalls = toolCalls, toolCallId = toolCallId, + contentFilterOffsets = contentFilterOffsets, + contentFilterResults = contentFilterResults, ) public constructor( @@ -70,6 +84,8 @@ public data class ChatMessage( functionCall: FunctionCall? = null, toolCalls: List? = null, toolCallId: ToolId? = null, + contentFilterResults: List? = null, + contentFilterOffsets: List? = null, ) : this( role = role, messageContent = content?.let { ListContent(it) }, @@ -77,6 +93,8 @@ public data class ChatMessage( functionCall = functionCall, toolCalls = toolCalls, toolCallId = toolCallId, + contentFilterOffsets = contentFilterOffsets, + contentFilterResults = contentFilterResults, ) val content: String? @@ -282,6 +300,16 @@ public class ChatMessageBuilder { */ public var toolCalls: List? = null + /** + * Azure content filter offsets + */ + public var contentFilterOffsets: List? = null + + /** + * Azure content filter results + */ + public var contentFilterResults: List? = null + /** * Tool call ID. */ @@ -313,6 +341,8 @@ public class ChatMessageBuilder { functionCall = functionCall, toolCalls = toolCalls, toolCallId = toolCallId, + contentFilterOffsets = contentFilterOffsets, + contentFilterResults = contentFilterResults, ) } } diff --git a/openai-core/src/commonMain/kotlin/com.aallam.openai.api/chat/ContentFilterOffsets.kt b/openai-core/src/commonMain/kotlin/com.aallam.openai.api/chat/ContentFilterOffsets.kt new file mode 100644 index 00000000..d9cdfa2c --- /dev/null +++ b/openai-core/src/commonMain/kotlin/com.aallam.openai.api/chat/ContentFilterOffsets.kt @@ -0,0 +1,11 @@ +package com.aallam.openai.api.chat + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +public data class ContentFilterOffsets( + @SerialName("check_offset") val checkOffset: Int?, + @SerialName("start_offset") val startOffset: Int?, + @SerialName("end_offset") val endOffset: Int?, +) diff --git a/openai-core/src/commonMain/kotlin/com.aallam.openai.api/chat/ContentFilterResults.kt b/openai-core/src/commonMain/kotlin/com.aallam.openai.api/chat/ContentFilterResults.kt new file mode 100644 index 00000000..e19cb2a4 --- /dev/null +++ b/openai-core/src/commonMain/kotlin/com.aallam.openai.api/chat/ContentFilterResults.kt @@ -0,0 +1,20 @@ +package com.aallam.openai.api.chat + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +public data class ContentFilterResult( + @SerialName("filtered") val filtered: Boolean, + @SerialName("severity") val severity: String, +) + +@Serializable +public data class ContentFilterResults( + @SerialName("hate") val hate: ContentFilterResult? = null, + @SerialName("self_harm") val selfHarm: ContentFilterResult? = null, + @SerialName("sexual") val sexual: ContentFilterResult? = null, + @SerialName("violence") val violence: ContentFilterResult? = null, + @SerialName("jailbreak") val jailbreak: ContentFilterResult? = null, + @SerialName("profanity") val profanity: ContentFilterResult? = null, +) diff --git a/openai-core/src/commonMain/kotlin/com.aallam.openai.api/core/FinishReason.kt b/openai-core/src/commonMain/kotlin/com.aallam.openai.api/core/FinishReason.kt index b8b1931b..290058c9 100644 --- a/openai-core/src/commonMain/kotlin/com.aallam.openai.api/core/FinishReason.kt +++ b/openai-core/src/commonMain/kotlin/com.aallam.openai.api/core/FinishReason.kt @@ -11,5 +11,6 @@ public value class FinishReason(public val value: String) { public val Length: FinishReason = FinishReason("length") public val FunctionCall: FinishReason = FinishReason("function_call") public val ToolCalls: FinishReason = FinishReason("tool_calls") + public val ContentFilter: FinishReason = FinishReason("content_filter") } } diff --git a/sample/jvm/src/main/kotlin/com/aallam/openai/sample/jvm/Chat.kt b/sample/jvm/src/main/kotlin/com/aallam/openai/sample/jvm/Chat.kt index 7224221c..1efc3c4a 100644 --- a/sample/jvm/src/main/kotlin/com/aallam/openai/sample/jvm/Chat.kt +++ b/sample/jvm/src/main/kotlin/com/aallam/openai/sample/jvm/Chat.kt @@ -28,7 +28,7 @@ suspend fun chat(openAI: OpenAI) { println("\n>️ Creating chat completions stream...") openAI.chatCompletions(chatCompletionRequest) - .onEach { print(it.choices.first().delta.content.orEmpty()) } + .onEach { print(it.choices.first().delta?.content.orEmpty()) } .onCompletion { println() } .collect() } diff --git a/sample/native/src/nativeMain/kotlin/main.kt b/sample/native/src/nativeMain/kotlin/main.kt index 6f87389e..38bea70c 100644 --- a/sample/native/src/nativeMain/kotlin/main.kt +++ b/sample/native/src/nativeMain/kotlin/main.kt @@ -89,7 +89,7 @@ fun main(): Unit = runBlocking { println("\n>️ Creating chat completions stream...") openAI.chatCompletions(chatCompletionRequest) - .onEach { print(it.choices.first().delta.content.orEmpty()) } + .onEach { print(it.choices.first().delta?.content.orEmpty()) } .onCompletion { println() } .launchIn(this) .join()