Skip to content

Commit 5883420

Browse files
committed
Add Azure OpenAI Content Filter Support
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":""}
1 parent a2ce122 commit 5883420

File tree

13 files changed

+224
-5
lines changed

13 files changed

+224
-5
lines changed

openai-client/src/commonMain/kotlin/com.aallam.openai.client/extension/internal/ChatMessageAssembler.kt

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,12 +11,14 @@ internal class ChatMessageAssembler {
1111
private val chatContent = StringBuilder()
1212
private var chatRole: ChatRole? = null
1313
private val toolCallsAssemblers = mutableMapOf<Int, ToolCallAssembler>()
14+
private var chatContentFilterOffsets = mutableListOf<ContentFilterOffsets>()
15+
private var chatContentFilterResults = mutableListOf<ContentFilterResults>()
1416

1517
/**
1618
* Merges a chat chunk into the chat message being assembled.
1719
*/
1820
fun merge(chunk: ChatChunk): ChatMessageAssembler {
19-
chunk.delta.run {
21+
chunk.delta?.run {
2022
role?.let { chatRole = it }
2123
content?.let { chatContent.append(it) }
2224
functionCall?.let { call ->
@@ -30,6 +32,12 @@ internal class ChatMessageAssembler {
3032
assembler.merge(toolCall)
3133
}
3234
}
35+
chunk.contentFilterOffsets?.also {
36+
chatContentFilterOffsets.add(it)
37+
}
38+
chunk.contentFilterResults?.also {
39+
chatContentFilterResults.add(it)
40+
}
3341
return this
3442
}
3543

@@ -39,6 +47,8 @@ internal class ChatMessageAssembler {
3947
fun build(): ChatMessage = chatMessage {
4048
this.role = chatRole
4149
this.content = chatContent.toString()
50+
this.contentFilterOffsets = chatContentFilterOffsets
51+
this.contentFilterResults = chatContentFilterResults
4252
if (chatFuncName.isNotEmpty() || chatFuncArgs.isNotEmpty()) {
4353
this.functionCall = FunctionCall(chatFuncName.toString(), chatFuncArgs.toString())
4454
this.name = chatFuncName.toString()

openai-client/src/commonTest/kotlin/com/aallam/openai/client/TestChatChunk.kt

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,9 @@ import com.aallam.openai.api.chat.ChatChunk
44
import com.aallam.openai.api.chat.ChatDelta
55
import com.aallam.openai.api.chat.ChatMessage
66
import com.aallam.openai.api.chat.ChatRole
7+
import com.aallam.openai.api.chat.ContentFilterOffsets
8+
import com.aallam.openai.api.chat.ContentFilterResult
9+
import com.aallam.openai.api.chat.ContentFilterResults
710
import com.aallam.openai.api.core.FinishReason
811
import com.aallam.openai.client.extension.mergeToChatMessage
912
import kotlin.test.Test
@@ -20,6 +23,8 @@ class TestChatChunk {
2023
role = ChatRole(role = "assistant"),
2124
content = ""
2225
),
26+
contentFilterOffsets = null,
27+
contentFilterResults = null,
2328
finishReason = null
2429
),
2530
ChatChunk(
@@ -28,6 +33,8 @@ class TestChatChunk {
2833
role = null,
2934
content = "The"
3035
),
36+
contentFilterOffsets = null,
37+
contentFilterResults = null,
3138
finishReason = null
3239
),
3340
ChatChunk(
@@ -36,6 +43,8 @@ class TestChatChunk {
3643
role = null,
3744
content = " World"
3845
),
46+
contentFilterOffsets = null,
47+
contentFilterResults = null,
3948
finishReason = null
4049
),
4150
ChatChunk(
@@ -44,6 +53,8 @@ class TestChatChunk {
4453
role = null,
4554
content = " Series"
4655
),
56+
contentFilterOffsets = null,
57+
contentFilterResults = null,
4758
finishReason = null
4859
),
4960
ChatChunk(
@@ -52,6 +63,8 @@ class TestChatChunk {
5263
role = null,
5364
content = " in"
5465
),
66+
contentFilterOffsets = null,
67+
contentFilterResults = null,
5568
finishReason = null
5669
),
5770
ChatChunk(
@@ -60,6 +73,8 @@ class TestChatChunk {
6073
role = null,
6174
content = " "
6275
),
76+
contentFilterOffsets = null,
77+
contentFilterResults = null,
6378
finishReason = null
6479
),
6580
ChatChunk(
@@ -68,6 +83,8 @@ class TestChatChunk {
6883
role = null,
6984
content = "202"
7085
),
86+
contentFilterOffsets = null,
87+
contentFilterResults = null,
7188
finishReason = null
7289
),
7390
ChatChunk(
@@ -76,6 +93,8 @@ class TestChatChunk {
7693
role = null,
7794
content = "0"
7895
),
96+
contentFilterOffsets = null,
97+
contentFilterResults = null,
7998
finishReason = null
8099
),
81100
ChatChunk(
@@ -84,6 +103,8 @@ class TestChatChunk {
84103
role = null,
85104
content = " is"
86105
),
106+
contentFilterOffsets = null,
107+
contentFilterResults = null,
87108
finishReason = null
88109
),
89110
ChatChunk(
@@ -92,6 +113,8 @@ class TestChatChunk {
92113
role = null,
93114
content = " being held"
94115
),
116+
contentFilterOffsets = null,
117+
contentFilterResults = null,
95118
finishReason = null
96119
),
97120
ChatChunk(
@@ -100,6 +123,8 @@ class TestChatChunk {
100123
role = null,
101124
content = " in"
102125
),
126+
contentFilterOffsets = null,
127+
contentFilterResults = null,
103128
finishReason = null
104129
),
105130
ChatChunk(
@@ -108,6 +133,8 @@ class TestChatChunk {
108133
role = null,
109134
content = " Texas"
110135
),
136+
contentFilterOffsets = null,
137+
contentFilterResults = null,
111138
finishReason = null
112139
),
113140
ChatChunk(
@@ -116,6 +143,8 @@ class TestChatChunk {
116143
role = null,
117144
content = "."
118145
),
146+
contentFilterOffsets = null,
147+
contentFilterResults = null,
119148
finishReason = null
120149
),
121150
ChatChunk(
@@ -124,6 +153,24 @@ class TestChatChunk {
124153
role = null,
125154
content = null
126155
),
156+
contentFilterOffsets = null,
157+
contentFilterResults = null,
158+
finishReason = FinishReason(value = "stop")
159+
),
160+
ChatChunk(
161+
index = 0,
162+
delta = null,
163+
contentFilterOffsets = ContentFilterOffsets(
164+
checkOffset = 1,
165+
startOffset = 1,
166+
endOffset = 1,
167+
),
168+
contentFilterResults = ContentFilterResults(
169+
hate = ContentFilterResult(
170+
filtered = false,
171+
severity = "high",
172+
)
173+
),
127174
finishReason = FinishReason(value = "stop")
128175
)
129176
)
@@ -132,6 +179,21 @@ class TestChatChunk {
132179
role = ChatRole.Assistant,
133180
content = "The World Series in 2020 is being held in Texas.",
134181
name = null,
182+
contentFilterResults = listOf(
183+
ContentFilterResults(
184+
hate = ContentFilterResult(
185+
filtered = false,
186+
severity = "high",
187+
)
188+
)
189+
),
190+
contentFilterOffsets = listOf(
191+
ContentFilterOffsets(
192+
checkOffset = 1,
193+
startOffset = 1,
194+
endOffset = 1,
195+
)
196+
),
135197
)
136198
assertEquals(chatMessage, message)
137199
}
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
package com.aallam.openai.client
2+
3+
import com.aallam.openai.api.chat.ChatCompletionChunk
4+
import com.aallam.openai.api.file.FileSource
5+
import com.aallam.openai.client.internal.JsonLenient
6+
import com.aallam.openai.client.internal.TestFileSystem
7+
import com.aallam.openai.client.internal.testFilePath
8+
import kotlin.test.Test
9+
import okio.buffer
10+
11+
class TestChatCompletionChunk {
12+
@Test
13+
fun testContentFilterDeserialization() {
14+
val json = FileSource(path = testFilePath("json/azureContentFilterChunk.json"), fileSystem = TestFileSystem)
15+
val actualJson = json.source.buffer().readByteArray().decodeToString()
16+
JsonLenient.decodeFromString<ChatCompletionChunk>(actualJson)
17+
}
18+
19+
@Test
20+
fun testDeserialization() {
21+
val json = FileSource(path = testFilePath("json/chatChunk.json"), fileSystem = TestFileSystem)
22+
val actualJson = json.source.buffer().readByteArray().decodeToString()
23+
JsonLenient.decodeFromString<ChatCompletionChunk>(actualJson)
24+
}
25+
}
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
{
2+
"choices": [
3+
{
4+
"content_filter_offsets": {
5+
"check_offset": 33188,
6+
"start_offset": 33188,
7+
"end_offset": 33557
8+
},
9+
"content_filter_results": {
10+
"hate": {
11+
"filtered": false,
12+
"severity": "safe"
13+
},
14+
"self_harm": {
15+
"filtered": false,
16+
"severity": "safe"
17+
},
18+
"sexual": {
19+
"filtered": false,
20+
"severity": "safe"
21+
},
22+
"violence": {
23+
"filtered": false,
24+
"severity": "safe"
25+
}
26+
},
27+
"finish_reason": null,
28+
"index": 0
29+
}
30+
],
31+
"created": 0,
32+
"id": "",
33+
"model": "",
34+
"object": ""
35+
}
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
{
2+
"choices": [
3+
{
4+
"delta": {
5+
"content": " engineering"
6+
},
7+
"finish_reason": null,
8+
"index": 0
9+
}
10+
],
11+
"created": 1716855566,
12+
"id": "chatcmpl-9TeqkT3BJs5zXQq12b204deXcY5nj",
13+
"model": "gpt-4o-2024-05-13",
14+
"object": "chat.completion.chunk",
15+
"system_fingerprint": "fp_5f4bad809a"
16+
}

openai-core/src/commonMain/kotlin/com.aallam.openai.api/chat/ChatChunk.kt

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
package com.aallam.openai.api.chat;
22

3-
import com.aallam.openai.api.BetaOpenAI
43
import com.aallam.openai.api.core.FinishReason
54
import kotlinx.serialization.SerialName
65
import kotlinx.serialization.Serializable
@@ -19,7 +18,17 @@ public data class ChatChunk(
1918
/**
2019
* The generated chat message.
2120
*/
22-
@SerialName("delta") public val delta: ChatDelta,
21+
@SerialName("delta") public val delta: ChatDelta? = null,
22+
23+
/**
24+
* Azure content filter offsets
25+
*/
26+
@SerialName("content_filter_offsets") public val contentFilterOffsets: ContentFilterOffsets? = null,
27+
28+
/**
29+
* Azure content filter results
30+
*/
31+
@SerialName("content_filter_results") public val contentFilterResults: ContentFilterResults? = null,
2332

2433
/**
2534
* The reason why OpenAI stopped generating.

openai-core/src/commonMain/kotlin/com.aallam.openai.api/chat/ChatMessage.kt

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,16 @@ public data class ChatMessage(
4545
* Tool call ID.
4646
*/
4747
@SerialName("tool_call_id") public val toolCallId: ToolId? = null,
48+
49+
/**
50+
* Azure Content Filter Results
51+
*/
52+
@SerialName("content_filter_results") public val contentFilterResults: List<ContentFilterResults>? = null,
53+
54+
/**
55+
* Azure Content Filter Offsets
56+
*/
57+
@SerialName("content_filter_offsets") public val contentFilterOffsets: List<ContentFilterOffsets>? = null,
4858
) {
4959

5060
public constructor(
@@ -54,13 +64,17 @@ public data class ChatMessage(
5464
functionCall: FunctionCall? = null,
5565
toolCalls: List<ToolCall>? = null,
5666
toolCallId: ToolId? = null,
67+
contentFilterResults: List<ContentFilterResults>? = null,
68+
contentFilterOffsets: List<ContentFilterOffsets>? = null,
5769
) : this(
5870
role = role,
5971
messageContent = content?.let { TextContent(it) },
6072
name = name,
6173
functionCall = functionCall,
6274
toolCalls = toolCalls,
6375
toolCallId = toolCallId,
76+
contentFilterOffsets = contentFilterOffsets,
77+
contentFilterResults = contentFilterResults,
6478
)
6579

6680
public constructor(
@@ -70,13 +84,17 @@ public data class ChatMessage(
7084
functionCall: FunctionCall? = null,
7185
toolCalls: List<ToolCall>? = null,
7286
toolCallId: ToolId? = null,
87+
contentFilterResults: List<ContentFilterResults>? = null,
88+
contentFilterOffsets: List<ContentFilterOffsets>? = null,
7389
) : this(
7490
role = role,
7591
messageContent = content?.let { ListContent(it) },
7692
name = name,
7793
functionCall = functionCall,
7894
toolCalls = toolCalls,
7995
toolCallId = toolCallId,
96+
contentFilterOffsets = contentFilterOffsets,
97+
contentFilterResults = contentFilterResults,
8098
)
8199

82100
val content: String?
@@ -282,6 +300,16 @@ public class ChatMessageBuilder {
282300
*/
283301
public var toolCalls: List<ToolCall>? = null
284302

303+
/**
304+
* Azure content filter offsets
305+
*/
306+
public var contentFilterOffsets: List<ContentFilterOffsets>? = null
307+
308+
/**
309+
* Azure content filter results
310+
*/
311+
public var contentFilterResults: List<ContentFilterResults>? = null
312+
285313
/**
286314
* Tool call ID.
287315
*/
@@ -313,6 +341,8 @@ public class ChatMessageBuilder {
313341
functionCall = functionCall,
314342
toolCalls = toolCalls,
315343
toolCallId = toolCallId,
344+
contentFilterOffsets = contentFilterOffsets,
345+
contentFilterResults = contentFilterResults,
316346
)
317347
}
318348
}

0 commit comments

Comments
 (0)