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()