Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

OPIK-547 Store and retrieve LLM provider api key #845

Merged
merged 3 commits into from
Dec 11, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions apps/opik-backend/config.yml
Original file line number Diff line number Diff line change
Expand Up @@ -88,3 +88,6 @@ metadata:

cors:
enabled: ${CORS:-false}

encryption:
key: ${OPIK_ENCRYPTION_KEY:-'GiTHubiLoVeYouAA'}
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import com.comet.opik.api.error.JsonInvalidFormatExceptionMapper;
import com.comet.opik.infrastructure.ConfigurationModule;
import com.comet.opik.infrastructure.EncryptionUtilsModule;
import com.comet.opik.infrastructure.OpikConfiguration;
import com.comet.opik.infrastructure.auth.AuthModule;
import com.comet.opik.infrastructure.bi.BiModule;
Expand Down Expand Up @@ -69,7 +70,7 @@ public void initialize(Bootstrap<OpikConfiguration> bootstrap) {
.withPlugins(new SqlObjectPlugin(), new Jackson2Plugin()))
.modules(new DatabaseAnalyticsModule(), new IdGeneratorModule(), new AuthModule(), new RedisModule(),
new RateLimitModule(), new NameGeneratorModule(), new HttpModule(), new EventModule(),
new ConfigurationModule(), new BiModule())
new ConfigurationModule(), new BiModule(), new EncryptionUtilsModule())
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We don't need a module since this will be static. We can just set the configuration using the run method below.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, it could be set in run. Since we are using DI this approach seems more consistent. Can be changed, don't have any preferences here.

.installers(JobGuiceyInstaller.class)
.listen(new OpikGuiceyLifecycleEventListener())
.enableAutoConfig()
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
package com.comet.opik.api;

import com.fasterxml.jackson.annotation.JsonProperty;
import lombok.Getter;
import lombok.RequiredArgsConstructor;

@Getter
@RequiredArgsConstructor
public enum LlmProvider {

@JsonProperty("openai")
OPEN_AI;
}
andrescrz marked this conversation as resolved.
Show resolved Hide resolved
Original file line number Diff line number Diff line change
Expand Up @@ -7,15 +7,12 @@
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.annotation.Nullable;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Pattern;
import lombok.Builder;

import java.time.Instant;
import java.util.List;
import java.util.UUID;

import static com.comet.opik.utils.ValidationUtils.NULL_OR_NOT_BLANK;

@Builder(toBuilder = true)
@JsonIgnoreProperties(ignoreUnknown = true)
// This annotation is used to specify the strategy to be used for naming of properties for the annotated type. Required so that OpenAPI schema generation uses snake_case
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
package com.comet.opik.api;

import com.comet.opik.utils.ProviderApiKeyDeserializer;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import com.fasterxml.jackson.annotation.JsonView;
import com.fasterxml.jackson.databind.PropertyNamingStrategies;
import com.fasterxml.jackson.databind.annotation.JsonDeserialize;
import com.fasterxml.jackson.databind.annotation.JsonNaming;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotBlank;
import lombok.Builder;
import lombok.NonNull;

import java.time.Instant;
import java.util.UUID;

@Builder(toBuilder = true)
@JsonIgnoreProperties(ignoreUnknown = true)
@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class)
public record ProviderApiKey(
@JsonView( {
View.Public.class}) @Schema(accessMode = Schema.AccessMode.READ_ONLY) UUID id,
@JsonView({View.Public.class, View.Write.class}) @NonNull LlmProvider provider,
@JsonView({
View.Write.class}) @NotBlank @JsonDeserialize(using = ProviderApiKeyDeserializer.class) String apiKey,
@JsonView({View.Public.class}) @Schema(accessMode = Schema.AccessMode.READ_ONLY) Instant createdAt,
@JsonView({View.Public.class}) @Schema(accessMode = Schema.AccessMode.READ_ONLY) String createdBy,
@JsonView({View.Public.class}) @Schema(accessMode = Schema.AccessMode.READ_ONLY) Instant lastUpdatedAt,
@JsonView({View.Public.class}) @Schema(accessMode = Schema.AccessMode.READ_ONLY) String lastUpdatedBy){
@Override
public String toString() {
return "ProviderApiKey{" +
"id=" + id +
", provider='" + provider + '\'' +
", createdAt=" + createdAt +
", createdBy='" + createdBy + '\'' +
", lastUpdatedAt=" + lastUpdatedAt +
", lastUpdatedBy='" + lastUpdatedBy + '\'' +
'}';
}

public static class View {
public static class Write {
}

public static class Public {
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
package com.comet.opik.api;

import com.comet.opik.utils.ProviderApiKeyDeserializer;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import com.fasterxml.jackson.databind.PropertyNamingStrategies;
import com.fasterxml.jackson.databind.annotation.JsonDeserialize;
import com.fasterxml.jackson.databind.annotation.JsonNaming;
import jakarta.validation.constraints.NotBlank;
import lombok.Builder;
import lombok.Getter;
import lombok.ToString;

@Builder(toBuilder = true)
@JsonIgnoreProperties(ignoreUnknown = true)
@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class)
@Getter
public class ProviderApiKeyUpdate {
@ToString.Exclude
@NotBlank
@JsonDeserialize(using = ProviderApiKeyDeserializer.class)
String apiKey;
}
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,8 @@ public Response toResponse(InvalidFormatException exception) {
log.info("Deserialization exception: {}", exception.getMessage());
int endIndex = errorMessage.indexOf(": Failed to deserialize");
return Response.status(Response.Status.BAD_REQUEST)
.entity(new ErrorMessage(List.of(endIndex == -1 ? "Unable to process JSON" : errorMessage.substring(0, endIndex))))
.entity(new ErrorMessage(
List.of(endIndex == -1 ? "Unable to process JSON" : errorMessage.substring(0, endIndex))))
.build();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
package com.comet.opik.api.resources.v1.priv;

import com.codahale.metrics.annotation.Timed;
import com.comet.opik.api.ProviderApiKey;
import com.comet.opik.api.ProviderApiKeyUpdate;
import com.comet.opik.api.error.ErrorMessage;
import com.comet.opik.domain.LlmProviderApiKeyService;
import com.comet.opik.infrastructure.auth.RequestContext;
import com.fasterxml.jackson.annotation.JsonView;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.headers.Header;
import io.swagger.v3.oas.annotations.media.Content;
import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.parameters.RequestBody;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.inject.Inject;
import jakarta.inject.Provider;
import jakarta.validation.Valid;
import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.PATCH;
import jakarta.ws.rs.POST;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.PathParam;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.core.Context;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;
import jakarta.ws.rs.core.UriInfo;
import lombok.NonNull;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;

import java.util.UUID;

@Path("/v1/private/llm-provider-key")
@Produces(MediaType.APPLICATION_JSON)
@Consumes(MediaType.APPLICATION_JSON)
@Timed
@Slf4j
@RequiredArgsConstructor(onConstructor_ = @Inject)
@Tag(name = "LlmProviderKey", description = "LLM Provider Key")
public class LlmProviderApiKeyResource {

private final @NonNull LlmProviderApiKeyService llmProviderApiKeyService;
private final @NonNull Provider<RequestContext> requestContext;

@GET
@Path("{id}")
@Operation(operationId = "getLlmProviderApiKeyById", summary = "Get LLM Provider's ApiKey by id", description = "Get LLM Provider's ApiKey by id", responses = {
@ApiResponse(responseCode = "200", description = "ProviderApiKey resource", content = @Content(schema = @Schema(implementation = ProviderApiKey.class))),
@ApiResponse(responseCode = "404", description = "Not found", content = @Content(schema = @Schema(implementation = ErrorMessage.class)))})
@JsonView({ProviderApiKey.View.Public.class})
public Response getById(@PathParam("id") UUID id) {

String workspaceId = requestContext.get().getWorkspaceId();

log.info("Getting Provider's ApiKey by id '{}' on workspace_id '{}'", id, workspaceId);

ProviderApiKey providerApiKey = llmProviderApiKeyService.get(id, workspaceId);

log.info("Got Provider's ApiKey by id '{}' on workspace_id '{}'", id, workspaceId);

return Response.ok().entity(providerApiKey).build();
}

@POST
@Operation(operationId = "storeLlmProviderApiKey", summary = "Store LLM Provider's ApiKey", description = "Store LLM Provider's ApiKey", responses = {
@ApiResponse(responseCode = "201", description = "Created", headers = {
@Header(name = "Location", required = true, example = "${basePath}/v1/private/proxy/api_key/{apiKeyId}", schema = @Schema(implementation = String.class))}),
@ApiResponse(responseCode = "401", description = "Bad Request", content = @Content(schema = @Schema(implementation = ErrorMessage.class))),
@ApiResponse(responseCode = "403", description = "Access forbidden", content = @Content(schema = @Schema(implementation = ErrorMessage.class)))
})
public Response saveApiKey(
@RequestBody(content = @Content(schema = @Schema(implementation = ProviderApiKey.class))) @JsonView(ProviderApiKey.View.Write.class) @Valid ProviderApiKey providerApiKey,
@Context UriInfo uriInfo) {
String workspaceId = requestContext.get().getWorkspaceId();
String userName = requestContext.get().getUserName();
log.info("Save api key for provider '{}', on workspace_id '{}'", providerApiKey.provider(), workspaceId);
var providerApiKeyId = llmProviderApiKeyService.saveApiKey(providerApiKey, userName, workspaceId).id();
log.info("Saved api key for provider '{}', on workspace_id '{}'", providerApiKey.provider(), workspaceId);

var uri = uriInfo.getAbsolutePathBuilder().path("/%s".formatted(providerApiKeyId)).build();

return Response.created(uri).build();
}

@PATCH
@Path("{id}")
@Operation(operationId = "updateLlmProviderApiKey", summary = "Update LLM Provider's ApiKey", description = "Update LLM Provider's ApiKey", responses = {
@ApiResponse(responseCode = "204", description = "No Content"),
@ApiResponse(responseCode = "401", description = "Bad Request", content = @Content(schema = @Schema(implementation = ErrorMessage.class))),
@ApiResponse(responseCode = "403", description = "Access forbidden", content = @Content(schema = @Schema(implementation = ErrorMessage.class))),
@ApiResponse(responseCode = "404", description = "Not found", content = @Content(schema = @Schema(implementation = ErrorMessage.class)))
})
public Response updateApiKey(@PathParam("id") UUID id,
@RequestBody(content = @Content(schema = @Schema(implementation = ProviderApiKeyUpdate.class))) @Valid ProviderApiKeyUpdate providerApiKeyUpdate) {
String workspaceId = requestContext.get().getWorkspaceId();
String userName = requestContext.get().getUserName();

log.info("Updating api key for provider with id '{}' on workspaceId '{}'", id, workspaceId);
llmProviderApiKeyService.updateApiKey(id, providerApiKeyUpdate, userName, workspaceId);
log.info("Updated api key for provider with id '{}' on workspaceId '{}'", id, workspaceId);

return Response.noContent().build();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
package com.comet.opik.domain;

import com.comet.opik.api.ProviderApiKey;
import com.comet.opik.infrastructure.db.UUIDArgumentFactory;
import org.jdbi.v3.sqlobject.config.RegisterArgumentFactory;
import org.jdbi.v3.sqlobject.config.RegisterConstructorMapper;
import org.jdbi.v3.sqlobject.customizer.Bind;
import org.jdbi.v3.sqlobject.customizer.BindMethods;
import org.jdbi.v3.sqlobject.statement.SqlQuery;
import org.jdbi.v3.sqlobject.statement.SqlUpdate;

import java.util.Optional;
import java.util.UUID;

@RegisterConstructorMapper(ProviderApiKey.class)
@RegisterArgumentFactory(UUIDArgumentFactory.class)
public interface LlmProviderApiKeyDAO {

@SqlUpdate("INSERT INTO llm_provider_api_key (id, provider, workspace_id, api_key, created_by, last_updated_by) VALUES (:bean.id, :bean.provider, :workspaceId, :bean.apiKey, :bean.createdBy, :bean.lastUpdatedBy)")
void save(@Bind("workspaceId") String workspaceId,
@BindMethods("bean") ProviderApiKey providerApiKey);

@SqlUpdate("UPDATE llm_provider_api_key SET " +
"api_key = :apiKey, " +
"last_updated_by = :lastUpdatedBy " +
"WHERE id = :id AND workspace_id = :workspaceId")
void update(@Bind("id") UUID id,
@Bind("workspaceId") String workspaceId,
@Bind("apiKey") String encryptedApiKey,
@Bind("lastUpdatedBy") String lastUpdatedBy);

@SqlQuery("SELECT * FROM llm_provider_api_key WHERE id = :id AND workspace_id = :workspaceId")
ProviderApiKey findById(@Bind("id") UUID id, @Bind("workspaceId") String workspaceId);

default Optional<ProviderApiKey> fetch(UUID id, String workspaceId) {
return Optional.ofNullable(findById(id, workspaceId));
}
}
Loading
Loading