diff --git a/.gitattributes b/.gitattributes old mode 100644 new mode 100755 index 00a51af..64b153f --- a/.gitattributes +++ b/.gitattributes @@ -1,6 +1,2 @@ -# -# https://help.github.com/articles/dealing-with-line-endings/ -# -# These are explicitly windows files and should use crlf *.bat text eol=crlf - +* text eol=lf diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml old mode 100644 new mode 100755 diff --git a/.github/workflows/dokka-publish.yml b/.github/workflows/dokka-publish.yml old mode 100644 new mode 100755 diff --git a/.gitignore b/.gitignore old mode 100644 new mode 100755 diff --git a/LICENSE b/LICENSE old mode 100644 new mode 100755 diff --git a/README.md b/README.md old mode 100644 new mode 100755 diff --git a/build.gradle.kts b/build.gradle.kts old mode 100644 new mode 100755 index 39d4a4b..1148f9f --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1,7 +1,7 @@ import com.github.breadmoirai.githubreleaseplugin.GithubReleaseTask group = "com.cjcrafter" -version = "2.0.2" +version = "2.0.2-SNAPSHOT" plugins { `java-library` diff --git a/examples/build.gradle.kts b/examples/build.gradle.kts old mode 100644 new mode 100755 index c1fe72e..1466e24 --- a/examples/build.gradle.kts +++ b/examples/build.gradle.kts @@ -14,7 +14,7 @@ dependencies { implementation("com.fasterxml.jackson.core:jackson-databind:2.15.3") implementation("io.github.cdimascio:dotenv-kotlin:6.4.1") - implementation("ch.qos.logback:logback-classic:1.4.11") + implementation("ch.qos.logback:logback-classic:1.4.12") // https://mvnrepository.com/artifact/org.mariuszgromada.math/MathParser.org-mXparser // Used for tool tests diff --git a/examples/src/main/java/assistant/AssistantExample.java b/examples/src/main/java/assistant/AssistantExample.java new file mode 100755 index 0000000..c41d82e --- /dev/null +++ b/examples/src/main/java/assistant/AssistantExample.java @@ -0,0 +1,117 @@ +package assistant; + +import com.cjcrafter.openai.OpenAI; +import com.cjcrafter.openai.assistants.CreateAssistantRequest; +import com.cjcrafter.openai.assistants.ModifyAssistantRequest; +import io.github.cdimascio.dotenv.Dotenv; + +import java.util.Scanner; + +public class AssistantExample { + + // To use dotenv, you need to add the "io.github.cdimascio:dotenv-kotlin:version" + // dependency. Then you can add a .env file in your project directory. + public static final OpenAI openai = OpenAI.builder() + .apiKey(Dotenv.load().get("OPENAI_TOKEN")) + .build(); + + public static final Scanner scan = new Scanner(System.in); + + public static void main(String[] args) { + + int input; + do { + System.out.println("1. Create"); + System.out.println("2. Retrieve"); + System.out.println("3. List"); + System.out.println("4. Delete"); + System.out.println("5. Modify"); + System.out.println("6. Exit"); + + System.out.print("Code: "); + input = Integer.parseInt(scan.nextLine()); + switch (input) { + case 1: + create(); + break; + case 2: + retrieve(); + break; + case 3: + list(); + break; + case 4: + delete(); + break; + case 5: + modify(); + break; + case 6: + System.out.println("Goodbye!"); + break; + default: + System.out.println("Invalid code!"); + } + } while (input != 6); + } + + public static void create() { + System.out.print("Model: "); + String model = scan.nextLine(); + System.out.print("Name: "); + String name = scan.nextLine(); + System.out.print("Description: "); + String description = scan.nextLine(); + System.out.print("Instructions: "); + String instructions = scan.nextLine(); + + CreateAssistantRequest request = CreateAssistantRequest.builder() + .model(model) + .name(name) + .description(description) + .instructions(instructions) + .build(); + + System.out.println("Request: " + request); + System.out.println("Response: " + openai.getAssistants().create(request)); + } + + public static void retrieve() { + System.out.print("ID: "); + String id = scan.nextLine(); + + System.out.println("Response: " + openai.getAssistants().retrieve(id)); + } + + public static void list() { + System.out.println("Response: " + openai.getAssistants().list()); + } + + public static void delete() { + System.out.print("ID: "); + String id = scan.nextLine(); + + System.out.println("Response: " + openai.getAssistants().delete(id)); + } + + + public static void modify() { + System.out.print("ID: "); + String id = scan.nextLine(); + System.out.print("Name: "); + String name = scan.nextLine(); + System.out.print("Description: "); + String description = scan.nextLine(); + System.out.print("Instructions: "); + String instructions = scan.nextLine(); + + ModifyAssistantRequest request = ModifyAssistantRequest.builder() + .name(name) + .description(description) + .instructions(instructions) + .build(); + + System.out.println("Request: " + request); + System.out.println("Response: " + openai.getAssistants().modify(id, request)); + } +} diff --git a/examples/src/main/java/assistant/ThreadExample.java b/examples/src/main/java/assistant/ThreadExample.java new file mode 100644 index 0000000..b68bb1e --- /dev/null +++ b/examples/src/main/java/assistant/ThreadExample.java @@ -0,0 +1,87 @@ +package assistant; + +import com.cjcrafter.openai.OpenAI; +import com.cjcrafter.openai.assistants.Assistant; +import com.cjcrafter.openai.assistants.ListAssistantResponse; +import com.cjcrafter.openai.threads.Thread; +import com.cjcrafter.openai.threads.message.*; +import com.cjcrafter.openai.threads.runs.CreateRunRequest; +import com.cjcrafter.openai.threads.runs.MessageCreationDetails; +import com.cjcrafter.openai.threads.runs.Run; +import com.cjcrafter.openai.threads.runs.RunStep; +import io.github.cdimascio.dotenv.Dotenv; + +import java.util.Scanner; + +public class ThreadExample { + + public static void main(String[] args) throws InterruptedException { + // To use dotenv, you need to add the "io.github.cdimascio:dotenv-kotlin:version" + // dependency. Then you can add a .env file in your project directory. + OpenAI openai = OpenAI.builder() + .apiKey(Dotenv.load().get("OPENAI_TOKEN")) + .build(); + + // Ask the user to choose an assistant + ListAssistantResponse assistants = openai.assistants().list(); + for (int i = 0; i < assistants.getData().size(); i++) { + Assistant assistant = assistants.getData().get(i); + System.out.println(i + ". " + assistant); + } + + Scanner scan = new Scanner(System.in); + int choice = Integer.parseInt(scan.nextLine()); + Assistant assistant = assistants.getData().get(choice); + + // We have to create a new thread. We'll save this thread, so we can + // add user messages and get responses later. + Thread thread = openai.threads().create(); + + while (true) { + + // Handle user input + System.out.println("Type your input below: "); + String input = scan.nextLine(); + openai.threads().messages(thread).create(CreateThreadMessageRequest.builder() + .role(ThreadUser.USER) + .content(input) + .build()); + + // After adding a message to the thread, we have to "run" the thread + Run run = openai.threads().runs(thread).create(CreateRunRequest.builder() + .assistant(assistant) + .build()); + + // This is a known limitation in OpenAI, and they are working to + // address this so that we can easily stream a response without + // nonsense like this. + while (!run.getStatus().isTerminal()) { + java.lang.Thread.sleep(1000); + run = openai.threads().runs(thread).retrieve(run); + } + + // Once the run stops, we want to retrieve the steps of the run. + // this includes message outputs, function calls, code + // interpreters, etc. + for (RunStep step : openai.threads().runs(thread).steps(run).list().getData()) { + if (step.getType() != RunStep.Type.MESSAGE_CREATION) { + System.out.println("Assistant made step: " + step.getType()); + continue; + } + + // This cast is safe since we checked the type above + MessageCreationDetails details = (MessageCreationDetails) step.getStepDetails(); + ThreadMessage message = openai.threads().messages(thread).retrieve(details.getMessageCreation().getMessageId()); + for (ThreadMessageContent content : message.getContent()) { + if (content.getType() != ThreadMessageContent.Type.TEXT) { + System.err.println("Unhandled message content type: " + content.getType()); + System.err.println("This will never occur since this Assistant doesn't use images."); + System.exit(-1); + } + + System.out.println(((TextContent) content).getText().getValue()); + } + } + } + } +} diff --git a/examples/src/main/java/chat/ChatCompletion.java b/examples/src/main/java/chat/ChatCompletionExample.java old mode 100644 new mode 100755 similarity index 93% rename from examples/src/main/java/chat/ChatCompletion.java rename to examples/src/main/java/chat/ChatCompletionExample.java index 6b29c75..c87a301 --- a/examples/src/main/java/chat/ChatCompletion.java +++ b/examples/src/main/java/chat/ChatCompletionExample.java @@ -1,5 +1,6 @@ package chat; +import com.cjcrafter.openai.Models; import com.cjcrafter.openai.OpenAI; import com.cjcrafter.openai.chat.ChatMessage; import com.cjcrafter.openai.chat.ChatRequest; @@ -13,7 +14,7 @@ /** * In this Java example, we will be using the Chat API to create a simple chatbot. */ -public class ChatCompletion { +public class ChatCompletionExample { public static void main(String[] args) { @@ -29,7 +30,7 @@ public static void main(String[] args) { // Here you can change the model's settings, add tools, and more. ChatRequest request = ChatRequest.builder() - .model("gpt-3.5-turbo") + .model(Models.Chat.GPT_3_5_TURBO) .messages(messages) .build(); diff --git a/examples/src/main/java/chat/StreamChatCompletion.java b/examples/src/main/java/chat/StreamChatCompletionExample.java old mode 100644 new mode 100755 similarity index 98% rename from examples/src/main/java/chat/StreamChatCompletion.java rename to examples/src/main/java/chat/StreamChatCompletionExample.java index b5166dd..7b5464f --- a/examples/src/main/java/chat/StreamChatCompletion.java +++ b/examples/src/main/java/chat/StreamChatCompletionExample.java @@ -15,7 +15,7 @@ * Instead of waiting for the full response to generate, we will "stream" tokens * 1 by 1 as they are generated. */ -public class StreamChatCompletion { +public class StreamChatCompletionExample { public static void main(String[] args) { diff --git a/examples/src/main/java/chat/StreamChatCompletionFunction.java b/examples/src/main/java/chat/StreamChatCompletionFunctionExample.java old mode 100644 new mode 100755 similarity index 95% rename from examples/src/main/java/chat/StreamChatCompletionFunction.java rename to examples/src/main/java/chat/StreamChatCompletionFunctionExample.java index 71b4147..b2018f5 --- a/examples/src/main/java/chat/StreamChatCompletionFunction.java +++ b/examples/src/main/java/chat/StreamChatCompletionFunctionExample.java @@ -23,7 +23,7 @@ * 1 by 1 as they are generated. We will also add a Math tool so that the chatbot * can solve math problems with a math parser. */ -public class StreamChatCompletionFunction { +public class StreamChatCompletionFunctionExample { public static void main(String[] args) { @@ -46,7 +46,7 @@ public static void main(String[] args) { ChatRequest request = ChatRequest.builder() .model("gpt-3.5-turbo") .messages(messages) - .addTool(FunctionTool.builder() + .addTool(Function.builder() .name("solve_math_problem") .description("Returns the result of a math problem as a double") .addStringParameter("equation", "The math problem for you to solve", true) @@ -98,10 +98,10 @@ public static ChatMessage handleToolCall(ToolCall call, List validTools) { // at tool calls (And you probably aren't very good at prompt // engineering yet!). OpenAI will often "Hallucinate" arguments. try { - if (call.getType() != ToolType.FUNCTION) + if (call.getType() != Tool.Type.FUNCTION) throw new HallucinationException("Unknown tool call type: " + call.getType()); - FunctionCall function = call.getFunction(); + FunctionCall function = ((FunctionToolCall) call).getFunction(); Map arguments = function.tryParseArguments(validTools); // You can pass null here for less strict parsing String equation = arguments.get("equation").asText(); double result = solveEquation(equation); diff --git a/examples/src/main/java/completion/Completion.java b/examples/src/main/java/completion/CompletionExample.java old mode 100644 new mode 100755 similarity index 97% rename from examples/src/main/java/completion/Completion.java rename to examples/src/main/java/completion/CompletionExample.java index 35ba1b7..272b75b --- a/examples/src/main/java/completion/Completion.java +++ b/examples/src/main/java/completion/CompletionExample.java @@ -8,7 +8,7 @@ * In this Java example, we will be using the Legacy Completion API to generate * a response. */ -public class Completion { +public class CompletionExample { public static void main(String[] args) { diff --git a/examples/src/main/kotlin/assistant/AssistantExample.kt b/examples/src/main/kotlin/assistant/AssistantExample.kt new file mode 100755 index 0000000..fa66c12 --- /dev/null +++ b/examples/src/main/kotlin/assistant/AssistantExample.kt @@ -0,0 +1,96 @@ +package assistant + +import com.cjcrafter.openai.assistants.createAssistantRequest +import com.cjcrafter.openai.assistants.modifyAssistantRequest +import com.cjcrafter.openai.openAI +import io.github.cdimascio.dotenv.dotenv + +// To use dotenv, you need to add the "io.github.cdimascio:dotenv-kotlin:version" +// dependency. Then you can add a .env file in your project directory. +private val openai = openAI { apiKey(dotenv()["OPENAI_TOKEN"]) } + +fun main() { + do { + println(""" + 1. Create + 2. Retrieve + 3. List + 4. Delete + 5. Modify + 6. Exit + """.trimIndent()) + print("Choice: ") + val choice = readln().toInt() + when (choice) { + 1 -> create() + 2 -> retrieve() + 3 -> list() + 4 -> delete() + 5 -> modify() + } + } while (choice != 6) +} + +fun create() { + print("Model: ") + val model = readln() + print("Name: ") + val name = readln() + print("Description: ") + val description = readln() + print("Instructions: ") + val instructions = readln() + + val request = createAssistantRequest { + model(model) + name(name) + description(description) + instructions(instructions) + } + + println("Request: $request") + val response = openai.assistants.create(request) + println("Response: $response") +} + +fun retrieve() { + print("ID: ") + val id = readln() + + val response = openai.assistants.retrieve(id) + println("Response: $response") +} + +fun list() { + val response = openai.assistants.list() + println("Response: $response") +} + +fun delete() { + print("ID: ") + val id = readln() + + val response = openai.assistants.delete(id) + println("Response: $response") +} + +fun modify() { + print("ID: ") + val id = readln() + print("Name: ") + val name = readln() + print("Description: ") + val description = readln() + print("Instructions: ") + val instructions = readln() + + val request = modifyAssistantRequest { + name(name) + description(description) + instructions(instructions) + } + + println("Request: $request") + val response = openai.assistants.modify(id, request) + println("Response: $response") +} \ No newline at end of file diff --git a/examples/src/main/kotlin/assistant/ThreadExample.kt b/examples/src/main/kotlin/assistant/ThreadExample.kt new file mode 100644 index 0000000..6501913 --- /dev/null +++ b/examples/src/main/kotlin/assistant/ThreadExample.kt @@ -0,0 +1,95 @@ +package assistant + +import com.cjcrafter.openai.chat.tool.CodeInterpreterToolCall +import com.cjcrafter.openai.chat.tool.FunctionToolCall +import com.cjcrafter.openai.chat.tool.RetrievalToolCall +import com.cjcrafter.openai.openAI +import com.cjcrafter.openai.threads.create +import com.cjcrafter.openai.threads.message.ImageContent +import com.cjcrafter.openai.threads.message.TextContent +import com.cjcrafter.openai.threads.message.ThreadUser +import com.cjcrafter.openai.threads.runs.MessageCreationDetails +import com.cjcrafter.openai.threads.runs.ToolCallsDetails +import io.github.cdimascio.dotenv.dotenv + +fun main() { + // To use dotenv, you need to add the "io.github.cdimascio:dotenv-kotlin:version" + // dependency. Then you can add a .env file in your project directory. + val openai = openAI { apiKey(dotenv()["OPENAI_TOKEN"]) } + + // Ask the user to choose an assistant + val assistants = openai.assistants.list() + assistants.data.forEachIndexed { index, assistant -> println("$index. $assistant") } + val choice = readln().toInt() + val assistant = assistants.data[choice] + + // We have to create a new thread. We'll save this thread, so we can add + // user messages and get responses later. + val thread = openai.threads.create() + + while (true) { + + // Handle user input + println("Type your input below: ") + val input = readln() + openai.threads.messages(thread).create { + role(ThreadUser.USER) + content(input) + + // You can also add files and metadata to the message + //addFile(file) + //addMetadata("key", "value") + } + + // After adding a message to the thread, we have to "run" the thread + var run = openai.threads.runs(thread).create { + assistant(assistant) + //instructions("You can override instructions, model, etc.") + } + + // This is a known limitation in OpenAI, and they are working to address + // this so that we can easily stream a response without nonsense like this. + while (!run.status.isTerminal) { + java.lang.Thread.sleep(1000) + run = openai.threads.runs(thread).retrieve(run) + } + + // Once the run stops, we want to retrieve the steps of the run. This + // includes message outputs, function calls, code interpreters, etc. + val steps = openai.threads.runs(thread).steps(run).list() + steps.data.forEach { step -> + when (val details = step.stepDetails) { + is MessageCreationDetails -> { + val messageId = details.messageCreation.messageId + val message = openai.threads.messages(thread).retrieve(messageId) + for (content in message.content) { + + when (content) { + is TextContent -> println(content.text.value) + is ImageContent -> println(content.imageFile.fileId) + } + + } + } + + is ToolCallsDetails -> { + for (toolCall in details.toolCalls) { + when (toolCall) { + is FunctionToolCall -> { + // Give the assistant the function output + } + is RetrievalToolCall -> { + // No need to do anything + } + is CodeInterpreterToolCall -> { + // Show the code outputs to the user + } + } + println(toolCall) + } + } + } + println("Step: $step") + } + } +} diff --git a/examples/src/main/kotlin/chat/ChatCompletion.kt b/examples/src/main/kotlin/chat/ChatCompletionExample.kt old mode 100644 new mode 100755 similarity index 100% rename from examples/src/main/kotlin/chat/ChatCompletion.kt rename to examples/src/main/kotlin/chat/ChatCompletionExample.kt diff --git a/examples/src/main/kotlin/chat/StreamChatCompletion.kt b/examples/src/main/kotlin/chat/StreamChatCompletionExample.kt old mode 100644 new mode 100755 similarity index 100% rename from examples/src/main/kotlin/chat/StreamChatCompletion.kt rename to examples/src/main/kotlin/chat/StreamChatCompletionExample.kt diff --git a/examples/src/main/kotlin/chat/StreamChatCompletionFunction.kt b/examples/src/main/kotlin/chat/StreamChatCompletionFunctionExample.kt old mode 100644 new mode 100755 similarity index 96% rename from examples/src/main/kotlin/chat/StreamChatCompletionFunction.kt rename to examples/src/main/kotlin/chat/StreamChatCompletionFunctionExample.kt index a2218e7..3a7e5c3 --- a/examples/src/main/kotlin/chat/StreamChatCompletionFunction.kt +++ b/examples/src/main/kotlin/chat/StreamChatCompletionFunctionExample.kt @@ -3,9 +3,9 @@ package chat import com.cjcrafter.openai.chat.* import com.cjcrafter.openai.chat.ChatMessage.Companion.toSystemMessage import com.cjcrafter.openai.chat.ChatMessage.Companion.toUserMessage +import com.cjcrafter.openai.chat.tool.FunctionToolCall import com.cjcrafter.openai.chat.tool.Tool import com.cjcrafter.openai.chat.tool.ToolCall -import com.cjcrafter.openai.chat.tool.ToolType import com.cjcrafter.openai.exception.HallucinationException import com.cjcrafter.openai.openAI import io.github.cdimascio.dotenv.dotenv @@ -85,10 +85,10 @@ fun handleToolCall(call: ToolCall, validTools: List?): ChatMessage { // at tool calls (And you probably aren't very good at prompt // engineering yet!). OpenAI will often "Hallucinate" arguments. return try { - if (call.type !== ToolType.FUNCTION) + if (call.type !== Tool.Type.FUNCTION) throw HallucinationException("Unknown tool call type: " + call.type) - val function = call.function + val function = (call as FunctionToolCall).function val arguments = function.tryParseArguments(validTools) // You can pass null here for less strict parsing val equation = arguments["equation"]!!.asText() val result = solveEquation(equation) diff --git a/examples/src/main/kotlin/completion/Completion.kt b/examples/src/main/kotlin/completion/CompletionExample.kt old mode 100644 new mode 100755 similarity index 100% rename from examples/src/main/kotlin/completion/Completion.kt rename to examples/src/main/kotlin/completion/CompletionExample.kt diff --git a/examples/src/main/kotlin/completion/StreamCompletion.kt b/examples/src/main/kotlin/completion/StreamCompletionExample.kt old mode 100644 new mode 100755 similarity index 100% rename from examples/src/main/kotlin/completion/StreamCompletion.kt rename to examples/src/main/kotlin/completion/StreamCompletionExample.kt diff --git a/examples/src/main/kotlin/embeddings/Embeddings.kt b/examples/src/main/kotlin/embeddings/EmbeddingsExample.kt old mode 100644 new mode 100755 similarity index 100% rename from examples/src/main/kotlin/embeddings/Embeddings.kt rename to examples/src/main/kotlin/embeddings/EmbeddingsExample.kt diff --git a/examples/src/main/kotlin/files/Files.kt b/examples/src/main/kotlin/files/FilesExample.kt old mode 100644 new mode 100755 similarity index 83% rename from examples/src/main/kotlin/files/Files.kt rename to examples/src/main/kotlin/files/FilesExample.kt index d6848ee..002fa48 --- a/examples/src/main/kotlin/files/Files.kt +++ b/examples/src/main/kotlin/files/FilesExample.kt @@ -2,7 +2,6 @@ package files import com.cjcrafter.openai.files.FilePurpose import com.cjcrafter.openai.files.uploadFileRequest -import com.cjcrafter.openai.files.listFilesRequest import com.cjcrafter.openai.openAI import io.github.cdimascio.dotenv.dotenv import java.io.File @@ -34,7 +33,7 @@ fun main() { } fun listFiles() { - val response = openai.listFiles(listFilesRequest { }) + val response = openai.files.list() println(response) } @@ -46,27 +45,27 @@ fun uploadFile() { file(input) purpose(FilePurpose.ASSISTANTS) } - val response = openai.uploadFile(request) + val response = openai.files.upload(request) println(response) } fun deleteFile() { print("Enter the file id: ") val fileId = readln() - val response = openai.deleteFile(fileId) + val response = openai.files.delete(fileId) println(response) } fun retrieveFile() { print("Enter the file id: ") val fileId = readln() - val response = openai.retrieveFile(fileId) + val response = openai.files.retrieve(fileId) println(response) } fun retrieveFileContents() { print("Enter the file id: ") val fileId = readln() - val contents = openai.retrieveFileContents(fileId) + val contents = openai.files.retrieveContents(fileId) println(contents) } \ No newline at end of file diff --git a/examples/src/main/resources/logback.xml b/examples/src/main/resources/logback.xml old mode 100644 new mode 100755 diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar old mode 100644 new mode 100755 diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties old mode 100644 new mode 100755 diff --git a/gradlew b/gradlew old mode 100644 new mode 100755 diff --git a/gradlew.bat b/gradlew.bat old mode 100644 new mode 100755 diff --git a/settings.gradle.kts b/settings.gradle.kts old mode 100644 new mode 100755 diff --git a/src/main/kotlin/com/cjcrafter/openai/AbstractListRequestBuilder.kt b/src/main/kotlin/com/cjcrafter/openai/AbstractListRequestBuilder.kt new file mode 100755 index 0000000..faf676d --- /dev/null +++ b/src/main/kotlin/com/cjcrafter/openai/AbstractListRequestBuilder.kt @@ -0,0 +1,94 @@ +package com.cjcrafter.openai + +import com.cjcrafter.openai.assistants.Assistant + +/** + * An abstract builder type for list requests. Many objects stored by the OpenAI + * API are stored in lists. This abstract request stores the data that each request + * has in common. + */ +abstract class AbstractListRequestBuilder { + + protected var limit: Int? = null + protected var order: ListOrder? = null + protected var after: String? = null + protected var before: String? = null + + /** + * The maximum number of results to return. This value must be between + * 1 and 100 (inclusive). + * + * @param limit The maximum number of results to return + * @throws IllegalArgumentException If the limit is not between 1 and 100 + */ + fun limit(limit: Int?) = apply { + if (limit != null && (limit < 1 || limit > 100)) + throw IllegalArgumentException("Limit must be between 1 and 100") + this.limit = limit + } + + /** + * How the returned list should be ordered. If not specified, the default + * value is [ListOrder.DESCENDING]. Use the [ascending] and [descending] + * methods. + * + * @param order The order to return the list in + */ + fun order(order: ListOrder?) = apply { this.order = order } + + /** + * A cursor for use in pagination. `after` is an object ID that defines + * your place in the list. For instance, if you make a list request and + * receive 100 objects, ending with `"obj_foo"`, your subsequent call + * can include `after="obj_foo"` in order to fetch the next page of the + * list. + * + * @param after The cursor to use for pagination + */ + fun after(after: String?) = apply { this.after = after } + + /** + * A cursor for use in pagination. `after` is an object ID that defines + * your place in the list. For instance, if you make a list request and + * receive 100 objects, ending with `"obj_foo"`, your subsequent call + * can include `after="obj_foo"` in order to fetch the next page of the + * list. + * + * @param after The cursor to use for pagination + */ + fun after(after: Assistant) = apply { this.after = after.id } + + /** + * A cursor for use in pagination. `before` is an object ID that defines + * your place in the list. For instance, if you make a list request and + * receive 100 objects, ending with `"obj_foo"`, your subsequent call can + * include `before="obj_foo"` in order to fetch the previous page of the + * list. + * + * @param before The cursor to use for pagination + */ + fun before(before: String?) = apply { this.before = before } + + /** + * A cursor for use in pagination. `before` is an object ID that defines + * your place in the list. For instance, if you make a list request and + * receive 100 objects, ending with `"obj_foo"`, your subsequent call can + * include `before="obj_foo"` in order to fetch the previous page of the + * list. + * + * @param before The cursor to use for pagination + */ + fun before(before: Assistant) = apply { this.before = before.id } + + /** + * Sets the order to [ListOrder.ASCENDING]. + */ + fun ascending() = apply { this.order = ListOrder.ASCENDING } + + /** + * Sets the order to [ListOrder.DESCENDING]. + */ + fun descending() = apply { this.order = ListOrder.DESCENDING } + + abstract fun build(): T +} \ No newline at end of file diff --git a/src/main/kotlin/com/cjcrafter/openai/AzureOpenAI.kt b/src/main/kotlin/com/cjcrafter/openai/AzureOpenAI.kt old mode 100644 new mode 100755 index 871c863..debb2d6 --- a/src/main/kotlin/com/cjcrafter/openai/AzureOpenAI.kt +++ b/src/main/kotlin/com/cjcrafter/openai/AzureOpenAI.kt @@ -23,16 +23,6 @@ class AzureOpenAI @ApiStatus.Internal constructor( baseUrl: String = "https://api.openai.com", private val apiVersion: String = "2023-03-15-preview", private val modelName: String = "" -) : OpenAIImpl(apiKey, organization, client, baseUrl) { +) : OpenAIImpl(apiKey, organization, client, "$baseUrl/openai/deployments/$modelName/endpoint?api-version=$apiVersion") { - override fun buildRequest(request: Any, endpoint: String): Request { - val json = objectMapper.writeValueAsString(request) - val body: RequestBody = json.toRequestBody(mediaType) - return Request.Builder() - .url("$baseUrl/openai/deployments/$modelName/$endpoint?api-version=$apiVersion") - .addHeader("Content-Type", "application/json") - .addHeader("api-key", apiKey) - .apply { if (organization != null) addHeader("OpenAI-Organization", organization) } - .post(body).build() - } } \ No newline at end of file diff --git a/src/main/kotlin/com/cjcrafter/openai/FinishReason.kt b/src/main/kotlin/com/cjcrafter/openai/FinishReason.kt old mode 100644 new mode 100755 diff --git a/src/main/kotlin/com/cjcrafter/openai/ListOrder.kt b/src/main/kotlin/com/cjcrafter/openai/ListOrder.kt new file mode 100755 index 0000000..332c037 --- /dev/null +++ b/src/main/kotlin/com/cjcrafter/openai/ListOrder.kt @@ -0,0 +1,28 @@ +package com.cjcrafter.openai + +import com.fasterxml.jackson.annotation.JsonProperty + +/** + * Represents the order for a list, sorted by time created. In general, since + * you probably want the most recent objects from the OpenAI API, you should + * use [DESCENDING] (which is the default value for all requests). + * + * @property jsonProperty How each enum is represented as raw json string. + */ +enum class ListOrder(val jsonProperty: String) { + + /** + * Ascending order. Objects created a long time ago are ordered before + * objects created more recently. + */ + @JsonProperty("asc") + ASCENDING(jsonProperty = "asc"), + + /** + * Descending order. Objects created more recently are ordered before + * objects created a long time ago. This is the default value for list + * requests. + */ + @JsonProperty("desc") + DESCENDING(jsonProperty = "desc"), +} \ No newline at end of file diff --git a/src/main/kotlin/com/cjcrafter/openai/Models.kt b/src/main/kotlin/com/cjcrafter/openai/Models.kt new file mode 100644 index 0000000..a2c3b45 --- /dev/null +++ b/src/main/kotlin/com/cjcrafter/openai/Models.kt @@ -0,0 +1,272 @@ +package com.cjcrafter.openai + +/** + * Holds all the available models for the OpenAI API. Most users are probably + * interested in [Models.Chat]. + * + * Note that this list is manually updated, and may fall out of date. For the + * most updated information, check the [OpenAI documentation](https://platform.openai.com/docs/models). + * If you notice that something is out of date, please [open an issue](https://github.com/CJCrafter/ChatGPT-Java-API/issues). + * + * When OpenAI marks a model as _'Legacy'_, the corresponding field in Models + * will be marked as [Deprecated]. Once it is reported that a model throws an + * error due to deprecation, the deprecation level will be set to [DeprecationLevel.ERROR]. + */ +object Models { + + /** + * Holds all available Chat models. Chat models are used to generate text + * in a conversational manner. Chat models work using conversational memory. + * + * Note that GPT-4 endpoints are only available to [paying customers](https://help.openai.com/en/articles/7102672-how-can-i-access-gpt-4). + * + * @see OpenAI.createChatCompletion + * @see OpenAI.streamChatCompletion + * @see OpenAI.assistants + */ + object Chat { + + ///////////////////////////////////////////////////// + // GPT 4.0 // + ///////////////////////////////////////////////////// + + /** + * `gpt-4` Turbo. Has a context window of 128,000 tokens with training + * data up to April 2023. This model has improved instruction following, + * JSON mode, reproducible output, parallel function calling, and more. + * Returns a maximum of 4,096 output tokens. + */ + const val GPT_4_1106_PREVIEW = "gpt-4-1106-preview" + + /** + * `gpt-4` Turbo with vision. Has a context window of 128,000 tokens with + * training data up to April 2023. Has the same capabilities as + * [GPT_4_1106_PREVIEW], but can also understand images. + */ + const val GPT_4_VISION_PREVIEW = "gpt-4-vision-preview" + + /** + * Points to the currently supported version of `gpt-4`. + * + * See [continuous model upgrades](https://platform.openai.com/docs/models/continuous-model-upgrades) + */ + const val GPT_4 = "gpt-4" + + /** + * Points to the currently supported version of `gpt-4` with a 32k context window. + * + * See [continuous model upgrades](https://platform.openai.com/docs/models/continuous-model-upgrades) + */ + const val GPT_4_32k = "gpt-4-32k" + + /** + * Snapshot of `gpt-4` from June 13th, 2023 with improved function calling + * support. Has a context window of 8,192 tokens with training data up + * to September 2021. + */ + const val GPT_4_0613 = "gpt-4-0613" + + /** + * Snapshot of `gpt-4-32k` from June 13th, 2023 with improved function + * calling support. Has a context window of 32,768 tokens with training + * data up to September 2021. + */ + const val GPT_4_32k_0613 = "gpt-4-32k-0613" + + /** + * Snapshot of `gpt-4` from March 14th 2023 with function calling support. + * This model version will be deprecated on June 13th, 2024. Has a + * context window of 8,192 tokens with training data up to September + * 2021. + */ + @Deprecated( + message = "This model will be removed on June 13th, 2024", + replaceWith = ReplaceWith("GPT_4_0613"), + level = DeprecationLevel.WARNING, + ) + const val GPT_4_0314 = "gpt-4-0314" + + /** + * Snapshot of `gpt-4` from March 14th 2023 with function calling support. + * This model version will be deprecated on June 13th, 2024. Has a + * context window of 32,768 tokens with training data up to September + * 2021. + */ + @Deprecated( + message = "This model will be removed on June 13th 2024", + replaceWith = ReplaceWith("GPT_4_32k_0613"), + level = DeprecationLevel.WARNING, + ) + const val GPT_4_32k_0314 = "gpt-4-32k-0314" + + ///////////////////////////////////////////////////// + // GPT 3.5 // + ///////////////////////////////////////////////////// + + /** + * Has a context window of 16,385 tokens with training data up to + * September 2021. This model has improved instruction following, JSON + * mode, reproducible outputs, parallel function calling, and more. + * Returns a maximum of 4,096 output tokens. + */ + const val GPT_3_5_TURBO_1106 = "gpt_3.5-turbo-1106" + + /** + * Points to the currently supported version of gpt-3.5-turbo. + * + * See [continuous model upgrades](https://platform.openai.com/docs/models/continuous-model-upgrades) + */ + const val GPT_3_5_TURBO = "gpt-3.5-turbo" + + /** + * Points to the currently supported version of gpt-3.5-turbo. + * + * See [continuous model upgrades](https://platform.openai.com/docs/models/continuous-model-upgrades) + */ + const val GPT_3_5_TURBO_16k = "gpt-3.5-turbo-16k" + + /** + * Snapshot of `gpt-3.5-turbo` from June 13th, 2023. This model version + * will be deprecated on June 13th, 2024. Has a context window of 4,096 + * tokens with training data up to September 2021. + */ + @Deprecated( + message = "This model will be removed on June 13th 2024", + replaceWith = ReplaceWith("GPT_3_5_TURBO_1106"), + level = DeprecationLevel.WARNING, + ) + const val GPT_3_5_TURBO_0613 = "gpt-3.5-turbo-0613" + + /** + * Snapshot of `gpt-3.5-turbo-16k` from June 13th, 2023. This model + * version will be deprecated on June 13th, 2024. Has a context window + * of 16,385 tokens with training data up to September 2021. + */ + @Deprecated( + message = "This model will be removed on June 13th 2024", + replaceWith = ReplaceWith("GPT_3_5_TURBO_1106"), + level = DeprecationLevel.WARNING, + ) + const val GPT_3_5_TURBO_16k_0613 = "gpt-3.5-turbo-16k-0613" + + /** + * Snapshot of `gpt-3.5-turbo` from March 1st, 2023. This model version + * will be deprecated on June 13th, 2024. Has a context window of 4,096 + * tokens with training data up to September 2021. + */ + @Deprecated( + message = "This model will be removed on June 13th 2024", + replaceWith = ReplaceWith("GPT_3_5_TURBO_1106"), + level = DeprecationLevel.WARNING, + ) + const val GPT_3_5_TURBO_0301 = "gpt-3.5-turbo-0301" + } + + /** + * Holds all available completion models. + * + * @see OpenAI.createCompletion + * @see OpenAI.streamCompletion + */ + object Completion { + + ///////////////////////////////////////////////////// + // GPT 3.5 // + ///////////////////////////////////////////////////// + + /** + * Similar to `text-davinci-003` but compatible with the legacy + * completions endpoint. + */ + const val GPT_3_5_TURBO_INSTRUCT = "gpt-3.5-turbo-instruct" + + /** + * Can do language tasks with better quality and consistency than the + * curie, babbage, or ada models. Will be deprecated on January 4th + * 2024. Has a context window of 4,096 tokens and training data up to + * June 2021. + */ + @Deprecated( + message = "This model will be removed on January 4th 2024", + replaceWith = ReplaceWith("GPT_3_5_TURBO_INSTRUCT"), + level = DeprecationLevel.ERROR, + ) + const val TEXT_DAVINCI_003 = "text-davinci-003" + + /** + * Similar capabilities to `text-davinci-003` but trained with + * supervised fine-tuning instead of reinforcement learning. Will be + * deprecated on January 4th 2024. Has a context window of 4,096 tokens + * and training data up to June 2021. + */ + @Deprecated( + message = "This model will be removed on January 4th 2024", + replaceWith = ReplaceWith("GPT_3_5_TURBO_INSTRUCT"), + level = DeprecationLevel.ERROR, + ) + const val TEXT_DAVINCI_002 = "text-davinci-002" + + /** + * Optimized for code-completion tasks. Will be deprecated on Jan 4th + * 2024. Has a context window of 8,001 tokens and training data up to + * June 2021. + */ + @Deprecated( + message = "This model will be removed on January 4th 2024", + replaceWith = ReplaceWith("GPT_3_5_TURBO_INSTRUCT"), + level = DeprecationLevel.ERROR, + ) + const val CODE_DAVINCI_002 = "code-davinci-002" + + ///////////////////////////////////////////////////// + // GPT 3.0 // + ///////////////////////////////////////////////////// + + /** + * Vary capable, faster and lower cost than `davinci`. Has a context + * window of 2,049 tokens and training data up to October 2019. + */ + const val TEXT_CURIE_001 = "text-curie-001" + + /** + * Capable of straightforward tasks, very fast, and lower cost. Has a + * context window of 2,049 tokens and training data up to October 2019. + */ + const val TEXT_BABBAGE_001 = "text-babbage-001" + + /** + * Capable of very simple tasks, usually the fastest model in the `gpt-3` + * series, and lowest cost. Has a context window of 2,049 tokens and + * training data up to October 2019. + */ + const val TEXT_ADA_001 = "text-ada-001" + + /** + * Most capable `gpt-3` model, can do any task the other models can do, + * often with higher quality. Can be fine-tuned. Has a context window of + * 2,049 tokens and training data up to October 2019. + */ + const val DAVINCI = "davinci" + + /** + * Very capable, but faster and lower cost than `davinci`. Can be + * fine-tuned. Has a context window of 2,049 tokens and training + * data up to October 2019. + */ + const val CURIE = "curie" + + /** + * Capable of straightforward tasks, very fast, and lower cost. Can be + * fine-tuned. Has a context window of 2,049 tokens and training data + * up to October 2019. + */ + const val BABBAGE = "babbage" + + /** + * Capable of very simple tasks, usually the fasted model in the `gpt-3` + * series, and lowest cost. Can be fine-tuned. Has a context window of + * 2,049 tokens and training data up to October 2019. + */ + const val ADA = "ada" + } +} \ No newline at end of file diff --git a/src/main/kotlin/com/cjcrafter/openai/OpenAI.kt b/src/main/kotlin/com/cjcrafter/openai/OpenAI.kt old mode 100644 new mode 100755 index 4fb98cb..5526ee5 --- a/src/main/kotlin/com/cjcrafter/openai/OpenAI.kt +++ b/src/main/kotlin/com/cjcrafter/openai/OpenAI.kt @@ -1,5 +1,7 @@ package com.cjcrafter.openai +import com.cjcrafter.openai.assistants.AssistantHandler +import com.cjcrafter.openai.assistants.AssistantHandlerImpl import com.cjcrafter.openai.chat.* import com.cjcrafter.openai.chat.tool.ToolChoice import com.cjcrafter.openai.completions.CompletionRequest @@ -8,6 +10,8 @@ import com.cjcrafter.openai.completions.CompletionResponseChunk import com.cjcrafter.openai.embeddings.EmbeddingsRequest import com.cjcrafter.openai.embeddings.EmbeddingsResponse import com.cjcrafter.openai.files.* +import com.cjcrafter.openai.threads.ThreadHandler +import com.cjcrafter.openai.threads.message.TextAnnotation import com.cjcrafter.openai.util.OpenAIDslMarker import com.fasterxml.jackson.annotation.JsonAutoDetect import com.fasterxml.jackson.annotation.JsonInclude @@ -119,60 +123,52 @@ interface OpenAI { fun createEmbeddings(request: EmbeddingsRequest): EmbeddingsResponse /** - * Calls the [list files](https://platform.openai.com/docs/api-reference/files) - * endpoint to return a list of all files that belong to your organization. - * This method is blocking. - * - * @param request The request to send to the API - * @return The list of files returned from the API + * Returns the handler for the files endpoint. This handler can be used to + * create, retrieve, and delete files. + */ + val files: FileHandler + + /** + * Returns the handler for the files endpoint. This method is purely + * syntactic sugar for Java users. */ @Contract(pure = true) - @ApiStatus.Experimental - fun listFiles(request: ListFilesRequest): ListFilesResponse + fun files(): FileHandler = files /** - * Uploads a file to the [files](https://platform.openai.com/docs/api-reference/files) - * endpoint. This method is blocking. - * - * @param request The file to upload - * @return The OpenAI file object created + * Returns the handler for the assistants endpoint. This handler can be used + * to create, retrieve, and delete assistants. */ - @ApiStatus.Experimental - fun uploadFile(request: UploadFileRequest): FileObject + @get:ApiStatus.Experimental + val assistants: AssistantHandler + /** + * Returns the handler for the assistants endpoint. This method is purely + * syntactic sugar for Java users. + */ @ApiStatus.Experimental - fun deleteFile(fileId: String): FileDeletionStatus + @Contract(pure = true) + fun assistants(): AssistantHandler = assistants /** - * Retrieves the file wrapper data using the [files](https://platform.openai.com/docs/api-reference/files) - * endpoint. This method is blocking. - * - * This method does not return the *contents* of the file, only some metadata. - * To retrieve the contents of the file, use [retrieveFileContents]. - * - * @param fileId The id of the file to retrieve - * @return The OpenAI file object + * Returns the handler for the threads endpoint. This handler can be used + * to create, retrieve, and delete threads. */ - @Contract(pure = true) - @ApiStatus.Experimental - fun retrieveFile(fileId: String): FileObject + @get:ApiStatus.Experimental + val threads: ThreadHandler /** - * Returns the contents of the file as a string. This method is blocking. - * - * OpenAI does not allow you to download files that you uploaded. Instead, - * this method will only work if the file's purpose is: - * 1. [FilePurpose.ASSISTANTS_OUTPUT] - * 2. [FilePurpose.FINE_TUNE_RESULTS] - * - * @param fileId The id of the file to retrieve - * @return The contents of the file as a string + * Returns the handler for the threads endpoint. This method is purely + * syntactic sugar for Java users. */ - @Contract(pure = true) @ApiStatus.Experimental - fun retrieveFileContents(fileId: String): String + @Contract(pure = true) + fun threads(): ThreadHandler = threads + /** + * Constructs a default [OpenAI] instance. + */ @OpenAIDslMarker open class Builder internal constructor() { protected var apiKey: String? = null @@ -180,11 +176,44 @@ interface OpenAI { protected var client: OkHttpClient = OkHttpClient() protected var baseUrl: String = "https://api.openai.com" + /** + * Sets the API key to use for requests. This is required. + * + * Your API key can be found at: [https://platform.openai.com/api-keys](https://platform.openai.com/api-keys). + * + * @param apiKey The API key to use for requests, starting with `sk-` + */ fun apiKey(apiKey: String) = apply { this.apiKey = apiKey } + + /** + * If you belong to multiple organizations, you can specify which one to use. + * Defaults to your default organization configured in the OpenAI dashboard. + * + * @param organization The organization ID to use for requests, starting with `org-` + */ fun organization(organization: String?) = apply { this.organization = organization } + + /** + * Sets the [OkHttpClient] used to make requests. Modify this if you want to + * change the timeout, add interceptors, add a proxy, etc. + * + * @param client The client to use for requests + */ fun client(client: OkHttpClient) = apply { this.client = client } + + /** + * Sets the base URL to use for requests. This is useful for testing. + * This can also be used to use the Azure OpenAI API, though we + * recommend using [azureBuilder] instead for that. Defaults to + * `https://api.openai.com`. + * + * @param baseUrl The base url + */ fun baseUrl(baseUrl: String) = apply { this.baseUrl = baseUrl } + /** + * Builds the OpenAI instance. + */ @Contract(pure = true) open fun build(): OpenAI { return OpenAIImpl( @@ -201,9 +230,19 @@ interface OpenAI { private var apiVersion: String? = null private var modelName: String? = null + /** + * Sets the azure api version + */ fun apiVersion(apiVersion: String) = apply { this.apiVersion = apiVersion } + + /** + * Sets the azure model name + */ fun modelName(modelName: String) = apply { this.modelName = modelName } + /** + * Builds the OpenAI instance. + */ @Contract(pure = true) override fun build(): OpenAI { return AzureOpenAI( diff --git a/src/main/kotlin/com/cjcrafter/openai/OpenAIDsl.kt b/src/main/kotlin/com/cjcrafter/openai/OpenAIDsl.kt old mode 100644 new mode 100755 diff --git a/src/main/kotlin/com/cjcrafter/openai/OpenAIImpl.kt b/src/main/kotlin/com/cjcrafter/openai/OpenAIImpl.kt old mode 100644 new mode 100755 index ba982ab..f3b0ea8 --- a/src/main/kotlin/com/cjcrafter/openai/OpenAIImpl.kt +++ b/src/main/kotlin/com/cjcrafter/openai/OpenAIImpl.kt @@ -1,5 +1,7 @@ package com.cjcrafter.openai +import com.cjcrafter.openai.assistants.AssistantHandler +import com.cjcrafter.openai.assistants.AssistantHandlerImpl import com.cjcrafter.openai.chat.* import com.cjcrafter.openai.completions.CompletionRequest import com.cjcrafter.openai.completions.CompletionResponse @@ -7,6 +9,8 @@ import com.cjcrafter.openai.completions.CompletionResponseChunk import com.cjcrafter.openai.embeddings.EmbeddingsRequest import com.cjcrafter.openai.embeddings.EmbeddingsResponse import com.cjcrafter.openai.files.* +import com.cjcrafter.openai.threads.ThreadHandler +import com.cjcrafter.openai.threads.ThreadHandlerImpl import com.fasterxml.jackson.databind.JavaType import com.fasterxml.jackson.databind.node.ObjectNode import okhttp3.* @@ -26,65 +30,7 @@ open class OpenAIImpl @ApiStatus.Internal constructor( protected val mediaType = "application/json; charset=utf-8".toMediaType() protected val objectMapper = OpenAI.createObjectMapper() - protected open fun buildRequest(request: Any, endpoint: String): Request { - val json = objectMapper.writeValueAsString(request) - val body: RequestBody = json.toRequestBody(mediaType) - return Request.Builder() - .url("$baseUrl/$endpoint") - .addHeader("Content-Type", "application/json") - .addHeader("Authorization", "Bearer $apiKey") - .apply { if (organization != null) addHeader("OpenAI-Organization", organization) } - .post(body).build() - } - - protected open fun buildRequestNoBody(endpoint: String, params: Map? = null): Request.Builder{ - val url = "$baseUrl/$endpoint".toHttpUrl().newBuilder() - .apply { - params?.forEach { (key, value) -> addQueryParameter(key, value.toString()) } - }.build().toString() - - return Request.Builder() - .url(url) - .addHeader("Authorization", "Bearer $apiKey") - .apply { if (organization != null) addHeader("OpenAI-Organization", organization) } - } - - protected open fun buildMultipartRequest( - endpoint: String, - function: MultipartBody.Builder.() -> Unit, - ): Request { - - val multipartBody = MultipartBody.Builder() - .setType(MultipartBody.FORM) - .apply(function) - .build() - - return Request.Builder() - .url("$baseUrl/$endpoint") - .addHeader("Authorization", "Bearer $apiKey") - .apply { if (organization != null) addHeader("OpenAI-Organization", organization) } - .post(multipartBody).build() - } - - protected open fun executeRequest(httpRequest: Request): String { - val httpResponse = client.newCall(httpRequest).execute() - if (!httpResponse.isSuccessful) { - val json = httpResponse.body?.byteStream()?.bufferedReader()?.readText() - httpResponse.close() - throw IOException("Unexpected code $httpResponse, received: $json") - } - - val jsonReader = httpResponse.body?.byteStream()?.bufferedReader() - ?: throw IOException("Response body is null") - val responseStr = jsonReader.readText() - OpenAI.logger.debug(responseStr) - return responseStr - } - - protected open fun executeRequest(httpRequest: Request, responseType: Class): T { - val str = executeRequest(httpRequest) - return objectMapper.readValue(str, responseType) - } + protected var requestHelper = RequestHelper(apiKey, organization, client, baseUrl) private fun streamResponses( request: Request, @@ -145,14 +91,14 @@ open class OpenAIImpl @ApiStatus.Internal constructor( override fun createCompletion(request: CompletionRequest): CompletionResponse { @Suppress("DEPRECATION") request.stream = false // use streamCompletion for stream=true - val httpRequest = buildRequest(request, COMPLETIONS_ENDPOINT) - return executeRequest(httpRequest, CompletionResponse::class.java) + val httpRequest = requestHelper.buildRequest(request, COMPLETIONS_ENDPOINT).build() + return requestHelper.executeRequest(httpRequest, CompletionResponse::class.java) } override fun streamCompletion(request: CompletionRequest): Iterable { @Suppress("DEPRECATION") request.stream = true - val httpRequest = buildRequest(request, COMPLETIONS_ENDPOINT) + val httpRequest = requestHelper.buildRequest(request, COMPLETIONS_ENDPOINT).build() return streamResponses(httpRequest, objectMapper.typeFactory.constructType(CompletionResponseChunk::class.java)) { response, newLine -> // We don't have any update logic, so we should ignore the old response and just return a new one objectMapper.readValue(newLine, CompletionResponseChunk::class.java) @@ -162,14 +108,14 @@ open class OpenAIImpl @ApiStatus.Internal constructor( override fun createChatCompletion(request: ChatRequest): ChatResponse { @Suppress("DEPRECATION") request.stream = false // use streamChatCompletion for stream=true - val httpRequest = buildRequest(request, CHAT_ENDPOINT) - return executeRequest(httpRequest, ChatResponse::class.java) + val httpRequest = requestHelper.buildRequest(request, CHAT_ENDPOINT).build() + return requestHelper.executeRequest(httpRequest, ChatResponse::class.java) } override fun streamChatCompletion(request: ChatRequest): Iterable { @Suppress("DEPRECATION") request.stream = true - val httpRequest = buildRequest(request, CHAT_ENDPOINT) + val httpRequest = requestHelper.buildRequest(request, CHAT_ENDPOINT).build() return streamResponses(httpRequest, objectMapper.typeFactory.constructType(ChatResponseChunk::class.java)) { response, newLine -> response.update(objectMapper.readTree(newLine) as ObjectNode) response @@ -177,43 +123,28 @@ open class OpenAIImpl @ApiStatus.Internal constructor( } override fun createEmbeddings(request: EmbeddingsRequest): EmbeddingsResponse { - val httpRequest = buildRequest(request, EMBEDDINGS_ENDPOINT) - return executeRequest(httpRequest, EmbeddingsResponse::class.java) - } - - override fun listFiles(request: ListFilesRequest): ListFilesResponse { - val httpRequest = buildRequestNoBody(FILES_ENDPOINT, request.toMap()).get().build() - return executeRequest(httpRequest, ListFilesResponse::class.java) - } - - override fun uploadFile(request: UploadFileRequest): FileObject { - val httpRequest = buildMultipartRequest(FILES_ENDPOINT) { - addFormDataPart("purpose", OpenAI.createObjectMapper().writeValueAsString(request.purpose).trim('"')) - addFormDataPart("file", request.fileName, request.requestBody) - } - return executeRequest(httpRequest, FileObject::class.java) + val httpRequest = requestHelper.buildRequest(request, EMBEDDINGS_ENDPOINT).build() + return requestHelper.executeRequest(httpRequest, EmbeddingsResponse::class.java) } - override fun deleteFile(fileId: String): FileDeletionStatus { - val httpRequest = buildRequestNoBody("$FILES_ENDPOINT/$fileId").delete().build() - return executeRequest(httpRequest, FileDeletionStatus::class.java) - } + private var files0: FileHandlerImpl? = null + override val files: FileHandler + get() = files0 ?: FileHandlerImpl(requestHelper, FILES_ENDPOINT).also { files0 = it } - override fun retrieveFile(fileId: String): FileObject { - val httpRequest = buildRequestNoBody("$FILES_ENDPOINT/$fileId").get().build() - return executeRequest(httpRequest, FileObject::class.java) - } - - override fun retrieveFileContents(fileId: String): String { - val httpRequest = buildRequestNoBody("$FILES_ENDPOINT/$fileId/content").get().build() - return executeRequest(httpRequest) - } + private var assistants0: AssistantHandlerImpl? = null + override val assistants: AssistantHandler + get() = assistants0 ?: AssistantHandlerImpl(requestHelper, ASSISTANTS_ENDPOINT).also { assistants0 = it } + private var threads0: ThreadHandlerImpl? = null + override val threads: ThreadHandler + get() = threads0 ?: ThreadHandlerImpl(requestHelper, THREADS_ENDPOINT).also { threads0 = it } companion object { const val COMPLETIONS_ENDPOINT = "v1/completions" const val CHAT_ENDPOINT = "v1/chat/completions" const val EMBEDDINGS_ENDPOINT = "v1/embeddings" const val FILES_ENDPOINT = "v1/files" + const val ASSISTANTS_ENDPOINT = "v1/assistants" + const val THREADS_ENDPOINT = "v1/threads" } } \ No newline at end of file diff --git a/src/main/kotlin/com/cjcrafter/openai/RequestHelper.kt b/src/main/kotlin/com/cjcrafter/openai/RequestHelper.kt new file mode 100755 index 0000000..8c70c86 --- /dev/null +++ b/src/main/kotlin/com/cjcrafter/openai/RequestHelper.kt @@ -0,0 +1,80 @@ +package com.cjcrafter.openai + +import okhttp3.HttpUrl.Companion.toHttpUrl +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.MultipartBody +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.RequestBody +import okhttp3.RequestBody.Companion.toRequestBody +import java.io.IOException + +open class RequestHelper( + protected val apiKey: String, + protected val organization: String? = null, + protected val client: OkHttpClient = OkHttpClient(), + protected val baseUrl: String = "https://api.openai.com", +) { + protected val mediaType = "application/json; charset=utf-8".toMediaType() + protected val objectMapper = OpenAI.createObjectMapper() + + open fun buildRequest(request: Any, endpoint: String): Request.Builder { + val json = objectMapper.writeValueAsString(request) + val body: RequestBody = json.toRequestBody(mediaType) + return Request.Builder() + .url("$baseUrl/$endpoint") + .addHeader("Content-Type", "application/json") + .addHeader("Authorization", "Bearer $apiKey") + .apply { if (organization != null) addHeader("OpenAI-Organization", organization) } + .post(body) + } + + open fun buildRequestNoBody(endpoint: String, params: Map? = null): Request.Builder { + val url = "$baseUrl/$endpoint".toHttpUrl().newBuilder() + .apply { + params?.forEach { (key, value) -> addQueryParameter(key, value.toString()) } + }.build().toString() + + return Request.Builder() + .url(url) + .addHeader("Authorization", "Bearer $apiKey") + .apply { if (organization != null) addHeader("OpenAI-Organization", organization) } + } + + open fun buildMultipartRequest( + endpoint: String, + function: MultipartBody.Builder.() -> Unit, + ): Request { + + val multipartBody = MultipartBody.Builder() + .setType(MultipartBody.FORM) + .apply(function) + .build() + + return Request.Builder() + .url("$baseUrl/$endpoint") + .addHeader("Authorization", "Bearer $apiKey") + .apply { if (organization != null) addHeader("OpenAI-Organization", organization) } + .post(multipartBody).build() + } + + open fun executeRequest(httpRequest: Request): String { + val httpResponse = client.newCall(httpRequest).execute() + if (!httpResponse.isSuccessful) { + val json = httpResponse.body?.byteStream()?.bufferedReader()?.readText() + httpResponse.close() + throw IOException("Unexpected code $httpResponse, received: $json") + } + + val jsonReader = httpResponse.body?.byteStream()?.bufferedReader() + ?: throw IOException("Response body is null") + val responseStr = jsonReader.readText() + OpenAI.logger.debug(responseStr) + return responseStr + } + + open fun executeRequest(httpRequest: Request, responseType: Class): T { + val str = executeRequest(httpRequest) + return objectMapper.readValue(str, responseType) + } +} \ No newline at end of file diff --git a/src/main/kotlin/com/cjcrafter/openai/assistants/AbstractAssistantBuilder.kt b/src/main/kotlin/com/cjcrafter/openai/assistants/AbstractAssistantBuilder.kt new file mode 100755 index 0000000..0b318b5 --- /dev/null +++ b/src/main/kotlin/com/cjcrafter/openai/assistants/AbstractAssistantBuilder.kt @@ -0,0 +1,165 @@ +package com.cjcrafter.openai.assistants + +import com.cjcrafter.openai.chat.tool.Tool +import com.cjcrafter.openai.files.FileObject +import com.cjcrafter.openai.util.OpenAIDslMarker + +/** + * Abstract class for creating/modifying assistants. This class is not + * meant to be used directly, instead, use the [CreateAssistantRequest.builder] + * and [ModifyAssistantRequest.builder] methods. + */ +@OpenAIDslMarker +abstract class AbstractAssistantBuilder protected constructor() { + protected var model: String? = null + protected var name: String? = null + protected var description: String? = null + protected var instructions: String? = null + protected var tools: MutableList? = null + protected var fileIds: MutableList? = null + protected var metadata: MutableMap? = null + + /** + * The model to the [Assistant] should default to using if no model is + * specified. Use [com.cjcrafter.openai.Models] to get a list of models. + * + * @param model The model to use + */ + fun model(model: String) = apply { this.model = model } + + /** + * The name of the assistant. + * + * @param name The name of the assistant + * @throws IllegalArgumentException if the name is more than 256 characters + */ + fun name(name: String?) = apply { + if (name != null && name.length > 256) + throw IllegalArgumentException("name must be less than 256 characters") + this.name = name + } + + /** + * The description of the assistant. This is typically extremely short, + * and for the user's benefit rather than for the assistant's benefit. + * + * @param description The description of the assistant + * @throws IllegalArgumentException if the description is more than 512 characters + */ + fun description(description: String?) = apply { + if (description != null && description.length > 512) + throw IllegalArgumentException("description must be less than 512 characters") + this.description = description + } + + /** + * Sets the instructions for the assistant to follow. Instructions + * are generally a set of "commands" in natural language that the + * assistant should follow. This option is where all of your prompt + * engineering goes. + * + * @param instructions The instructions for the assistant to follow + * @throws IllegalArgumentException if the instructions are more than 32768 characters + */ + fun instructions(instructions: String?) = apply { + if (instructions != null && instructions.length > 32768) + throw IllegalArgumentException("instructions must be less than 32768 characters") + this.instructions = instructions + } + + /** + * Sets the tools available to the assistant. When using tools, you + * should use [instructions] to command the assistant to use the tools. + * Otherwise, the assistant tends to skip over the tools. + * + * In general, you should use [addTool] instead of this method. If you + * are a Kotlin developer, you should use the extension functions. + * + * @param tools The tools available to the assistant + * @throws IllegalArgumentException if there are more than 128 tools + */ + fun tools(tools: MutableList?) = apply { + if (tools != null && tools.size > 128) + throw IllegalArgumentException("cannot have more than 128 tools") + this.tools = tools + } + + /** + * Sets the files available to the assistant for data analysis and + * retrieval. Make sure that the assistant has at least one of + * data analysis or retrieval tools. + * + * In general, you should use [addFile] instead of this method. + * + * @param fileIds The file IDs + * @throws IllegalArgumentException if there are more than 20 files + */ + fun fileIds(fileIds: MutableList?) = apply { + if (fileIds != null && fileIds.size > 20) + throw IllegalArgumentException("cannot have more than 20 files") + this.fileIds = fileIds + } + + /** + * Sets the metadata map. This metadata is not used by OpenAI, instead, + * it is data that can be used by **YOU**, the developer, to store + * information. + * + * The metadata map must have 16 or fewer keys, and each key must be + * less than 64 characters. Each value must be less than 512 characters. + * + * @param metadata The metadata map + * @throws IllegalArgumentException if the map has more than 16 keys, or + * if any key is more than 64 characters, or if any value is more than + * 512 characters + */ + fun metadata(metadata: MutableMap?) = apply { + if (metadata != null) { + if (metadata.size > 16) + throw IllegalArgumentException("metadata cannot have more than 16 keys") + + for ((key, value) in metadata) { + if (key.length > 64) + throw IllegalArgumentException("metadata key must be less than 64 characters") + if (value.length > 512) + throw IllegalArgumentException("metadata value must be less than 512 characters") + } + } + + this.metadata = metadata + } + + /** + * Adds the given tool to the assistant's tools. + * + * @throws IllegalStateException if there are already 128 tools + */ + fun addTool(tool: Tool) = apply { + if (tools == null) tools = mutableListOf() + if (tools!!.size > 128) + throw IllegalStateException("cannot have more than 128 tools") + tools!!.add(tool) + } + + /** + * Adds the given file to the assistant's files. + * + * @throws IllegalStateException if there are already 20 files + */ + fun addFile(fileId: String) = apply { + if (fileIds == null) fileIds = mutableListOf() + if (fileIds!!.size > 20) + throw IllegalStateException("cannot have more than 20 files") + + fileIds!!.add(fileId) + } + + /** + * Adds the given file to the assistant's files. + * + * @throws IllegalStateException if there are already 20 files + */ + fun addFile(file: FileObject) = addFile(file.id) + + abstract fun build(): T +} \ No newline at end of file diff --git a/src/main/kotlin/com/cjcrafter/openai/assistants/Assistant.kt b/src/main/kotlin/com/cjcrafter/openai/assistants/Assistant.kt new file mode 100755 index 0000000..99a970c --- /dev/null +++ b/src/main/kotlin/com/cjcrafter/openai/assistants/Assistant.kt @@ -0,0 +1,29 @@ +package com.cjcrafter.openai.assistants + +import com.cjcrafter.openai.chat.tool.Tool +import com.fasterxml.jackson.annotation.JsonProperty + +/** + * Represents the assistant metadata returned by the OpenAI API. + * + * @property id The unique id of the assistant, always starts with asst_ + * @property createdAt The unix timestamp of when the assistant was created + * @property name The name of the assistant, if present + * @property description The description of the assistant, if present + * @property model The model used by the assistant + * @property instructions The instructions for the assistant, if present + * @property tools The tools used by the assistant + * @property fileIds The list of file ids used in tools + * @property metadata Data stored by YOU, the developer, to store additional information + */ +data class Assistant( + @JsonProperty(required = true) val id: String, + @JsonProperty("created_at", required = true) val createdAt: Int, + @JsonProperty(required = true) val name: String?, + @JsonProperty(required = true) val description: String?, + @JsonProperty(required = true) val model: String, + @JsonProperty(required = true) val instructions: String?, + @JsonProperty(required = true) val tools: List, + @JsonProperty("file_ids", required = true) val fileIds: List, + @JsonProperty(required = true) val metadata: Map, +) diff --git a/src/main/kotlin/com/cjcrafter/openai/assistants/AssistantDeletionStatus.kt b/src/main/kotlin/com/cjcrafter/openai/assistants/AssistantDeletionStatus.kt new file mode 100755 index 0000000..a26fa3f --- /dev/null +++ b/src/main/kotlin/com/cjcrafter/openai/assistants/AssistantDeletionStatus.kt @@ -0,0 +1,14 @@ +package com.cjcrafter.openai.assistants + +import com.fasterxml.jackson.annotation.JsonProperty + +/** + * Represents the result of a deletion request for an assistant. + * + * @property id The id of the assistant that was deleted + * @property deleted Whether the assistant was deleted + */ +data class AssistantDeletionStatus( + @JsonProperty(required = true) val id: String, + @JsonProperty(required = true) val deleted: Boolean, +) diff --git a/src/main/kotlin/com/cjcrafter/openai/assistants/AssistantDsl.kt b/src/main/kotlin/com/cjcrafter/openai/assistants/AssistantDsl.kt new file mode 100755 index 0000000..7944199 --- /dev/null +++ b/src/main/kotlin/com/cjcrafter/openai/assistants/AssistantDsl.kt @@ -0,0 +1,13 @@ +package com.cjcrafter.openai.assistants + +fun createAssistantRequest(block: CreateAssistantRequest.Builder.() -> Unit): CreateAssistantRequest { + return CreateAssistantRequest.builder().apply(block).build() +} + +fun modifyAssistantRequest(block: ModifyAssistantRequest.Builder.() -> Unit): ModifyAssistantRequest { + return ModifyAssistantRequest.builder().apply(block).build() +} + +fun listAssistantRequest(block: ListAssistantRequest.Builder.() -> Unit): ListAssistantRequest { + return ListAssistantRequest.builder().apply(block).build() +} \ No newline at end of file diff --git a/src/main/kotlin/com/cjcrafter/openai/assistants/AssistantHandler.kt b/src/main/kotlin/com/cjcrafter/openai/assistants/AssistantHandler.kt new file mode 100755 index 0000000..5042afb --- /dev/null +++ b/src/main/kotlin/com/cjcrafter/openai/assistants/AssistantHandler.kt @@ -0,0 +1,99 @@ +package com.cjcrafter.openai.assistants + +import org.jetbrains.annotations.Contract + +/** + * Represents the handler for the assistants endpoint. This class holds all the + * actions that can be performed on an assistant. + */ +interface AssistantHandler { + + /** + * Creates a new assistant with the given properties. This method will + * return the data associated with the assistant. + * + * @param request The request to create the assistant + * @return The created assistant + */ + fun create(request: CreateAssistantRequest): Assistant + + /** + * Retrieves the assistant with the given id. This method will return a new + * instance of the assistant, and will not modify the given instance. + * + * This method is useful for getting the latest data for an assistant + * (Though, in general, you should be using the return value of the + * [modify] method instead of making additional API calls). + * + * @param assistant The assistant to retrieve + * @return The new instance of the retrieved assistant + */ + @Contract(pure = true) + fun retrieve(assistant: Assistant): Assistant = retrieve(assistant.id) + + /** + * Retrieve the assistant with the given id. + * + * @param id The id of the assistant to retrieve + * @return The assistant with the given id + */ + @Contract(pure = true) + fun retrieve(id: String): Assistant + + /** + * Attempts to delete the given assistant. To confirm deletion, you should + * check [AssistantDeletionStatus.deleted]. + * + * @param assistant The assistant to delete + * @return The deletion status of the assistant + */ + fun delete(assistant: Assistant): AssistantDeletionStatus = delete(assistant.id) + + /** + * Attempts to delete the assistant with the given id. To confirm deletion, + * you should check [AssistantDeletionStatus.deleted]. + * + * @param id The id of the assistant to delete + * @return The deletion status of the assistant + */ + fun delete(id: String): AssistantDeletionStatus + + /** + * Lists the 20 most recent assistants. + * + * @return The list of assistants + */ + @Contract(pure = true) + fun list(): ListAssistantResponse = list(null) + + /** + * Lists assistants with the given query parameters. + * + * @param request The query parameters + * @return The list of assistants + */ + @Contract(pure = true) + fun list(request: ListAssistantRequest?): ListAssistantResponse // Cannot use @JvmOverloads in interfaces + + /** + * Shorthand to modify the given assistant. Note that this method will + * return a new instance of the assistant, and will not modify the given + * instance. + * + * @param assistant The assistant to modify + * @param request The request to modify the assistant + */ + fun modify(assistant: Assistant, request: ModifyAssistantRequest): Assistant = modify(assistant.id, request) + + /** + * Modifies the given assistant. For any nonnull fields in the given + * request, the corresponding field in the assistant will be overwritten. + * + * If you want to append to the corresponding field, you must first retrieve + * the assistant, modify the field, and then call this method. + * + * @param request The request to modify the assistant + * @return The modified assistant + */ + fun modify(id: String, request: ModifyAssistantRequest): Assistant +} \ No newline at end of file diff --git a/src/main/kotlin/com/cjcrafter/openai/assistants/AssistantHandlerImpl.kt b/src/main/kotlin/com/cjcrafter/openai/assistants/AssistantHandlerImpl.kt new file mode 100755 index 0000000..bdac347 --- /dev/null +++ b/src/main/kotlin/com/cjcrafter/openai/assistants/AssistantHandlerImpl.kt @@ -0,0 +1,40 @@ +package com.cjcrafter.openai.assistants + +import com.cjcrafter.openai.RequestHelper + +/** + * Holds the default underlying implementation of the [AssistantHandler] interface. + * + * @param requestHelper The request helper to use + * @param endpoint The assistants endpoint + */ +class AssistantHandlerImpl( + private val requestHelper: RequestHelper, + private val endpoint: String +): AssistantHandler { + + override fun create(request: CreateAssistantRequest): Assistant { + val httpRequest = requestHelper.buildRequest(request, endpoint).addHeader("OpenAI-Beta", "assistants=v1").build() + return requestHelper.executeRequest(httpRequest, Assistant::class.java) + } + + override fun retrieve(id: String): Assistant { + val httpRequest = requestHelper.buildRequestNoBody("$endpoint/$id").addHeader("OpenAI-Beta", "assistants=v1").get().build() + return requestHelper.executeRequest(httpRequest, Assistant::class.java) + } + + override fun delete(id: String): AssistantDeletionStatus { + val httpRequest = requestHelper.buildRequestNoBody("$endpoint/$id").addHeader("OpenAI-Beta", "assistants=v1").delete().build() + return requestHelper.executeRequest(httpRequest, AssistantDeletionStatus::class.java) + } + + override fun list(request: ListAssistantRequest?): ListAssistantResponse { + val httpRequest = requestHelper.buildRequestNoBody(endpoint, request?.toMap()).addHeader("OpenAI-Beta", "assistants=v1").get().build() + return requestHelper.executeRequest(httpRequest, ListAssistantResponse::class.java) + } + + override fun modify(id: String, request: ModifyAssistantRequest): Assistant { + val httpRequest = requestHelper.buildRequest(request, "$endpoint/$id").addHeader("OpenAI-Beta", "assistants=v1").build() + return requestHelper.executeRequest(httpRequest, Assistant::class.java) + } +} \ No newline at end of file diff --git a/src/main/kotlin/com/cjcrafter/openai/assistants/CreateAssistantRequest.kt b/src/main/kotlin/com/cjcrafter/openai/assistants/CreateAssistantRequest.kt new file mode 100755 index 0000000..8a5a26f --- /dev/null +++ b/src/main/kotlin/com/cjcrafter/openai/assistants/CreateAssistantRequest.kt @@ -0,0 +1,61 @@ +package com.cjcrafter.openai.assistants + +import com.cjcrafter.openai.chat.tool.Tool +import com.fasterxml.jackson.annotation.JsonProperty + +/** + * Represents the request body for creating an assistant. + * + * Be careful when modifying the instance variables. Try to use the [builder] + * method instead, as it will detect common errors before sending your request + * to the OpenAI API. + * + * @property model The id of the model to use for the assistant + * @property name The name of the assistant + * @property description The description of the assistant + * @property instructions The instructions for the assistant to follow + * @property tools The tools available to the assistant + * @property fileIds The files that can be used in tools + * @property metadata A custom metadata map + */ +data class CreateAssistantRequest internal constructor( + var model: String, + var name: String? = null, + var description: String? = null, + var instructions: String? = null, + var tools: MutableList? = null, + @JsonProperty("file_ids") var fileIds: MutableList? = null, + var metadata: MutableMap? = null, +) { + + class Builder internal constructor() : AbstractAssistantBuilder() { + + /** + * Builds the [CreateAssistantRequest] instance. + * + * @throws IllegalStateException if required properties were not set. + */ + override fun build(): CreateAssistantRequest { + return CreateAssistantRequest( + model = model ?: throw IllegalStateException("model must be set"), + name = name, + description = description, + instructions = instructions, + tools = tools, + fileIds = fileIds, + metadata = metadata, + ) + } + } + + companion object { + + /** + * Instantiates a new [CreateAssistantRequest] builder instance. Make sure you set + * the required value(s): + * - [model] + */ + @JvmStatic + fun builder() = Builder() + } +} diff --git a/src/main/kotlin/com/cjcrafter/openai/assistants/ListAssistantRequest.kt b/src/main/kotlin/com/cjcrafter/openai/assistants/ListAssistantRequest.kt new file mode 100755 index 0000000..30818ec --- /dev/null +++ b/src/main/kotlin/com/cjcrafter/openai/assistants/ListAssistantRequest.kt @@ -0,0 +1,51 @@ +package com.cjcrafter.openai.assistants + +import com.cjcrafter.openai.AbstractListRequestBuilder +import com.cjcrafter.openai.ListOrder +import com.cjcrafter.openai.util.OpenAIDslMarker +import org.jetbrains.annotations.ApiStatus + +/** + * Represents a request sent to the OpenAI API to list assistants. + * + * @property limit The maximum number of results to return, between 1 and 100 inclusive + * @property order The order to return the list in + * @property after The cursor to use for pagination + * @property before The cursor to use for pagination + */ +data class ListAssistantRequest( + var limit: Int? = null, + var order: ListOrder? = null, + var after: String? = null, + var before: String? = null, +) { + + /** + * Converts the request to a map of query parameters. + */ + @ApiStatus.Internal + fun toMap(): Map = buildMap { + if (limit != null) put("limit", limit!!) + if (order != null) put("order", order!!.jsonProperty) + if (after != null) put("after", after!!) + if (before != null) put("before", before!!) + } + + @OpenAIDslMarker + class Builder internal constructor() : AbstractListRequestBuilder() { + + /** + * Builds the [ListAssistantRequest] object. + */ + override fun build() = ListAssistantRequest(limit, order, after, before) + } + + companion object { + + /** + * Creates a new [ListAssistantRequest] builder. + */ + @JvmStatic + fun builder() = Builder() + } +} diff --git a/src/main/kotlin/com/cjcrafter/openai/assistants/ListAssistantResponse.kt b/src/main/kotlin/com/cjcrafter/openai/assistants/ListAssistantResponse.kt new file mode 100755 index 0000000..8e1b024 --- /dev/null +++ b/src/main/kotlin/com/cjcrafter/openai/assistants/ListAssistantResponse.kt @@ -0,0 +1,20 @@ +package com.cjcrafter.openai.assistants + +import com.fasterxml.jackson.annotation.JsonProperty + +/** + * A data class which represents a response from the OpenAI API containing a list of [Assistant]s. + * + * When the list is empty, [firstId] and [lastId] will be `null`. + * + * @property data The list of assistants + * @property firstId The ID of the first [Assistant] in the list, or null + * @property lastId The ID of the last [Assistant] in the list, or null + * @property hasMore Whether there are more [Assistant]s to retrieve from the API + */ +data class ListAssistantResponse( + @JsonProperty(required = true) val data: List, + @JsonProperty("first_id") val firstId: String?, + @JsonProperty("last_id") val lastId: String?, + @JsonProperty("has_more", required = true) val hasMore: Boolean, +) diff --git a/src/main/kotlin/com/cjcrafter/openai/assistants/ModifyAssistantRequest.kt b/src/main/kotlin/com/cjcrafter/openai/assistants/ModifyAssistantRequest.kt new file mode 100755 index 0000000..c60810a --- /dev/null +++ b/src/main/kotlin/com/cjcrafter/openai/assistants/ModifyAssistantRequest.kt @@ -0,0 +1,71 @@ +package com.cjcrafter.openai.assistants + +import com.cjcrafter.openai.chat.tool.Tool +import com.fasterxml.jackson.annotation.JsonProperty + +/** + * Represents the request body for modifying an assistant. Modifications work + * by overriding the previous value. If this request has a non-null value for + * a property, then the value currently stored in the assistant will be replaced. + * + * If you want to add a tool, file, or metadata without overriding the previous + * value, then you must: + * 1. Get the current values of the assistant + * 2. Add the new tools/files/metadata + * 3. Send a [ModifyAssistantRequest] with the new values + * + * Be careful when modifying the instance variables. Try to use the [builder] + * method instead, as it will detect common errors before sending your request. + * + * @property model The new model to use for the assistant + * @property name The new name of the assistant + * @property description The new description of the assistant + * @property instructions The new instructions for the assistant to follow + * @property tools The new tools available to the assistant + * @property fileIds The new files that can be used in tools + * @property metadata The new custom metadata map + */ +data class ModifyAssistantRequest( + var model: String? = null, + var name: String? = null, + var description: String? = null, + var instructions: String? = null, + var tools: MutableList? = null, + @JsonProperty("file_ids") var fileIds: MutableList? = null, + var metadata: MutableMap? = null, +) { + + class Builder internal constructor() : AbstractAssistantBuilder() { + + /** + * Builds the [ModifyAssistantRequest] instance. + * + * @throws IllegalStateException if no properties are set. At least 1 should be set. + */ + override fun build(): ModifyAssistantRequest { + // It doesn't make sense to build a ModifyAssistantRequest without any modifications + if (model == null && name == null && description == null && instructions == null && tools == null && fileIds == null && metadata == null) + throw IllegalStateException("At least one property must be set") + + return ModifyAssistantRequest( + model = model, + name = name, + description = description, + instructions = instructions, + tools = tools, + fileIds = fileIds, + metadata = metadata, + ) + } + } + + companion object { + + /** + * Instantiates a new [ModifyAssistantRequest] builder instance. Make sure + * you set **at least** 1 property before calling [build]. + */ + @JvmStatic + fun builder() = Builder() + } +} \ No newline at end of file diff --git a/src/main/kotlin/com/cjcrafter/openai/chat/ChatChoice.kt b/src/main/kotlin/com/cjcrafter/openai/chat/ChatChoice.kt old mode 100644 new mode 100755 diff --git a/src/main/kotlin/com/cjcrafter/openai/chat/ChatChoiceChunk.kt b/src/main/kotlin/com/cjcrafter/openai/chat/ChatChoiceChunk.kt old mode 100644 new mode 100755 diff --git a/src/main/kotlin/com/cjcrafter/openai/chat/ChatMessage.kt b/src/main/kotlin/com/cjcrafter/openai/chat/ChatMessage.kt old mode 100644 new mode 100755 diff --git a/src/main/kotlin/com/cjcrafter/openai/chat/ChatRequest.kt b/src/main/kotlin/com/cjcrafter/openai/chat/ChatRequest.kt old mode 100644 new mode 100755 index 3678acd..4abbe65 --- a/src/main/kotlin/com/cjcrafter/openai/chat/ChatRequest.kt +++ b/src/main/kotlin/com/cjcrafter/openai/chat/ChatRequest.kt @@ -1,7 +1,5 @@ package com.cjcrafter.openai.chat -import com.cjcrafter.openai.chat.tool.AbstractTool -import com.cjcrafter.openai.chat.tool.FunctionTool import com.cjcrafter.openai.chat.tool.Tool import com.cjcrafter.openai.chat.tool.ToolChoice import com.cjcrafter.openai.util.OpenAIDslMarker @@ -86,9 +84,9 @@ data class ChatRequest @JvmOverloads internal constructor( * * @param tool */ - fun addTool(tool: AbstractTool) = apply { + fun addTool(tool: Tool) = apply { if (tools == null) tools = mutableListOf() - tools!!.add(tool.toTool()) + tools!!.add(tool) } /** diff --git a/src/main/kotlin/com/cjcrafter/openai/chat/ChatRequestDsl.kt b/src/main/kotlin/com/cjcrafter/openai/chat/ChatRequestDsl.kt old mode 100644 new mode 100755 index 5c2d487..b1c8c5e --- a/src/main/kotlin/com/cjcrafter/openai/chat/ChatRequestDsl.kt +++ b/src/main/kotlin/com/cjcrafter/openai/chat/ChatRequestDsl.kt @@ -1,6 +1,7 @@ package com.cjcrafter.openai.chat -import com.cjcrafter.openai.chat.tool.FunctionTool +import com.cjcrafter.openai.chat.tool.Function +import com.cjcrafter.openai.chat.tool.Tool /** * Creates a [ChatRequest] using the [ChatRequest.Builder] using Kotlin DSL. @@ -8,6 +9,6 @@ import com.cjcrafter.openai.chat.tool.FunctionTool fun chatRequest(block: ChatRequest.Builder.() -> Unit) = ChatRequest.builder().apply(block).build() /** - * Adds a [FunctionTool] to the [ChatRequest] using Kotlin DSL. + * Adds a [Tool.FunctionTool] to the [ChatRequest] using Kotlin DSL. */ -fun ChatRequest.Builder.function(block: FunctionTool.Builder.() -> Unit) = addTool(FunctionTool.builder().apply(block).build()) \ No newline at end of file +fun ChatRequest.Builder.function(block: Function.Builder.() -> Unit) = addTool(Function.builder().apply(block).build()) \ No newline at end of file diff --git a/src/main/kotlin/com/cjcrafter/openai/chat/ChatResponse.kt b/src/main/kotlin/com/cjcrafter/openai/chat/ChatResponse.kt old mode 100644 new mode 100755 diff --git a/src/main/kotlin/com/cjcrafter/openai/chat/ChatResponseChunk.kt b/src/main/kotlin/com/cjcrafter/openai/chat/ChatResponseChunk.kt old mode 100644 new mode 100755 diff --git a/src/main/kotlin/com/cjcrafter/openai/chat/ChatResponseFormat.kt b/src/main/kotlin/com/cjcrafter/openai/chat/ChatResponseFormat.kt old mode 100644 new mode 100755 diff --git a/src/main/kotlin/com/cjcrafter/openai/chat/ChatUsage.kt b/src/main/kotlin/com/cjcrafter/openai/chat/ChatUsage.kt old mode 100644 new mode 100755 diff --git a/src/main/kotlin/com/cjcrafter/openai/chat/ChatUser.kt b/src/main/kotlin/com/cjcrafter/openai/chat/ChatUser.kt old mode 100644 new mode 100755 diff --git a/src/main/kotlin/com/cjcrafter/openai/chat/tool/AbstractTool.kt b/src/main/kotlin/com/cjcrafter/openai/chat/tool/AbstractTool.kt deleted file mode 100644 index 145ef4c..0000000 --- a/src/main/kotlin/com/cjcrafter/openai/chat/tool/AbstractTool.kt +++ /dev/null @@ -1,27 +0,0 @@ -package com.cjcrafter.openai.chat.tool - -/** - * Represents a tool that can be used by ChatGPT in a chat completion. - */ -abstract class AbstractTool { - - /** - * What type of tool this is. This will always match this class's type. For - * example, for functions: - * ``` - * a.getToolType() == ToolType.FUNCTION - * # implies - * a is FunctionTool // instanceof in Java - * ``` - */ - abstract fun getToolType(): ToolType - - /** - * Wraps this tool in a [Tool] object for use by the OpenAI API. - */ - fun toTool(): Tool { - return when (val type = getToolType()) { - ToolType.FUNCTION -> Tool(type, function=this as FunctionTool) - } - } -} \ No newline at end of file diff --git a/src/main/kotlin/com/cjcrafter/openai/chat/tool/ChatMessageDelta.kt b/src/main/kotlin/com/cjcrafter/openai/chat/tool/ChatMessageDelta.kt old mode 100644 new mode 100755 diff --git a/src/main/kotlin/com/cjcrafter/openai/chat/tool/CodeInterpreter.kt b/src/main/kotlin/com/cjcrafter/openai/chat/tool/CodeInterpreter.kt new file mode 100644 index 0000000..f37ba9c --- /dev/null +++ b/src/main/kotlin/com/cjcrafter/openai/chat/tool/CodeInterpreter.kt @@ -0,0 +1,84 @@ +package com.cjcrafter.openai.chat.tool + +import com.fasterxml.jackson.annotation.JsonProperty +import com.fasterxml.jackson.annotation.JsonSubTypes +import com.fasterxml.jackson.annotation.JsonTypeInfo + +/** + * Represents the data for a [Tool.Type.CODE_INTERPRETER] tool call. This + * contains the code that was run by the code interpreter, and the outputs + * of the code interpreter. + * + * @property input The code that was run by the code interpreter + * @property outputs The data output by the code interpreter + */ +data class CodeInterpreter( + @JsonProperty(required = true) val input: String, + @JsonProperty(required = true) val outputs: List +) { + + /** + * A sealed class that represents the output of 1 [CodeInterpreter] step. + */ + @JsonTypeInfo(use = JsonTypeInfo.Id.NAME, include = JsonTypeInfo.As.PROPERTY, property = "type") + @JsonSubTypes( + JsonSubTypes.Type(LogOutput::class, name = "logs"), + JsonSubTypes.Type(ImageOutput::class, name = "image"), + ) + sealed class Output { + + /** + * The type of the output. + */ + abstract val type: Type + + enum class Type { + + /** + * When the code interpreter outputs text. + */ + @JsonProperty("logs") + LOGS, + + /** + * When the code interpreter outputs an image file. + */ + @JsonProperty("image") + IMAGE, + } + } + + /** + * Represents an [Output.Type.LOGS] output. This only holds the text that + * was outputted by the code interpreter. + * + * @property text The text output + */ + data class LogOutput( + @JsonProperty(required = true) val logs: String, + ) : Output() { + override val type: Type = Type.LOGS + } + + /** + * Represents an [Output.Type.IMAGE] output. This only holds the ID of the + * generated image file. You can use this ID to retrieve the image file. + * + * @property image The image output + * @see com.cjcrafter.openai.files.FileHandler.retrieve + */ + data class ImageOutput( + val image: Image, + ) : Output() { + override val type: Type = Type.IMAGE + + /** + * Holds data about the image output by the code interpreter. + * + * @property fileId The unique ID of the output file, which can be used to retrieve the file + */ + data class Image( + @JsonProperty("file_id", required = true) val fileId: String, + ) + } +} \ No newline at end of file diff --git a/src/main/kotlin/com/cjcrafter/openai/chat/tool/CodeInterpreterToolCall.kt b/src/main/kotlin/com/cjcrafter/openai/chat/tool/CodeInterpreterToolCall.kt new file mode 100644 index 0000000..983b0d5 --- /dev/null +++ b/src/main/kotlin/com/cjcrafter/openai/chat/tool/CodeInterpreterToolCall.kt @@ -0,0 +1,18 @@ +package com.cjcrafter.openai.chat.tool + +import com.fasterxml.jackson.annotation.JsonProperty + +/** + * Represents a tool call by an [com.cjcrafter.openai.assistants.Assistant] + * to a code interpreter. + * + * @property id The unique id of this tool call + * @property codeInterpreter The details about the input and output + */ +data class CodeInterpreterToolCall( + @JsonProperty(required = true) override val id: String, + @JsonProperty("code_interpreter", required = true) val codeInterpreter: CodeInterpreter, +) : ToolCall() { + override val type = Tool.Type.CODE_INTERPRETER + +} \ No newline at end of file diff --git a/src/main/kotlin/com/cjcrafter/openai/chat/tool/FunctionTool.kt b/src/main/kotlin/com/cjcrafter/openai/chat/tool/Function.kt old mode 100644 new mode 100755 similarity index 93% rename from src/main/kotlin/com/cjcrafter/openai/chat/tool/FunctionTool.kt rename to src/main/kotlin/com/cjcrafter/openai/chat/tool/Function.kt index 0aad008..4140b00 --- a/src/main/kotlin/com/cjcrafter/openai/chat/tool/FunctionTool.kt +++ b/src/main/kotlin/com/cjcrafter/openai/chat/tool/Function.kt @@ -18,19 +18,16 @@ import org.jetbrains.annotations.ApiStatus * @property parameters Which parameters can ChatGPT pass to the function * @property description A description of the function */ -data class FunctionTool internal constructor( +data class Function internal constructor( @FunctionTag var name: String, var parameters: FunctionParameters, var description: String? = null, -) : AbstractTool() { - +) { init { if (!name.matches(RegexInternals.FUNCTION)) throw IllegalArgumentException("Function name must match ${RegexInternals.FUNCTION}") } - override fun getToolType() = ToolType.FUNCTION - @OpenAIDslMarker class Builder internal constructor() { @FunctionTag private var name: String? = null @@ -124,10 +121,12 @@ data class FunctionTool internal constructor( parameters = FunctionParameters() } - fun build() = FunctionTool( - name = name ?: throw IllegalStateException("Name must be set"), - parameters = parameters ?: throw IllegalStateException("Parameters must be set"), - description = description, + fun build() = Tool.FunctionTool( + Function( + name = name ?: throw IllegalStateException("Name must be set"), + parameters = parameters ?: throw IllegalStateException("Parameters must be set"), + description = description, + ) ) } diff --git a/src/main/kotlin/com/cjcrafter/openai/chat/tool/FunctionCall.kt b/src/main/kotlin/com/cjcrafter/openai/chat/tool/FunctionCall.kt old mode 100644 new mode 100755 index 4672c63..7b1aa99 --- a/src/main/kotlin/com/cjcrafter/openai/chat/tool/FunctionCall.kt +++ b/src/main/kotlin/com/cjcrafter/openai/chat/tool/FunctionCall.kt @@ -1,28 +1,50 @@ package com.cjcrafter.openai.chat.tool +import com.cjcrafter.openai.OpenAI +import com.cjcrafter.openai.assistants.Assistant import com.cjcrafter.openai.exception.HallucinationException import com.fasterxml.jackson.core.JsonProcessingException import com.fasterxml.jackson.databind.JsonNode import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper import com.fasterxml.jackson.module.kotlin.readValue -import org.jetbrains.annotations.ApiStatus /** - * Represents a function call by ChatGPT. When ChatGPT calls a function, you - * should be parsing the arguments, calling some method (getting current weather - * at a location, getting a stock price, modifying a database, etc.), and then - * replying to ChatGPT with the result. + * Represents a function call by either a chat completion, or an [Assistant]. * - * ChatGPT may *hallucinate*, or make up, function calls. To handle hallucinations, + * When a function call is made, you MUST respond with the result of the function. + * This means that you should parse the arguments, call some function (an API call, + * getting current weather, getting a stock price, modifying a database, etc.), and + * sending the result of that function back. + * + * For chat completions ([OpenAI.createChatCompletion]), you should send the result + * of the function as a [com.cjcrafter.openai.chat.ChatMessage] with the + * corresponding [ToolCall.id] as the function id. + * + * For [Assistant]s, you should use [com.cjcrafter.openai.threads.runs.RunHandler.submitToolOutputs] + * with the corresponding [ToolCall.id] as the tool call id. For [Assistant]s, + * it is important to submit tool outputs _within a timely manner_, usually + * within 10 minutes of starting a [com.cjcrafter.openai.threads.runs.Run]. + * Otherwise, the [com.cjcrafter.openai.threads.runs.Run] will expire, and you + * will not be able to submit your tool call. + * + * ChatGPT may _hallucinate_, or make up, function calls. To handle hallucinations, * we have provided [tryParseArguments]. * * @property name The name of the function which was called * @property arguments The raw json representation of the arguments + * @property output The result of the function call if it has been set, only used for [Assistant]s. You should not set this. */ data class FunctionCall( var name: String, var arguments: String, + var output: String? = null, ) { + + /** + * Used internally to update the function call. This is used when the chat + * completion is streamed via [OpenAI.streamChatCompletion]. This is not + * used by [Assistant]s. + */ internal fun update(delta: FunctionCallDelta) { // The only field that updates is arguments arguments += delta.arguments @@ -44,14 +66,14 @@ data class FunctionCall( * @param tools The list of tools that ChatGPT has access to, or null to skip advanced checking. * @return The parsed arguments. */ - @JvmOverloads @Throws(HallucinationException::class) fun tryParseArguments(tools: List? = null): Map { var parameters: FunctionParameters? = null if (tools != null) { - parameters = tools.find { it.type == ToolType.FUNCTION && it.function.name == name }?.function?.parameters + val functionTool: Tool.FunctionTool = tools.find { it is Tool.FunctionTool && it.function.name == name } as? Tool.FunctionTool ?: throw HallucinationException("Unknown function: $name") + parameters = functionTool.function.parameters } try { diff --git a/src/main/kotlin/com/cjcrafter/openai/chat/tool/FunctionCallDelta.kt b/src/main/kotlin/com/cjcrafter/openai/chat/tool/FunctionCallDelta.kt old mode 100644 new mode 100755 diff --git a/src/main/kotlin/com/cjcrafter/openai/chat/tool/FunctionParameters.kt b/src/main/kotlin/com/cjcrafter/openai/chat/tool/FunctionParameters.kt old mode 100644 new mode 100755 diff --git a/src/main/kotlin/com/cjcrafter/openai/chat/tool/FunctionProperty.kt b/src/main/kotlin/com/cjcrafter/openai/chat/tool/FunctionProperty.kt old mode 100644 new mode 100755 diff --git a/src/main/kotlin/com/cjcrafter/openai/chat/tool/FunctionToolCall.kt b/src/main/kotlin/com/cjcrafter/openai/chat/tool/FunctionToolCall.kt new file mode 100644 index 0000000..967faf6 --- /dev/null +++ b/src/main/kotlin/com/cjcrafter/openai/chat/tool/FunctionToolCall.kt @@ -0,0 +1,21 @@ +package com.cjcrafter.openai.chat.tool + +import com.fasterxml.jackson.annotation.JsonProperty + +/** + * Represents a tool call for [Tool.Type.FUNCTION]. + * + * @property id The unique id of the tool call. You should use this id in your reply. + * @property function The details about which function was called, parameters, etc. + */ +data class FunctionToolCall( + @JsonProperty(required = true) override val id: String, + @JsonProperty(required = true) val function: FunctionCall, +) : ToolCall() { + override val type = Tool.Type.FUNCTION + + override fun update(delta: ToolCallDelta) { + if (delta.function != null) + function.update(delta.function) + } +} \ No newline at end of file diff --git a/src/main/kotlin/com/cjcrafter/openai/chat/tool/FunctionToolDsl.kt b/src/main/kotlin/com/cjcrafter/openai/chat/tool/FunctionToolDsl.kt old mode 100644 new mode 100755 index a8fdf38..47e2e31 --- a/src/main/kotlin/com/cjcrafter/openai/chat/tool/FunctionToolDsl.kt +++ b/src/main/kotlin/com/cjcrafter/openai/chat/tool/FunctionToolDsl.kt @@ -3,4 +3,4 @@ package com.cjcrafter.openai.chat.tool /** * Creates a [FunctionTool] using the [FunctionTool.Builder] using Kotlin DSL. */ -fun functionTool(init: FunctionTool.Builder.() -> Unit) = FunctionTool.builder().apply(init).build() \ No newline at end of file +fun functionTool(init: Function.Builder.() -> Unit) = Function.builder().apply(init).build() \ No newline at end of file diff --git a/src/main/kotlin/com/cjcrafter/openai/chat/tool/RetrievalToolCall.kt b/src/main/kotlin/com/cjcrafter/openai/chat/tool/RetrievalToolCall.kt new file mode 100644 index 0000000..9dfa290 --- /dev/null +++ b/src/main/kotlin/com/cjcrafter/openai/chat/tool/RetrievalToolCall.kt @@ -0,0 +1,23 @@ +package com.cjcrafter.openai.chat.tool + +import com.fasterxml.jackson.annotation.JsonProperty +import org.jetbrains.annotations.ApiStatus + +/** + * Represents a tool call for [Tool.Type.RETRIEVAL]. + * + * Currently, this tool call serves no functionality other than to inform you + * that some retrieval operation has occurred. Currently, the OpenAI API does + * not return any information about the retrieval ([retrieval] will always be + * an empty map. [retrieval]'s data type may be changed in the future, I + * recommend you __DO NOT__ use it). + * + * @property id The unique id of this tool call + * @property retrieval An empty map, do not use this! + */ +data class RetrievalToolCall( + @JsonProperty(required = true) override val id: String, + @JsonProperty(required = true) @ApiStatus.Experimental val retrieval: Map, +): ToolCall() { + override val type = Tool.Type.RETRIEVAL +} \ No newline at end of file diff --git a/src/main/kotlin/com/cjcrafter/openai/chat/tool/Tool.kt b/src/main/kotlin/com/cjcrafter/openai/chat/tool/Tool.kt old mode 100644 new mode 100755 index 9ea5afa..1a82e48 --- a/src/main/kotlin/com/cjcrafter/openai/chat/tool/Tool.kt +++ b/src/main/kotlin/com/cjcrafter/openai/chat/tool/Tool.kt @@ -1,15 +1,89 @@ package com.cjcrafter.openai.chat.tool +import com.cjcrafter.openai.assistants.Assistant +import com.cjcrafter.openai.threads.message.TextAnnotation +import com.fasterxml.jackson.annotation.JsonProperty +import com.fasterxml.jackson.annotation.JsonSubTypes +import com.fasterxml.jackson.annotation.JsonTypeInfo + /** - * A tool that can be used by ChatGPT. Currently, the only type of tool is a - * function. OpenAI will likely add more types of tools in the future. To avoid - * breaking changes in your code, use [com.cjcrafter.openai.chat.ChatRequest.Builder.addTool] - * instead of using this class directly. - * - * @property type The type of tool this is (currently only functions). - * @property function The function. This is only used if [type] is [ToolType.FUNCTION]. + * Represents a tool that can be used by ChatGPT in a chat completion. */ -data class Tool( - var type: ToolType, - var function: FunctionTool, +@JsonTypeInfo(use = JsonTypeInfo.Id.NAME, include = JsonTypeInfo.As.PROPERTY, property = "type") +@JsonSubTypes( + JsonSubTypes.Type(Tool.FunctionTool::class, name = "function"), + JsonSubTypes.Type(Tool.RetrievalTool::class, name = "retrieval"), + JsonSubTypes.Type(Tool.CodeInterpreterTool::class, name = "code_interpreter"), ) +sealed class Tool { + + /** + * Represents a tool that calls a function. + * + * @see Function.builder + */ + data class FunctionTool( + @JsonProperty(required = true) var function: Function, + ): Tool() { + override val type = Type.FUNCTION + } + + /** + * Represents a tool that retrieves data from uploaded files. + * + * Note that retrieval tools are only supported by [Assistant]s + */ + data object RetrievalTool: Tool() { + override val type = Type.RETRIEVAL + } + + /** + * Represents a tool that runs Python code on the OpenAI server. + * + * Note that code interpreter tools are only supported by [Assistant]s + */ + data object CodeInterpreterTool: Tool() { + override val type = Type.CODE_INTERPRETER + } + + /** + * Represents the type of the tool. + */ + enum class Type { + + /** + * A tool that calls a function. + * + * @see FunctionTool + */ + @JsonProperty("function") + FUNCTION, + + /** + * A tool that retrieves data from uploaded files. + * + * Note that retrieval tools are only supported by [Assistant]s + */ + @JsonProperty("retrieval") + RETRIEVAL, + + /** + * A tool that runs Python code on the OpenAI server. + * + * Note that code interpreter tools are only supported by [Assistant]s + */ + @JsonProperty("code_interpreter") + CODE_INTERPRETER, + } + + /** + * What type of tool this is. This will always match this class's type. For + * example, for functions: + * ``` + * a.getToolType() == ToolType.FUNCTION + * # implies + * a is FunctionTool // instanceof in Java + * ``` + */ + abstract val type: Type +} \ No newline at end of file diff --git a/src/main/kotlin/com/cjcrafter/openai/chat/tool/ToolCall.kt b/src/main/kotlin/com/cjcrafter/openai/chat/tool/ToolCall.kt old mode 100644 new mode 100755 index dea7c19..7966844 --- a/src/main/kotlin/com/cjcrafter/openai/chat/tool/ToolCall.kt +++ b/src/main/kotlin/com/cjcrafter/openai/chat/tool/ToolCall.kt @@ -1,8 +1,11 @@ package com.cjcrafter.openai.chat.tool +import com.fasterxml.jackson.annotation.JsonSubTypes +import com.fasterxml.jackson.annotation.JsonTypeInfo + /** * Wraps a tool call by ChatGPT. You should check the [type] of the tool call, - * and handle the request. For example, if the type is [ToolType.FUNCTION], you + * and handle the request. For example, if the type is [Tool.Type.FUNCTION], you * should call the function and return the result. * * When making subsequent requests to chat completions, you should make sure to @@ -10,17 +13,20 @@ package com.cjcrafter.openai.chat.tool * call. * * @property id The id of this call. You should use this to construct a [com.cjcrafter.openai.chat.ChatUser.TOOL] message. - * @property type The type of tool call. Currently, the only type is [ToolType.FUNCTION]. - * @property function The function call containing the function name and arguments. + * @property type The type of tool call. */ -data class ToolCall( - var id: String, - var type: ToolType, - var function: FunctionCall, -) { - internal fun update(delta: ToolCallDelta) { - // The only field that updates is function - if (delta.function != null) - function.update(delta.function) +@JsonTypeInfo(use = JsonTypeInfo.Id.NAME, include = JsonTypeInfo.As.PROPERTY, property = "type") +@JsonSubTypes( + JsonSubTypes.Type(FunctionToolCall::class, name = "function"), + JsonSubTypes.Type(RetrievalToolCall::class, name = "retrieval"), + JsonSubTypes.Type(CodeInterpreterToolCall::class, name = "code_interpreter"), +) +sealed class ToolCall { + + abstract val id: String + abstract val type: Tool.Type + + internal open fun update(delta: ToolCallDelta) { + // Nothing to update } } \ No newline at end of file diff --git a/src/main/kotlin/com/cjcrafter/openai/chat/tool/ToolCallDelta.kt b/src/main/kotlin/com/cjcrafter/openai/chat/tool/ToolCallDelta.kt old mode 100644 new mode 100755 index fbd1379..d02a026 --- a/src/main/kotlin/com/cjcrafter/openai/chat/tool/ToolCallDelta.kt +++ b/src/main/kotlin/com/cjcrafter/openai/chat/tool/ToolCallDelta.kt @@ -12,12 +12,14 @@ package com.cjcrafter.openai.chat.tool data class ToolCallDelta( val index: Int, val id: String? = null, - val type: ToolType? = null, + val type: Tool.Type? = null, val function: FunctionCallDelta? = null, ) { - internal fun toToolCall() = ToolCall( - id = id ?: throw IllegalStateException("id must be set"), - type = type ?: throw IllegalStateException("type must be set"), - function = function?.toFunctionCall() ?: throw IllegalStateException("function must be set"), - ) + internal fun toToolCall(): ToolCall { + // This is for chat, which is always a function tool call + return FunctionToolCall( + id = id ?: throw IllegalStateException("id must be set"), + function = function?.toFunctionCall() ?: throw IllegalStateException("function must be set"), + ) + } } \ No newline at end of file diff --git a/src/main/kotlin/com/cjcrafter/openai/chat/tool/ToolChoice.kt b/src/main/kotlin/com/cjcrafter/openai/chat/tool/ToolChoice.kt old mode 100644 new mode 100755 index b6acc9f..79ad50f --- a/src/main/kotlin/com/cjcrafter/openai/chat/tool/ToolChoice.kt +++ b/src/main/kotlin/com/cjcrafter/openai/chat/tool/ToolChoice.kt @@ -1,10 +1,21 @@ package com.cjcrafter.openai.chat.tool +import com.cjcrafter.openai.chat.ChatRequest import com.cjcrafter.openai.jackson.ToolChoiceDeserializer import com.cjcrafter.openai.jackson.ToolChoiceSerializer /** - * Represents the configuration for tool choice. Defaults to [Auto]. + * Sometimes, you may want chat to be forced to use a tool. Sometimes you may + * want to prevent chat from using a tool. This sealed class represents all + * options that can be used with the Chat endpoint. + * + * In general, you should use [ToolChoice.Auto] unless you have a specific + * reason not to. + * + * Use the helper methods in the chat request builder: + * * [ChatRequest.Builder.useAutoTool] -> [ToolChoice.Auto] + * * [ChatRequest.Builder.useNoTool] -> [ToolChoice.None] + * * [ChatRequest.Builder.useFunctionTool] -> [ToolChoice.Function] */ sealed class ToolChoice { @@ -12,12 +23,12 @@ sealed class ToolChoice { * Lets ChatGPT automatically decide whether to use a tool, and which tool * to use. This is the default value (when `toolChoice` is `null`). */ - object Auto : ToolChoice() + data object Auto : ToolChoice() /** * Prevents ChatGPT from using any tool. */ - object None : ToolChoice() + data object None : ToolChoice() /** * Forces ChatGPT to use the specified function. diff --git a/src/main/kotlin/com/cjcrafter/openai/chat/tool/ToolType.kt b/src/main/kotlin/com/cjcrafter/openai/chat/tool/ToolType.kt deleted file mode 100644 index f4fb391..0000000 --- a/src/main/kotlin/com/cjcrafter/openai/chat/tool/ToolType.kt +++ /dev/null @@ -1,18 +0,0 @@ -package com.cjcrafter.openai.chat.tool - -import com.fasterxml.jackson.annotation.JsonProperty - -/** - * Represents the type of tool. Currently, the only type of tool is a function. - * In the future, this may include Data Analysis and DALL-E. - */ -enum class ToolType { - - /** - * A tool that calls a function. - * - * @see FunctionTool - */ - @JsonProperty("function") - FUNCTION; -} \ No newline at end of file diff --git a/src/main/kotlin/com/cjcrafter/openai/completions/CompletionChoice.kt b/src/main/kotlin/com/cjcrafter/openai/completions/CompletionChoice.kt old mode 100644 new mode 100755 diff --git a/src/main/kotlin/com/cjcrafter/openai/completions/CompletionChoiceChunk.kt b/src/main/kotlin/com/cjcrafter/openai/completions/CompletionChoiceChunk.kt old mode 100644 new mode 100755 diff --git a/src/main/kotlin/com/cjcrafter/openai/completions/CompletionRequest.kt b/src/main/kotlin/com/cjcrafter/openai/completions/CompletionRequest.kt old mode 100644 new mode 100755 diff --git a/src/main/kotlin/com/cjcrafter/openai/completions/CompletionRequestDsl.kt b/src/main/kotlin/com/cjcrafter/openai/completions/CompletionRequestDsl.kt old mode 100644 new mode 100755 diff --git a/src/main/kotlin/com/cjcrafter/openai/completions/CompletionResponse.kt b/src/main/kotlin/com/cjcrafter/openai/completions/CompletionResponse.kt old mode 100644 new mode 100755 diff --git a/src/main/kotlin/com/cjcrafter/openai/completions/CompletionResponseChunk.kt b/src/main/kotlin/com/cjcrafter/openai/completions/CompletionResponseChunk.kt old mode 100644 new mode 100755 diff --git a/src/main/kotlin/com/cjcrafter/openai/completions/CompletionUsage.kt b/src/main/kotlin/com/cjcrafter/openai/completions/CompletionUsage.kt old mode 100644 new mode 100755 diff --git a/src/main/kotlin/com/cjcrafter/openai/embeddings/Embedding.kt b/src/main/kotlin/com/cjcrafter/openai/embeddings/Embedding.kt old mode 100644 new mode 100755 diff --git a/src/main/kotlin/com/cjcrafter/openai/embeddings/EmbeddingsRequest.kt b/src/main/kotlin/com/cjcrafter/openai/embeddings/EmbeddingsRequest.kt old mode 100644 new mode 100755 diff --git a/src/main/kotlin/com/cjcrafter/openai/embeddings/EmbeddingsRequestDsl.kt b/src/main/kotlin/com/cjcrafter/openai/embeddings/EmbeddingsRequestDsl.kt old mode 100644 new mode 100755 diff --git a/src/main/kotlin/com/cjcrafter/openai/embeddings/EmbeddingsResponse.kt b/src/main/kotlin/com/cjcrafter/openai/embeddings/EmbeddingsResponse.kt old mode 100644 new mode 100755 diff --git a/src/main/kotlin/com/cjcrafter/openai/embeddings/EmbeddingsUsage.kt b/src/main/kotlin/com/cjcrafter/openai/embeddings/EmbeddingsUsage.kt old mode 100644 new mode 100755 diff --git a/src/main/kotlin/com/cjcrafter/openai/embeddings/EncodingFormat.kt b/src/main/kotlin/com/cjcrafter/openai/embeddings/EncodingFormat.kt old mode 100644 new mode 100755 diff --git a/src/main/kotlin/com/cjcrafter/openai/exception/HallucinationException.kt b/src/main/kotlin/com/cjcrafter/openai/exception/HallucinationException.kt old mode 100644 new mode 100755 diff --git a/src/main/kotlin/com/cjcrafter/openai/files/FileDeletionStatus.kt b/src/main/kotlin/com/cjcrafter/openai/files/FileDeletionStatus.kt old mode 100644 new mode 100755 diff --git a/src/main/kotlin/com/cjcrafter/openai/files/FileHandler.kt b/src/main/kotlin/com/cjcrafter/openai/files/FileHandler.kt new file mode 100755 index 0000000..a390d54 --- /dev/null +++ b/src/main/kotlin/com/cjcrafter/openai/files/FileHandler.kt @@ -0,0 +1,97 @@ +package com.cjcrafter.openai.files + +import org.jetbrains.annotations.Contract + +/** + * Represents the handler for the files endpoint. This class holds all the + * actions that can be performed on a file. + */ +interface FileHandler { + + /** + * Uploads the given file. Returns the data associated with the file, + * including the id (the id is useful for fine-tuning and assistants). + * + * By default, an organization can have up to 100gb of files. If you need + * more space, you can contact OpenAI support. Individual files can be up + * to 512mb in size. + * + * @param request The request to upload the file + * @return The uploaded file + */ + fun upload(request: UploadFileRequest): FileObject + + /** + * Retrieves the file associated with the given file. This method will + * return a new instance of the file, and will not modify the given instance. + * + * This method is practically useless, as files cannot be modified. + * + * @param file The file to retrieve + * @return The new instance of the retrieved file + */ + @Contract(pure = true) + fun retrieve(file: FileObject): FileObject = retrieve(file.id) + + /** + * Retrieve the file with the given id. + * + * @param id The id of the file to retrieve + * @return The file with the given id + */ + @Contract(pure = true) + fun retrieve(id: String): FileObject + + /** + * Retrieves the content of the given file. + * + * @param file The file to retrieve the content of + * @return The content of the file + */ + @Contract(pure = true) + fun retrieveContents(file: FileObject): String = retrieveContents(file.id) + + /** + * Retrieves the content of the file with the given id. + * + * @param id The id of the file to retrieve the content of + * @return The content of the file + */ + @Contract(pure = true) + fun retrieveContents(id: String): String + + /** + * Attempts to delete the given file. To confirm deletion, you should + * check [FileDeletionStatus.deleted]. + * + * @param file The assistant to delete + * @return The deletion status of the assistant + */ + fun delete(file: FileObject): FileDeletionStatus = delete(file.id) + + /** + * Attempts to delete the file with the given id. To confirm deletion, + * you should check [FileDeletionStatus.deleted]. + * + * @param id The id of the file to delete + * @return The deletion status of the file + */ + fun delete(id: String): FileDeletionStatus + + /** + * Lists the 20 most recent files. + * + * @return The list of files + */ + @Contract(pure = true) + fun list(): ListFilesResponse = list(null) + + /** + * Lists files with the given query parameters. + * + * @param request The query parameters + * @return The list of assistants + */ + @Contract(pure = true) + fun list(request: ListFilesRequest?): ListFilesResponse // Cannot use @JvmOverloads in interfaces +} \ No newline at end of file diff --git a/src/main/kotlin/com/cjcrafter/openai/files/FileHandlerImpl.kt b/src/main/kotlin/com/cjcrafter/openai/files/FileHandlerImpl.kt new file mode 100755 index 0000000..9c4cb7f --- /dev/null +++ b/src/main/kotlin/com/cjcrafter/openai/files/FileHandlerImpl.kt @@ -0,0 +1,33 @@ +package com.cjcrafter.openai.files + +import com.cjcrafter.openai.RequestHelper + +class FileHandlerImpl( + private val requestHelper: RequestHelper, + private val endpoint: String +): FileHandler { + override fun upload(request: UploadFileRequest): FileObject { + val httpRequest = requestHelper.buildRequest(request, endpoint).addHeader("OpenAI-Beta", "assistants=v1").build() + return requestHelper.executeRequest(httpRequest, FileObject::class.java) + } + + override fun retrieve(id: String): FileObject { + val httpRequest = requestHelper.buildRequestNoBody("$endpoint/$id").addHeader("OpenAI-Beta", "assistants=v1").get().build() + return requestHelper.executeRequest(httpRequest, FileObject::class.java) + } + + override fun retrieveContents(id: String): String { + val httpRequest = requestHelper.buildRequestNoBody("$endpoint/$id/content").addHeader("OpenAI-Beta", "assistants=v1").get().build() + return requestHelper.executeRequest(httpRequest) + } + + override fun delete(id: String): FileDeletionStatus { + val httpRequest = requestHelper.buildRequestNoBody("$endpoint/$id").addHeader("OpenAI-Beta", "assistants=v1").delete().build() + return requestHelper.executeRequest(httpRequest, FileDeletionStatus::class.java) + } + + override fun list(request: ListFilesRequest?): ListFilesResponse { + val httpRequest = requestHelper.buildRequestNoBody(endpoint, request?.toMap()).addHeader("OpenAI-Beta", "assistants=v1").get().build() + return requestHelper.executeRequest(httpRequest, ListFilesResponse::class.java) + } +} \ No newline at end of file diff --git a/src/main/kotlin/com/cjcrafter/openai/files/FileObject.kt b/src/main/kotlin/com/cjcrafter/openai/files/FileObject.kt old mode 100644 new mode 100755 diff --git a/src/main/kotlin/com/cjcrafter/openai/files/FilePurpose.kt b/src/main/kotlin/com/cjcrafter/openai/files/FilePurpose.kt old mode 100644 new mode 100755 diff --git a/src/main/kotlin/com/cjcrafter/openai/files/ListFilesRequest.kt b/src/main/kotlin/com/cjcrafter/openai/files/ListFilesRequest.kt old mode 100644 new mode 100755 diff --git a/src/main/kotlin/com/cjcrafter/openai/files/ListFilesRequestDsl.kt b/src/main/kotlin/com/cjcrafter/openai/files/ListFilesRequestDsl.kt old mode 100644 new mode 100755 diff --git a/src/main/kotlin/com/cjcrafter/openai/files/ListFilesResponse.kt b/src/main/kotlin/com/cjcrafter/openai/files/ListFilesResponse.kt old mode 100644 new mode 100755 index 2710116..9033adb --- a/src/main/kotlin/com/cjcrafter/openai/files/ListFilesResponse.kt +++ b/src/main/kotlin/com/cjcrafter/openai/files/ListFilesResponse.kt @@ -6,6 +6,7 @@ import com.fasterxml.jackson.annotation.JsonProperty * Represents a response from the [list files](https://platform.openai.com/docs/api-reference/files) * endpoint. * + * @property hasMore Whether there are more files to retrieve from the API. * @property data The list of files. */ data class ListFilesResponse( diff --git a/src/main/kotlin/com/cjcrafter/openai/files/UploadFileRequest.kt b/src/main/kotlin/com/cjcrafter/openai/files/UploadFileRequest.kt old mode 100644 new mode 100755 diff --git a/src/main/kotlin/com/cjcrafter/openai/files/UploadFileRequestDsl.kt b/src/main/kotlin/com/cjcrafter/openai/files/UploadFileRequestDsl.kt old mode 100644 new mode 100755 diff --git a/src/main/kotlin/com/cjcrafter/openai/jackson/ToolChoiceSerializers.kt b/src/main/kotlin/com/cjcrafter/openai/jackson/ToolChoiceSerializers.kt old mode 100644 new mode 100755 diff --git a/src/main/kotlin/com/cjcrafter/openai/threads/CreateThreadRequest.kt b/src/main/kotlin/com/cjcrafter/openai/threads/CreateThreadRequest.kt new file mode 100755 index 0000000..7eba497 --- /dev/null +++ b/src/main/kotlin/com/cjcrafter/openai/threads/CreateThreadRequest.kt @@ -0,0 +1,102 @@ +package com.cjcrafter.openai.threads + +import com.cjcrafter.openai.threads.message.CreateThreadMessageRequest +import com.cjcrafter.openai.threads.message.ThreadUser +import com.cjcrafter.openai.util.BuilderHelper +import com.cjcrafter.openai.util.OpenAIDslMarker + +/** + * Represents a request to create a new [Thread]. + * + * @property messages The messages to send to the thread + * @property metadata The metadata to add to the thread + */ +data class CreateThreadRequest( + val messages: MutableList? = null, + val metadata: MutableMap? = null, +) { + @OpenAIDslMarker + class Builder internal constructor() { + private var messages: MutableList? = null + private var metadata: MutableMap? = null + + /** + * Sets which messages are already present in the thread. + * + * @param messages The messages to set + * @throws IllegalArgumentException if any of the messages are not user messages, + * or if any of the metadata keys or values are too long + */ + fun messages(messages: MutableList) = apply { + for (message in messages) + checkMessage(message) + + this.messages = messages + } + + /** + * Sets the metadata for the thread. Useful for developers. + * + * @param metadata The metadata to set + * @throws IllegalArgumentException if any of the metadata keys or values are too long + */ + fun metadata(metadata: MutableMap) = apply { + BuilderHelper.assertMetadata(metadata) + this.metadata = metadata + } + + /** + * Adds a message to the thread. + * + * @param message The message to add + * @throws IllegalArgumentException if the message is not a user message, + * or if any of the metadata keys or values are too long + */ + fun addMessage(message: CreateThreadMessageRequest) = apply { + checkMessage(message) + if (messages == null) + messages = mutableListOf() + messages!!.add(message) + } + + /** + * Add metadata to the thread. Useful for developers. + * + * @param key The metadata key, 64 characters or less + * @param value The metadata value, 512 characters or less + */ + fun addMetadata(key: String, value: String) = apply { + BuilderHelper.assertMetadata(key, value) + if (metadata == null) + metadata = mutableMapOf() + BuilderHelper.tryAddMetadata(metadata!!) + metadata!![key] = value + } + + private fun checkMessage(message: CreateThreadMessageRequest) { + if (message.role != ThreadUser.USER) + throw IllegalArgumentException("Only user messages can be sent to the API") + + message.metadata?.let { BuilderHelper.assertMetadata(it) } + } + + /** + * Builds the [CreateThreadRequest] instance. + */ + fun build(): CreateThreadRequest { + return CreateThreadRequest( + messages = messages, + metadata = metadata, + ) + } + } + + companion object { + + /** + * Instantiates a new [CreateThreadRequest] builder instance. + */ + @JvmStatic + fun builder() = Builder() + } +} diff --git a/src/main/kotlin/com/cjcrafter/openai/threads/ModifyThreadRequest.kt b/src/main/kotlin/com/cjcrafter/openai/threads/ModifyThreadRequest.kt new file mode 100755 index 0000000..ce26381 --- /dev/null +++ b/src/main/kotlin/com/cjcrafter/openai/threads/ModifyThreadRequest.kt @@ -0,0 +1,46 @@ +package com.cjcrafter.openai.threads + +import com.cjcrafter.openai.util.BuilderHelper +import com.cjcrafter.openai.util.OpenAIDslMarker + +data class ModifyThreadRequest( + var metadata: MutableMap?, +) { + @OpenAIDslMarker + class Builder internal constructor() { + private var metadata: MutableMap? = null + + fun metadata(metadata: MutableMap) = apply { + BuilderHelper.assertMetadata(metadata) + this.metadata = metadata + } + + /** + * Add metadata to the thread. Useful for developers. + * + * @param key The metadata key, 64 characters or less + * @param value The metadata value, 512 characters or less + */ + fun addMetadata(key: String, value: String) = apply { + BuilderHelper.assertMetadata(key, value) + if (metadata == null) + metadata = mutableMapOf() + BuilderHelper.tryAddMetadata(metadata!!) + metadata!![key] = value + } + + /** + * Build the [ModifyThreadRequest]. + */ + fun build() = ModifyThreadRequest(metadata) + } + + companion object { + + /** + * Creates a new [ModifyThreadRequest] builder. + */ + @JvmStatic + fun builder() = Builder() + } +} \ No newline at end of file diff --git a/src/main/kotlin/com/cjcrafter/openai/threads/Thread.kt b/src/main/kotlin/com/cjcrafter/openai/threads/Thread.kt new file mode 100755 index 0000000..8ac94d6 --- /dev/null +++ b/src/main/kotlin/com/cjcrafter/openai/threads/Thread.kt @@ -0,0 +1,18 @@ +package com.cjcrafter.openai.threads + +import com.cjcrafter.openai.assistants.Assistant +import com.fasterxml.jackson.annotation.JsonProperty + +/** + * Represents a thread object returned by the OpenAI API. Threads are objects + * that contain a list of messages that can interacted with by an [Assistant]. + * + * @property id The id of the thread + * @property createdAt The unix timestamp of when the thread was created + * @property metadata The metadata associated with the thread + */ +data class Thread( + @JsonProperty(required = true) val id: String, + @JsonProperty("created_at", required = true) val createdAt: Int, + @JsonProperty(required = true) val metadata: Map, +) diff --git a/src/main/kotlin/com/cjcrafter/openai/threads/ThreadDeletionStatus.kt b/src/main/kotlin/com/cjcrafter/openai/threads/ThreadDeletionStatus.kt new file mode 100755 index 0000000..7731389 --- /dev/null +++ b/src/main/kotlin/com/cjcrafter/openai/threads/ThreadDeletionStatus.kt @@ -0,0 +1,12 @@ +package com.cjcrafter.openai.threads + +/** + * Represents the status of a thread deletion request. + * + * @property id The id of the thread that was deleted + * @property deleted Whether the thread was deleted + */ +data class ThreadDeletionStatus( + val id: String, + val deleted: Boolean, +) diff --git a/src/main/kotlin/com/cjcrafter/openai/threads/ThreadDsl.kt b/src/main/kotlin/com/cjcrafter/openai/threads/ThreadDsl.kt new file mode 100644 index 0000000..19f12c2 --- /dev/null +++ b/src/main/kotlin/com/cjcrafter/openai/threads/ThreadDsl.kt @@ -0,0 +1,52 @@ +package com.cjcrafter.openai.threads + +import com.cjcrafter.openai.threads.message.CreateThreadMessageRequest +import com.cjcrafter.openai.threads.message.MessageHandler +import com.cjcrafter.openai.threads.message.ThreadMessage +import com.cjcrafter.openai.threads.runs.CreateRunRequest +import com.cjcrafter.openai.threads.runs.Run +import com.cjcrafter.openai.threads.runs.RunHandler + +fun createThreadRequest(block: CreateThreadRequest.Builder.() -> Unit): CreateThreadRequest { + return CreateThreadRequest.builder().apply(block).build() +} + +fun ThreadHandler.create(block: CreateThreadRequest.Builder.() -> Unit): Thread { + val request = createThreadRequest(block) + return create(request) +} + +fun modifyThreadRequest(block: ModifyThreadRequest.Builder.() -> Unit): ModifyThreadRequest { + return ModifyThreadRequest.builder().apply(block).build() +} + +fun ThreadHandler.modify(thread: Thread, block: ModifyThreadRequest.Builder.() -> Unit): Thread { + return modify(thread.id, block) +} + +fun ThreadHandler.modify(id: String, block: ModifyThreadRequest.Builder.() -> Unit): Thread { + val request = modifyThreadRequest(block) + return modify(id, request) +} + +/* MESSAGES */ + +fun createThreadMessage(block: CreateThreadMessageRequest.Builder.() -> Unit): CreateThreadMessageRequest { + return CreateThreadMessageRequest.builder().apply(block).build() +} + +fun MessageHandler.create(block: CreateThreadMessageRequest.Builder.() -> Unit): ThreadMessage { + val request = createThreadMessage(block) + return create(request) +} + +/* RUNS */ + +fun createRunRequest(block: CreateRunRequest.Builder.() -> Unit): CreateRunRequest { + return CreateRunRequest.builder().apply(block).build() +} + +fun RunHandler.create(block: CreateRunRequest.Builder.() -> Unit): Run { + val request = createRunRequest(block) + return create(request) +} \ No newline at end of file diff --git a/src/main/kotlin/com/cjcrafter/openai/threads/ThreadHandler.kt b/src/main/kotlin/com/cjcrafter/openai/threads/ThreadHandler.kt new file mode 100755 index 0000000..86bd2c1 --- /dev/null +++ b/src/main/kotlin/com/cjcrafter/openai/threads/ThreadHandler.kt @@ -0,0 +1,135 @@ +package com.cjcrafter.openai.threads + +import com.cjcrafter.openai.threads.message.MessageHandler +import com.cjcrafter.openai.threads.runs.RunHandler +import org.jetbrains.annotations.Contract + +/** + * Handler used to interact with a [Thread] objects. + */ +interface ThreadHandler { + + /** + * Creates a new empty thread. + * + * @return The created thread + */ + fun create(): Thread = create(createThreadRequest { /* intentionally empty */ }) + + /** + * Creates a new thread with the given options. + * + * @param request The values of the thread to create + * @return The created thread + */ + fun create(request: CreateThreadRequest): Thread + + /** + * Retrieves the updated thread object from the given thread. + * + * This method returns a new thread object wrapper. The thread parameter is + * used only for [Thread.id]. This method is useful for getting updated + * information about a thread's status or values. + * + * @param thread The thread to retrieve + * @return The retrieved thread + */ + @Contract(pure = true) + fun retrieve(thread: Thread): Thread = retrieve(thread.id) + + /** + * Retrieves the thread with the given id. + * + * @param id The id of the thread to retrieve + * @return The retrieved thread + */ + @Contract(pure = true) + fun retrieve(id: String): Thread + + /** + * Deletes the given thread from the OpenAI API. + * + * You should **always** check the deletion status to ensure the thread was + * deleted successfully. After confirming deletion, you should discard all + * of your references to the thread, since they are now invalid. + * + * @param thread The thread to delete + * @return The deletion status + */ + fun delete(thread: Thread): ThreadDeletionStatus = delete(thread.id) + + /** + * Deletes the thread with the given id from the OpenAI API. + * + * You should **always** check the deletion status to ensure the thread was + * deleted successfully. After confirming deletion, you should discard all + * of your references to the thread, since they are now invalid. + * + * @param id The id of the thread to delete + * @return The deletion status + */ + fun delete(id: String): ThreadDeletionStatus + + /** + * Modifies the given thread to have the given updated values. + * + * This method returns a new thread object wrapper. The thread parameter is + * used only for [Thread.id]. After this request, you should discard all of + * your references to the thread, since they are now outdated. + * + * @param thread The thread to modify + * @param request The values to update the thread with + * @return The modified thread + */ + fun modify(thread: Thread, request: ModifyThreadRequest): Thread = modify(thread.id, request) + + /** + * Modifies the thread with the given id to have the given updated values. + * + * This method returns a new thread object wrapper. After this request, you + * should discard your references to the thread, since they are now outdated. + * + * @param id The id of the thread to modify + * @param request The values to update the thread with + * @return The modified thread + */ + fun modify(id: String, request: ModifyThreadRequest): Thread + + /** + * Returns a handler for interacting with the messages in the given thread. + * + * @param thread The thread to get the messages for + * @return The handler for interacting with the messages + */ + @Contract(pure = true) + fun messages(thread: Thread): MessageHandler = messages(thread.id) + + /** + * Returns a handler for interacting with the messages in the thread with + * the given id. + * + * @param threadId The id of the thread to get the messages for + * @return The handler for interacting with the messages + */ + @Contract(pure = true) + fun messages(threadId: String): MessageHandler + + /** + * Returns a handler for interacting with the runs in the given thread. + * + * @param thread The thread to get the runs for + * @return The handler for interacting with the runs + */ + @Contract(pure = true) + fun runs(thread: Thread): RunHandler = runs(thread.id) + + /** + * Returns a handler for interacting with the runs in the thread with + * the given id. + * + * @param threadId The id of the thread to get the runs for + * @return The handler for interacting with the runs + */ + @Contract(pure = true) + fun runs(threadId: String): RunHandler +} \ No newline at end of file diff --git a/src/main/kotlin/com/cjcrafter/openai/threads/ThreadHandlerImpl.kt b/src/main/kotlin/com/cjcrafter/openai/threads/ThreadHandlerImpl.kt new file mode 100755 index 0000000..5dff348 --- /dev/null +++ b/src/main/kotlin/com/cjcrafter/openai/threads/ThreadHandlerImpl.kt @@ -0,0 +1,46 @@ +package com.cjcrafter.openai.threads + +import com.cjcrafter.openai.RequestHelper +import com.cjcrafter.openai.threads.message.MessageHandler +import com.cjcrafter.openai.threads.message.MessageHandlerImpl +import com.cjcrafter.openai.threads.runs.RunHandler +import com.cjcrafter.openai.threads.runs.RunHandlerImpl + +class ThreadHandlerImpl( + private val requestHelper: RequestHelper, + private val endpoint: String, +): ThreadHandler { + override fun create(request: CreateThreadRequest): Thread { + val httpRequest = requestHelper.buildRequest(request, endpoint).addHeader("OpenAI-Beta", "assistants=v1").build() + return requestHelper.executeRequest(httpRequest, Thread::class.java) + } + + override fun retrieve(id: String): Thread { + val httpRequest = requestHelper.buildRequestNoBody("$endpoint/$id").addHeader("OpenAI-Beta", "assistants=v1").get().build() + return requestHelper.executeRequest(httpRequest, Thread::class.java) + } + + override fun delete(id: String): ThreadDeletionStatus { + val httpRequest = requestHelper.buildRequestNoBody("$endpoint/$id").addHeader("OpenAI-Beta", "assistants=v1").delete().build() + return requestHelper.executeRequest(httpRequest, ThreadDeletionStatus::class.java) + } + + override fun modify(id: String, request: ModifyThreadRequest): Thread { + val httpRequest = requestHelper.buildRequest(request, "$endpoint/$id").addHeader("OpenAI-Beta", "assistants=v1").build() + return requestHelper.executeRequest(httpRequest, Thread::class.java) + } + + private val messageHandlers = mutableMapOf() + override fun messages(threadId: String): MessageHandler { + return messageHandlers.getOrPut(threadId) { + MessageHandlerImpl(requestHelper, "$endpoint/$threadId/messages", threadId) + } + } + + private val runHandlers = mutableMapOf() + override fun runs(threadId: String): RunHandler { + return runHandlers.getOrPut(threadId) { + RunHandlerImpl(requestHelper, "$endpoint/$threadId/runs", threadId) + } + } +} \ No newline at end of file diff --git a/src/main/kotlin/com/cjcrafter/openai/threads/message/CreateThreadMessageRequest.kt b/src/main/kotlin/com/cjcrafter/openai/threads/message/CreateThreadMessageRequest.kt new file mode 100755 index 0000000..2aa5d96 --- /dev/null +++ b/src/main/kotlin/com/cjcrafter/openai/threads/message/CreateThreadMessageRequest.kt @@ -0,0 +1,118 @@ +package com.cjcrafter.openai.threads.message + +import com.cjcrafter.openai.files.FileObject +import com.cjcrafter.openai.util.BuilderHelper +import com.cjcrafter.openai.util.OpenAIDslMarker +import com.fasterxml.jackson.annotation.JsonProperty + +/** + * Represents a request to create a new message in a [Thread]. + * + * @property role The role of the user creating the message. + * @property content The content of the message. + * @property fileIds The IDs of the files to attach to the message. + * @property metadata The metadata to attach to the message. + */ +data class CreateThreadMessageRequest( + var role: ThreadUser, + var content: String, + @JsonProperty("file_ids") var fileIds: MutableList?, + var metadata: MutableMap?, +) { + @OpenAIDslMarker + class Builder internal constructor() { + private var role: ThreadUser? = null + private var content: String? = null + private var fileIds: MutableList? = null + private var metadata: MutableMap? = null + + /** + * Who is creating the message. This must always be [ThreadUser.USER]. + * + * @param role The user creating the message + */ + fun role(role: ThreadUser) = apply { + if (role != ThreadUser.USER) + throw IllegalArgumentException("role must be USER") + + this.role = role + } + + /** + * The content of the message. + * + * @param content The content of the message + */ + fun content(content: String) = apply { + this.content = content + } + + /** + * The IDs of the files to attach to the message. + * + * @param fileIds The IDs of the files to attach to the message + */ + fun fileIds(fileIds: MutableList) = apply { + this.fileIds = fileIds + } + + /** + * The metadata to attach to the message. + * + * @param metadata The metadata to attach to the message + */ + fun metadata(metadata: MutableMap) = apply { + BuilderHelper.assertMetadata(metadata) + this.metadata = metadata + } + + /** + * Adds a file to the list of files to attach to the message. + * + * @param file The file to attach to the message + */ + fun addFile(file: FileObject) = apply { + if (fileIds == null) fileIds = mutableListOf() + fileIds!!.add(file.id) + } + + /** + * Adds a file to the list of files to attach to the message. + * + * @param fileId The ID of the file to attach to the message + */ + fun addFile(fileId: String) = apply { + if (fileIds == null) fileIds = mutableListOf() + fileIds!!.add(fileId) + } + + /** + * Adds a metadata key-value pair to the message. + * + * @param key The key of the metadata + * @param value The value of the metadata + */ + fun addMetadata(key: String, value: String) = apply { + BuilderHelper.assertMetadata(key, value) + if (metadata == null) metadata = mutableMapOf() + BuilderHelper.tryAddMetadata(metadata!!) + metadata!![key] = value + } + + fun build() = CreateThreadMessageRequest( + role ?: throw IllegalArgumentException("role must be set"), + content ?: throw IllegalArgumentException("content must be set"), + fileIds, + metadata, + ) + } + + companion object { + + /** + * Returns a new [Builder] instance to create a [CreateThreadMessageRequest]. + */ + @JvmStatic + fun builder() = Builder() + } +} diff --git a/src/main/kotlin/com/cjcrafter/openai/threads/message/ImageContent.kt b/src/main/kotlin/com/cjcrafter/openai/threads/message/ImageContent.kt new file mode 100755 index 0000000..96c0f91 --- /dev/null +++ b/src/main/kotlin/com/cjcrafter/openai/threads/message/ImageContent.kt @@ -0,0 +1,14 @@ +package com.cjcrafter.openai.threads.message + +import com.fasterxml.jackson.annotation.JsonProperty + +data class ImageContent( + @JsonProperty("image_file", required = true) val imageFile: ImageFile, +): ThreadMessageContent() { + + override val type = Type.IMAGE_FILE + + data class ImageFile( + @JsonProperty("file_id", required = true) val fileId: String, + ) +} \ No newline at end of file diff --git a/src/main/kotlin/com/cjcrafter/openai/threads/message/ListMessageFilesRequest.kt b/src/main/kotlin/com/cjcrafter/openai/threads/message/ListMessageFilesRequest.kt new file mode 100755 index 0000000..3bb59e5 --- /dev/null +++ b/src/main/kotlin/com/cjcrafter/openai/threads/message/ListMessageFilesRequest.kt @@ -0,0 +1,51 @@ +package com.cjcrafter.openai.threads.message + +import com.cjcrafter.openai.AbstractListRequestBuilder +import com.cjcrafter.openai.ListOrder +import com.cjcrafter.openai.util.OpenAIDslMarker +import org.jetbrains.annotations.ApiStatus + +/** + * Represents a request to list all [MessageFile]s in a [ThreadMessage]. + * + * @property limit The maximum number of results to return, between 1 and 100 inclusive + * @property order The order to return the list in + * @property after The cursor to use for pagination + * @property before The cursor to use for pagination + */ +data class ListMessageFilesRequest( + var limit: Int? = null, + var order: ListOrder? = null, + var after: String? = null, + var before: String? = null, +) { + + /** + * Converts the request to a map of query parameters. + */ + @ApiStatus.Internal + fun toMap(): Map = buildMap { + if (limit != null) put("limit", limit!!) + if (order != null) put("order", order!!.jsonProperty) + if (after != null) put("after", after!!) + if (before != null) put("before", before!!) + } + + @OpenAIDslMarker + class Builder internal constructor() : AbstractListRequestBuilder() { + + /** + * Builds the [ListMessageFilesRequest] object. + */ + override fun build(): ListMessageFilesRequest = ListMessageFilesRequest(limit, order, after, before) + } + + companion object { + + /** + * Creates a new [ListMessageFilesRequest] builder. + */ + @JvmStatic + fun builder() = Builder() + } +} diff --git a/src/main/kotlin/com/cjcrafter/openai/threads/message/ListMessageFilesResponse.kt b/src/main/kotlin/com/cjcrafter/openai/threads/message/ListMessageFilesResponse.kt new file mode 100755 index 0000000..6008ab1 --- /dev/null +++ b/src/main/kotlin/com/cjcrafter/openai/threads/message/ListMessageFilesResponse.kt @@ -0,0 +1,20 @@ +package com.cjcrafter.openai.threads.message + +import com.fasterxml.jackson.annotation.JsonProperty + +/** + * A data class which represents a response from the OpenAI API containing a list of [MessageFile]s. + * + * When the list is empty, [firstId] and [lastId] will be `null`. + * + * @property data The list of [MessageFile]s + * @property firstId The ID of the first [MessageFile] in the list, or null + * @property lastId The ID of the last [MessageFile] in the list, or null + * @property hasMore Whether there are more [MessageFile]s to retrieve from the API + */ +data class ListMessageFilesResponse( + @JsonProperty(required = true) val data: List, + @JsonProperty("first_id") val firstId: String?, + @JsonProperty("last_id") val lastId: String?, + @JsonProperty("has_more", required = true) val hasMore: Boolean, +) diff --git a/src/main/kotlin/com/cjcrafter/openai/threads/message/ListThreadMessagesRequest.kt b/src/main/kotlin/com/cjcrafter/openai/threads/message/ListThreadMessagesRequest.kt new file mode 100755 index 0000000..a8c49fb --- /dev/null +++ b/src/main/kotlin/com/cjcrafter/openai/threads/message/ListThreadMessagesRequest.kt @@ -0,0 +1,55 @@ +package com.cjcrafter.openai.threads.message + +import com.cjcrafter.openai.AbstractListRequestBuilder +import com.cjcrafter.openai.ListOrder +import com.cjcrafter.openai.threads.Thread +import com.cjcrafter.openai.util.OpenAIDslMarker +import org.jetbrains.annotations.ApiStatus + +/** + * Represents a request to list [ThreadMessage]s in a [Thread]. If a thread has + * too many messages, you may need to use the [after] or [before] parameters to + * page through them via multiple requests. + * + * @property limit The maximum number of results to return, between 1 and 100 inclusive + * @property order The order to return the list in + * @property after The cursor to use for pagination + * @property before The cursor to use for pagination + * @constructor Create empty List thread messages request + */ +data class ListThreadMessagesRequest( + var limit: Int? = null, + var order: ListOrder? = null, + var after: String? = null, + var before: String? = null, +) { + + /** + * Converts the request to a map of query parameters. + */ + @ApiStatus.Internal + fun toMap(): Map = buildMap { + if (limit != null) put("limit", limit!!) + if (order != null) put("order", order!!.jsonProperty) + if (after != null) put("after", after!!) + if (before != null) put("before", before!!) + } + + @OpenAIDslMarker + class Builder internal constructor() : AbstractListRequestBuilder() { + + /** + * Builds the [ListThreadMessagesRequest] object. + */ + override fun build(): ListThreadMessagesRequest = ListThreadMessagesRequest(limit, order, after, before) + } + + companion object { + + /** + * Creates a new [ListThreadMessagesRequest] builder. + */ + @JvmStatic + fun builder() = Builder() + } +} \ No newline at end of file diff --git a/src/main/kotlin/com/cjcrafter/openai/threads/message/ListThreadMessagesResponse.kt b/src/main/kotlin/com/cjcrafter/openai/threads/message/ListThreadMessagesResponse.kt new file mode 100755 index 0000000..5e56c56 --- /dev/null +++ b/src/main/kotlin/com/cjcrafter/openai/threads/message/ListThreadMessagesResponse.kt @@ -0,0 +1,20 @@ +package com.cjcrafter.openai.threads.message + +import com.fasterxml.jackson.annotation.JsonProperty + +/** + * A data class which represents a response from the OpenAI API containing a list of [ThreadMessage]s. + * + * If the list is empty, [firstId] and [lastId] will be `null`. + * + * @property data The list of thread messages + * @property firstId The id of the first message in the list, or null + * @property lastId The id of the last message in the list, or null + * @property hasMore Whether there are more messages to retrieve + */ +data class ListThreadMessagesResponse( + @JsonProperty(required = true) val data: List, + @JsonProperty("first_id") val firstId: String?, + @JsonProperty("last_id") val lastId: String?, + @JsonProperty("has_more", required = true) val hasMore: Boolean, +) \ No newline at end of file diff --git a/src/main/kotlin/com/cjcrafter/openai/threads/message/MessageFile.kt b/src/main/kotlin/com/cjcrafter/openai/threads/message/MessageFile.kt new file mode 100755 index 0000000..4534505 --- /dev/null +++ b/src/main/kotlin/com/cjcrafter/openai/threads/message/MessageFile.kt @@ -0,0 +1,16 @@ +package com.cjcrafter.openai.threads.message + +import com.fasterxml.jackson.annotation.JsonProperty + +/** + * Represents a file attached to a [ThreadMessage]. + * + * @property id The ID of the file, which can be used in endpoints + * @property createdAt The timestamp of when the file was created + * @property messageId The ID of the message that this file is attached to + */ +data class MessageFile( + @JsonProperty(required = true) val id: String, + @JsonProperty("created_at", required = true) val createdAt: Int, + @JsonProperty("message_id", required = true) val messageId: String, +) diff --git a/src/main/kotlin/com/cjcrafter/openai/threads/message/MessageFileHandler.kt b/src/main/kotlin/com/cjcrafter/openai/threads/message/MessageFileHandler.kt new file mode 100755 index 0000000..1da8b6f --- /dev/null +++ b/src/main/kotlin/com/cjcrafter/openai/threads/message/MessageFileHandler.kt @@ -0,0 +1,55 @@ +package com.cjcrafter.openai.threads.message + +import com.cjcrafter.openai.threads.Thread +import org.jetbrains.annotations.Contract + +/** + * Handler used to interact with a [MessageFile] objects. + */ +interface MessageFileHandler { + + /** + * The id of the [Thread] that this handler is for. + */ + val threadId: String + + /** + * The id of the [ThreadMessage] that this handler is for. + */ + val messageId: String + + /** + * Retrieves the file. + * + * @param file The file to retrieve + * @return The retrieved file + */ + @Contract(pure = true) + fun retrieve(file: MessageFile): MessageFile = retrieve(file.id) + + /** + * Retrieves the file with the given id. + * + * @param fileId The id of the file to retrieve + * @return The retrieved file + */ + @Contract(pure = true) + fun retrieve(fileId: String): MessageFile + + /** + * Lists the 20 most recent files in the message. + * + * @return The list of files + */ + @Contract(pure = true) + fun list(): ListMessageFilesResponse = list(null) + + /** + * Lists the files in the message. + * + * @param request The request to use for listing the files + * @return The list of files + */ + @Contract(pure = true) + fun list(request: ListMessageFilesRequest? = null): ListMessageFilesResponse +} \ No newline at end of file diff --git a/src/main/kotlin/com/cjcrafter/openai/threads/message/MessageFileHandlerImpl.kt b/src/main/kotlin/com/cjcrafter/openai/threads/message/MessageFileHandlerImpl.kt new file mode 100755 index 0000000..b4639ec --- /dev/null +++ b/src/main/kotlin/com/cjcrafter/openai/threads/message/MessageFileHandlerImpl.kt @@ -0,0 +1,20 @@ +package com.cjcrafter.openai.threads.message + +import com.cjcrafter.openai.RequestHelper + +class MessageFileHandlerImpl( + private val requestHelper: RequestHelper, + private val endpoint: String, + override val threadId: String, + override val messageId: String, +): MessageFileHandler { + override fun retrieve(fileId: String): MessageFile { + val httpRequest = requestHelper.buildRequestNoBody("$endpoint/$fileId").addHeader("OpenAI-Beta", "assistants=v1").get().build() + return requestHelper.executeRequest(httpRequest, MessageFile::class.java) + } + + override fun list(request: ListMessageFilesRequest?): ListMessageFilesResponse { + val httpRequest = requestHelper.buildRequestNoBody(endpoint, request?.toMap()).addHeader("OpenAI-Beta", "assistants=v1").get().build() + return requestHelper.executeRequest(httpRequest, ListMessageFilesResponse::class.java) + } +} \ No newline at end of file diff --git a/src/main/kotlin/com/cjcrafter/openai/threads/message/MessageHandler.kt b/src/main/kotlin/com/cjcrafter/openai/threads/message/MessageHandler.kt new file mode 100755 index 0000000..33c1904 --- /dev/null +++ b/src/main/kotlin/com/cjcrafter/openai/threads/message/MessageHandler.kt @@ -0,0 +1,94 @@ +package com.cjcrafter.openai.threads.message + +import org.jetbrains.annotations.Contract + +/** + * Handler used to interact with a [ThreadMessage] objects. + */ +interface MessageHandler { + + /** + * The id of the [Thread] that this handler is for. + */ + val threadId: String + + /** + * Creates a new [ThreadMessage] object. + * + * @param request The values of the message to create + * @return The created message + */ + fun create(request: CreateThreadMessageRequest): ThreadMessage + + /** + * Retrieves the updated message object from the given message. + * + * @param msg The message to retrieve + * @return The retrieved message + */ + @Contract(pure = true) + fun retrieve(msg: ThreadMessage): ThreadMessage = retrieve(msg.id) + + /** + * Retrieves the message with the given id. + * + * @param id The id of the message to retrieve + * @return The retrieved message + */ + @Contract(pure = true) + fun retrieve(id: String): ThreadMessage + + /** + * Modifies the given message to have the given updated values. + * + * @param message The message to modify + * @param request The values to update the message with + * @return The modified message + */ + fun modify(message: ThreadMessage, request: ModifyThreadMessageRequest): ThreadMessage = modify(message.id, request) + + /** + * Modifies the message with the given id to have the given updated values. + * + * @param messageId The id of the message to modify + * @param request The values to update the message with + * @return The modified message + */ + fun modify(messageId: String, request: ModifyThreadMessageRequest): ThreadMessage + + /** + * Lists the 20 most recent messages in the thread. + * + * @return The list of messages + */ + @Contract(pure = true) + fun list(): ListThreadMessagesResponse = list(null) + + /** + * Lists messages in the thread. + * + * @param request The values to filter the messages by + * @return The list of messages + */ + @Contract(pure = true) + fun list(request: ListThreadMessagesRequest?): ListThreadMessagesResponse + + /** + * Returns a handler for interacting with the files in the given message. + * + * @param msg The message to get the files for + * @return The handler for interacting with the files + */ + @Contract(pure = true) + fun files(msg: ThreadMessage): MessageFileHandler = files(msg.id) + + /** + * Returns a handler for interacting with the files in the message with the + * given id. + * + * @param messageId The id of the message to get the files for + * @return The handler for interacting with the files + */ + @Contract(pure = true) + fun files(messageId: String): MessageFileHandler +} diff --git a/src/main/kotlin/com/cjcrafter/openai/threads/message/MessageHandlerImpl.kt b/src/main/kotlin/com/cjcrafter/openai/threads/message/MessageHandlerImpl.kt new file mode 100755 index 0000000..c48e414 --- /dev/null +++ b/src/main/kotlin/com/cjcrafter/openai/threads/message/MessageHandlerImpl.kt @@ -0,0 +1,37 @@ +package com.cjcrafter.openai.threads.message + +import com.cjcrafter.openai.RequestHelper + +class MessageHandlerImpl( + private val requestHelper: RequestHelper, + private val endpoint: String, + override val threadId: String, +): MessageHandler { + + override fun create(request: CreateThreadMessageRequest): ThreadMessage { + val httpRequest = requestHelper.buildRequest(request, endpoint).addHeader("OpenAI-Beta", "assistants=v1").build() + return requestHelper.executeRequest(httpRequest, ThreadMessage::class.java) + } + + override fun retrieve(id: String): ThreadMessage { + val httpRequest = requestHelper.buildRequestNoBody("$endpoint/$id").addHeader("OpenAI-Beta", "assistants=v1").get().build() + return requestHelper.executeRequest(httpRequest, ThreadMessage::class.java) + } + + override fun modify(messageId: String, request: ModifyThreadMessageRequest): ThreadMessage { + val httpRequest = requestHelper.buildRequest(request, "$endpoint/$messageId").addHeader("OpenAI-Beta", "assistants=v1").build() + return requestHelper.executeRequest(httpRequest, ThreadMessage::class.java) + } + + override fun list(request: ListThreadMessagesRequest?): ListThreadMessagesResponse { + val httpRequest = requestHelper.buildRequestNoBody(endpoint, request?.toMap()).addHeader("OpenAI-Beta", "assistants=v1").get().build() + return requestHelper.executeRequest(httpRequest, ListThreadMessagesResponse::class.java) + } + + private val fileHandlers = mutableMapOf() + override fun files(messageId: String): MessageFileHandler { + return fileHandlers.getOrPut(messageId) { + MessageFileHandlerImpl(requestHelper, "$endpoint/$messageId/files", threadId, messageId) + } + } +} \ No newline at end of file diff --git a/src/main/kotlin/com/cjcrafter/openai/threads/message/ModifyThreadMessageRequest.kt b/src/main/kotlin/com/cjcrafter/openai/threads/message/ModifyThreadMessageRequest.kt new file mode 100755 index 0000000..6bedd3a --- /dev/null +++ b/src/main/kotlin/com/cjcrafter/openai/threads/message/ModifyThreadMessageRequest.kt @@ -0,0 +1,44 @@ +package com.cjcrafter.openai.threads.message + +import com.cjcrafter.openai.util.BuilderHelper +import com.cjcrafter.openai.util.OpenAIDslMarker + +/** + * Represents a request to modify a [ThreadMessage]. + * + * @property metadata The new metadata map to override + */ +data class ModifyThreadMessageRequest( + var metadata: MutableMap +) { + @OpenAIDslMarker + class Builder internal constructor() { + private var metadata: MutableMap? = null + + /** + * Sets the new metadata map. + * + * @param metadata The new metadata map. + * @throws IllegalArgumentException If the metadata map has more than 16 entries, + * or any key has more than 64 characters, or any value has more than 512 characters + */ + fun metadata(metadata: MutableMap) = apply { + BuilderHelper.assertMetadata(metadata) + this.metadata = metadata + } + + /** + * Adds metadata to the metadata map. + * + * @param key The key, which must be <= 64 characters + * @param value The value, which must be <= 512 characters + * @throws IllegalArgumentException If the key or value is too long + */ + fun addMetadata(key: String, value: String) = apply { + BuilderHelper.assertMetadata(key, value) + if (metadata == null) metadata = mutableMapOf() + BuilderHelper.tryAddMetadata(metadata!!) + metadata!![key] = value + } + } +} \ No newline at end of file diff --git a/src/main/kotlin/com/cjcrafter/openai/threads/message/TextAnnotation.kt b/src/main/kotlin/com/cjcrafter/openai/threads/message/TextAnnotation.kt new file mode 100755 index 0000000..79ec192 --- /dev/null +++ b/src/main/kotlin/com/cjcrafter/openai/threads/message/TextAnnotation.kt @@ -0,0 +1,83 @@ +package com.cjcrafter.openai.threads.message + +import com.cjcrafter.openai.assistants.Assistant +import com.cjcrafter.openai.chat.tool.Tool.RetrievalTool +import com.cjcrafter.openai.chat.tool.Tool.CodeInterpreterTool +import com.fasterxml.jackson.annotation.JsonProperty +import com.fasterxml.jackson.annotation.JsonSubTypes +import com.fasterxml.jackson.annotation.JsonTypeInfo + +/** + * A [TextContent] created by an [Assistant] may contain annotations. + * + * Annotations will be present in the [TextContent.Text.value], and may look + * like this: + * - `【13†source】` + * - `sandbox:/mnt/data/file.csv` + * + * You should replace the text in the message content with however you would like + * to annotate your files. For example, you could replace the text with a link + * to the file, or you could replace the text with the file contents. + */ +@JsonTypeInfo(use = JsonTypeInfo.Id.NAME, include = JsonTypeInfo.As.PROPERTY, property = "type") +@JsonSubTypes( + JsonSubTypes.Type(TextAnnotation.FileCitation::class, name = "file_citation"), + JsonSubTypes.Type(TextAnnotation.FilePath::class, name = "file_path"), +) +sealed class TextAnnotation { + + /** + * File citations are created by the [RetrievalTool] and define references + * to a specific quote in a specific file that used by the [Assistant] to + * generate the response. + * + * @property text The text in the message content that needs to be replaced + * @property fileCitation The specific quote that was used in retrieval + * @property startIndex The index of the first character that needs to be replaced + * @property endIndex The index of the first character that does not need to be replaced + */ + data class FileCitation( + @JsonProperty(required = true) val text: String, + @JsonProperty("file_citation", required = true) val fileCitation: FileQuote, + @JsonProperty("start_index", required = true) val startIndex: Int, + @JsonProperty("end_index", required = true) val endIndex: Int, + ): TextAnnotation() { + + /** + * Holds data about the file quote generated by the retrieval tool. + * + * @property fileId The id of the file the quote was taken from + * @property quote The quote that was used to generate the response + */ + data class FileQuote( + @JsonProperty("file_id", required = true) val fileId: String, + @JsonProperty(required = true) val quote: String, + ) + } + + /** + * File paths are created by the [CodeInterpreterTool] and contain references + * to the files generated by the tool. + * + * @property text The text in the message content that needs to be replaced + * @property filePath The file that was generated by the code interpreter + * @property startIndex The index of the first character that needs to be replaced + * @property endIndex The index of the first character that does not need to be replaced + */ + data class FilePath( + @JsonProperty(required = true) val text: String, + @JsonProperty("file_path", required = true) val filePath: FileWrapper, + @JsonProperty("start_index", required = true) val startIndex: Int, + @JsonProperty("end_index", required = true) val endIndex: Int, + ): TextAnnotation() { + + /** + * Holds data about the file generated by the code interpreter. + * + * @property fileId The id of the file, can be used to retrieve the file + */ + data class FileWrapper( + @JsonProperty("file_id", required = true) val fileId: String, + ) + } +} \ No newline at end of file diff --git a/src/main/kotlin/com/cjcrafter/openai/threads/message/TextContent.kt b/src/main/kotlin/com/cjcrafter/openai/threads/message/TextContent.kt new file mode 100755 index 0000000..13eb995 --- /dev/null +++ b/src/main/kotlin/com/cjcrafter/openai/threads/message/TextContent.kt @@ -0,0 +1,27 @@ +package com.cjcrafter.openai.threads.message + +/** + * Represents the text output of a message. + * + * Note that you may need to handle text annotations. Check out [TextAnnotation] + * for more information. + * + * @property text The text content of the message + */ +data class TextContent( + val text: Text +) : ThreadMessageContent() { + + override val type = Type.TEXT + + /** + * Represents the text content of a message. + * + * @property value The text content of the message + * @property annotations The annotations of the text content + */ + data class Text( + val value: String, + val annotations: List + ) +} \ No newline at end of file diff --git a/src/main/kotlin/com/cjcrafter/openai/threads/message/ThreadMessage.kt b/src/main/kotlin/com/cjcrafter/openai/threads/message/ThreadMessage.kt new file mode 100755 index 0000000..96bb187 --- /dev/null +++ b/src/main/kotlin/com/cjcrafter/openai/threads/message/ThreadMessage.kt @@ -0,0 +1,28 @@ +package com.cjcrafter.openai.threads.message + +import com.fasterxml.jackson.annotation.JsonProperty + +/** + * Represents a message in a [Thread]. + * + * @property id The unique id of the message + * @property createdAt The unix timestamp of when the message was created + * @property threadId The id of the thread that this message belongs to + * @property role The role of the user that created the message + * @property content The content of the message + * @property assistantId The id of the assistant that this message belongs to, or null + * @property runId The id of the run that created this message, or null + * @property fileIds The ids of the files attached to this message + * @property metadata The metadata attached to this message + */ +data class ThreadMessage( + @JsonProperty(required = true) val id: String, + @JsonProperty("created_at", required = true) val createdAt: Int, + @JsonProperty("thread_id", required = true) val threadId: String, + @JsonProperty(required = true) val role: ThreadUser, + @JsonProperty(required = true) val content: List, + @JsonProperty("assistant_id") val assistantId: String?, + @JsonProperty("run_id") val runId: String?, + @JsonProperty("file_ids", required = true) val fileIds: List, + @JsonProperty(required = true) val metadata: Map, +) diff --git a/src/main/kotlin/com/cjcrafter/openai/threads/message/ThreadMessageContent.kt b/src/main/kotlin/com/cjcrafter/openai/threads/message/ThreadMessageContent.kt new file mode 100755 index 0000000..4f86c69 --- /dev/null +++ b/src/main/kotlin/com/cjcrafter/openai/threads/message/ThreadMessageContent.kt @@ -0,0 +1,37 @@ +package com.cjcrafter.openai.threads.message + +import com.fasterxml.jackson.annotation.JsonSubTypes +import com.fasterxml.jackson.annotation.JsonTypeInfo + +/** + * A sealed class which represents a message in a thread. + */ +@JsonTypeInfo(use = JsonTypeInfo.Id.NAME, include = JsonTypeInfo.As.PROPERTY, property = "type") +@JsonSubTypes( + JsonSubTypes.Type(TextContent::class, name = "text"), + JsonSubTypes.Type(ImageContent::class, name = "image_file"), +) +sealed class ThreadMessageContent { + + /** + * The type of content. + */ + abstract val type: Type + + enum class Type { + + /** + * A message containing text, usually as a markdown string. + * + * @see TextContent + */ + TEXT, + + /** + * A message containing an image, stored as a file id. + * + * @see ImageContent + */ + IMAGE_FILE, + } +} \ No newline at end of file diff --git a/src/main/kotlin/com/cjcrafter/openai/threads/message/ThreadUser.kt b/src/main/kotlin/com/cjcrafter/openai/threads/message/ThreadUser.kt new file mode 100755 index 0000000..b81b723 --- /dev/null +++ b/src/main/kotlin/com/cjcrafter/openai/threads/message/ThreadUser.kt @@ -0,0 +1,22 @@ +package com.cjcrafter.openai.threads.message + +import com.cjcrafter.openai.threads.Thread +import com.fasterxml.jackson.annotation.JsonProperty + +/** + * Represents a user that sends messages in a [Thread]. + */ +enum class ThreadUser { + + /** + * Marks messages from the user, or the client. + */ + @JsonProperty("user") + USER, + + /** + * Marks messages from the assistant, or the bot. + */ + @JsonProperty("assistant") + ASSISTANT, +} \ No newline at end of file diff --git a/src/main/kotlin/com/cjcrafter/openai/threads/runs/CreateRunRequest.kt b/src/main/kotlin/com/cjcrafter/openai/threads/runs/CreateRunRequest.kt new file mode 100644 index 0000000..ef60f73 --- /dev/null +++ b/src/main/kotlin/com/cjcrafter/openai/threads/runs/CreateRunRequest.kt @@ -0,0 +1,89 @@ +package com.cjcrafter.openai.threads.runs + +import com.cjcrafter.openai.assistants.Assistant +import com.cjcrafter.openai.chat.tool.Tool +import com.cjcrafter.openai.util.OpenAIDslMarker +import com.fasterxml.jackson.annotation.JsonProperty +import org.jetbrains.annotations.Contract + +/** + * A data class which represents a request to create a [Run]. + * + * @property assistantId The ID of the Assistant to use for the run + * @property model The ID of the model to override the assistant's default model + * @property instructions The instructions to override the assistant's default instructions + * @property tools The tools to override the assistant's default tools + * @property metadata The metadata to associate with the run + */ +data class CreateRunRequest( + @JsonProperty("assistant_id") var assistantId: String, + var model: String? = null, + var instructions: String? = null, + var tools: List? = null, + var metadata: Map? = null, +) { + + /** + * Builder for [CreateRunRequest]. + */ + @OpenAIDslMarker + class Builder internal constructor() { + + private var assistantId: String? = null + private var model: String? = null + private var instructions: String? = null + private var tools: MutableList? = null + private var metadata: MutableMap? = null + + /** + * Sets the assistant to use for the run. Shorthand for [assistantId]. + */ + fun assistant(assistant: Assistant) = apply { this.assistantId = assistant.id } + + /** + * Sets the assistant to use for the run. + */ + fun assistantId(assistantId: String) = apply { this.assistantId = assistantId } + + /** + * Sets the model to override the assistant's default model. + */ + fun model(model: String) = apply { this.model = model } + + /** + * Sets the instructions to override the assistant's default instructions. + */ + fun instructions(instructions: String) = apply { this.instructions = instructions } + + /** + * Sets the tools to override the assistant's default tools. + */ + fun tools(tools: MutableList) = apply { this.tools = tools } + + /** + * Sets the metadata to attach to the [Run]. + */ + fun metadata(metadata: MutableMap) = apply { this.metadata = metadata } + + /** + * Builds the [CreateRunRequest]. + */ + fun build() = CreateRunRequest( + assistantId = assistantId ?: throw IllegalStateException("assistantId must be set"), + model = model, + instructions = instructions, + tools = tools, + metadata = metadata, + ) + } + + companion object { + + /** + * Creates a new [Builder] for [CreateRunRequest]. + */ + @JvmStatic + @Contract(pure = true) + fun builder() = Builder() + } +} \ No newline at end of file diff --git a/src/main/kotlin/com/cjcrafter/openai/threads/runs/ListRunStepsRequest.kt b/src/main/kotlin/com/cjcrafter/openai/threads/runs/ListRunStepsRequest.kt new file mode 100644 index 0000000..d60912f --- /dev/null +++ b/src/main/kotlin/com/cjcrafter/openai/threads/runs/ListRunStepsRequest.kt @@ -0,0 +1,54 @@ +package com.cjcrafter.openai.threads.runs + +import com.cjcrafter.openai.AbstractListRequestBuilder +import com.cjcrafter.openai.ListOrder +import com.cjcrafter.openai.util.OpenAIDslMarker +import org.jetbrains.annotations.ApiStatus + +/** + * Represents a request to list all [RunStep]s in a [Run]. If a [Run] has too + * many steps, you may need to use the [after] or [before] parameters to page + * through them via multiple requests (Though it is extraordinarily rare to + * have more then 100 steps in 1 run). + * + * @property limit The maximum number of results to return, between 1 and 100 inclusive + * @property order The order to return the list in + * @property after The cursor to use for pagination + * @property before The cursor to use for pagination + */ +data class ListRunStepsRequest( + var limit: Int? = null, + var order: ListOrder? = null, + var after: String? = null, + var before: String? = null, +) { + + /** + * Converts the request to a map of query parameters. + */ + @ApiStatus.Internal + fun toMap(): Map = buildMap { + if (limit != null) put("limit", limit!!) + if (order != null) put("order", order!!.jsonProperty) + if (after != null) put("after", after!!) + if (before != null) put("before", before!!) + } + + @OpenAIDslMarker + class Builder internal constructor(): AbstractListRequestBuilder() { + + /** + * Builds the [ListRunsRequest] object. + */ + override fun build(): ListRunStepsRequest = ListRunStepsRequest(limit, order, after, before) + } + + companion object { + + /** + * Creates a new [ListRunsRequest] builder. + */ + @JvmStatic + fun builder(): Builder = Builder() + } +} \ No newline at end of file diff --git a/src/main/kotlin/com/cjcrafter/openai/threads/runs/ListRunStepsResponse.kt b/src/main/kotlin/com/cjcrafter/openai/threads/runs/ListRunStepsResponse.kt new file mode 100644 index 0000000..94c1b55 --- /dev/null +++ b/src/main/kotlin/com/cjcrafter/openai/threads/runs/ListRunStepsResponse.kt @@ -0,0 +1,20 @@ +package com.cjcrafter.openai.threads.runs + +import com.fasterxml.jackson.annotation.JsonProperty + +/** + * A data class which represents a response from the OpenAI API containing a list of [RunStep]s. + * + * When the list is empty, [firstId] and [lastId] will be `null`. + * + * @property data The list of [RunStep]s + * @property firstId The ID of the first [RunStep] in the list, or null + * @property lastId The ID of the last [RunStep] in the list, or null + * @property hasMore Whether there are more [RunStep]s to retrieve from the API + */ +data class ListRunStepsResponse( + @JsonProperty(required = true) val data: List, + @JsonProperty("first_id") val firstId: String?, + @JsonProperty("last_id") val lastId: String?, + @JsonProperty("has_more", required = true) val hasMore: Boolean, +) diff --git a/src/main/kotlin/com/cjcrafter/openai/threads/runs/ListRunsRequest.kt b/src/main/kotlin/com/cjcrafter/openai/threads/runs/ListRunsRequest.kt new file mode 100644 index 0000000..09bd446 --- /dev/null +++ b/src/main/kotlin/com/cjcrafter/openai/threads/runs/ListRunsRequest.kt @@ -0,0 +1,53 @@ +package com.cjcrafter.openai.threads.runs + +import com.cjcrafter.openai.AbstractListRequestBuilder +import com.cjcrafter.openai.ListOrder +import com.cjcrafter.openai.util.OpenAIDslMarker +import org.jetbrains.annotations.ApiStatus + +/** + * Represents a request to list all runs for a Thread. If a thread has too many + * runs, you may need to use the [after] or [before] parameters to page through + * them via multiple requests. + * + * @property limit The maximum number of results to return, between 1 and 100 inclusive + * @property order The order to return the list in + * @property after The cursor to use for pagination + * @property before The cursor to use for pagination + */ +data class ListRunsRequest( + var limit: Int? = null, + var order: ListOrder? = null, + var after: String? = null, + var before: String? = null, +) { + + /** + * Converts the request to a map of query parameters. + */ + @ApiStatus.Internal + fun toMap(): Map = buildMap { + if (limit != null) put("limit", limit!!) + if (order != null) put("order", order!!.jsonProperty) + if (after != null) put("after", after!!) + if (before != null) put("before", before!!) + } + + @OpenAIDslMarker + class Builder internal constructor(): AbstractListRequestBuilder() { + + /** + * Builds the [ListRunsRequest] object. + */ + override fun build(): ListRunsRequest = ListRunsRequest(limit, order, after, before) + } + + companion object { + + /** + * Creates a new [ListRunsRequest] builder. + */ + @JvmStatic + fun builder(): Builder = Builder() + } +} \ No newline at end of file diff --git a/src/main/kotlin/com/cjcrafter/openai/threads/runs/ListRunsResponse.kt b/src/main/kotlin/com/cjcrafter/openai/threads/runs/ListRunsResponse.kt new file mode 100644 index 0000000..f3a79d8 --- /dev/null +++ b/src/main/kotlin/com/cjcrafter/openai/threads/runs/ListRunsResponse.kt @@ -0,0 +1,20 @@ +package com.cjcrafter.openai.threads.runs + +import com.fasterxml.jackson.annotation.JsonProperty + +/** + * A data class which represents a response from the OpenAI API containing a list of [Run]s. + * + * When the list is empty, [firstId] and [lastId] will be `null`. + * + * @property data The list of [Run]s + * @property firstId The ID of the first [Run] in the list, or null + * @property lastId The ID of the last [Run] in the list, or null + * @property hasMore Whether there are more [Run]s to retrieve from the API + */ +data class ListRunsResponse( + @JsonProperty(required = true) val data: List, + @JsonProperty("first_id") val firstId: String?, + @JsonProperty("last_id") val lastId: String?, + @JsonProperty("has_more", required = true) val hasMore: Boolean, +) diff --git a/src/main/kotlin/com/cjcrafter/openai/threads/runs/MessageCreationDetails.kt b/src/main/kotlin/com/cjcrafter/openai/threads/runs/MessageCreationDetails.kt new file mode 100644 index 0000000..e610dda --- /dev/null +++ b/src/main/kotlin/com/cjcrafter/openai/threads/runs/MessageCreationDetails.kt @@ -0,0 +1,28 @@ +package com.cjcrafter.openai.threads.runs + +import com.cjcrafter.openai.threads.message.MessageHandler +import com.fasterxml.jackson.annotation.JsonProperty + +/** + * Represents the details of a [RunStep.Type.MESSAGE_CREATION] step. This + * stores the ID of the message that was created. You can use this ID to get + * the message object from the [MessageHandler.retrieve] function. + * + * @property messageCreation The data for the message creation + */ +data class MessageCreationDetails( + @JsonProperty("message_creation", required = true) val messageCreation: MessageCreation +) : RunStep.Details() { + + override val type = RunStep.Type.MESSAGE_CREATION + + /** + * Holds the ID of the message that was created. + * + * @property messageId The ID of the message that was created. You can use + * this ID to get the message object from the [MessageHandler.retrieve] function + */ + data class MessageCreation( + @JsonProperty("message_id", required = true) val messageId: String + ) +} \ No newline at end of file diff --git a/src/main/kotlin/com/cjcrafter/openai/threads/runs/ModifyRunRequest.kt b/src/main/kotlin/com/cjcrafter/openai/threads/runs/ModifyRunRequest.kt new file mode 100644 index 0000000..fd579fa --- /dev/null +++ b/src/main/kotlin/com/cjcrafter/openai/threads/runs/ModifyRunRequest.kt @@ -0,0 +1,45 @@ +package com.cjcrafter.openai.threads.runs + +import com.cjcrafter.openai.util.BuilderHelper +import com.cjcrafter.openai.util.OpenAIDslMarker + +/** + * A data class which represents a request to modify a [Run]. + * + * @property metadata The metadata to associate with the run + */ +data class ModifyRunRequest( + var metadata: MutableMap?, +) { + @OpenAIDslMarker + class Builder internal constructor() { + private var metadata: MutableMap? = null + + fun metadata(metadata: MutableMap) = apply { + BuilderHelper.assertMetadata(metadata) + this.metadata = metadata + } + + /** + * Add metadata to the run. Useful for attaching data to a run. + * + * @param key The metadata key, 64 characters or less + * @param value The metadata value, 512 characters or less + */ + fun addMetadata(key: String, value: String) = apply { + BuilderHelper.assertMetadata(key, value) + if (metadata == null) + metadata = mutableMapOf() + BuilderHelper.tryAddMetadata(metadata!!) + metadata!![key] = value + } + } + + companion object { + + /** + * Creates a new [Builder] for [ModifyRunRequest]. + */ + fun builder() = Builder() + } +} \ No newline at end of file diff --git a/src/main/kotlin/com/cjcrafter/openai/threads/runs/RequiredAction.kt b/src/main/kotlin/com/cjcrafter/openai/threads/runs/RequiredAction.kt new file mode 100644 index 0000000..3b7c39f --- /dev/null +++ b/src/main/kotlin/com/cjcrafter/openai/threads/runs/RequiredAction.kt @@ -0,0 +1,48 @@ +package com.cjcrafter.openai.threads.runs + +import com.cjcrafter.openai.chat.tool.ToolCall +import com.fasterxml.jackson.annotation.JsonProperty +import com.fasterxml.jackson.annotation.JsonSubTypes +import com.fasterxml.jackson.annotation.JsonTypeInfo + +/** + * Represents a required action for a run. This is used when the Assistant + * requests tool calls. In this future, this may be used for other required + * actions as well. + */ +@JsonTypeInfo(use = JsonTypeInfo.Id.NAME, include = JsonTypeInfo.As.PROPERTY, property = "type") +@JsonSubTypes( + JsonSubTypes.Type(RequiredAction.SubmitToolCallAction::class, name = "submit_tool_outputs"), +) +sealed class RequiredAction { + + /** + * Returns the type of the required action. This is effectively the same as + * checking the type via [Class.isInstance] (or `instanceof` in java, `is` + * in Kotlin). + */ + abstract val type: Type + + /** + * Represents the type of the required action. + */ + enum class Type { + + /** + * Used when the Assistant requests a tool call. + */ + @JsonProperty("submit_tool_calls") + SUBMIT_TOOL_CALLS, + } + + /** + * Represents a request by the Assistant for a list of tool calls. + * + * @property toolCalls The list of tool calls + */ + data class SubmitToolCallAction( + @JsonProperty("tool_calls", required = true) val toolCalls: List, + ): RequiredAction() { + override val type = Type.SUBMIT_TOOL_CALLS + } +} \ No newline at end of file diff --git a/src/main/kotlin/com/cjcrafter/openai/threads/runs/Run.kt b/src/main/kotlin/com/cjcrafter/openai/threads/runs/Run.kt new file mode 100644 index 0000000..463e973 --- /dev/null +++ b/src/main/kotlin/com/cjcrafter/openai/threads/runs/Run.kt @@ -0,0 +1,47 @@ +package com.cjcrafter.openai.threads.runs + +import com.cjcrafter.openai.chat.tool.Tool +import com.fasterxml.jackson.annotation.JsonProperty + +/** + * Represents a run object returned by the OpenAI API. The run object itself + * isn't highly useful for most applications, but it is used to retrieve the + * messages from the run via [RunStep]s. + * + * @property id The unique id of the run, used for retrieving the run + * @property createdAt The unix timestamp of when the run was created + * @property threadId The id of the thread that the run belongs to + * @property assistantId The id of the assistant which is handling the run + * @property status The current status of the run + * @property requiredAction The required action for the run, if any + * @property lastError The last error associated with the run, if any + * @property expiresAt The unix timestamp of when the run will expire + * @property startedAt The unix timestamp of when the run started + * @property cancelledAt The unix timestamp of when the run was cancelled + * @property failedAt The unix timestamp of when the run failed + * @property completedAt The unix timestamp of when the run completed + * @property model The model used for the run + * @property instructions The instructions used for the run + * @property tools The tools used for the run + * @property fileIds The file ids used for the run + * @property metadata The metadata associated with the run + */ +data class Run( + @JsonProperty(required = true) val id: String, + @JsonProperty("created_at", required = true) val createdAt: Int, + @JsonProperty("thread_id", required = true) val threadId: String, + @JsonProperty("assistant_id", required = true) val assistantId: String, + @JsonProperty(required = true) val status: RunStatus, + @JsonProperty("required_action") val requiredAction: RequiredAction?, + @JsonProperty("last_error") val lastError: RunError?, + @JsonProperty("expires_at", required = true) val expiresAt: Int, + @JsonProperty("started_at") val startedAt: Int?, + @JsonProperty("cancelled_at") val cancelledAt: Int?, + @JsonProperty("failed_at") val failedAt: Int?, + @JsonProperty("completed_at") val completedAt: Int?, + @JsonProperty(required = true) val model: String, + @JsonProperty(required = true) val instructions: String, + @JsonProperty(required = true) val tools: List, + @JsonProperty("file_ids", required = true) val fileIds: List, + @JsonProperty(required = true) val metadata: Map, +) diff --git a/src/main/kotlin/com/cjcrafter/openai/threads/runs/RunError.kt b/src/main/kotlin/com/cjcrafter/openai/threads/runs/RunError.kt new file mode 100644 index 0000000..9378470 --- /dev/null +++ b/src/main/kotlin/com/cjcrafter/openai/threads/runs/RunError.kt @@ -0,0 +1,23 @@ +package com.cjcrafter.openai.threads.runs + +import com.fasterxml.jackson.annotation.JsonProperty + +/** + * A data class which represents an error in a [Run]. + * + * @property code The reason a run failed + * @property message A human-readable description of the error + */ +data class RunError( + val code: ErrorCode, + val message: String, +) { + enum class ErrorCode { + + @JsonProperty("server_error") + SERVER_ERROR, + + @JsonProperty("rate_limit_exceeded") + RATE_LIMIT_EXCEEDED, + } +} \ No newline at end of file diff --git a/src/main/kotlin/com/cjcrafter/openai/threads/runs/RunHandler.kt b/src/main/kotlin/com/cjcrafter/openai/threads/runs/RunHandler.kt new file mode 100644 index 0000000..031c5d6 --- /dev/null +++ b/src/main/kotlin/com/cjcrafter/openai/threads/runs/RunHandler.kt @@ -0,0 +1,136 @@ +package com.cjcrafter.openai.threads.runs + +import org.jetbrains.annotations.Contract + +/** + * Handler used to interact with a [Run] objects. + */ +interface RunHandler { + + /** + * The ID of the thread this handler is for. + */ + val threadId: String + + /** + * Creates (and starts) a new [Run] object. + * + * @param request The values of the run to create + * @return The created run + */ + fun create(request: CreateRunRequest): Run + + /** + * Retrieves the updated run object from the given run. + * + * This method returns a new run object wrapper. The run parameter is + * used only for [Run.id]. This method is useful for getting updated + * information about a run's status or values. + * + * @param run The run to retrieve + * @return The retrieved run + */ + @Contract(pure = true) + fun retrieve(run: Run): Run = retrieve(run.id) + + /** + * Retrieves the run with the given id. + * + * @param id The id of the run to retrieve + * @return The retrieved run + */ + @Contract(pure = true) + fun retrieve(id: String): Run + + /** + * Modifies the given run to have the given updated values. + * + * @param run The run to modify + * @param request The values to update the run with + */ + fun modify(run: Run, request: ModifyRunRequest): Run = modify(run.id, request) + + /** + * Modifies the run with the given id to have the given updated values. + * + * @param id The id of the run to modify + * @param request The values to update the run with + */ + fun modify(id: String, request: ModifyRunRequest): Run + + /** + * Lists the 20 most recent runs for the thread. + * + * @return The list of runs + */ + @Contract(pure = true) + fun list(): ListRunsResponse = list(null) + + /** + * Lists runs for the thread. + * + * @param request The values to filter the runs by + * @return The list of runs + */ + @Contract(pure = true) + fun list(request: ListRunsRequest?): ListRunsResponse + + /** + * When a run has the status [RunStatus.REQUIRED_ACTION] and the action + * type is [RequiredAction.Type.SUBMIT_TOOL_CALLS], this method can be used + * to submit the outputs from the tool calls once they're all completed. + * All outputs must be submitted in a single request. + * + * @param run The run to submit the outputs for + * @param submission The tool outputs + * @return The updated run + */ + fun submitToolOutputs(run: Run, submission: SubmitToolOutputs): Run = submitToolOutputs(run.id, submission) + + /** + * When a run has the status [RunStatus.REQUIRED_ACTION] and the action + * type is [RequiredAction.Type.SUBMIT_TOOL_CALLS], this method can be used + * to submit the outputs from the tool calls once they're all completed. + * All outputs must be submitted in a single request. + * + * @param id The id of the run to submit the outputs for + * @param submission The tool outputs + * @return The updated run + */ + fun submitToolOutputs(id: String, submission: SubmitToolOutputs): Run + + /** + * Cancels the given run that is [RunStatus.IN_PROGRESS]. + * + * @param run The run to cancel + * @return The updated run + */ + fun cancel(run: Run): Run = cancel(run.id) + + /** + * Cancels the run with the given id that is [RunStatus.IN_PROGRESS]. + * + * @param id The id of the run to cancel + * @return The updated run + */ + fun cancel(id: String): Run + + /** + * Returns a handler for interacting with the steps in the given run. + * + * @param run The run to get the steps for + * @return The steps handler + */ + @Contract(pure = true) + fun steps(run: Run): RunStepHandler = steps(run.id) + + /** + * Returns a handler for interacting with the steps in the run with the + * given id. + * + * @param id The id of the run to get the steps for + * @return The steps handler + */ + @Contract(pure = true) + fun steps(id: String): RunStepHandler +} \ No newline at end of file diff --git a/src/main/kotlin/com/cjcrafter/openai/threads/runs/RunHandlerImpl.kt b/src/main/kotlin/com/cjcrafter/openai/threads/runs/RunHandlerImpl.kt new file mode 100644 index 0000000..46fc841 --- /dev/null +++ b/src/main/kotlin/com/cjcrafter/openai/threads/runs/RunHandlerImpl.kt @@ -0,0 +1,46 @@ +package com.cjcrafter.openai.threads.runs + +import com.cjcrafter.openai.RequestHelper + +class RunHandlerImpl( + private val requestHelper: RequestHelper, + private val endpoint: String, + override val threadId: String, +): RunHandler { + override fun create(request: CreateRunRequest): Run { + val httpRequest = requestHelper.buildRequest(request, endpoint).addHeader("OpenAI-Beta", "assistants=v1").build() + return requestHelper.executeRequest(httpRequest, Run::class.java) + } + + override fun retrieve(id: String): Run { + val httpRequest = requestHelper.buildRequestNoBody("$endpoint/$id").addHeader("OpenAI-Beta", "assistants=v1").get().build() + return requestHelper.executeRequest(httpRequest, Run::class.java) + } + + override fun modify(id: String, request: ModifyRunRequest): Run { + val httpRequest = requestHelper.buildRequest(request, "$endpoint/$id").addHeader("OpenAI-Beta", "assistants=v1").build() + return requestHelper.executeRequest(httpRequest, Run::class.java) + } + + override fun list(request: ListRunsRequest?): ListRunsResponse { + val httpRequest = requestHelper.buildRequestNoBody(endpoint, request?.toMap()).addHeader("OpenAI-Beta", "assistants=v1").get().build() + return requestHelper.executeRequest(httpRequest, ListRunsResponse::class.java) + } + + override fun submitToolOutputs(id: String, submission: SubmitToolOutputs): Run { + val httpRequest = requestHelper.buildRequest(submission, "$endpoint/$id/submit_tool_outputs").addHeader("OpenAI-Beta", "assistants=v1").build() + return requestHelper.executeRequest(httpRequest, Run::class.java) + } + + override fun cancel(id: String): Run { + val httpRequest = requestHelper.buildRequestNoBody("$endpoint/$id/cancel").addHeader("OpenAI-Beta", "assistants=v1").method("POST", null).build() + return requestHelper.executeRequest(httpRequest, Run::class.java) + } + + private val stepHandlers = mutableMapOf() + override fun steps(id: String): RunStepHandler { + return stepHandlers.getOrPut(id) { + RunStepHandlerImpl(requestHelper, "$endpoint/$id/steps", threadId, id) + } + } +} \ No newline at end of file diff --git a/src/main/kotlin/com/cjcrafter/openai/threads/runs/RunStatus.kt b/src/main/kotlin/com/cjcrafter/openai/threads/runs/RunStatus.kt new file mode 100644 index 0000000..14f61a1 --- /dev/null +++ b/src/main/kotlin/com/cjcrafter/openai/threads/runs/RunStatus.kt @@ -0,0 +1,87 @@ +package com.cjcrafter.openai.threads.runs + +import com.cjcrafter.openai.assistants.Assistant +import com.cjcrafter.openai.chat.tool.Tool +import com.cjcrafter.openai.threads.Thread +import com.cjcrafter.openai.threads.message.ThreadMessage +import com.fasterxml.jackson.annotation.JsonProperty + +/** + * Represents the current state of a [Run]. You can view the current status of + * a run by looking at the [Run.status] field. + * + * @property isTerminal Whether the OpenAI API is "done," and expecting you to + * take some action. + */ +enum class RunStatus( + val isTerminal: Boolean, +) { + + /** + * When [Run]s are first created or when you complete the [RequiredAction], + * they are moved to this 'queued' status. They should _almost immediately_ + * move to [IN_PROGRESS]. + */ + @JsonProperty("queued") + QUEUED(false), + + /** + * While [IN_PROGRESS], the [Assistant] uses the mode and tools to perform + * steps. You can view progress being made by the [Run] by examining the + * [RunStep]s. + */ + @JsonProperty("in_progress") + IN_PROGRESS(false), + + /** + * When using the [Tool.FunctionTool], the [Run] will move to a [REQUIRED_ACTION] + * state once the model determines the names and arguments of the functions + * to be called. You must then run those functions and submit the outputs + * using [RunHandler.submitToolOutputs] before the run proceeds. If the + * outputs are not provided before the [Run.expiresAt] timestamp passes + * (roughly 10 minutes past creation), the run will move to an [EXPIRED] + * status. + */ + @JsonProperty("required_action") + REQUIRED_ACTION(true), + + /** + * You can attempt to cancel an [IN_PROGRESS] [Run] using [RunHandler.cancel]. + * Once the attempt to cancel succeeds, status of the [Run] moves to + * [CANCELLED]. Cancellation is attempted but not guarenteed. + */ + @JsonProperty("cancelling") + CANCELLING(false), + + /** + * The [Run] was successfully cancelled. + */ + @JsonProperty("cancelled") + CANCELLED(true), + + /** + * You can view the reason for the failure by looking at the [Run.lastError] + * object in the run (see [RunError]). The timestamp for the failure is + * recorded under the [Run.failedAt]. + */ + @JsonProperty("failed") + FAILED(true), + + /** + * The [Run] successfully completed! You can now view all [ThreadMessage]s + * the [Assistant] added to the [Thread], and all the steps the [Run] took. + * You can also continue the conversation by adding more [ThreadMessage]s + * to the [Thread] and creating another [Run]. + */ + @JsonProperty("completed") + COMPLETED(true), + + /** + * This happens when the function calling outputs were not submitted before + * [Run.expiresAt] and the [Run] expires. Additionally, if the runs take + * too long to execute and go beyond the time stated in [Run.expiresAt], + * OpenAI's systems will expire the [Run]. + */ + @JsonProperty("expired") + EXPIRED(true), +} \ No newline at end of file diff --git a/src/main/kotlin/com/cjcrafter/openai/threads/runs/RunStep.kt b/src/main/kotlin/com/cjcrafter/openai/threads/runs/RunStep.kt new file mode 100644 index 0000000..5f246e8 --- /dev/null +++ b/src/main/kotlin/com/cjcrafter/openai/threads/runs/RunStep.kt @@ -0,0 +1,83 @@ +package com.cjcrafter.openai.threads.runs + +import com.cjcrafter.openai.assistants.Assistant +import com.cjcrafter.openai.chat.tool.Tool +import com.cjcrafter.openai.threads.runs.RunStatus.* +import com.fasterxml.jackson.annotation.JsonProperty +import com.fasterxml.jackson.annotation.JsonSubTypes +import com.fasterxml.jackson.annotation.JsonTypeInfo + +/** + * Each [Run] is broken down into steps. It is common for a [Run] to only have + * 1 step; creating a message. But with tool calls involved, an [Assistant] can + * create an arbitrary number of steps. + * + * @property id The unique id of this step, which can be used in [RunStepHandler.retrieve]. + * Always starts with 'step_'. This is different from [Run.id], which is also stored in this class as [runId] + * @property createdAt The unix timestamp of when this step was created + * @property assistantId The ID of the [Assistant] that created this step + * @property threadId The ID of the [Thread] that this step was created in + * @property runId The ID of the [Run] that this step was created in + * @property type The type of data stored by this step. This will match [Details.type] stored in [stepDetails] + * @property status The status of this step (Can be either [IN_PROGRESS], [CANCELLED], [FAILED], [COMPLETED], or [EXPIRED]) + * @property stepDetails The data generated at this step. The type of this data is determined by [type]. + * Make sure to check the type of this data before using it. + * @property lastError The last error associated with this run step. Will be `null` if there are no errors. + * @property expiredAt The unix timestamp of when this step expired. This is only present if the parent run has expired. + * @property cancelledAt The unix timestamp for when the step was cancelled. + * @property failedAt The unix timestamp for when the step failed. + * @property completedAt The unix timestamp for when the step completed. + * @property metadata Metadata for this step. This can be useful for storing additional information about the object. + */ +data class RunStep( + @JsonProperty(required = true) val id: String, + @JsonProperty("created_at", required = true) val createdAt: Int, + @JsonProperty("assistant_id", required = true) val assistantId: String, + @JsonProperty("thread_id", required = true) val threadId: String, + @JsonProperty("run_id", required = true) val runId: String, + @JsonProperty(required = true) val type: Type, + @JsonProperty(required = true) val status: RunStatus, + @JsonProperty("step_details", required = true) val stepDetails: Details, + @JsonProperty("last_error") val lastError: RunError?, + @JsonProperty("expired_at") val expiredAt: Int?, + @JsonProperty("cancelled_at") val cancelledAt: Int?, + @JsonProperty("failed_at") val failedAt: Int?, + @JsonProperty("completed_at") val completedAt: Int?, + @JsonProperty val metadata: Map = emptyMap(), +) { + + /** + * An enum that holds all possible types of steps. + */ + enum class Type { + + /** + * When this step of the run created a message. + */ + @JsonProperty("message_creation") + MESSAGE_CREATION, + + /** + * When this step of the run created a tool call. This is used for all + * tool call types, not just [Tool.Type.FUNCTION]. + */ + @JsonProperty("tool_calls") + TOOL_CALLS, + } + + /** + * A sealed class that represents the details of a step. + */ + @JsonTypeInfo(use = JsonTypeInfo.Id.NAME, include = JsonTypeInfo.As.PROPERTY, property = "type") + @JsonSubTypes( + JsonSubTypes.Type(MessageCreationDetails::class, name = "message_creation"), + JsonSubTypes.Type(ToolCallsDetails::class, name = "tool_calls"), + ) + sealed class Details { + + /** + * The type of data stored by this step. This will match [RunStep.type]. + */ + abstract val type: Type + } +} \ No newline at end of file diff --git a/src/main/kotlin/com/cjcrafter/openai/threads/runs/RunStepHandler.kt b/src/main/kotlin/com/cjcrafter/openai/threads/runs/RunStepHandler.kt new file mode 100644 index 0000000..085829f --- /dev/null +++ b/src/main/kotlin/com/cjcrafter/openai/threads/runs/RunStepHandler.kt @@ -0,0 +1,58 @@ +package com.cjcrafter.openai.threads.runs + +import org.jetbrains.annotations.Contract + +/** + * Handler used to interact with a [RunStep] objects. + */ +interface RunStepHandler { + + /** + * The ID of the thread this handler is for. + */ + val threadId: String + + /** + * The ID of the run this handler is for. + */ + val runId: String + + /** + * Retrieves the updated run step object from the given run step. + * + * This method returns a new run step object wrapper. The run step parameter is + * used only for [RunStep.id]. This method is useful for getting updated + * information about a run step's status or values. + * + * @param runStep The run step to retrieve + * @return The retrieved run step + */ + @Contract(pure = true) + fun retrieve(runStep: RunStep) = retrieve(runStep.id) + + /** + * Retrieves the run step with the given id. + * + * @param id The id of the run step to retrieve + * @return The retrieved run step + */ + @Contract(pure = true) + fun retrieve(id: String): RunStep + + /** + * Lists the 20 most recent steps of a run. + * + * @return The list of steps + */ + @Contract(pure = true) + fun list(): ListRunStepsResponse = list(null) + + /** + * Lists steps from this run. + * + * @param request The request to use for listing the steps + * @return The list of steps + */ + @Contract(pure = true) + fun list(request: ListRunStepsRequest?): ListRunStepsResponse +} \ No newline at end of file diff --git a/src/main/kotlin/com/cjcrafter/openai/threads/runs/RunStepHandlerImpl.kt b/src/main/kotlin/com/cjcrafter/openai/threads/runs/RunStepHandlerImpl.kt new file mode 100644 index 0000000..4a1bc5d --- /dev/null +++ b/src/main/kotlin/com/cjcrafter/openai/threads/runs/RunStepHandlerImpl.kt @@ -0,0 +1,21 @@ +package com.cjcrafter.openai.threads.runs + +import com.cjcrafter.openai.RequestHelper + +class RunStepHandlerImpl( + private val requestHelper: RequestHelper, + private val endpoint: String, + override val threadId: String, + override val runId: String, +) : RunStepHandler { + + override fun retrieve(id: String): RunStep { + val httpRequest = requestHelper.buildRequestNoBody("$endpoint/$id").addHeader("OpenAI-Beta", "assistants=v1").get().build() + return requestHelper.executeRequest(httpRequest, RunStep::class.java) + } + + override fun list(request: ListRunStepsRequest?): ListRunStepsResponse { + val httpRequest = requestHelper.buildRequestNoBody(endpoint, request?.toMap()).addHeader("OpenAI-Beta", "assistants=v1").get().build() + return requestHelper.executeRequest(httpRequest, ListRunStepsResponse::class.java) + } +} \ No newline at end of file diff --git a/src/main/kotlin/com/cjcrafter/openai/threads/runs/SubmitToolOutputs.kt b/src/main/kotlin/com/cjcrafter/openai/threads/runs/SubmitToolOutputs.kt new file mode 100644 index 0000000..7541503 --- /dev/null +++ b/src/main/kotlin/com/cjcrafter/openai/threads/runs/SubmitToolOutputs.kt @@ -0,0 +1,38 @@ +package com.cjcrafter.openai.threads.runs + +import com.cjcrafter.openai.assistants.Assistant +import com.cjcrafter.openai.util.OpenAIDslMarker +import com.fasterxml.jackson.annotation.JsonProperty + +/** + * A data class holding your tool's outputs. Used after an [Assistant] makes + * a tool call. + */ +data class SubmitToolOutputs( + @JsonProperty("tool_outputs") var toolOutputs: MutableList, +) { + @OpenAIDslMarker + class Builder internal constructor() { + private var toolOutputs: MutableList? = null + + fun toolOutputs(toolOutputs: MutableList) = apply { this.toolOutputs = toolOutputs } + + fun addToolOutput(toolCallOutputs: ToolCallOutputs) = apply { + if (toolOutputs == null) toolOutputs = mutableListOf() + toolOutputs!!.add(toolCallOutputs) + } + + fun build() = SubmitToolOutputs( + toolOutputs ?: throw IllegalStateException("toolOutputs must be set") + ) + } + + companion object { + + /** + * Creates a new [SubmitToolOutputs] builder. + */ + @JvmStatic + fun builder() = Builder() + } +} \ No newline at end of file diff --git a/src/main/kotlin/com/cjcrafter/openai/threads/runs/ToolCallOutputs.kt b/src/main/kotlin/com/cjcrafter/openai/threads/runs/ToolCallOutputs.kt new file mode 100644 index 0000000..df6ae07 --- /dev/null +++ b/src/main/kotlin/com/cjcrafter/openai/threads/runs/ToolCallOutputs.kt @@ -0,0 +1,39 @@ +package com.cjcrafter.openai.threads.runs + +import com.cjcrafter.openai.util.OpenAIDslMarker +import com.fasterxml.jackson.annotation.JsonProperty + +/** + * Represents the response to a specific tool call. This is used when submitting tool outputs + * back to the [RunHandler]. + * + * @property toolCallId The ID of the tool call. + * @property output The output of the tool call, usually as a JSON string. + */ +data class ToolCallOutputs( + @JsonProperty("tool_call_id") var toolCallId: String, + var output: String, +) { + @OpenAIDslMarker + class Builder internal constructor() { + private var toolCallId: String? = null + private var output: String? = null + + fun toolCallId(toolCallId: String) = apply { this.toolCallId = toolCallId } + fun output(output: String) = apply { this.output = output } + + fun build() = ToolCallOutputs( + toolCallId ?: throw IllegalStateException("toolCallId must be set"), + output ?: throw IllegalStateException("output must be set") + ) + } + + companion object { + + /** + * Returns a new [Builder] instance for building [ToolCallOutputs] objects. + */ + @JvmStatic + fun builder() = Builder() + } +} \ No newline at end of file diff --git a/src/main/kotlin/com/cjcrafter/openai/threads/runs/ToolCallsDetails.kt b/src/main/kotlin/com/cjcrafter/openai/threads/runs/ToolCallsDetails.kt new file mode 100644 index 0000000..ded0209 --- /dev/null +++ b/src/main/kotlin/com/cjcrafter/openai/threads/runs/ToolCallsDetails.kt @@ -0,0 +1,15 @@ +package com.cjcrafter.openai.threads.runs + +import com.cjcrafter.openai.chat.tool.ToolCall +import com.fasterxml.jackson.annotation.JsonProperty + +/** + * Contains the list of all tool calls made during a run step. + * + * @property toolCalls All tool calls made during this step. + */ +data class ToolCallsDetails( + @JsonProperty("tool_calls", required = true) val toolCalls: List +) : RunStep.Details() { + override val type = RunStep.Type.TOOL_CALLS +} \ No newline at end of file diff --git a/src/main/kotlin/com/cjcrafter/openai/util/BuilderHelper.kt b/src/main/kotlin/com/cjcrafter/openai/util/BuilderHelper.kt new file mode 100755 index 0000000..187a84c --- /dev/null +++ b/src/main/kotlin/com/cjcrafter/openai/util/BuilderHelper.kt @@ -0,0 +1,48 @@ +package com.cjcrafter.openai.util + +object BuilderHelper { + + const val MAX_METADATA_SIZE = 16 + const val MAX_METADATA_KEY_LENGTH = 64 + const val MAX_METADATA_VALUE_LENGTH = 512 + + /** + * Asserts the given metadata key-value pair is valid, and can be sent to + * the OpenAI API. + * + * @param key The key, which must be <= 64 characters + * @param value The value, which must be <= 512 characters + * @throws IllegalArgumentException If the key or value is too long + */ + fun assertMetadata(key: String, value: String) { + if (key.length > MAX_METADATA_KEY_LENGTH) + throw IllegalArgumentException("key must be <= $MAX_METADATA_KEY_LENGTH characters, got ${key.length}: $key") + if (value.length > MAX_METADATA_VALUE_LENGTH) + throw IllegalArgumentException("value must be <= $MAX_METADATA_VALUE_LENGTH characters, got ${value.length}: $value") + } + + /** + * Asserts the given metadata map contains valid key-value pairs (see + * [assertMetadata]), and that the map has <= 16 key-value pairs. + * + * @param metadata The map of metadata to validate + * @throws IllegalArgumentException If the map has too many key-value pairs, + * or if any of the key-value pairs are invalid + */ + fun assertMetadata(metadata: Map) { + if (metadata.size > MAX_METADATA_SIZE) + throw IllegalArgumentException("metadata must have <= $MAX_METADATA_SIZE key-value pairs, got ${metadata.size}") + metadata.forEach { (key, value) -> assertMetadata(key, value) } + } + + /** + * Throws an exception if no more metadata can be added to the given map. + * + * @param metadata The map to check + * @throws IllegalArgumentException If the map has 16 key-value pairs already + */ + fun tryAddMetadata(metadata: Map) { + if (metadata.size == MAX_METADATA_SIZE) + throw IllegalArgumentException("Tried to add metadata to a map with $MAX_METADATA_SIZE key-value pairs") + } +} \ No newline at end of file diff --git a/src/main/kotlin/com/cjcrafter/openai/util/FunctionTag.kt b/src/main/kotlin/com/cjcrafter/openai/util/FunctionTag.kt old mode 100644 new mode 100755 diff --git a/src/main/kotlin/com/cjcrafter/openai/util/OpenAIDslMarker.kt b/src/main/kotlin/com/cjcrafter/openai/util/OpenAIDslMarker.kt old mode 100644 new mode 100755 diff --git a/src/main/kotlin/com/cjcrafter/openai/util/RegexInternals.kt b/src/main/kotlin/com/cjcrafter/openai/util/RegexInternals.kt old mode 100644 new mode 100755 diff --git a/src/test/kotlin/com/cjcrafter/openai/MockedTest.kt b/src/test/kotlin/com/cjcrafter/openai/MockedTest.kt old mode 100644 new mode 100755 diff --git a/src/test/kotlin/com/cjcrafter/openai/chat/ChatRequestTest.kt b/src/test/kotlin/com/cjcrafter/openai/chat/ChatRequestTest.kt old mode 100644 new mode 100755 diff --git a/src/test/kotlin/com/cjcrafter/openai/chat/MockedChatStreamTest.kt b/src/test/kotlin/com/cjcrafter/openai/chat/MockedChatStreamTest.kt old mode 100644 new mode 100755 index 0944780..714db0d --- a/src/test/kotlin/com/cjcrafter/openai/chat/MockedChatStreamTest.kt +++ b/src/test/kotlin/com/cjcrafter/openai/chat/MockedChatStreamTest.kt @@ -2,8 +2,6 @@ package com.cjcrafter.openai.chat import com.cjcrafter.openai.MockedTest import com.cjcrafter.openai.chat.ChatMessage.Companion.toSystemMessage -import com.cjcrafter.openai.chat.tool.ToolType -import com.cjcrafter.openai.openAI import okhttp3.mockwebserver.MockResponse import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Test diff --git a/src/test/kotlin/com/cjcrafter/openai/chat/tool/FunctionCallTest.kt b/src/test/kotlin/com/cjcrafter/openai/chat/tool/FunctionCallTest.kt old mode 100644 new mode 100755 diff --git a/src/test/kotlin/com/cjcrafter/openai/embeddings/MockedEmbeddingsTest.kt b/src/test/kotlin/com/cjcrafter/openai/embeddings/MockedEmbeddingsTest.kt old mode 100644 new mode 100755 diff --git a/src/test/kotlin/com/cjcrafter/openai/files/MockedFilesTest.kt b/src/test/kotlin/com/cjcrafter/openai/files/MockedFilesTest.kt old mode 100644 new mode 100755 index c1b5425..62c4fff --- a/src/test/kotlin/com/cjcrafter/openai/files/MockedFilesTest.kt +++ b/src/test/kotlin/com/cjcrafter/openai/files/MockedFilesTest.kt @@ -11,8 +11,7 @@ class MockedFilesTest : MockedTest() { fun listFiles() { mockWebServer.enqueue(MockResponse().setBody(readResource("list_files.json"))) - val dummyRequest = listFilesRequest { /* empty */ } - val response = openai.listFiles(dummyRequest) + val response = openai.files.list() // Intentionally empty... parsing to a valid response is the test } @@ -25,7 +24,7 @@ class MockedFilesTest : MockedTest() { file(File("README.md")) purpose(FilePurpose.ASSISTANTS) } - val response = openai.uploadFile(dummyRequest) + val response = openai.files.upload(dummyRequest) // Intentionally empty... parsing to a valid response is the test } @@ -34,7 +33,7 @@ class MockedFilesTest : MockedTest() { fun retrieveFile() { mockWebServer.enqueue(MockResponse().setBody(readResource("file.json"))) - val response = openai.retrieveFile("file-123abc") + val response = openai.files.retrieve("file-123abc") // Intentionally empty... parsing to a valid response is the test } @@ -43,7 +42,7 @@ class MockedFilesTest : MockedTest() { fun deleteFile() { mockWebServer.enqueue(MockResponse().setBody(readResource("delete_file.json"))) - val response = openai.deleteFile("file-123abc") + val response = openai.files.delete("file-123abc") // Intentionally empty... parsing to a valid response is the test } diff --git a/src/test/kotlin/com/cjcrafter/openai/util/BuilderHelperTest.kt b/src/test/kotlin/com/cjcrafter/openai/util/BuilderHelperTest.kt new file mode 100755 index 0000000..83b09c9 --- /dev/null +++ b/src/test/kotlin/com/cjcrafter/openai/util/BuilderHelperTest.kt @@ -0,0 +1,60 @@ +package com.cjcrafter.openai.util + +import org.junit.jupiter.api.Test + +import org.junit.jupiter.api.Assertions.* + +class BuilderHelperTest { + + @Test + fun `test key too long`() { + assertThrows(IllegalArgumentException::class.java) { + BuilderHelper.assertMetadata("a".repeat(BuilderHelper.MAX_METADATA_KEY_LENGTH + 1), "a") + } + } + + @Test + fun `test value too long`() { + assertThrows(IllegalArgumentException::class.java) { + BuilderHelper.assertMetadata("a", "a".repeat(BuilderHelper.MAX_METADATA_VALUE_LENGTH + 1)) + } + } + + @Test + fun `test map too big`() { + val metadata = buildMap { + for (i in 0..BuilderHelper.MAX_METADATA_SIZE) + put("$i", "a") + } + assertThrows(IllegalArgumentException::class.java) { + BuilderHelper.assertMetadata(metadata) + } + } + + @Test + fun `test key too long in map`() { + val metadata = mapOf("a".repeat(BuilderHelper.MAX_METADATA_KEY_LENGTH + 1) to "a") + assertThrows(IllegalArgumentException::class.java) { + BuilderHelper.assertMetadata(metadata) + } + } + + @Test + fun `test value too long in map`() { + val metadata = mapOf("a" to "a".repeat(BuilderHelper.MAX_METADATA_VALUE_LENGTH + 1)) + assertThrows(IllegalArgumentException::class.java) { + BuilderHelper.assertMetadata(metadata) + } + } + + @Test + fun `test map already full`() { + val metadata = buildMap { + for (i in 0 until BuilderHelper.MAX_METADATA_SIZE) + put("$i", "a") + } + assertThrows(IllegalArgumentException::class.java) { + BuilderHelper.tryAddMetadata(metadata) + } + } +} \ No newline at end of file diff --git a/src/test/kotlin/com/cjcrafter/openai/util/RegexInternalsTest.kt b/src/test/kotlin/com/cjcrafter/openai/util/RegexInternalsTest.kt new file mode 100755 index 0000000..0f1f3f6 --- /dev/null +++ b/src/test/kotlin/com/cjcrafter/openai/util/RegexInternalsTest.kt @@ -0,0 +1,20 @@ +package com.cjcrafter.openai.util + +import org.junit.jupiter.api.Assertions.* +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.ValueSource + +class RegexInternalsTest { + + @ParameterizedTest + @ValueSource(strings = ["a", "A", "0", "_", "-", "aA0_-"]) + fun `test valid function names`(name: String) { + assertTrue(RegexInternals.FUNCTION.matches(name)) + } + + @ParameterizedTest + @ValueSource(strings = ["", "\$hello", "#hello", "hello\$", "hello#", "hello\$world", "hello#world"]) + fun `test invalid function names`(name: String) { + assertFalse(RegexInternals.FUNCTION.matches(name)) + } +} \ No newline at end of file diff --git a/src/test/resources/create_assistant.json b/src/test/resources/create_assistant.json new file mode 100755 index 0000000..40f145a --- /dev/null +++ b/src/test/resources/create_assistant.json @@ -0,0 +1,12 @@ +{ + "id": "asst_1TTP9rnSIROj9r8uhr5bloW8", + "object": "assistant", + "created_at": 1700254368, + "name": "MyAssistant", + "description": "Assists with tasks", + "model": "gpt-4", + "instructions": "Assist me", + "tools": [], + "file_ids": [], + "metadata": {} +} diff --git a/src/test/resources/create_embeddings.txt b/src/test/resources/create_embeddings.txt old mode 100644 new mode 100755 diff --git a/src/test/resources/delete_file.json b/src/test/resources/delete_file.json old mode 100644 new mode 100755 diff --git a/src/test/resources/file.json b/src/test/resources/file.json old mode 100644 new mode 100755 diff --git a/src/test/resources/list_files.json b/src/test/resources/list_files.json old mode 100644 new mode 100755 diff --git a/src/test/resources/stream_chat_completion_1.txt b/src/test/resources/stream_chat_completion_1.txt old mode 100644 new mode 100755 diff --git a/src/test/resources/stream_chat_completion_2.txt b/src/test/resources/stream_chat_completion_2.txt old mode 100644 new mode 100755