config) {
+ return config.map(Logger::from).orElse(DEFAULT);
+ }
+
+ private boolean shouldLog(LogLevel messageLevel) {
+ return !silent && level.getValue() <= messageLevel.getValue();
+ }
+
+ public boolean isDebug() {
+ return shouldLog(LogLevel.DEBUG);
+ }
+
+ public boolean isInfo() {
+ return shouldLog(LogLevel.INFO);
+ }
+
+ public boolean isWarn() {
+ return shouldLog(LogLevel.WARN);
+ }
+
+ public boolean isError() {
+ return shouldLog(LogLevel.ERROR);
+ }
+
+ public void debug(String message) {
+ if (isDebug()) {
+ logger.debug(message);
+ }
+ }
+
+ public void info(String message) {
+ if (isInfo()) {
+ logger.info(message);
+ }
+ }
+
+ public void warn(String message) {
+ if (isWarn()) {
+ logger.warn(message);
+ }
+ }
+
+ public void error(String message) {
+ if (isError()) {
+ logger.error(message);
+ }
+ }
+}
diff --git a/src/main/java/com/vital/api/core/LoggingInterceptor.java b/src/main/java/com/vital/api/core/LoggingInterceptor.java
new file mode 100644
index 00000000..a7896c89
--- /dev/null
+++ b/src/main/java/com/vital/api/core/LoggingInterceptor.java
@@ -0,0 +1,104 @@
+/**
+ * This file was auto-generated by Fern from our API Definition.
+ */
+package com.vital.api.core;
+
+import java.io.IOException;
+import java.util.Arrays;
+import java.util.HashSet;
+import java.util.Set;
+import okhttp3.Interceptor;
+import okhttp3.Request;
+import okhttp3.Response;
+
+/**
+ * OkHttp interceptor that logs HTTP requests and responses.
+ *
+ * Logs request method, URL, and headers (with sensitive values redacted) at debug level.
+ * Logs response status at debug level, and 4xx/5xx responses at error level.
+ * Does nothing if the logger is silent.
+ */
+public final class LoggingInterceptor implements Interceptor {
+
+ private static final Set SENSITIVE_HEADERS = new HashSet<>(Arrays.asList(
+ "authorization",
+ "www-authenticate",
+ "x-api-key",
+ "api-key",
+ "apikey",
+ "x-api-token",
+ "x-auth-token",
+ "auth-token",
+ "proxy-authenticate",
+ "proxy-authorization",
+ "cookie",
+ "set-cookie",
+ "x-csrf-token",
+ "x-xsrf-token",
+ "x-session-token",
+ "x-access-token"));
+
+ private final Logger logger;
+
+ public LoggingInterceptor(Logger logger) {
+ this.logger = logger;
+ }
+
+ @Override
+ public Response intercept(Chain chain) throws IOException {
+ Request request = chain.request();
+
+ if (logger.isDebug()) {
+ StringBuilder sb = new StringBuilder();
+ sb.append("HTTP Request: ").append(request.method()).append(" ").append(request.url());
+ sb.append(" headers={");
+ boolean first = true;
+ for (String name : request.headers().names()) {
+ if (!first) {
+ sb.append(", ");
+ }
+ sb.append(name).append("=");
+ if (SENSITIVE_HEADERS.contains(name.toLowerCase())) {
+ sb.append("[REDACTED]");
+ } else {
+ sb.append(request.header(name));
+ }
+ first = false;
+ }
+ sb.append("}");
+ sb.append(" has_body=").append(request.body() != null);
+ logger.debug(sb.toString());
+ }
+
+ Response response = chain.proceed(request);
+
+ if (logger.isDebug()) {
+ StringBuilder sb = new StringBuilder();
+ sb.append("HTTP Response: status=").append(response.code());
+ sb.append(" url=").append(response.request().url());
+ sb.append(" headers={");
+ boolean first = true;
+ for (String name : response.headers().names()) {
+ if (!first) {
+ sb.append(", ");
+ }
+ sb.append(name).append("=");
+ if (SENSITIVE_HEADERS.contains(name.toLowerCase())) {
+ sb.append("[REDACTED]");
+ } else {
+ sb.append(response.header(name));
+ }
+ first = false;
+ }
+ sb.append("}");
+ logger.debug(sb.toString());
+ }
+
+ if (response.code() >= 400 && logger.isError()) {
+ logger.error("HTTP Error: status=" + response.code() + " url="
+ + response.request().url());
+ }
+
+ return response;
+ }
+}
diff --git a/src/main/java/com/vital/api/core/SseEvent.java b/src/main/java/com/vital/api/core/SseEvent.java
new file mode 100644
index 00000000..779c1ec1
--- /dev/null
+++ b/src/main/java/com/vital/api/core/SseEvent.java
@@ -0,0 +1,114 @@
+/**
+ * This file was auto-generated by Fern from our API Definition.
+ */
+package com.vital.api.core;
+
+import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
+import com.fasterxml.jackson.annotation.JsonInclude;
+import com.fasterxml.jackson.annotation.JsonProperty;
+import java.util.Objects;
+import java.util.Optional;
+
+/**
+ * Represents a Server-Sent Event with all standard fields.
+ * Used for event-level discrimination where the discriminator is at the SSE envelope level.
+ *
+ * @param The type of the data field
+ */
+@JsonInclude(JsonInclude.Include.NON_ABSENT)
+@JsonIgnoreProperties(ignoreUnknown = true)
+public final class SseEvent {
+ private final String event;
+ private final T data;
+ private final String id;
+ private final Long retry;
+
+ private SseEvent(String event, T data, String id, Long retry) {
+ this.event = event;
+ this.data = data;
+ this.id = id;
+ this.retry = retry;
+ }
+
+ @JsonProperty("event")
+ public Optional getEvent() {
+ return Optional.ofNullable(event);
+ }
+
+ @JsonProperty("data")
+ public T getData() {
+ return data;
+ }
+
+ @JsonProperty("id")
+ public Optional getId() {
+ return Optional.ofNullable(id);
+ }
+
+ @JsonProperty("retry")
+ public Optional getRetry() {
+ return Optional.ofNullable(retry);
+ }
+
+ @Override
+ public boolean equals(Object o) {
+ if (this == o) return true;
+ if (o == null || getClass() != o.getClass()) return false;
+ SseEvent> sseEvent = (SseEvent>) o;
+ return Objects.equals(event, sseEvent.event)
+ && Objects.equals(data, sseEvent.data)
+ && Objects.equals(id, sseEvent.id)
+ && Objects.equals(retry, sseEvent.retry);
+ }
+
+ @Override
+ public int hashCode() {
+ return Objects.hash(event, data, id, retry);
+ }
+
+ @Override
+ public String toString() {
+ return "SseEvent{" + "event='"
+ + event + '\'' + ", data="
+ + data + ", id='"
+ + id + '\'' + ", retry="
+ + retry + '}';
+ }
+
+ public static Builder builder() {
+ return new Builder<>();
+ }
+
+ public static final class Builder {
+ private String event;
+ private T data;
+ private String id;
+ private Long retry;
+
+ private Builder() {}
+
+ public Builder event(String event) {
+ this.event = event;
+ return this;
+ }
+
+ public Builder data(T data) {
+ this.data = data;
+ return this;
+ }
+
+ public Builder id(String id) {
+ this.id = id;
+ return this;
+ }
+
+ public Builder retry(Long retry) {
+ this.retry = retry;
+ return this;
+ }
+
+ public SseEvent build() {
+ return new SseEvent<>(event, data, id, retry);
+ }
+ }
+}
diff --git a/src/main/java/com/vital/api/core/SseEventParser.java b/src/main/java/com/vital/api/core/SseEventParser.java
new file mode 100644
index 00000000..b5b9a668
--- /dev/null
+++ b/src/main/java/com/vital/api/core/SseEventParser.java
@@ -0,0 +1,228 @@
+/**
+ * This file was auto-generated by Fern from our API Definition.
+ */
+package com.vital.api.core;
+
+import com.fasterxml.jackson.annotation.JsonSubTypes;
+import com.fasterxml.jackson.annotation.JsonTypeInfo;
+import com.fasterxml.jackson.annotation.JsonTypeName;
+import com.fasterxml.jackson.core.type.TypeReference;
+import java.lang.reflect.Field;
+import java.util.Arrays;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.Map;
+import java.util.Optional;
+import java.util.Set;
+
+/**
+ * Utility class for parsing Server-Sent Events with support for discriminated unions.
+ *
+ * Handles two discrimination patterns:
+ *
+ * - Data-level discrimination: The discriminator (e.g., 'type') is inside the JSON data payload.
+ * Jackson's polymorphic deserialization handles this automatically.
+ * - Event-level discrimination: The discriminator (e.g., 'event') is at the SSE envelope level.
+ * This requires constructing the full SSE envelope for Jackson to process.
+ *
+ */
+public final class SseEventParser {
+
+ private static final Set SSE_ENVELOPE_FIELDS = new HashSet<>(Arrays.asList("event", "data", "id", "retry"));
+
+ private SseEventParser() {
+ // Utility class
+ }
+
+ /**
+ * Parse an SSE event using event-level discrimination.
+ *
+ * Constructs the full SSE envelope object with event, data, id, and retry fields,
+ * then deserializes it to the target union type.
+ *
+ * @param eventType The SSE event type (from event: field)
+ * @param data The SSE data content (from data: field)
+ * @param id The SSE event ID (from id: field), may be null
+ * @param retry The SSE retry value (from retry: field), may be null
+ * @param unionClass The target union class
+ * @param discriminatorProperty The property name used for discrimination (e.g., "event")
+ * @param The target type
+ * @return The deserialized object
+ */
+ public static T parseEventLevelUnion(
+ String eventType, String data, String id, Long retry, Class unionClass, String discriminatorProperty) {
+ try {
+ // Determine if data should be parsed as JSON based on the variant's expected type
+ Object parsedData = parseDataForVariant(eventType, data, unionClass, discriminatorProperty);
+
+ // Construct the SSE envelope object
+ Map envelope = new HashMap<>();
+ envelope.put(discriminatorProperty, eventType);
+ envelope.put("data", parsedData);
+ if (id != null) {
+ envelope.put("id", id);
+ }
+ if (retry != null) {
+ envelope.put("retry", retry);
+ }
+
+ // Serialize to JSON and deserialize to target type
+ String envelopeJson = ObjectMappers.JSON_MAPPER.writeValueAsString(envelope);
+ return ObjectMappers.JSON_MAPPER.readValue(envelopeJson, unionClass);
+ } catch (Exception e) {
+ throw new RuntimeException("Failed to parse SSE event with event-level discrimination", e);
+ }
+ }
+
+ /**
+ * Parse an SSE event using data-level discrimination.
+ *
+ * Simply parses the data field as JSON and deserializes it to the target type.
+ * Jackson's polymorphic deserialization handles the discrimination automatically.
+ *
+ * @param data The SSE data content (from data: field)
+ * @param valueType The target type
+ * @param The target type
+ * @return The deserialized object
+ */
+ public static T parseDataLevelUnion(String data, Class valueType) {
+ try {
+ return ObjectMappers.JSON_MAPPER.readValue(data, valueType);
+ } catch (Exception e) {
+ throw new RuntimeException("Failed to parse SSE data with data-level discrimination", e);
+ }
+ }
+
+ /**
+ * Determines if the given discriminator property indicates event-level discrimination.
+ * Event-level discrimination occurs when the discriminator is an SSE envelope field.
+ *
+ * @param discriminatorProperty The discriminator property name
+ * @return true if event-level discrimination, false otherwise
+ */
+ public static boolean isEventLevelDiscrimination(String discriminatorProperty) {
+ return SSE_ENVELOPE_FIELDS.contains(discriminatorProperty);
+ }
+
+ /**
+ * Attempts to find the discriminator property from the union class's Jackson annotations.
+ *
+ * @param unionClass The union class to inspect
+ * @return The discriminator property name, or empty if not found
+ */
+ public static Optional findDiscriminatorProperty(Class> unionClass) {
+ try {
+ // Look for JsonTypeInfo on the class itself
+ JsonTypeInfo typeInfo = unionClass.getAnnotation(JsonTypeInfo.class);
+ if (typeInfo != null && !typeInfo.property().isEmpty()) {
+ return Optional.of(typeInfo.property());
+ }
+
+ // Look for inner Value interface with JsonTypeInfo
+ for (Class> innerClass : unionClass.getDeclaredClasses()) {
+ typeInfo = innerClass.getAnnotation(JsonTypeInfo.class);
+ if (typeInfo != null && !typeInfo.property().isEmpty()) {
+ return Optional.of(typeInfo.property());
+ }
+ }
+ } catch (Exception e) {
+ // Ignore reflection errors
+ }
+ return Optional.empty();
+ }
+
+ /**
+ * Parse the data field based on what the matching variant expects.
+ * If the variant expects a String for its data field, returns the raw string.
+ * Otherwise, parses the data as JSON.
+ */
+ private static Object parseDataForVariant(
+ String eventType, String data, Class> unionClass, String discriminatorProperty) {
+ if (data == null || data.isEmpty()) {
+ return data;
+ }
+
+ try {
+ // Try to find the variant class that matches this event type
+ Class> variantClass = findVariantClass(unionClass, eventType, discriminatorProperty);
+ if (variantClass != null) {
+ // Check if the variant expects a String for the data field
+ Field dataField = findField(variantClass, "data");
+ if (dataField != null && String.class.equals(dataField.getType())) {
+ // Variant expects String - return raw data
+ return data;
+ }
+ }
+
+ // Try to parse as JSON
+ return ObjectMappers.JSON_MAPPER.readValue(data, new TypeReference