Skip to content

Commit 78a8af0

Browse files
authored
Merge pull request #8 from CJCrafter/realtime-chat
Realtime chat
2 parents 902ad6f + 49c3283 commit 78a8af0

File tree

9 files changed

+328
-17
lines changed

9 files changed

+328
-17
lines changed

src/main/kotlin/com/cjcrafter/openai/chat/FinishReason.kt renamed to src/main/kotlin/com/cjcrafter/openai/FinishReason.kt

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
package com.cjcrafter.openai.chat
1+
package com.cjcrafter.openai
22

33
/**
44
* [FinishReason] wraps the possible reasons that a generation model may stop
@@ -23,9 +23,9 @@ enum class FinishReason {
2323
LENGTH,
2424

2525
/**
26-
* [TEMPERATURE] is a rare occurrence, and only happens when the
27-
* [ChatRequest.temperature] is low enough that it is impossible for the
28-
* model to continue generating text.
26+
* [CONTENT_FILTER] occurs due to a flag from OpenAI's content filters.
27+
* This occurrence is rare, and usually only happens when you blatantly
28+
* misuse/violate OpenAI's terms.
2929
*/
30-
TEMPERATURE
30+
CONTENT_FILTER
3131
}

src/main/kotlin/com/cjcrafter/openai/chat/ChatBot.kt

Lines changed: 100 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,14 @@
11
package com.cjcrafter.openai.chat
22

33
import com.google.gson.*
4-
import okhttp3.MediaType
4+
import okhttp3.*
55
import okhttp3.MediaType.Companion.toMediaType
6-
import okhttp3.OkHttpClient
76
import okhttp3.OkHttpClient.Builder
8-
import okhttp3.Request
9-
import okhttp3.RequestBody
107
import okhttp3.RequestBody.Companion.toRequestBody
118
import java.io.IOException
9+
import java.lang.IllegalArgumentException
1210
import java.util.concurrent.TimeUnit
11+
import java.util.function.Consumer
1312

1413
/**
1514
* The ChatBot class wraps the OpenAI API and lets you send messages and
@@ -41,7 +40,9 @@ class ChatBot(private val apiKey: String) {
4140
.readTimeout(0, TimeUnit.SECONDS).build()
4241
private val mediaType: MediaType = "application/json; charset=utf-8".toMediaType()
4342
private val gson: Gson = GsonBuilder()
44-
.registerTypeAdapter(ChatUser::class.java, JsonSerializer<ChatUser> { src, _, context -> context!!.serialize(src!!.name.lowercase())!! })
43+
.registerTypeAdapter(
44+
ChatUser::class.java,
45+
JsonSerializer<ChatUser> { src, _, context -> context!!.serialize(src!!.name.lowercase())!! })
4546
.create()
4647

4748
/**
@@ -56,7 +57,9 @@ class ChatBot(private val apiKey: String) {
5657
* @throws IllegalArgumentException If the input arguments are invalid.
5758
*/
5859
@Throws(IOException::class)
59-
fun generateResponse(request: ChatRequest?): ChatResponse {
60+
fun generateResponse(request: ChatRequest): ChatResponse {
61+
request.stream = false // use streamResponse for stream=true
62+
6063
val json = gson.toJson(request)
6164
val body: RequestBody = json.toRequestBody(mediaType)
6265
val httpRequest: Request = Request.Builder()
@@ -83,4 +86,95 @@ class ChatBot(private val apiKey: String) {
8386
throw ex
8487
}
8588
}
89+
90+
/**
91+
* This is a helper method that calls [streamResponse], which lets you use
92+
* the generated tokens in real time (As ChatGPT generates them).
93+
*
94+
* This method does not block the thread. Method calls to [onResponse] are
95+
* not handled by the main thread. It is crucial to consider thread safety
96+
* within the context of your program.
97+
*
98+
* @param request The input information for ChatGPT.
99+
* @param onResponse The method to call for each chunk.
100+
* @since 1.2.0
101+
*/
102+
fun streamResponseKotlin(request: ChatRequest, onResponse: ChatResponseChunk.() -> Unit) {
103+
streamResponse(request, { it.onResponse() })
104+
}
105+
106+
/**
107+
* Uses ChatGPT to generate tokens in real time. As ChatGPT generates
108+
* content, those tokens are sent in a stream in real time. This allows you
109+
* to update the user without long delays between their input and OpenAI's
110+
* response.
111+
*
112+
* For *"simpler"* calls, you can use [generateResponse] which will block
113+
* the thread until the entire response is generated.
114+
*
115+
* Instead of using the [ChatResponse], this method uses [ChatResponseChunk].
116+
* This means that it is not possible to retrieve the number of tokens from
117+
* this method,
118+
*
119+
* This method does not block the thread. Method calls to [onResponse] are
120+
* not handled by the main thread. It is crucial to consider thread safety
121+
* within the context of your program.
122+
*
123+
* @param request The input information for ChatGPT.
124+
* @param onResponse The method to call for each chunk.
125+
* @param onFailure The method to call if the HTTP fails. This method will
126+
* not be called if OpenAI returns an error.
127+
* @see generateResponse
128+
* @see streamResponseKotlin
129+
* @since 1.2.0
130+
*/
131+
@JvmOverloads
132+
fun streamResponse(
133+
request: ChatRequest,
134+
onResponse: Consumer<ChatResponseChunk>, // use Consumer instead of Kotlin for better Java syntax
135+
onFailure: Consumer<IOException> = Consumer { it.printStackTrace() }
136+
) {
137+
request.stream = true // use requestResponse for stream=false
138+
139+
val json = gson.toJson(request)
140+
val body: RequestBody = json.toRequestBody(mediaType)
141+
val httpRequest: Request = Request.Builder()
142+
.url("https://api.openai.com/v1/chat/completions")
143+
.addHeader("Content-Type", "application/json")
144+
.addHeader("Authorization", "Bearer $apiKey")
145+
.post(body)
146+
.build()
147+
148+
client.newCall(httpRequest).enqueue(object : Callback {
149+
var cache: ChatResponseChunk? = null
150+
151+
override fun onFailure(call: Call, e: IOException) {
152+
onFailure.accept(e)
153+
}
154+
155+
override fun onResponse(call: Call, response: Response) {
156+
response.body?.source()?.use { source ->
157+
while (!source.exhausted()) {
158+
159+
// Parse the JSON string as a map. Every string starts
160+
// with "data: ", so we need to remove that.
161+
var jsonResponse = source.readUtf8Line() ?: continue
162+
if (jsonResponse.isEmpty())
163+
continue
164+
jsonResponse = jsonResponse.substring("data: ".length)
165+
if (jsonResponse == "[DONE]")
166+
continue
167+
168+
val rootObject = JsonParser.parseString(jsonResponse).asJsonObject
169+
if (cache == null)
170+
cache = ChatResponseChunk(rootObject)
171+
else
172+
cache!!.update(rootObject)
173+
174+
onResponse.accept(cache!!)
175+
}
176+
}
177+
}
178+
})
179+
}
86180
}

src/main/kotlin/com/cjcrafter/openai/chat/ChatChoice.kt

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
package com.cjcrafter.openai.chat
22

3+
import com.cjcrafter.openai.FinishReason
34
import com.google.gson.JsonObject
45

56
/**
@@ -15,18 +16,18 @@ import com.google.gson.JsonObject
1516
*
1617
* @property index The index in the array... 0 if [ChatRequest.n]=1.
1718
* @property message The generated text.
18-
* @property finishReason Why did the bot stop generating tokens?
19+
* @property finishReason The reason the bot stopped generating tokens.
1920
* @constructor Create a new chat choice, for internal usage.
2021
* @see FinishReason
2122
*/
22-
data class ChatChoice(val index: Int, val message: ChatMessage, val finishReason: FinishReason?) {
23+
data class ChatChoice(val index: Int, val message: ChatMessage, val finishReason: FinishReason) {
2324

2425
/**
2526
* JSON constructor for internal usage.
2627
*/
2728
constructor(json: JsonObject) : this(
2829
json["index"].asInt,
2930
ChatMessage(json["message"].asJsonObject),
30-
if (json["finish_reason"].isJsonNull) null else FinishReason.valueOf(json["finish_reason"].asString.uppercase())
31+
FinishReason.valueOf(json["finish_reason"].asString.uppercase())
3132
)
3233
}
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
package com.cjcrafter.openai.chat
2+
3+
import com.cjcrafter.openai.FinishReason
4+
import com.google.gson.JsonObject
5+
6+
/**
7+
*
8+
* The OpenAI API returns a list of [ChatChoiceChunk]. The "new content" is
9+
* saved to the [delta] property. To access everything that is currently
10+
* generated, use [message].
11+
*
12+
* By default, only 1 [ChatChoiceChunk] is generated (since [ChatRequest.n] == 1).
13+
* When you increase `n`, more options are generated. The more options you
14+
* generate, the more tokens you use. In general, it is best to **ONLY**
15+
* generate 1 response, and to let the user regenerate the response.
16+
*
17+
* @property index The index in the array... 0 if [ChatRequest.n]=1.
18+
* @property message All tokens that are currently generated.
19+
* @property delta The newly generated tokens (*can be empty!*)
20+
* @property finishReason The reason the bot stopped generating tokens.
21+
* @constructor Create a new chat choice, for internal usage.
22+
* @see FinishReason
23+
* @see ChatChoice
24+
*/
25+
data class ChatChoiceChunk(val index: Int, val message: ChatMessage, var delta: String, var finishReason: FinishReason?) {
26+
27+
/**
28+
* JSON constructor for internal usage.
29+
*/
30+
constructor(json: JsonObject) : this(
31+
32+
// The first message from ChatGPT looks like this:
33+
// data: {"id":"chatcmpl-6xUB4Vi8jEG8u4hMBTMeO8KXgA87z","object":"chat.completion.chunk","created":1679635374,"model":"gpt-3.5-turbo-0301","choices":[{"delta":{"role":"assistant"},"index":0,"finish_reason":null}]}
34+
// So the only data we have so far is that ChatGPT will be responding.
35+
json["index"].asInt,
36+
ChatMessage(ChatUser.ASSISTANT, ""),
37+
"",
38+
null
39+
)
40+
41+
internal fun update(json: JsonObject) {
42+
val deltaJson = json["delta"].asJsonObject
43+
delta = if (deltaJson.has("content")) deltaJson["content"].asString else ""
44+
message.content += delta
45+
finishReason = if (json["finish_reason"].isJsonNull) null else FinishReason.valueOf(json["finish_reason"].asString.uppercase())
46+
}
47+
}
48+
49+
/*
50+
Below is a potential Steam response from OpenAI. You can see that the first
51+
message contains 0 generated content, and the last message (before "[DONE]")
52+
adds the finish_reason.
53+
54+
data: {"id":"chatcmpl-6xUB4Vi8jEG8u4hMBTMeO8KXgA87z","object":"chat.completion.chunk","created":1679635374,"model":"gpt-3.5-turbo-0301","choices":[{"delta":{"role":"assistant"},"index":0,"finish_reason":null}]}
55+
56+
data: {"id":"chatcmpl-6xUB4Vi8jEG8u4hMBTMeO8KXgA87z","object":"chat.completion.chunk","created":1679635374,"model":"gpt-3.5-turbo-0301","choices":[{"delta":{"content":"Hello"},"index":0,"finish_reason":null}]}
57+
58+
data: {"id":"chatcmpl-6xUB4Vi8jEG8u4hMBTMeO8KXgA87z","object":"chat.completion.chunk","created":1679635374,"model":"gpt-3.5-turbo-0301","choices":[{"delta":{"content":" World"},"index":0,"finish_reason":null}]}
59+
60+
data: {"id":"chatcmpl-6xUB4Vi8jEG8u4hMBTMeO8KXgA87z","object":"chat.completion.chunk","created":1679635374,"model":"gpt-3.5-turbo-0301","choices":[{"delta":{"content":"."},"index":0,"finish_reason":null}]}
61+
62+
data: {"id":"chatcmpl-6xUB4Vi8jEG8u4hMBTMeO8KXgA87z","object":"chat.completion.chunk","created":1679635374,"model":"gpt-3.5-turbo-0301","choices":[{"delta":{},"index":0,"finish_reason":"stop"}]}
63+
64+
data: [DONE]
65+
*/

src/main/kotlin/com/cjcrafter/openai/chat/ChatMessage.kt

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,11 +7,13 @@ import com.google.gson.JsonObject
77
* conversation, we need to map each message to who sent it. This data class
88
* wraps a message with the user who sent the message.
99
*
10+
* Note that
11+
*
1012
* @property role The user who sent this message.
1113
* @property content The string content of the message.
1214
* @see ChatUser
1315
*/
14-
data class ChatMessage(val role: ChatUser, val content: String) {
16+
data class ChatMessage(var role: ChatUser, var content: String) {
1517

1618
/**
1719
* JSON constructor for internal usage.

src/main/kotlin/com/cjcrafter/openai/chat/ChatResponse.kt

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,8 +16,6 @@ import java.util.*
1616
* @property choices The list of generated messages.
1717
* @property usage The number of tokens used in this request/response.
1818
* @constructor Create Chat response (for internal usage).
19-
* @see ChatChoice
20-
* @see ChatUsage
2119
*/
2220
data class ChatResponse(
2321
val id: String,
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
package com.cjcrafter.openai.chat
2+
3+
import com.google.gson.JsonObject
4+
import java.time.Instant
5+
import java.time.ZoneId
6+
import java.time.ZonedDateTime
7+
import java.util.*
8+
9+
/**
10+
* The [ChatResponseChunk] contains all the data returned by the OpenAI Chat API.
11+
* For most use cases, [ChatResponseChunk.get] (passing 0 to the index argument)
12+
* is all you need.
13+
*
14+
* This class is similar to [ChatResponse], except with [ChatResponseChunk] you
15+
* determine the number of generated tokens.
16+
*
17+
* @property id The unique id for your request.
18+
* @property created The Unix timestamp (measured in seconds since 00:00:00 UTC on January 1, 1970) when the API response was created.
19+
* @property choices The list of generated messages.
20+
* @constructor Create Chat response (for internal usage).
21+
* @see ChatResponse
22+
*/
23+
data class ChatResponseChunk(
24+
val id: String,
25+
val created: Long,
26+
val choices: List<ChatChoiceChunk>,
27+
) {
28+
29+
/**
30+
* JSON constructor for internal usage.
31+
*/
32+
constructor(json: JsonObject) : this(
33+
json["id"].asString,
34+
json["created"].asLong,
35+
json["choices"].asJsonArray.map { ChatChoiceChunk(it.asJsonObject) },
36+
)
37+
38+
internal fun update(json: JsonObject) {
39+
json["choices"].asJsonArray.forEachIndexed { index, jsonElement ->
40+
choices[index].update(jsonElement.asJsonObject)
41+
}
42+
}
43+
44+
/**
45+
* Returns the [Instant] time that the OpenAI Chat API sent this response.
46+
* The time is measured as a unix timestamp (measured in seconds since
47+
* 00:00:00 UTC on January 1, 1970).
48+
*
49+
* Note that users expect time to be measured in their timezone, so
50+
* [getZonedTime] is preferred.
51+
*
52+
* @return The instant the api created this response.
53+
* @see getZonedTime
54+
*/
55+
fun getTime(): Instant {
56+
return Instant.ofEpochSecond(created)
57+
}
58+
59+
/**
60+
* Returns the time-zoned instant that the OpenAI Chat API sent this
61+
* response. By default, this method uses the system's timezone.
62+
*
63+
* @param timezone The user's timezone.
64+
* @return The timezone adjusted date time.
65+
* @see TimeZone.getDefault
66+
*/
67+
@JvmOverloads
68+
fun getZonedTime(timezone: ZoneId = TimeZone.getDefault().toZoneId()): ZonedDateTime {
69+
return ZonedDateTime.ofInstant(getTime(), timezone)
70+
}
71+
72+
// TODO add tokenizier so we can determine token count
73+
74+
/**
75+
* Shorthand for accessing the generated messages (shorthand for
76+
* [ChatResponseChunk.choices]).
77+
*
78+
* @param index The index of the message (`0` for most use cases).
79+
* @return The generated [ChatChoiceChunk] at the index.
80+
*/
81+
operator fun get(index: Int): ChatChoiceChunk {
82+
return choices[index]
83+
}
84+
}

0 commit comments

Comments
 (0)