Skip to content
Open
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
20 changes: 20 additions & 0 deletions apps/opik-backend/config.yml
Original file line number Diff line number Diff line change
Expand Up @@ -1189,6 +1189,26 @@ partitionMetrics:
lwdTables: ${PARTITION_METRICS_LWD_TABLES:-traces,spans}

# LLM providers client configuration
# Configuration for dynamic token auth on custom LLM providers (auth_config recipes)
llmProviderTokenAuth:
# Default: 0.25
# Description: Refresh the cached token once its remaining lifetime drops below this fraction of the total lifetime
refreshFraction: ${LLM_PROVIDER_TOKEN_AUTH_REFRESH_FRACTION:-0.25}
# Default: 10s
# Description: Timeout for the token fetch call to the configured auth service
fetchTimeout: ${LLM_PROVIDER_TOKEN_AUTH_FETCH_TIMEOUT:-10s}
# Default: 15s
# Description: Lease time for the cross-pod single-flight lock around a token fetch
lockTimeout: ${LLM_PROVIDER_TOKEN_AUTH_LOCK_TIMEOUT:-15s}
# Default: 1000000
# Description: Maximum accepted size (in characters) of a token endpoint reply. Capped at 10000000
maxResponseChars: ${LLM_PROVIDER_TOKEN_AUTH_MAX_RESPONSE_CHARS:-1000000}
# Default: strict
# Description: SSRF guard on the token URL. "strict" refuses non-HTTPS and private/internal
# destinations; "relaxed" allows internal gateways. Strict by default so a missing override fails
# loudly instead of exposing the deployment
destinationGuard: ${LLM_PROVIDER_TOKEN_AUTH_DESTINATION_GUARD:-strict}

llmProviderClient:
# Default: 3
# Description: Max amount of attempts to reach the LLM provider before giving up
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
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.Valid;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Pattern;
import jakarta.validation.constraints.Size;
Expand Down Expand Up @@ -41,6 +42,10 @@ public record ProviderApiKey(
@JsonView({View.Public.class, View.Write.class}) Map<String, String> configuration,
@JsonView({View.Public.class,
View.Write.class}) @Pattern(regexp = NULL_OR_NOT_BLANK, message = "must not be blank") String baseUrl,
@JsonView({View.Public.class,
View.Write.class}) @Valid @Schema(description = "Dynamic token auth recipe. When set, Opik fetches a short-lived bearer from the configured auth service instead of using a static api_key. "
+
"Only supported for custom providers. Secret credential values read back masked.") ProviderAuthConfig authConfig,
@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,
Expand All @@ -57,6 +62,7 @@ public String toString() {
", name='" + name + '\'' +
", providerName='" + providerName + '\'' +
", headers=" + headers +
", authConfig=" + authConfig +
", baseUrl='" + baseUrl + '\'' +
", createdAt=" + createdAt +
", createdBy='" + createdBy + '\'' +
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
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.Valid;
import jakarta.validation.constraints.Size;
import lombok.Builder;

Expand All @@ -27,7 +28,11 @@ public record ProviderApiKeyUpdate(
@JsonView({ProviderApiKey.View.Public.class, ProviderApiKey.View.Write.class}) Map<String, String> headers,
@JsonView({ProviderApiKey.View.Public.class,
ProviderApiKey.View.Write.class}) Map<String, String> configuration,
@JsonView({ProviderApiKey.View.Public.class, ProviderApiKey.View.Write.class}) String baseUrl) {
@JsonView({ProviderApiKey.View.Public.class, ProviderApiKey.View.Write.class}) String baseUrl,
@JsonView({ProviderApiKey.View.Public.class,
ProviderApiKey.View.Write.class}) @Valid @Schema(description = "Dynamic token auth recipe. Send the '"
+ ProviderAuthConfig.SECRET_SENTINEL
+ "' sentinel as a credential value to keep the stored secret; send an empty object to clear the auth config.") ProviderAuthConfig authConfig) {

@Override
public String toString() {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
package com.comet.opik.api;

import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import com.fasterxml.jackson.databind.PropertyNamingStrategies;
import com.fasterxml.jackson.databind.annotation.JsonNaming;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.Valid;
import lombok.Builder;

import java.util.UUID;

/**
* Test-connection request for a provider's dynamic token auth. Two modes, both executed by the
* backend: by {@code provider_id} alone the stored recipe is used (secrets never transit the
* browser); with an {@code auth_config} the submitted values are used, resolving
* {@code __SECRET__} sentinels against the stored recipe when {@code provider_id} is also given.
*/
@Builder(toBuilder = true)
@JsonIgnoreProperties(ignoreUnknown = true)
@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class)
public record ProviderAuthCheck(
@Schema(description = "Test the stored auth config of this provider; also the sentinel-resolution target when auth_config is sent") UUID providerId,
@Valid @Schema(description = "Auth config to test as-submitted; omit to test the stored one") ProviderAuthConfig authConfig) {

@Builder(toBuilder = true)
@JsonIgnoreProperties(ignoreUnknown = true)
@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class)
public record Result(
@Schema(description = "Lifetime of the fetched token in seconds; the token itself is never returned") long lifetimeSeconds) {
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
package com.comet.opik.api;

import com.comet.opik.utils.ValidationUtils;
import com.fasterxml.jackson.annotation.JsonCreator;
import com.fasterxml.jackson.annotation.JsonIgnore;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import com.fasterxml.jackson.annotation.JsonValue;
import com.fasterxml.jackson.annotation.JsonView;
import com.fasterxml.jackson.databind.PropertyNamingStrategies;
import com.fasterxml.jackson.databind.annotation.JsonNaming;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.Valid;
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
import lombok.Builder;
import lombok.Getter;
import lombok.RequiredArgsConstructor;
import org.apache.commons.collections4.CollectionUtils;

import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;

/**
* Recipe describing how to fetch a short-lived bearer token before calling a custom LLM provider.
* Stored as a single AES-GCM-encrypted JSON document in {@code llm_provider_api_key.auth_config};
* a row without one behaves exactly as before (static api_key auth).
*/
@Builder(toBuilder = true)
@JsonIgnoreProperties(ignoreUnknown = true)
@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class)
public record ProviderAuthConfig(
@JsonView({
ProviderApiKey.View.Public.class,
ProviderApiKey.View.Write.class}) @Schema(description = "Auth service URL the credentials are sent to", example = "https://developer.api.example.com/authentication/v1/token") String tokenUrl,
@JsonView({ProviderApiKey.View.Public.class,
ProviderApiKey.View.Write.class}) @Schema(description = "How credentials are sent: form body (default), JSON body, or basic auth (id/secret in an HTTP Basic header, remaining fields in the form body)") SendAs sendAs,
@JsonView({ProviderApiKey.View.Public.class,
ProviderApiKey.View.Write.class}) @Valid @Schema(description = "Fields sent to the token URL. Values flagged as secret are write-only: they read back as the '"
+ ProviderAuthConfig.SECRET_SENTINEL + "' sentinel") List<Credential> credentials,
@JsonView({ProviderApiKey.View.Public.class,
ProviderApiKey.View.Write.class}) @Size(max = 250) @Schema(description = "Field holding the token in the reply; dot-path for nested replies", example = "access_token") String tokenField,
@JsonView({ProviderApiKey.View.Public.class,
ProviderApiKey.View.Write.class}) @Size(max = 250) @Schema(description = "Field holding the token lifetime in seconds in the reply; dot-path for nested replies", example = "expires_in") String expiresField,
@JsonView({ProviderApiKey.View.Public.class,
ProviderApiKey.View.Write.class}) @Min(0) @Max(31_536_000) @Schema(description = "Lifetime in seconds assumed when the reply doesn't state one, capped at one year; 0 means such tokens are not cached (fetched per call)") Long fallbackTtlSeconds) {

public static final String SECRET_SENTINEL = "__SECRET__";
private static final ProviderAuthConfig EMPTY = ProviderAuthConfig.builder().build();

@Builder(toBuilder = true)
@JsonIgnoreProperties(ignoreUnknown = true)
@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class)
public record Credential(
@JsonView({
ProviderApiKey.View.Public.class,
ProviderApiKey.View.Write.class}) @NotBlank @Size(max = 250) String key,
@JsonView({ProviderApiKey.View.Public.class, ProviderApiKey.View.Write.class}) String value,
@JsonView({ProviderApiKey.View.Public.class,
ProviderApiKey.View.Write.class}) @Schema(description = "Secret values are encrypted at rest and never read back; once true it cannot be unset") boolean secret) {

@Override
public String toString() {
return "Credential{key='" + key + "', value='*******', secret=" + secret + '}';
}
}

@Getter
@RequiredArgsConstructor
public enum SendAs {
FORM("form"),
JSON("json"),
BASIC("basic"),
;

@JsonValue
private final String value;

@JsonCreator
public static SendAs fromString(String value) {
return Arrays.stream(values())
.filter(sendAs -> sendAs.value.equals(value))
.findFirst()
.orElseThrow(() -> new IllegalArgumentException("Unknown send_as '%s'".formatted(value)));
}
}

/**
* A literal empty object ({@code {}}) is the API convention for clearing the auth config on
* update, mirroring how an empty headers map clears headers.
*/
@JsonIgnore
public boolean isEmpty() {
return equals(EMPTY);
}
Comment thread
miguelgrc marked this conversation as resolved.

/**
* Requiredness rules shared by the create validator and the update path. Field-level Jakarta
* annotations can't carry these because the empty clear-object must pass bean validation.
*/
@JsonIgnore
public List<String> validationErrors() {
var errors = new ArrayList<String>();
if (!ValidationUtils.isAbsoluteUri(tokenUrl)) {
errors.add("auth_config.token_url must be a valid absolute URI");
}
if (CollectionUtils.isEmpty(credentials)) {
errors.add("auth_config.credentials must not be empty");
}
return errors;
}

/**
* Copy safe to return from the API: secret values are replaced with {@link #SECRET_SENTINEL}.
*/
public ProviderAuthConfig mask() {
if (CollectionUtils.isEmpty(credentials)) {
return this;
}
return toBuilder()
.credentials(credentials.stream()
.map(credential -> credential.secret()
? credential.toBuilder().value(SECRET_SENTINEL).build()
: credential)
.toList())
.build();
}

@Override
public String toString() {
return "ProviderAuthConfig{" +
"tokenUrl='" + tokenUrl + '\'' +
", sendAs=" + sendAs +
", credentials=" + credentials +
", tokenField='" + tokenField + '\'' +
", expiresField='" + expiresField + '\'' +
", fallbackTtlSeconds=" + fallbackTtlSeconds +
'}';
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,13 @@
import com.comet.opik.api.BatchDelete;
import com.comet.opik.api.ProviderApiKey;
import com.comet.opik.api.ProviderApiKeyUpdate;
import com.comet.opik.api.ProviderAuthCheck;
import com.comet.opik.api.error.ErrorMessage;
import com.comet.opik.domain.LlmProviderApiKeyService;
import com.comet.opik.infrastructure.auth.RequestContext;
import com.comet.opik.infrastructure.auth.RequiredPermissions;
import com.comet.opik.infrastructure.auth.WorkspaceUserPermission;
import com.comet.opik.infrastructure.ratelimit.RateLimited;
import com.fasterxml.jackson.annotation.JsonView;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.headers.Header;
Expand Down Expand Up @@ -71,6 +73,9 @@ public Response find() {
.apiKey(providerApiKey.apiKey() != null
? maskApiKey(decrypt(providerApiKey.apiKey()))
: "null")
.authConfig(providerApiKey.authConfig() != null
? providerApiKey.authConfig().mask()
: null)
.build())
.toList();

Expand Down Expand Up @@ -101,6 +106,7 @@ public Response getById(@PathParam("id") UUID id) {

return Response.ok().entity(providerApiKey.toBuilder()
.apiKey(providerApiKey.apiKey() != null ? maskApiKey(decrypt(providerApiKey.apiKey())) : null)
.authConfig(providerApiKey.authConfig() != null ? providerApiKey.authConfig().mask() : null)
.build()).build();
}

Expand Down Expand Up @@ -147,6 +153,30 @@ public Response updateApiKey(@PathParam("id") UUID id,
return Response.noContent().build();
}

@POST
@Path("/auth-config/test")
@RateLimited
@RequiredPermissions(WorkspaceUserPermission.AI_PROVIDER_UPDATE)
@Operation(operationId = "testLlmProviderAuthConfig", summary = "Test a provider's dynamic token auth", description = "Runs the token fetch once, backend-side, and reports the token lifetime. The token itself is never returned. "
+
"Send provider_id to test the stored config, auth_config to test submitted values, or both to resolve secret sentinels against the stored config.", responses = {
@ApiResponse(responseCode = "200", description = "Token fetched", content = @Content(schema = @Schema(implementation = ProviderAuthCheck.Result.class))),
@ApiResponse(responseCode = "400", 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 testAuthConfig(
@NotNull @RequestBody(content = @Content(schema = @Schema(implementation = ProviderAuthCheck.class))) @Valid ProviderAuthCheck providerAuthTest) {
String workspaceId = requestContext.get().getWorkspaceId();

log.info("Testing LLM provider auth config on workspace_id '{}'", workspaceId);
ProviderAuthCheck.Result result = llmProviderApiKeyService.testAuthConfig(providerAuthTest, workspaceId);
log.info("Tested LLM provider auth config on workspace_id '{}': token received, lifetime '{}'s",
workspaceId, result.lifetimeSeconds());

return Response.ok(result).build();
}

@POST
@Path("/delete")
@RequiredPermissions(WorkspaceUserPermission.AI_PROVIDER_UPDATE)
Expand Down
Original file line number Diff line number Diff line change
@@ -1,10 +1,14 @@
package com.comet.opik.api.validation;

import com.comet.opik.api.ProviderApiKey;
import com.comet.opik.api.ProviderAuthConfig;
import com.comet.opik.infrastructure.EncryptionUtils;
import jakarta.validation.ConstraintValidator;
import jakarta.validation.ConstraintValidatorContext;

import java.util.List;
import java.util.Optional;

import static org.apache.commons.lang3.StringUtils.isBlank;

public class ProviderApiKeyValidator
Expand Down Expand Up @@ -37,7 +41,15 @@ public boolean isValid(ProviderApiKey providerApiKey, ConstraintValidatorContext
}

// If provider supports naming, no need to validate api key
return true;
return isValidAuthConfig(providerApiKey, context);
}

if (providerApiKey.authConfig() != null) {
context.buildConstraintViolationWithTemplate(
"auth_config is only supported for providers with provider_name (custom LLM, Bedrock, Ollama)")
.addPropertyNode("authConfig")
.addConstraintViolation();
return false;
}

// Validate API key for non-custom providers
Expand All @@ -50,4 +62,40 @@ public boolean isValid(ProviderApiKey providerApiKey, ConstraintValidatorContext

return true;
}

private boolean isValidAuthConfig(ProviderApiKey providerApiKey, ConstraintValidatorContext context) {
var authConfig = providerApiKey.authConfig();
if (authConfig == null) {
return true;
}

boolean valid = true;
for (String error : authConfig.validationErrors()) {
context.buildConstraintViolationWithTemplate(error)
.addPropertyNode("authConfig")
.addConstraintViolation();
valid = false;
}

// The sentinel means "keep the stored secret" — meaningless on create, where nothing is stored yet
boolean hasSentinel = Optional.ofNullable(authConfig.credentials()).orElse(List.of()).stream()
.anyMatch(credential -> ProviderAuthConfig.SECRET_SENTINEL.equals(credential.value()));
if (hasSentinel) {
context.buildConstraintViolationWithTemplate(
"auth_config credential values must not be the '%s' sentinel on create"
.formatted(ProviderAuthConfig.SECRET_SENTINEL))
.addPropertyNode("authConfig")
.addConstraintViolation();
valid = false;
}

if (providerApiKey.apiKey() != null && !isBlank(EncryptionUtils.decrypt(providerApiKey.apiKey()))) {
context.buildConstraintViolationWithTemplate("api_key must not be set when auth_config is set")
.addPropertyNode("apiKey")
.addConstraintViolation();
valid = false;
}

return valid;
}
}
Loading
Loading