From 5eaf39b9335b0e33ec00da05384cded0fac3a0f0 Mon Sep 17 00:00:00 2001 From: Stefan Bratanov Date: Sun, 25 Aug 2024 19:57:20 +0300 Subject: [PATCH] Add support for Invites --- README.md | 18 +++- .../jvm/openai/BatchClient.java | 2 +- .../stefanbratanov/jvm/openai/Endpoint.java | 4 +- .../stefanbratanov/jvm/openai/Invite.java | 11 ++ .../jvm/openai/InviteRequest.java | 34 ++++++ .../jvm/openai/InvitesClient.java | 102 ++++++++++++++++++ .../stefanbratanov/jvm/openai/OpenAI.java | 35 ++++++ .../openai/OpenAIAdminIntegrationTest.java | 48 +++++++++ .../OpenApiSpecificationValidationTest.java | 51 +++++---- .../jvm/openai/TestDataUtil.java | 19 +++- 10 files changed, 301 insertions(+), 23 deletions(-) create mode 100644 src/main/java/io/github/stefanbratanov/jvm/openai/Invite.java create mode 100644 src/main/java/io/github/stefanbratanov/jvm/openai/InviteRequest.java create mode 100644 src/main/java/io/github/stefanbratanov/jvm/openai/InvitesClient.java create mode 100644 src/test/java/io/github/stefanbratanov/jvm/openai/OpenAIAdminIntegrationTest.java diff --git a/README.md b/README.md index f8fbb33..725e75e 100644 --- a/README.md +++ b/README.md @@ -43,7 +43,7 @@ ChatCompletion chatCompletion = chatClient.createChatCompletion(createChatComple ## Supported APIs -> **_NOTE:_** Legacy APIs are not supported +#### Endpoints | API | Status | |---------------------------------------------------------------------------|:------:| @@ -58,7 +58,7 @@ ChatCompletion chatCompletion = chatClient.createChatCompletion(createChatComple | [Models](https://platform.openai.com/docs/api-reference/models) | ✔️ | | [Moderations](https://platform.openai.com/docs/api-reference/moderations) | ✔️ | -#### Beta APIs +#### Assistants (Beta) | API | Status | |--------------------------------------------------------------------------------------------------------|:------:| @@ -71,6 +71,20 @@ ChatCompletion chatCompletion = chatClient.createChatCompletion(createChatComple | [Vector Store Files](https://platform.openai.com/docs/api-reference/vector-stores-files) | ✔️ | | [Vector Store File Batches](https://platform.openai.com/docs/api-reference/vector-stores-file-batches) | ✔️ | +#### Administration + +| API | Status | +|----------------------------------------------------------------------------------|:------:| +| [Invites](https://platform.openai.com/docs/api-reference/invite) | ✔️ | +| [Users](https://platform.openai.com/docs/api-reference/users) | | +| [Projects](https://platform.openai.com/docs/api-reference/projects) | | +| [Project Users](https://platform.openai.com/docs/api-reference/project-users) | | +| [Project Service Accounts](https://platform.openai.com/docs/api-reference/project-service-accounts) | | +| [Project API Keys](https://platform.openai.com/docs/api-reference/project-api-keys) | | +| [Audit Logs](https://platform.openai.com/docs/api-reference/audit-logs) | | + +> **_NOTE:_** Legacy APIs are not supported + ## More examples - Configure an organization and project diff --git a/src/main/java/io/github/stefanbratanov/jvm/openai/BatchClient.java b/src/main/java/io/github/stefanbratanov/jvm/openai/BatchClient.java index 76370a8..b4b3ff8 100644 --- a/src/main/java/io/github/stefanbratanov/jvm/openai/BatchClient.java +++ b/src/main/java/io/github/stefanbratanov/jvm/openai/BatchClient.java @@ -82,7 +82,7 @@ public Batch cancelBatch(String batchId) { * @param limit A limit on the number of objects to be returned. * @throws OpenAIException in case of API errors */ - public PaginatedBatches listBatches(Optional after, Optional limit) { + public PaginatedBatches listBatches(Optional after, Optional limit) { String queryParameters = createQueryParameters( Map.of(Constants.LIMIT_QUERY_PARAMETER, limit, Constants.AFTER_QUERY_PARAMETER, after)); diff --git a/src/main/java/io/github/stefanbratanov/jvm/openai/Endpoint.java b/src/main/java/io/github/stefanbratanov/jvm/openai/Endpoint.java index 99ef0c9..901b699 100644 --- a/src/main/java/io/github/stefanbratanov/jvm/openai/Endpoint.java +++ b/src/main/java/io/github/stefanbratanov/jvm/openai/Endpoint.java @@ -18,7 +18,9 @@ enum Endpoint { // Beta THREADS("threads"), ASSISTANTS("assistants"), - VECTOR_STORES("vector_stores"); + VECTOR_STORES("vector_stores"), + // Administration + INVITES("organization/invites"); private final String path; diff --git a/src/main/java/io/github/stefanbratanov/jvm/openai/Invite.java b/src/main/java/io/github/stefanbratanov/jvm/openai/Invite.java new file mode 100644 index 0000000..20ca4f8 --- /dev/null +++ b/src/main/java/io/github/stefanbratanov/jvm/openai/Invite.java @@ -0,0 +1,11 @@ +package io.github.stefanbratanov.jvm.openai; + +/** Represents an individual `invite` to the organization. */ +public record Invite( + String id, + String email, + String role, + String status, + long invitedAt, + long expiresAt, + Long acceptedAt) {} diff --git a/src/main/java/io/github/stefanbratanov/jvm/openai/InviteRequest.java b/src/main/java/io/github/stefanbratanov/jvm/openai/InviteRequest.java new file mode 100644 index 0000000..44f4746 --- /dev/null +++ b/src/main/java/io/github/stefanbratanov/jvm/openai/InviteRequest.java @@ -0,0 +1,34 @@ +package io.github.stefanbratanov.jvm.openai; + +public record InviteRequest(String email, String role) { + + public static Builder newBuilder() { + return new Builder(); + } + + public static class Builder { + + private String email; + private String role; + + /** + * @param email Send an email to this address + */ + public Builder email(String email) { + this.email = email; + return this; + } + + /** + * @param role `owner` or `reader` + */ + public Builder role(String role) { + this.role = role; + return this; + } + + public InviteRequest build() { + return new InviteRequest(email, role); + } + } +} diff --git a/src/main/java/io/github/stefanbratanov/jvm/openai/InvitesClient.java b/src/main/java/io/github/stefanbratanov/jvm/openai/InvitesClient.java new file mode 100644 index 0000000..d3466ac --- /dev/null +++ b/src/main/java/io/github/stefanbratanov/jvm/openai/InvitesClient.java @@ -0,0 +1,102 @@ +package io.github.stefanbratanov.jvm.openai; + +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.time.Duration; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +/** + * Invite and manage invitations for an organization. Invited users are automatically added to the + * Default project. + * + *

Based on Invites + */ +public class InvitesClient extends OpenAIClient { + + private final URI baseUrl; + + InvitesClient( + URI baseUrl, + String[] authenticationHeaders, + HttpClient httpClient, + Optional requestTimeout) { + super(authenticationHeaders, httpClient, requestTimeout); + this.baseUrl = baseUrl; + } + + /** + * Returns a list of invites in the organization. + * + * @param after A cursor for use in pagination. after is an object ID that defines your place in + * the list. + * @param limit A limit on the number of objects to be returned. + * @throws OpenAIException in case of API errors + */ + public PaginatedInvites listInvites(Optional after, Optional limit) { + String queryParameters = + createQueryParameters( + Map.of(Constants.LIMIT_QUERY_PARAMETER, limit, Constants.AFTER_QUERY_PARAMETER, after)); + HttpRequest httpRequest = + newHttpRequestBuilder() + .uri(baseUrl.resolve(Endpoint.INVITES.getPath() + queryParameters)) + .GET() + .build(); + HttpResponse httpResponse = sendHttpRequest(httpRequest); + return deserializeResponse(httpResponse.body(), PaginatedInvites.class); + } + + public record PaginatedInvites( + List data, String firstId, String lastId, boolean hasMore) {} + + /** + * Create an invite for a user to the organization. The invite must be accepted by the user before + * they have access to the organization. + * + * @throws OpenAIException in case of API errors + */ + public Invite createInvite(InviteRequest request) { + HttpRequest httpRequest = + newHttpRequestBuilder(Constants.CONTENT_TYPE_HEADER, Constants.JSON_MEDIA_TYPE) + .uri(baseUrl.resolve(Endpoint.INVITES.getPath())) + .POST(createBodyPublisher(request)) + .build(); + HttpResponse httpResponse = sendHttpRequest(httpRequest); + return deserializeResponse(httpResponse.body(), Invite.class); + } + + /** + * Retrieves an invite. + * + * @param inviteId The ID of the invite to retrieve. + * @throws OpenAIException in case of API errors + */ + public Invite retrieveInvite(String inviteId) { + HttpRequest httpRequest = + newHttpRequestBuilder() + .uri(baseUrl.resolve(Endpoint.INVITES.getPath() + "/" + inviteId)) + .GET() + .build(); + HttpResponse httpResponse = sendHttpRequest(httpRequest); + return deserializeResponse(httpResponse.body(), Invite.class); + } + + /** + * Delete an invite. If the invite has already been accepted, it cannot be deleted. + * + * @param inviteId The ID of the invite to delete. + * @throws OpenAIException in case of API errors + */ + public DeletionStatus deleteInvite(String inviteId) { + HttpRequest httpRequest = + newHttpRequestBuilder() + .uri(baseUrl.resolve(Endpoint.INVITES.getPath() + "/" + inviteId)) + .DELETE() + .build(); + HttpResponse httpResponse = sendHttpRequest(httpRequest); + return deserializeResponse(httpResponse.body(), DeletionStatus.class); + } +} diff --git a/src/main/java/io/github/stefanbratanov/jvm/openai/OpenAI.java b/src/main/java/io/github/stefanbratanov/jvm/openai/OpenAI.java index 4d30dbc..f07d7ab 100644 --- a/src/main/java/io/github/stefanbratanov/jvm/openai/OpenAI.java +++ b/src/main/java/io/github/stefanbratanov/jvm/openai/OpenAI.java @@ -32,10 +32,12 @@ public final class OpenAI { private final VectorStoresClient vectorStoresClient; private final VectorStoreFilesClient vectorStoreFilesClient; private final VectorStoreFileBatchesClient vectorStoreFileBatchesClient; + private final InvitesClient invitesClient; private OpenAI( URI baseUrl, Optional apiKey, + Optional adminKey, Optional organization, Optional project, HttpClient httpClient, @@ -54,6 +56,7 @@ private OpenAI( modelsClient = new ModelsClient(baseUrl, authenticationHeaders, httpClient, requestTimeout); moderationsClient = new ModerationsClient(baseUrl, authenticationHeaders, httpClient, requestTimeout); + // Assistants assistantsClient = new AssistantsClient(baseUrl, authenticationHeaders, httpClient, requestTimeout); threadsClient = new ThreadsClient(baseUrl, authenticationHeaders, httpClient, requestTimeout); @@ -67,6 +70,10 @@ private OpenAI( vectorStoreFileBatchesClient = new VectorStoreFileBatchesClient( baseUrl, authenticationHeaders, httpClient, requestTimeout); + // Administration + String[] adminAuthenticationHeaders = createAdminAuthenticationHeaders(adminKey); + invitesClient = + new InvitesClient(baseUrl, adminAuthenticationHeaders, httpClient, requestTimeout); } /** @@ -216,6 +223,14 @@ public VectorStoreFileBatchesClient vectorStoreFileBatchesClient() { return vectorStoreFileBatchesClient; } + /** + * @return a client based on Invites + */ + public InvitesClient invitesClient() { + return invitesClient; + } + private String[] createAuthenticationHeaders( Optional apiKey, Optional organization, Optional project) { List authHeaders = new ArrayList<>(); @@ -240,6 +255,16 @@ private String[] createAuthenticationHeaders( return authHeaders.toArray(new String[] {}); } + private String[] createAdminAuthenticationHeaders(Optional adminKey) { + List authHeaders = new ArrayList<>(); + adminKey.ifPresent( + key -> { + authHeaders.add(Constants.AUTHORIZATION_HEADER); + authHeaders.add("Bearer " + key); + }); + return authHeaders.toArray(new String[] {}); + } + public static Builder newBuilder() { return new Builder(); } @@ -256,6 +281,7 @@ public static class Builder { private static final String DEFAULT_BASE_URL = "https://api.openai.com/v1/"; private Optional apiKey = Optional.empty(); + private Optional adminKey = Optional.empty(); private String baseUrl = DEFAULT_BASE_URL; @@ -274,6 +300,14 @@ public Builder apiKey(String apiKey) { return this; } + /** + * @param adminKey the API key used for administration endpoints. + */ + public Builder adminKey(String adminKey) { + this.adminKey = Optional.of(adminKey); + return this; + } + /** * @param baseUrl the url which exposes the OpenAI API */ @@ -325,6 +359,7 @@ public OpenAI build() { return new OpenAI( URI.create(baseUrl), apiKey, + adminKey, organization, project, httpClient.orElseGet(HttpClient::newHttpClient), diff --git a/src/test/java/io/github/stefanbratanov/jvm/openai/OpenAIAdminIntegrationTest.java b/src/test/java/io/github/stefanbratanov/jvm/openai/OpenAIAdminIntegrationTest.java new file mode 100644 index 0000000..949f31a --- /dev/null +++ b/src/test/java/io/github/stefanbratanov/jvm/openai/OpenAIAdminIntegrationTest.java @@ -0,0 +1,48 @@ +package io.github.stefanbratanov.jvm.openai; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.time.Instant; +import java.util.List; +import java.util.Optional; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable; + +@EnabledIfEnvironmentVariable(named = "OPENAI_ADMIN_KEY", matches = ".*\\S.*") +class OpenAIAdminIntegrationTest { + + private static OpenAI openAI; + + @BeforeAll + public static void setUp() { + String adminKey = System.getenv("OPENAI_ADMIN_KEY"); + openAI = OpenAI.newBuilder().adminKey(adminKey).build(); + } + + @Test + void testInvitesClient() { + InvitesClient invitesClient = openAI.invitesClient(); + + InviteRequest inviteRequest = + InviteRequest.newBuilder().email("foobar@example.com").role("reader").build(); + + Invite invite = invitesClient.createInvite(inviteRequest); + + assertThat(invite.email()).isEqualTo("foobar@example.com"); + assertThat(invite.status()).isEqualTo("pending"); + assertThat(invite.expiresAt()).isGreaterThan(Instant.now().getEpochSecond()); + + List invites = invitesClient.listInvites(Optional.empty(), Optional.empty()).data(); + + assertThat(invites).hasSize(1).containsExactly(invite); + + Invite retrievedInvite = invitesClient.retrieveInvite(invite.id()); + + assertThat(retrievedInvite).isEqualTo(invite); + + // cleanup + DeletionStatus deletionStatus = invitesClient.deleteInvite(invite.id()); + assertThat(deletionStatus.deleted()).isTrue(); + } +} diff --git a/src/test/java/io/github/stefanbratanov/jvm/openai/OpenApiSpecificationValidationTest.java b/src/test/java/io/github/stefanbratanov/jvm/openai/OpenApiSpecificationValidationTest.java index 99a72a3..c7d6237 100644 --- a/src/test/java/io/github/stefanbratanov/jvm/openai/OpenApiSpecificationValidationTest.java +++ b/src/test/java/io/github/stefanbratanov/jvm/openai/OpenApiSpecificationValidationTest.java @@ -34,7 +34,7 @@ public static void setUp() { validator = OpenApiInteractionValidator.createFor(api).build(); } - @RepeatedTest(50) + @RepeatedTest(25) void validateAudio() { SpeechRequest speechRequest = testDataUtil.randomSpeechRequest(); @@ -46,7 +46,7 @@ void validateAudio() { // can't validate multipart/form-data so won't validate other endpoints } - @RepeatedTest(50) + @RepeatedTest(25) void validateChat() { CreateChatCompletionRequest createChatCompletionRequest = testDataUtil.randomCreateChatCompletionRequest(); @@ -64,7 +64,7 @@ void validateChat() { validate(request, response); } - @RepeatedTest(50) + @RepeatedTest(25) void validateEmbeddings() { EmbeddingsRequest embeddingsRequest = testDataUtil.randomEmbeddingsRequest(); @@ -79,7 +79,7 @@ void validateEmbeddings() { validate(request, response); } - @RepeatedTest(50) + @RepeatedTest(25) void validateFineTuning() { CreateFineTuningJobRequest createFineTuningJobRequest = testDataUtil.randomCreateFineTuningJobRequest(); @@ -128,7 +128,7 @@ void validateFineTuning() { listCheckpointsResponse); } - @RepeatedTest(50) + @RepeatedTest(25) void validateBatch() { CreateBatchRequest createBatchRequest = testDataUtil.randomCreateBatchRequest(); @@ -156,7 +156,7 @@ void validateBatch() { "Instance type (integer) does not match any allowed primitive type (allowed: [\"string\"]"); } - @RepeatedTest(50) + @RepeatedTest(25) void validateFiles() { File file = testDataUtil.randomFile(); @@ -169,7 +169,7 @@ void validateFiles() { "Object has missing required properties ([\"object\",\"status\"])"); } - @RepeatedTest(50) + @RepeatedTest(25) void validateUploads() { CreateUploadRequest createUploadRequest = testDataUtil.randomCreateUploadRequest(); @@ -212,7 +212,7 @@ void validateUploads() { "Object has missing required properties ([\"step_number\"]"); } - @RepeatedTest(50) + @RepeatedTest(25) void validateImages() { CreateImageRequest createImageRequest = testDataUtil.randomCreateImageRequest(); @@ -230,7 +230,7 @@ void validateImages() { // can't validate multipart/form-data so won't validate other endpoints } - @RepeatedTest(50) + @RepeatedTest(25) void validateModels() { Model model = testDataUtil.randomModelObject(); @@ -239,7 +239,7 @@ void validateModels() { validate("/" + Endpoint.MODELS + "/{model}", Method.GET, response); } - @RepeatedTest(50) + @RepeatedTest(25) void validateModerations() { ModerationRequest moderationRequest = testDataUtil.randomModerationRequest(); @@ -254,7 +254,7 @@ void validateModerations() { validate(request, response); } - @RepeatedTest(50) + @RepeatedTest(25) void validateAssistants() { CreateAssistantRequest createAssistantRequest = testDataUtil.randomCreateAssistantRequest(); @@ -281,7 +281,7 @@ void validateAssistants() { validate(request, response); } - @RepeatedTest(50) + @RepeatedTest(25) void validateThreads() { CreateThreadRequest createThreadRequest = testDataUtil.randomCreateThreadRequest(); @@ -306,7 +306,7 @@ void validateThreads() { validate(request, response); } - @RepeatedTest(50) + @RepeatedTest(25) void validateMessages() { CreateMessageRequest createMessageRequest = testDataUtil.randomCreateMessageRequest(); @@ -323,7 +323,7 @@ void validateMessages() { validate(request, response); } - @RepeatedTest(50) + @RepeatedTest(25) void validateRuns() { CreateRunRequest createRunRequest = testDataUtil.randomCreateRunRequest(); @@ -368,7 +368,7 @@ void validateRuns() { validate(request); } - @RepeatedTest(50) + @RepeatedTest(25) void validateRunSteps() { PaginatedThreadRunSteps paginatedThreadRunSteps = testDataUtil.randomPaginatedThreadRunSteps(); @@ -386,7 +386,7 @@ void validateRunSteps() { response); } - @RepeatedTest(50) + @RepeatedTest(25) void validateVectorStores() { CreateVectorStoreRequest createVectorStoreRequest = testDataUtil.randomCreateVectorStoreRequest(); @@ -404,7 +404,7 @@ void validateVectorStores() { validate(request, response); } - @RepeatedTest(50) + @RepeatedTest(25) void validateVectorStoreFiles() { CreateVectorStoreFileRequest createVectorStoreFileRequest = testDataUtil.randomCreateVectorStoreFileRequest(); @@ -422,7 +422,7 @@ void validateVectorStoreFiles() { validate(request, response); } - @RepeatedTest(50) + @RepeatedTest(25) void validateVectorStoreFileBatches() { CreateVectorStoreFileBatchRequest createVectorStoreFileBatchRequest = testDataUtil.randomCreateVectorStoreFileBatchRequest(); @@ -440,6 +440,21 @@ void validateVectorStoreFileBatches() { validate(request, response); } + @RepeatedTest(25) + void validateInvites() { + InviteRequest inviteRequest = testDataUtil.randomInviteRequest(); + + Request request = + createRequestWithBody( + Method.POST, "/" + Endpoint.INVITES.getPath(), serializeObject(inviteRequest)); + + Invite invite = testDataUtil.randomInvite(); + + Response response = createResponseWithBody(serializeObject(invite)); + + validate(request, response); + } + private void validate(Request request, Response response, String... reportMessagesToIgnore) { ValidationReport report = validator.validate(request, response); validateReport(report, reportMessagesToIgnore); diff --git a/src/test/java/io/github/stefanbratanov/jvm/openai/TestDataUtil.java b/src/test/java/io/github/stefanbratanov/jvm/openai/TestDataUtil.java index 2bf6054..8951389 100644 --- a/src/test/java/io/github/stefanbratanov/jvm/openai/TestDataUtil.java +++ b/src/test/java/io/github/stefanbratanov/jvm/openai/TestDataUtil.java @@ -714,6 +714,24 @@ public VectorStoreFileBatch randomVectorStoreFileBatch() { randomInt(0, 40))); } + public InviteRequest randomInviteRequest() { + return InviteRequest.newBuilder() + .email("user@example.com") + .role(oneOf("owner", "reader")) + .build(); + } + + public Invite randomInvite() { + return new Invite( + randomString(5), + "user@example.com", + oneOf("owner", "reader"), + oneOf("accepted", "expired", "pending"), + randomLong(10_000, 1_000_000), + randomLong(10_000, 1_000_000), + randomLong(10_000, 1_000_000)); + } + private ChunkingStrategy.StaticChunkingStrategy randomStaticChunkingStrategy() { int randomMaxChunkSizeTokens = randomInt(100, 4096); return ChunkingStrategy.staticChunkingStrategy( @@ -957,7 +975,6 @@ private String randomModel() { "gpt-4o", "gpt-4o-2024-08-06", "gpt-4o-2024-05-13", - "gpt-4o-2024-08-06", "gpt-4o-mini", "gpt-4o-mini-2024-07-18", "gpt-4-turbo",