timeout) {
+ this.timeout = timeout;
+ return this;
+ }
+
+ /**
+ * Override the maximum number of retries. Defaults to 2 retries.
+ */
+ public Builder maxRetries(int maxRetries) {
+ this.maxRetries = maxRetries;
+ return this;
+ }
+
+ public Builder httpClient(OkHttpClient httpClient) {
+ this.httpClient = httpClient;
+ return this;
+ }
+
+ /**
+ * Set a custom WebSocketFactory for creating WebSocket connections.
+ */
+ public Builder webSocketFactory(WebSocketFactory webSocketFactory) {
+ this.webSocketFactory = Optional.of(webSocketFactory);
+ return this;
+ }
+
+ /**
+ * Configure logging for the SDK. Silent by default — no log output unless explicitly configured.
+ */
+ public Builder logging(LogConfig logging) {
+ this.logging = Optional.of(logging);
+ return this;
+ }
+
+ public ClientOptions build() {
+ OkHttpClient.Builder httpClientBuilder =
+ this.httpClient != null ? this.httpClient.newBuilder() : new OkHttpClient.Builder();
+
+ if (this.httpClient != null) {
+ timeout.ifPresent(timeout -> httpClientBuilder
+ .callTimeout(timeout, TimeUnit.SECONDS)
+ .connectTimeout(0, TimeUnit.SECONDS)
+ .writeTimeout(0, TimeUnit.SECONDS)
+ .readTimeout(0, TimeUnit.SECONDS));
+ } else {
+ httpClientBuilder
+ .callTimeout(this.timeout.orElse(60), TimeUnit.SECONDS)
+ .connectTimeout(0, TimeUnit.SECONDS)
+ .writeTimeout(0, TimeUnit.SECONDS)
+ .readTimeout(0, TimeUnit.SECONDS)
+ .addInterceptor(new RetryInterceptor(this.maxRetries));
+ }
+
+ Logger logger = Logger.from(this.logging);
+ httpClientBuilder.addInterceptor(new LoggingInterceptor(logger));
+
+ this.httpClient = httpClientBuilder.build();
+ this.timeout = Optional.of(httpClient.callTimeoutMillis() / 1000);
+
+ return new ClientOptions(
+ environment,
+ headers,
+ headerSuppliers,
+ httpClient,
+ this.timeout.get(),
+ this.maxRetries,
+ this.webSocketFactory,
+ this.logging);
+ }
+
+ /**
+ * Create a new Builder initialized with values from an existing ClientOptions
+ */
+ public static Builder from(ClientOptions clientOptions) {
+ Builder builder = new Builder();
+ builder.environment = clientOptions.environment();
+ builder.timeout = Optional.of(clientOptions.timeout(null));
+ builder.httpClient = clientOptions.httpClient();
+ builder.headers.putAll(clientOptions.headers);
+ builder.headerSuppliers.putAll(clientOptions.headerSuppliers);
+ builder.maxRetries = clientOptions.maxRetries();
+ builder.logging = clientOptions.logging();
+ return builder;
+ }
+ }
+}
diff --git a/src/main/java/com/deepgram/core/ConsoleLogger.java b/src/main/java/com/deepgram/core/ConsoleLogger.java
new file mode 100644
index 0000000..a0b46a9
--- /dev/null
+++ b/src/main/java/com/deepgram/core/ConsoleLogger.java
@@ -0,0 +1,51 @@
+/**
+ * This file was auto-generated by Fern from our API Definition.
+ */
+package com.deepgram.core;
+
+import java.util.logging.Level;
+
+/**
+ * Default logger implementation that writes to the console using {@link java.util.logging.Logger}.
+ *
+ * Uses the "fern" logger name with a simple format of "LEVEL - message".
+ */
+public final class ConsoleLogger implements ILogger {
+
+ private static final java.util.logging.Logger logger = java.util.logging.Logger.getLogger("fern");
+
+ static {
+ if (logger.getHandlers().length == 0) {
+ java.util.logging.ConsoleHandler handler = new java.util.logging.ConsoleHandler();
+ handler.setFormatter(new java.util.logging.SimpleFormatter() {
+ @Override
+ public String format(java.util.logging.LogRecord record) {
+ return record.getLevel() + " - " + record.getMessage() + System.lineSeparator();
+ }
+ });
+ logger.addHandler(handler);
+ logger.setUseParentHandlers(false);
+ logger.setLevel(Level.ALL);
+ }
+ }
+
+ @Override
+ public void debug(String message) {
+ logger.log(Level.FINE, message);
+ }
+
+ @Override
+ public void info(String message) {
+ logger.log(Level.INFO, message);
+ }
+
+ @Override
+ public void warn(String message) {
+ logger.log(Level.WARNING, message);
+ }
+
+ @Override
+ public void error(String message) {
+ logger.log(Level.SEVERE, message);
+ }
+}
diff --git a/src/main/java/com/deepgram/core/DateTimeDeserializer.java b/src/main/java/com/deepgram/core/DateTimeDeserializer.java
new file mode 100644
index 0000000..6eccd67
--- /dev/null
+++ b/src/main/java/com/deepgram/core/DateTimeDeserializer.java
@@ -0,0 +1,55 @@
+/**
+ * This file was auto-generated by Fern from our API Definition.
+ */
+package com.deepgram.core;
+
+import com.fasterxml.jackson.core.JsonParser;
+import com.fasterxml.jackson.core.JsonToken;
+import com.fasterxml.jackson.databind.DeserializationContext;
+import com.fasterxml.jackson.databind.JsonDeserializer;
+import com.fasterxml.jackson.databind.module.SimpleModule;
+import java.io.IOException;
+import java.time.Instant;
+import java.time.LocalDateTime;
+import java.time.OffsetDateTime;
+import java.time.ZoneOffset;
+import java.time.format.DateTimeFormatter;
+import java.time.temporal.TemporalAccessor;
+import java.time.temporal.TemporalQueries;
+
+/**
+ * Custom deserializer that handles converting ISO8601 dates into {@link OffsetDateTime} objects.
+ */
+class DateTimeDeserializer extends JsonDeserializer {
+ private static final SimpleModule MODULE;
+
+ static {
+ MODULE = new SimpleModule().addDeserializer(OffsetDateTime.class, new DateTimeDeserializer());
+ }
+
+ /**
+ * Gets a module wrapping this deserializer as an adapter for the Jackson ObjectMapper.
+ *
+ * @return A {@link SimpleModule} to be plugged onto Jackson ObjectMapper.
+ */
+ public static SimpleModule getModule() {
+ return MODULE;
+ }
+
+ @Override
+ public OffsetDateTime deserialize(JsonParser parser, DeserializationContext context) throws IOException {
+ JsonToken token = parser.currentToken();
+ if (token == JsonToken.VALUE_NUMBER_INT) {
+ return OffsetDateTime.ofInstant(Instant.ofEpochSecond(parser.getValueAsLong()), ZoneOffset.UTC);
+ } else {
+ TemporalAccessor temporal = DateTimeFormatter.ISO_DATE_TIME.parseBest(
+ parser.getValueAsString(), OffsetDateTime::from, LocalDateTime::from);
+
+ if (temporal.query(TemporalQueries.offset()) == null) {
+ return LocalDateTime.from(temporal).atOffset(ZoneOffset.UTC);
+ } else {
+ return OffsetDateTime.from(temporal);
+ }
+ }
+ }
+}
diff --git a/src/main/java/com/deepgram/core/DeepgramApiApiException.java b/src/main/java/com/deepgram/core/DeepgramApiApiException.java
new file mode 100644
index 0000000..1948db1
--- /dev/null
+++ b/src/main/java/com/deepgram/core/DeepgramApiApiException.java
@@ -0,0 +1,73 @@
+/**
+ * This file was auto-generated by Fern from our API Definition.
+ */
+package com.deepgram.core;
+
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import okhttp3.Response;
+
+/**
+ * This exception type will be thrown for any non-2XX API responses.
+ */
+public class DeepgramApiApiException extends DeepgramApiException {
+ /**
+ * The error code of the response that triggered the exception.
+ */
+ private final int statusCode;
+
+ /**
+ * The body of the response that triggered the exception.
+ */
+ private final Object body;
+
+ private final Map> headers;
+
+ public DeepgramApiApiException(String message, int statusCode, Object body) {
+ super(message);
+ this.statusCode = statusCode;
+ this.body = body;
+ this.headers = new HashMap<>();
+ }
+
+ public DeepgramApiApiException(String message, int statusCode, Object body, Response rawResponse) {
+ super(message);
+ this.statusCode = statusCode;
+ this.body = body;
+ this.headers = new HashMap<>();
+ rawResponse.headers().forEach(header -> {
+ String key = header.component1();
+ String value = header.component2();
+ this.headers.computeIfAbsent(key, _str -> new ArrayList<>()).add(value);
+ });
+ }
+
+ /**
+ * @return the statusCode
+ */
+ public int statusCode() {
+ return this.statusCode;
+ }
+
+ /**
+ * @return the body
+ */
+ public Object body() {
+ return this.body;
+ }
+
+ /**
+ * @return the headers
+ */
+ public Map> headers() {
+ return this.headers;
+ }
+
+ @Override
+ public String toString() {
+ return "DeepgramApiApiException{" + "message: " + getMessage() + ", statusCode: " + statusCode + ", body: "
+ + ObjectMappers.stringify(body) + "}";
+ }
+}
diff --git a/src/main/java/com/deepgram/core/DeepgramApiException.java b/src/main/java/com/deepgram/core/DeepgramApiException.java
new file mode 100644
index 0000000..c18833e
--- /dev/null
+++ b/src/main/java/com/deepgram/core/DeepgramApiException.java
@@ -0,0 +1,17 @@
+/**
+ * This file was auto-generated by Fern from our API Definition.
+ */
+package com.deepgram.core;
+
+/**
+ * This class serves as the base exception for all errors in the SDK.
+ */
+public class DeepgramApiException extends RuntimeException {
+ public DeepgramApiException(String message) {
+ super(message);
+ }
+
+ public DeepgramApiException(String message, Exception e) {
+ super(message, e);
+ }
+}
diff --git a/src/main/java/com/deepgram/core/DeepgramApiHttpResponse.java b/src/main/java/com/deepgram/core/DeepgramApiHttpResponse.java
new file mode 100644
index 0000000..1c177c9
--- /dev/null
+++ b/src/main/java/com/deepgram/core/DeepgramApiHttpResponse.java
@@ -0,0 +1,37 @@
+/**
+ * This file was auto-generated by Fern from our API Definition.
+ */
+package com.deepgram.core;
+
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import okhttp3.Response;
+
+public final class DeepgramApiHttpResponse {
+
+ private final T body;
+
+ private final Map> headers;
+
+ public DeepgramApiHttpResponse(T body, Response rawResponse) {
+ this.body = body;
+
+ Map> headers = new HashMap<>();
+ rawResponse.headers().forEach(header -> {
+ String key = header.component1();
+ String value = header.component2();
+ headers.computeIfAbsent(key, _str -> new ArrayList<>()).add(value);
+ });
+ this.headers = headers;
+ }
+
+ public T body() {
+ return this.body;
+ }
+
+ public Map> headers() {
+ return headers;
+ }
+}
diff --git a/src/main/java/com/deepgram/core/DisconnectReason.java b/src/main/java/com/deepgram/core/DisconnectReason.java
new file mode 100644
index 0000000..2a0a005
--- /dev/null
+++ b/src/main/java/com/deepgram/core/DisconnectReason.java
@@ -0,0 +1,26 @@
+/**
+ * This file was auto-generated by Fern from our API Definition.
+ */
+package com.deepgram.core;
+
+/**
+ * Reason for WebSocket disconnection.
+ */
+public class DisconnectReason {
+ private final int code;
+
+ private final String reason;
+
+ public DisconnectReason(int code, String reason) {
+ this.code = code;
+ this.reason = reason;
+ }
+
+ public int getCode() {
+ return code;
+ }
+
+ public String getReason() {
+ return reason;
+ }
+}
diff --git a/src/main/java/com/deepgram/core/DoubleSerializer.java b/src/main/java/com/deepgram/core/DoubleSerializer.java
new file mode 100644
index 0000000..7f902f8
--- /dev/null
+++ b/src/main/java/com/deepgram/core/DoubleSerializer.java
@@ -0,0 +1,43 @@
+/**
+ * This file was auto-generated by Fern from our API Definition.
+ */
+package com.deepgram.core;
+
+import com.fasterxml.jackson.core.JsonGenerator;
+import com.fasterxml.jackson.databind.JsonSerializer;
+import com.fasterxml.jackson.databind.SerializerProvider;
+import com.fasterxml.jackson.databind.module.SimpleModule;
+import java.io.IOException;
+
+/**
+ * Custom serializer that writes integer-valued doubles without a decimal point.
+ * For example, {@code 24000.0} is serialized as {@code 24000} instead of {@code 24000.0}.
+ * Non-integer values like {@code 3.14} are serialized normally.
+ */
+class DoubleSerializer extends JsonSerializer {
+ private static final SimpleModule MODULE;
+
+ static {
+ MODULE = new SimpleModule()
+ .addSerializer(Double.class, new DoubleSerializer())
+ .addSerializer(double.class, new DoubleSerializer());
+ }
+
+ /**
+ * Gets a module wrapping this serializer as an adapter for the Jackson ObjectMapper.
+ *
+ * @return A {@link SimpleModule} to be plugged onto Jackson ObjectMapper.
+ */
+ public static SimpleModule getModule() {
+ return MODULE;
+ }
+
+ @Override
+ public void serialize(Double value, JsonGenerator gen, SerializerProvider serializers) throws IOException {
+ if (value != null && value == Math.floor(value) && !Double.isInfinite(value) && !Double.isNaN(value)) {
+ gen.writeNumber(value.longValue());
+ } else {
+ gen.writeNumber(value);
+ }
+ }
+}
diff --git a/src/main/java/com/deepgram/core/Environment.java b/src/main/java/com/deepgram/core/Environment.java
new file mode 100644
index 0000000..681ef6c
--- /dev/null
+++ b/src/main/java/com/deepgram/core/Environment.java
@@ -0,0 +1,67 @@
+/**
+ * This file was auto-generated by Fern from our API Definition.
+ */
+package com.deepgram.core;
+
+public final class Environment {
+ public static final Environment PRODUCTION =
+ new Environment("https://api.deepgram.com", "wss://agent.deepgram.com", "wss://api.deepgram.com");
+
+ public static final Environment AGENT =
+ new Environment("https://agent.deepgram.com", "wss://agent.deepgram.com", "wss://api.deepgram.com");
+
+ private final String base;
+
+ private final String agent;
+
+ private final String production;
+
+ Environment(String base, String agent, String production) {
+ this.base = base;
+ this.agent = agent;
+ this.production = production;
+ }
+
+ public String getBaseURL() {
+ return this.base;
+ }
+
+ public String getAgentURL() {
+ return this.agent;
+ }
+
+ public String getProductionURL() {
+ return this.production;
+ }
+
+ public static Builder custom() {
+ return new Builder();
+ }
+
+ public static class Builder {
+ private String base;
+
+ private String agent;
+
+ private String production;
+
+ public Builder base(String base) {
+ this.base = base;
+ return this;
+ }
+
+ public Builder agent(String agent) {
+ this.agent = agent;
+ return this;
+ }
+
+ public Builder production(String production) {
+ this.production = production;
+ return this;
+ }
+
+ public Environment build() {
+ return new Environment(base, agent, production);
+ }
+ }
+}
diff --git a/src/main/java/com/deepgram/core/FileStream.java b/src/main/java/com/deepgram/core/FileStream.java
new file mode 100644
index 0000000..a476df6
--- /dev/null
+++ b/src/main/java/com/deepgram/core/FileStream.java
@@ -0,0 +1,60 @@
+/**
+ * This file was auto-generated by Fern from our API Definition.
+ */
+package com.deepgram.core;
+
+import java.io.InputStream;
+import java.util.Objects;
+import okhttp3.MediaType;
+import okhttp3.RequestBody;
+import org.jetbrains.annotations.Nullable;
+
+/**
+ * Represents a file stream with associated metadata for file uploads.
+ */
+public class FileStream {
+ private final InputStream inputStream;
+ private final String fileName;
+ private final MediaType contentType;
+
+ /**
+ * Constructs a FileStream with the given input stream and optional metadata.
+ *
+ * @param inputStream The input stream of the file content. Must not be null.
+ * @param fileName The name of the file, or null if unknown.
+ * @param contentType The MIME type of the file content, or null if unknown.
+ * @throws NullPointerException if inputStream is null
+ */
+ public FileStream(InputStream inputStream, @Nullable String fileName, @Nullable MediaType contentType) {
+ this.inputStream = Objects.requireNonNull(inputStream, "Input stream cannot be null");
+ this.fileName = fileName;
+ this.contentType = contentType;
+ }
+
+ public FileStream(InputStream inputStream) {
+ this(inputStream, null, null);
+ }
+
+ public InputStream getInputStream() {
+ return inputStream;
+ }
+
+ @Nullable
+ public String getFileName() {
+ return fileName;
+ }
+
+ @Nullable
+ public MediaType getContentType() {
+ return contentType;
+ }
+
+ /**
+ * Creates a RequestBody suitable for use with OkHttp client.
+ *
+ * @return A RequestBody instance representing this file stream.
+ */
+ public RequestBody toRequestBody() {
+ return new InputStreamRequestBody(contentType, inputStream);
+ }
+}
diff --git a/src/main/java/core/ILogger.java b/src/main/java/com/deepgram/core/ILogger.java
similarity index 68%
rename from src/main/java/core/ILogger.java
rename to src/main/java/com/deepgram/core/ILogger.java
index 4e1c3e5..4708b93 100644
--- a/src/main/java/core/ILogger.java
+++ b/src/main/java/com/deepgram/core/ILogger.java
@@ -1,14 +1,15 @@
-/** This file was auto-generated by Fern from our API Definition. */
-package core;
+/**
+ * This file was auto-generated by Fern from our API Definition.
+ */
+package com.deepgram.core;
/**
* Interface for custom logger implementations.
*
- * Implement this interface to provide a custom logging backend for the SDK. The SDK will call
- * the appropriate method based on the log level.
+ *
Implement this interface to provide a custom logging backend for the SDK.
+ * The SDK will call the appropriate method based on the log level.
*
*
Example:
- *
*
{@code
* public class MyCustomLogger implements ILogger {
* public void debug(String message) {
@@ -27,11 +28,11 @@
* }
*/
public interface ILogger {
- void debug(String message);
+ void debug(String message);
- void info(String message);
+ void info(String message);
- void warn(String message);
+ void warn(String message);
- void error(String message);
+ void error(String message);
}
diff --git a/src/main/java/com/deepgram/core/InputStreamRequestBody.java b/src/main/java/com/deepgram/core/InputStreamRequestBody.java
new file mode 100644
index 0000000..9c218c0
--- /dev/null
+++ b/src/main/java/com/deepgram/core/InputStreamRequestBody.java
@@ -0,0 +1,74 @@
+/**
+ * This file was auto-generated by Fern from our API Definition.
+ */
+package com.deepgram.core;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.util.Objects;
+import okhttp3.MediaType;
+import okhttp3.RequestBody;
+import okio.BufferedSink;
+import okio.Okio;
+import okio.Source;
+import org.jetbrains.annotations.Nullable;
+
+/**
+ * A custom implementation of OkHttp's RequestBody that wraps an InputStream.
+ * This class allows streaming of data from an InputStream directly to an HTTP request body,
+ * which is useful for file uploads or sending large amounts of data without loading it all into memory.
+ */
+public class InputStreamRequestBody extends RequestBody {
+ private final InputStream inputStream;
+ private final MediaType contentType;
+
+ /**
+ * Constructs an InputStreamRequestBody with the specified content type and input stream.
+ *
+ * @param contentType the MediaType of the content, or null if not known
+ * @param inputStream the InputStream containing the data to be sent
+ * @throws NullPointerException if inputStream is null
+ */
+ public InputStreamRequestBody(@Nullable MediaType contentType, InputStream inputStream) {
+ this.contentType = contentType;
+ this.inputStream = Objects.requireNonNull(inputStream, "inputStream == null");
+ }
+
+ /**
+ * Returns the content type of this request body.
+ *
+ * @return the MediaType of the content, or null if not specified
+ */
+ @Nullable
+ @Override
+ public MediaType contentType() {
+ return contentType;
+ }
+
+ /**
+ * Returns the content length of this request body, if known.
+ * This method attempts to determine the length using the InputStream's available() method,
+ * which may not always accurately reflect the total length of the stream.
+ *
+ * @return the content length, or -1 if the length is unknown
+ * @throws IOException if an I/O error occurs
+ */
+ @Override
+ public long contentLength() throws IOException {
+ return inputStream.available() == 0 ? -1 : inputStream.available();
+ }
+
+ /**
+ * Writes the content of the InputStream to the given BufferedSink.
+ * This method is responsible for transferring the data from the InputStream to the network request.
+ *
+ * @param sink the BufferedSink to write the content to
+ * @throws IOException if an I/O error occurs during writing
+ */
+ @Override
+ public void writeTo(BufferedSink sink) throws IOException {
+ try (Source source = Okio.source(inputStream)) {
+ sink.writeAll(source);
+ }
+ }
+}
diff --git a/src/main/java/com/deepgram/core/LogConfig.java b/src/main/java/com/deepgram/core/LogConfig.java
new file mode 100644
index 0000000..fc3678f
--- /dev/null
+++ b/src/main/java/com/deepgram/core/LogConfig.java
@@ -0,0 +1,98 @@
+/**
+ * This file was auto-generated by Fern from our API Definition.
+ */
+package com.deepgram.core;
+
+/**
+ * Configuration for SDK logging.
+ *
+ * Use the builder to configure logging behavior:
+ *
{@code
+ * LogConfig config = LogConfig.builder()
+ * .level(LogLevel.DEBUG)
+ * .silent(false)
+ * .build();
+ * }
+ *
+ * Or with a custom logger:
+ *
{@code
+ * LogConfig config = LogConfig.builder()
+ * .level(LogLevel.DEBUG)
+ * .logger(new MyCustomLogger())
+ * .silent(false)
+ * .build();
+ * }
+ *
+ * Defaults:
+ *
+ * - {@code level} — {@link LogLevel#INFO}
+ * - {@code logger} — {@link ConsoleLogger} (writes to stderr via java.util.logging)
+ * - {@code silent} — {@code true} (no output unless explicitly enabled)
+ *
+ */
+public final class LogConfig {
+
+ private final LogLevel level;
+ private final ILogger logger;
+ private final boolean silent;
+
+ private LogConfig(LogLevel level, ILogger logger, boolean silent) {
+ this.level = level;
+ this.logger = logger;
+ this.silent = silent;
+ }
+
+ public LogLevel level() {
+ return level;
+ }
+
+ public ILogger logger() {
+ return logger;
+ }
+
+ public boolean silent() {
+ return silent;
+ }
+
+ public static Builder builder() {
+ return new Builder();
+ }
+
+ public static final class Builder {
+ private LogLevel level = LogLevel.INFO;
+ private ILogger logger = new ConsoleLogger();
+ private boolean silent = true;
+
+ private Builder() {}
+
+ /**
+ * Set the minimum log level. Only messages at this level or above will be logged.
+ * Defaults to {@link LogLevel#INFO}.
+ */
+ public Builder level(LogLevel level) {
+ this.level = level;
+ return this;
+ }
+
+ /**
+ * Set a custom logger implementation. Defaults to {@link ConsoleLogger}.
+ */
+ public Builder logger(ILogger logger) {
+ this.logger = logger;
+ return this;
+ }
+
+ /**
+ * Set whether logging is silent (disabled). Defaults to {@code true}.
+ * Set to {@code false} to enable log output.
+ */
+ public Builder silent(boolean silent) {
+ this.silent = silent;
+ return this;
+ }
+
+ public LogConfig build() {
+ return new LogConfig(level, logger, silent);
+ }
+ }
+}
diff --git a/src/main/java/com/deepgram/core/LogLevel.java b/src/main/java/com/deepgram/core/LogLevel.java
new file mode 100644
index 0000000..48738a4
--- /dev/null
+++ b/src/main/java/com/deepgram/core/LogLevel.java
@@ -0,0 +1,36 @@
+/**
+ * This file was auto-generated by Fern from our API Definition.
+ */
+package com.deepgram.core;
+
+/**
+ * Log levels for SDK logging configuration.
+ * Silent by default — no log output unless explicitly configured.
+ */
+public enum LogLevel {
+ DEBUG(1),
+ INFO(2),
+ WARN(3),
+ ERROR(4);
+
+ private final int value;
+
+ LogLevel(int value) {
+ this.value = value;
+ }
+
+ public int getValue() {
+ return value;
+ }
+
+ /**
+ * Parse a log level from a string (case-insensitive).
+ *
+ * @param level the level string (debug, info, warn, error)
+ * @return the corresponding LogLevel
+ * @throws IllegalArgumentException if the string does not match any level
+ */
+ public static LogLevel fromString(String level) {
+ return LogLevel.valueOf(level.toUpperCase());
+ }
+}
diff --git a/src/main/java/com/deepgram/core/Logger.java b/src/main/java/com/deepgram/core/Logger.java
new file mode 100644
index 0000000..b0d95dc
--- /dev/null
+++ b/src/main/java/com/deepgram/core/Logger.java
@@ -0,0 +1,97 @@
+/**
+ * This file was auto-generated by Fern from our API Definition.
+ */
+package com.deepgram.core;
+
+/**
+ * SDK logger that filters messages based on level and silent mode.
+ *
+ * Silent by default — no log output unless explicitly configured.
+ * Create via {@link LogConfig} or directly:
+ *
{@code
+ * Logger logger = new Logger(LogLevel.DEBUG, new ConsoleLogger(), false);
+ * logger.debug("request sent");
+ * }
+ */
+public final class Logger {
+
+ private static final Logger DEFAULT = new Logger(LogLevel.INFO, new ConsoleLogger(), true);
+
+ private final LogLevel level;
+ private final ILogger logger;
+ private final boolean silent;
+
+ public Logger(LogLevel level, ILogger logger, boolean silent) {
+ this.level = level;
+ this.logger = logger;
+ this.silent = silent;
+ }
+
+ /**
+ * Returns a default silent logger (no output).
+ */
+ public static Logger getDefault() {
+ return DEFAULT;
+ }
+
+ /**
+ * Creates a Logger from a {@link LogConfig}. If config is {@code null}, returns the default silent logger.
+ */
+ public static Logger from(LogConfig config) {
+ if (config == null) {
+ return DEFAULT;
+ }
+ return new Logger(config.level(), config.logger(), config.silent());
+ }
+
+ /**
+ * Creates a Logger from an {@code Optional}. If empty, returns the default silent logger.
+ */
+ public static Logger from(java.util.Optional 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/deepgram/core/LoggingInterceptor.java b/src/main/java/com/deepgram/core/LoggingInterceptor.java
new file mode 100644
index 0000000..ea3c5ea
--- /dev/null
+++ b/src/main/java/com/deepgram/core/LoggingInterceptor.java
@@ -0,0 +1,104 @@
+/**
+ * This file was auto-generated by Fern from our API Definition.
+ */
+package com.deepgram.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/deepgram/core/MediaTypes.java b/src/main/java/com/deepgram/core/MediaTypes.java
new file mode 100644
index 0000000..320f0e0
--- /dev/null
+++ b/src/main/java/com/deepgram/core/MediaTypes.java
@@ -0,0 +1,13 @@
+/**
+ * This file was auto-generated by Fern from our API Definition.
+ */
+package com.deepgram.core;
+
+import okhttp3.MediaType;
+
+public final class MediaTypes {
+
+ public static final MediaType APPLICATION_JSON = MediaType.parse("application/json");
+
+ private MediaTypes() {}
+}
diff --git a/src/main/java/com/deepgram/core/Nullable.java b/src/main/java/com/deepgram/core/Nullable.java
new file mode 100644
index 0000000..044fe7d
--- /dev/null
+++ b/src/main/java/com/deepgram/core/Nullable.java
@@ -0,0 +1,140 @@
+/**
+ * This file was auto-generated by Fern from our API Definition.
+ */
+package com.deepgram.core;
+
+import java.util.Optional;
+import java.util.function.Function;
+
+public final class Nullable {
+
+ private final Either, Null> value;
+
+ private Nullable() {
+ this.value = Either.left(Optional.empty());
+ }
+
+ private Nullable(T value) {
+ if (value == null) {
+ this.value = Either.right(Null.INSTANCE);
+ } else {
+ this.value = Either.left(Optional.of(value));
+ }
+ }
+
+ public static Nullable ofNull() {
+ return new Nullable<>(null);
+ }
+
+ public static Nullable of(T value) {
+ return new Nullable<>(value);
+ }
+
+ public static Nullable empty() {
+ return new Nullable<>();
+ }
+
+ public static Nullable ofOptional(Optional value) {
+ if (value.isPresent()) {
+ return of(value.get());
+ } else {
+ return empty();
+ }
+ }
+
+ public boolean isNull() {
+ return this.value.isRight();
+ }
+
+ public boolean isEmpty() {
+ return this.value.isLeft() && !this.value.getLeft().isPresent();
+ }
+
+ public T get() {
+ if (this.isNull()) {
+ return null;
+ }
+
+ return this.value.getLeft().get();
+ }
+
+ public Nullable map(Function super T, ? extends U> mapper) {
+ if (this.isNull()) {
+ return Nullable.ofNull();
+ }
+
+ return Nullable.ofOptional(this.value.getLeft().map(mapper));
+ }
+
+ @Override
+ public boolean equals(Object other) {
+ if (!(other instanceof Nullable)) {
+ return false;
+ }
+
+ if (((Nullable>) other).isNull() && this.isNull()) {
+ return true;
+ }
+
+ return this.value.getLeft().equals(((Nullable>) other).value.getLeft());
+ }
+
+ private static final class Either {
+ private L left = null;
+ private R right = null;
+
+ private Either(L left, R right) {
+ if (left != null && right != null) {
+ throw new IllegalArgumentException("Left and right argument cannot both be non-null.");
+ }
+
+ if (left == null && right == null) {
+ throw new IllegalArgumentException("Left and right argument cannot both be null.");
+ }
+
+ if (left != null) {
+ this.left = left;
+ }
+
+ if (right != null) {
+ this.right = right;
+ }
+ }
+
+ public static Either left(L left) {
+ return new Either<>(left, null);
+ }
+
+ public static Either right(R right) {
+ return new Either<>(null, right);
+ }
+
+ public boolean isLeft() {
+ return this.left != null;
+ }
+
+ public boolean isRight() {
+ return this.right != null;
+ }
+
+ public L getLeft() {
+ if (!this.isLeft()) {
+ throw new IllegalArgumentException("Cannot get left from right Either.");
+ }
+ return this.left;
+ }
+
+ public R getRight() {
+ if (!this.isRight()) {
+ throw new IllegalArgumentException("Cannot get right from left Either.");
+ }
+ return this.right;
+ }
+ }
+
+ private static final class Null {
+ private static final Null INSTANCE = new Null();
+
+ private Null() {}
+ }
+}
diff --git a/src/main/java/com/deepgram/core/NullableNonemptyFilter.java b/src/main/java/com/deepgram/core/NullableNonemptyFilter.java
new file mode 100644
index 0000000..3eb1740
--- /dev/null
+++ b/src/main/java/com/deepgram/core/NullableNonemptyFilter.java
@@ -0,0 +1,22 @@
+/**
+ * This file was auto-generated by Fern from our API Definition.
+ */
+package com.deepgram.core;
+
+import java.util.Optional;
+
+public final class NullableNonemptyFilter {
+ @Override
+ public boolean equals(Object o) {
+ boolean isOptionalEmpty = isOptionalEmpty(o);
+
+ return isOptionalEmpty;
+ }
+
+ private boolean isOptionalEmpty(Object o) {
+ if (o instanceof Optional) {
+ return !((Optional>) o).isPresent();
+ }
+ return false;
+ }
+}
diff --git a/src/main/java/com/deepgram/core/ObjectMappers.java b/src/main/java/com/deepgram/core/ObjectMappers.java
new file mode 100644
index 0000000..e79a331
--- /dev/null
+++ b/src/main/java/com/deepgram/core/ObjectMappers.java
@@ -0,0 +1,46 @@
+/**
+ * This file was auto-generated by Fern from our API Definition.
+ */
+package com.deepgram.core;
+
+import com.fasterxml.jackson.annotation.JsonInclude;
+import com.fasterxml.jackson.core.JsonProcessingException;
+import com.fasterxml.jackson.databind.DeserializationFeature;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.fasterxml.jackson.databind.SerializationFeature;
+import com.fasterxml.jackson.databind.json.JsonMapper;
+import com.fasterxml.jackson.datatype.jdk8.Jdk8Module;
+import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
+import java.io.IOException;
+
+public final class ObjectMappers {
+ public static final ObjectMapper JSON_MAPPER = JsonMapper.builder()
+ .addModule(new Jdk8Module())
+ .addModule(new JavaTimeModule())
+ .addModule(DateTimeDeserializer.getModule())
+ .addModule(DoubleSerializer.getModule())
+ .disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES)
+ .disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS)
+ .build();
+
+ private ObjectMappers() {}
+
+ public static String stringify(Object o) {
+ try {
+ return JSON_MAPPER
+ .setSerializationInclusion(JsonInclude.Include.ALWAYS)
+ .writerWithDefaultPrettyPrinter()
+ .writeValueAsString(o);
+ } catch (IOException e) {
+ return o.getClass().getName() + "@" + Integer.toHexString(o.hashCode());
+ }
+ }
+
+ public static Object parseErrorBody(String responseBodyString) {
+ try {
+ return JSON_MAPPER.readValue(responseBodyString, Object.class);
+ } catch (JsonProcessingException ignored) {
+ return responseBodyString;
+ }
+ }
+}
diff --git a/src/main/java/com/deepgram/core/OkHttpWebSocketFactory.java b/src/main/java/com/deepgram/core/OkHttpWebSocketFactory.java
new file mode 100644
index 0000000..70e6c86
--- /dev/null
+++ b/src/main/java/com/deepgram/core/OkHttpWebSocketFactory.java
@@ -0,0 +1,31 @@
+/**
+ * This file was auto-generated by Fern from our API Definition.
+ */
+package com.deepgram.core;
+
+import okhttp3.OkHttpClient;
+import okhttp3.Request;
+import okhttp3.WebSocket;
+import okhttp3.WebSocketListener;
+
+/**
+ * Default WebSocketFactory implementation using OkHttpClient.
+ * This factory delegates WebSocket creation to the provided OkHttpClient instance.
+ */
+public final class OkHttpWebSocketFactory implements WebSocketFactory {
+ private final OkHttpClient okHttpClient;
+
+ /**
+ * Creates a new OkHttpWebSocketFactory with the specified OkHttpClient.
+ *
+ * @param okHttpClient The OkHttpClient instance to use for creating WebSockets
+ */
+ public OkHttpWebSocketFactory(OkHttpClient okHttpClient) {
+ this.okHttpClient = okHttpClient;
+ }
+
+ @Override
+ public WebSocket create(Request request, WebSocketListener listener) {
+ return okHttpClient.newWebSocket(request, listener);
+ }
+}
diff --git a/src/main/java/com/deepgram/core/QueryStringMapper.java b/src/main/java/com/deepgram/core/QueryStringMapper.java
new file mode 100644
index 0000000..d0e0361
--- /dev/null
+++ b/src/main/java/com/deepgram/core/QueryStringMapper.java
@@ -0,0 +1,142 @@
+/**
+ * This file was auto-generated by Fern from our API Definition.
+ */
+package com.deepgram.core;
+
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.fasterxml.jackson.databind.node.ArrayNode;
+import com.fasterxml.jackson.databind.node.ObjectNode;
+import java.util.AbstractMap;
+import java.util.ArrayList;
+import java.util.Iterator;
+import java.util.List;
+import java.util.Map;
+import okhttp3.HttpUrl;
+import okhttp3.MultipartBody;
+
+public class QueryStringMapper {
+
+ private static final ObjectMapper MAPPER = ObjectMappers.JSON_MAPPER;
+
+ public static void addQueryParameter(HttpUrl.Builder httpUrl, String key, Object value, boolean arraysAsRepeats) {
+ JsonNode valueNode = MAPPER.valueToTree(value);
+
+ List> flat;
+ if (valueNode.isObject()) {
+ flat = flattenObject((ObjectNode) valueNode, arraysAsRepeats);
+ } else if (valueNode.isArray()) {
+ flat = flattenArray((ArrayNode) valueNode, "", arraysAsRepeats);
+ } else {
+ if (valueNode.isTextual()) {
+ httpUrl.addQueryParameter(key, valueNode.textValue());
+ } else {
+ httpUrl.addQueryParameter(key, valueNode.toString());
+ }
+ return;
+ }
+
+ for (Map.Entry field : flat) {
+ if (field.getValue().isTextual()) {
+ httpUrl.addQueryParameter(key + field.getKey(), field.getValue().textValue());
+ } else {
+ httpUrl.addQueryParameter(key + field.getKey(), field.getValue().toString());
+ }
+ }
+ }
+
+ public static void addFormDataPart(
+ MultipartBody.Builder multipartBody, String key, Object value, boolean arraysAsRepeats) {
+ JsonNode valueNode = MAPPER.valueToTree(value);
+
+ List> flat;
+ if (valueNode.isObject()) {
+ flat = flattenObject((ObjectNode) valueNode, arraysAsRepeats);
+ } else if (valueNode.isArray()) {
+ flat = flattenArray((ArrayNode) valueNode, "", arraysAsRepeats);
+ } else {
+ if (valueNode.isTextual()) {
+ multipartBody.addFormDataPart(key, valueNode.textValue());
+ } else {
+ multipartBody.addFormDataPart(key, valueNode.toString());
+ }
+ return;
+ }
+
+ for (Map.Entry field : flat) {
+ if (field.getValue().isTextual()) {
+ multipartBody.addFormDataPart(
+ key + field.getKey(), field.getValue().textValue());
+ } else {
+ multipartBody.addFormDataPart(
+ key + field.getKey(), field.getValue().toString());
+ }
+ }
+ }
+
+ public static List> flattenObject(ObjectNode object, boolean arraysAsRepeats) {
+ List> flat = new ArrayList<>();
+
+ Iterator> fields = object.fields();
+ while (fields.hasNext()) {
+ Map.Entry field = fields.next();
+
+ String key = "[" + field.getKey() + "]";
+
+ if (field.getValue().isObject()) {
+ List> flatField =
+ flattenObject((ObjectNode) field.getValue(), arraysAsRepeats);
+ addAll(flat, flatField, key);
+ } else if (field.getValue().isArray()) {
+ List> flatField =
+ flattenArray((ArrayNode) field.getValue(), key, arraysAsRepeats);
+ addAll(flat, flatField, "");
+ } else {
+ flat.add(new AbstractMap.SimpleEntry<>(key, field.getValue()));
+ }
+ }
+
+ return flat;
+ }
+
+ private static List> flattenArray(
+ ArrayNode array, String key, boolean arraysAsRepeats) {
+ List> flat = new ArrayList<>();
+
+ Iterator elements = array.elements();
+
+ int index = 0;
+ while (elements.hasNext()) {
+ JsonNode element = elements.next();
+
+ String indexKey = key + "[" + index + "]";
+
+ if (arraysAsRepeats) {
+ indexKey = key;
+ }
+
+ if (element.isObject()) {
+ List> flatField = flattenObject((ObjectNode) element, arraysAsRepeats);
+ addAll(flat, flatField, indexKey);
+ } else if (element.isArray()) {
+ List> flatField = flattenArray((ArrayNode) element, "", arraysAsRepeats);
+ addAll(flat, flatField, indexKey);
+ } else {
+ flat.add(new AbstractMap.SimpleEntry<>(indexKey, element));
+ }
+
+ index++;
+ }
+
+ return flat;
+ }
+
+ private static void addAll(
+ List> target, List> source, String prefix) {
+ for (Map.Entry entry : source) {
+ Map.Entry entryToAdd =
+ new AbstractMap.SimpleEntry<>(prefix + entry.getKey(), entry.getValue());
+ target.add(entryToAdd);
+ }
+ }
+}
diff --git a/src/main/java/com/deepgram/core/ReconnectingWebSocketListener.java b/src/main/java/com/deepgram/core/ReconnectingWebSocketListener.java
new file mode 100644
index 0000000..0ca455a
--- /dev/null
+++ b/src/main/java/com/deepgram/core/ReconnectingWebSocketListener.java
@@ -0,0 +1,494 @@
+/**
+ * This file was auto-generated by Fern from our API Definition.
+ */
+package com.deepgram.core;
+
+import static java.util.concurrent.TimeUnit.*;
+
+import java.util.ArrayList;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.ConcurrentLinkedQueue;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.Executors;
+import java.util.concurrent.ScheduledExecutorService;
+import java.util.concurrent.TimeoutException;
+import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.concurrent.atomic.AtomicInteger;
+import java.util.function.Supplier;
+import okhttp3.Response;
+import okhttp3.WebSocket;
+import okhttp3.WebSocketListener;
+import okio.ByteString;
+
+/**
+ * WebSocketListener with automatic reconnection, exponential backoff, and message queuing.
+ * Provides production-ready resilience for WebSocket connections.
+ */
+public abstract class ReconnectingWebSocketListener extends WebSocketListener {
+ private final long minReconnectionDelayMs;
+
+ private final long maxReconnectionDelayMs;
+
+ private final double reconnectionDelayGrowFactor;
+
+ private final int maxRetries;
+
+ private final int maxEnqueuedMessages;
+
+ private final AtomicInteger retryCount = new AtomicInteger(0);
+
+ private final AtomicBoolean connectLock = new AtomicBoolean(false);
+
+ private final AtomicBoolean shouldReconnect = new AtomicBoolean(true);
+
+ protected volatile WebSocket webSocket;
+
+ private volatile long connectionEstablishedTime = 0L;
+
+ private final ConcurrentLinkedQueue messageQueue = new ConcurrentLinkedQueue<>();
+
+ private final ConcurrentLinkedQueue binaryMessageQueue = new ConcurrentLinkedQueue<>();
+
+ private final ScheduledExecutorService reconnectExecutor = Executors.newSingleThreadScheduledExecutor();
+
+ private final Supplier extends WebSocket> connectionSupplier;
+
+ /**
+ * Creates a new reconnecting WebSocket listener.
+ *
+ * @param options Reconnection configuration options
+ * @param connectionSupplier Supplier that creates new WebSocket connections
+ */
+ public ReconnectingWebSocketListener(
+ ReconnectingWebSocketListener.ReconnectOptions options, Supplier extends WebSocket> connectionSupplier) {
+ this.minReconnectionDelayMs = options.minReconnectionDelayMs;
+ this.maxReconnectionDelayMs = options.maxReconnectionDelayMs;
+ this.reconnectionDelayGrowFactor = options.reconnectionDelayGrowFactor;
+ this.maxRetries = options.maxRetries;
+ this.maxEnqueuedMessages = options.maxEnqueuedMessages;
+ this.connectionSupplier = connectionSupplier;
+ }
+
+ /**
+ * Initiates a WebSocket connection with automatic reconnection enabled.
+ *
+ * Connection behavior:
+ * - Times out after 4000 milliseconds
+ * - Thread-safe via atomic lock (returns immediately if connection in progress)
+ * - Retry count not incremented for initial connection attempt
+ *
+ * Error handling:
+ * - TimeoutException: Includes retry attempt context
+ * - InterruptedException: Preserves thread interruption status
+ * - ExecutionException: Extracts actual cause and adds context
+ */
+ public void connect() {
+ if (!connectLock.compareAndSet(false, true)) {
+ return;
+ }
+ if (retryCount.get() >= maxRetries) {
+ connectLock.set(false);
+ return;
+ }
+ try {
+ CompletableFuture extends WebSocket> connectionFuture = CompletableFuture.supplyAsync(connectionSupplier);
+ try {
+ webSocket = connectionFuture.get(4000, MILLISECONDS);
+ } catch (TimeoutException e) {
+ connectionFuture.cancel(true);
+ TimeoutException timeoutError =
+ new TimeoutException("WebSocket connection timeout after " + 4000 + " milliseconds"
+ + (retryCount.get() > 0
+ ? " (retry attempt #" + retryCount.get()
+ : " (initial connection attempt)"));
+ onWebSocketFailure(null, timeoutError, null);
+ if (shouldReconnect.get()) {
+ scheduleReconnect();
+ }
+ } catch (InterruptedException e) {
+ connectionFuture.cancel(true);
+ Thread.currentThread().interrupt();
+ InterruptedException interruptError = new InterruptedException("WebSocket connection interrupted"
+ + (retryCount.get() > 0
+ ? " during retry attempt #" + retryCount.get()
+ : " during initial connection"));
+ interruptError.initCause(e);
+ onWebSocketFailure(null, interruptError, null);
+ } catch (ExecutionException e) {
+ Throwable cause = e.getCause() != null ? e.getCause() : e;
+ String context = retryCount.get() > 0
+ ? "WebSocket connection failed during retry attempt #" + retryCount.get()
+ : "WebSocket connection failed during initial attempt";
+ RuntimeException wrappedException = new RuntimeException(
+ context + ": " + cause.getClass().getSimpleName() + ": " + cause.getMessage());
+ wrappedException.initCause(cause);
+ onWebSocketFailure(null, wrappedException, null);
+ if (shouldReconnect.get()) {
+ scheduleReconnect();
+ }
+ }
+ } finally {
+ connectLock.set(false);
+ }
+ }
+
+ /**
+ * Disconnects the WebSocket and disables automatic reconnection.
+ *
+ * This method:
+ * - Disables automatic reconnection
+ * - Clears queued messages to prevent stale data
+ * - Closes the WebSocket with standard close code 1000
+ * - Properly shuts down the reconnect executor to prevent thread leaks
+ * - Waits up to 5 seconds for executor termination
+ */
+ public void disconnect() {
+ shouldReconnect.set(false);
+ messageQueue.clear();
+ binaryMessageQueue.clear();
+ if (webSocket != null) {
+ webSocket.close(1000, "Client disconnecting");
+ }
+ reconnectExecutor.shutdown();
+ try {
+ if (!reconnectExecutor.awaitTermination(5, SECONDS)) {
+ reconnectExecutor.shutdownNow();
+ }
+ } catch (InterruptedException e) {
+ reconnectExecutor.shutdownNow();
+ Thread.currentThread().interrupt();
+ }
+ }
+
+ /**
+ * Sends a message or queues it if not connected.
+ *
+ * Thread-safe: Synchronized to prevent race conditions with flushMessageQueue().
+ *
+ * Behavior:
+ * - If connected: Attempts direct send, queues if buffer full
+ * - If disconnected: Queues message up to maxEnqueuedMessages limit
+ * - If queue full: Message is dropped
+ *
+ * @param message The message to send
+ * @return true if sent immediately, false if queued or dropped
+ */
+ public synchronized boolean send(String message) {
+ WebSocket ws = webSocket;
+ if (ws != null) {
+ boolean sent = ws.send(message);
+ if (!sent && messageQueue.size() < maxEnqueuedMessages) {
+ messageQueue.offer(message);
+ return false;
+ }
+ return sent;
+ } else {
+ if (messageQueue.size() < maxEnqueuedMessages) {
+ messageQueue.offer(message);
+ return false;
+ }
+ return false;
+ }
+ }
+
+ /**
+ * Sends binary data or queues it if not connected.
+ *
+ * Thread-safe: Synchronized to prevent race conditions with flushMessageQueue().
+ *
+ * Behavior:
+ * - If connected: Attempts direct send, queues if buffer full
+ * - If disconnected: Queues data up to maxEnqueuedMessages limit
+ * - If queue full: Data is dropped
+ *
+ * @param data The binary data to send
+ * @return true if sent immediately, false if queued or dropped
+ */
+ public synchronized boolean sendBinary(ByteString data) {
+ WebSocket ws = webSocket;
+ if (ws != null) {
+ boolean sent = ws.send(data);
+ if (!sent && binaryMessageQueue.size() < maxEnqueuedMessages) {
+ binaryMessageQueue.offer(data);
+ return false;
+ }
+ return sent;
+ } else {
+ if (binaryMessageQueue.size() < maxEnqueuedMessages) {
+ binaryMessageQueue.offer(data);
+ return false;
+ }
+ return false;
+ }
+ }
+
+ /**
+ * Gets the current WebSocket instance.
+ * Thread-safe method to access the WebSocket connection.
+ * @return the WebSocket or null if not connected
+ */
+ public WebSocket getWebSocket() {
+ return webSocket;
+ }
+
+ /**
+ * @hidden
+ */
+ @Override
+ public void onOpen(WebSocket webSocket, Response response) {
+ this.webSocket = webSocket;
+ connectionEstablishedTime = System.currentTimeMillis();
+ retryCount.set(0);
+ flushMessageQueue();
+ onWebSocketOpen(webSocket, response);
+ }
+
+ @Override
+ public void onMessage(WebSocket webSocket, String text) {
+ onWebSocketMessage(webSocket, text);
+ }
+
+ @Override
+ public void onMessage(WebSocket webSocket, ByteString bytes) {
+ onWebSocketBinaryMessage(webSocket, bytes);
+ }
+
+ /**
+ * @hidden
+ */
+ @Override
+ public void onFailure(WebSocket webSocket, Throwable t, Response response) {
+ this.webSocket = null;
+ long uptime = 0L;
+ if (connectionEstablishedTime > 0) {
+ uptime = System.currentTimeMillis() - connectionEstablishedTime;
+ if (uptime >= 5000) {
+ retryCount.set(0);
+ }
+ }
+ connectionEstablishedTime = 0L;
+ Throwable enhancedError = t;
+ if (t != null) {
+ String errorContext = "WebSocket connection failed";
+ if (uptime > 0) {
+ errorContext += " after " + (uptime / 1000) + " seconds";
+ }
+ if (response != null) {
+ errorContext += " with HTTP " + response.code() + " " + response.message();
+ }
+ enhancedError =
+ new RuntimeException(errorContext + ": " + t.getClass().getSimpleName() + ": " + t.getMessage());
+ enhancedError.initCause(t);
+ }
+ onWebSocketFailure(webSocket, enhancedError, response);
+ if (shouldReconnect.get()) {
+ scheduleReconnect();
+ }
+ }
+
+ /**
+ * @hidden
+ */
+ @Override
+ public void onClosed(WebSocket webSocket, int code, String reason) {
+ this.webSocket = null;
+ if (connectionEstablishedTime > 0) {
+ long uptime = System.currentTimeMillis() - connectionEstablishedTime;
+ if (uptime >= 5000) {
+ retryCount.set(0);
+ }
+ }
+ connectionEstablishedTime = 0L;
+ onWebSocketClosed(webSocket, code, reason);
+ if (code != 1000 && shouldReconnect.get()) {
+ scheduleReconnect();
+ }
+ }
+
+ /**
+ * Calculates the next reconnection delay using exponential backoff.
+ *
+ * Uses 0-based retry count where:
+ * - 0 = initial connection (not used by this method)
+ * - 1 = first retry (returns minReconnectionDelayMs)
+ * - 2+ = exponential backoff up to maxReconnectionDelayMs
+ */
+ private long getNextDelay() {
+ if (retryCount.get() == 1) {
+ return minReconnectionDelayMs;
+ }
+ long delay = (long) (minReconnectionDelayMs * Math.pow(reconnectionDelayGrowFactor, retryCount.get() - 1));
+ return Math.min(delay, maxReconnectionDelayMs);
+ }
+
+ /**
+ * Schedules a reconnection attempt with appropriate delay.
+ * Increments retry count and uses exponential backoff.
+ */
+ private void scheduleReconnect() {
+ retryCount.incrementAndGet();
+ long delay = getNextDelay();
+ reconnectExecutor.schedule(this::connect, delay, MILLISECONDS);
+ }
+
+ /**
+ * Sends all queued messages after reconnection.
+ *
+ * Thread-safe: Synchronized to prevent race conditions with send() method.
+ *
+ * Algorithm:
+ * 1. Drains queue into temporary list to avoid holding lock during sends
+ * 2. Attempts to send each message in order
+ * 3. If any send fails, re-queues that message and all subsequent messages
+ * 4. Preserves message ordering during re-queueing
+ * 5. Repeats for binary message queue
+ */
+ private synchronized void flushMessageQueue() {
+ WebSocket ws = webSocket;
+ if (ws != null) {
+ ArrayList tempQueue = new ArrayList<>();
+ String message;
+ while ((message = messageQueue.poll()) != null) {
+ tempQueue.add(message);
+ }
+ for (int i = 0; i < tempQueue.size(); i++) {
+ if (!ws.send(tempQueue.get(i))) {
+ for (int j = i; j < tempQueue.size(); j++) {
+ messageQueue.offer(tempQueue.get(j));
+ }
+ break;
+ }
+ }
+ ArrayList tempBinaryQueue = new ArrayList<>();
+ ByteString binaryMsg;
+ while ((binaryMsg = binaryMessageQueue.poll()) != null) {
+ tempBinaryQueue.add(binaryMsg);
+ }
+ for (int i = 0; i < tempBinaryQueue.size(); i++) {
+ if (!ws.send(tempBinaryQueue.get(i))) {
+ for (int j = i; j < tempBinaryQueue.size(); j++) {
+ binaryMessageQueue.offer(tempBinaryQueue.get(j));
+ }
+ break;
+ }
+ }
+ }
+ }
+
+ protected abstract void onWebSocketOpen(WebSocket webSocket, Response response);
+
+ protected abstract void onWebSocketMessage(WebSocket webSocket, String text);
+
+ protected abstract void onWebSocketBinaryMessage(WebSocket webSocket, ByteString bytes);
+
+ protected abstract void onWebSocketFailure(WebSocket webSocket, Throwable t, Response response);
+
+ protected abstract void onWebSocketClosed(WebSocket webSocket, int code, String reason);
+
+ /**
+ * Configuration options for automatic reconnection.
+ */
+ public static final class ReconnectOptions {
+ public final long minReconnectionDelayMs;
+
+ public final long maxReconnectionDelayMs;
+
+ public final double reconnectionDelayGrowFactor;
+
+ public final int maxRetries;
+
+ public final int maxEnqueuedMessages;
+
+ private ReconnectOptions(Builder builder) {
+ this.minReconnectionDelayMs = builder.minReconnectionDelayMs;
+ this.maxReconnectionDelayMs = builder.maxReconnectionDelayMs;
+ this.reconnectionDelayGrowFactor = builder.reconnectionDelayGrowFactor;
+ this.maxRetries = builder.maxRetries;
+ this.maxEnqueuedMessages = builder.maxEnqueuedMessages;
+ }
+
+ public static Builder builder() {
+ return new Builder();
+ }
+
+ public static final class Builder {
+ private long minReconnectionDelayMs;
+
+ private long maxReconnectionDelayMs;
+
+ private double reconnectionDelayGrowFactor;
+
+ private int maxRetries;
+
+ private int maxEnqueuedMessages;
+
+ public Builder() {
+ this.minReconnectionDelayMs = 1000;
+ this.maxReconnectionDelayMs = 10000;
+ this.reconnectionDelayGrowFactor = 1.3;
+ this.maxRetries = 2147483647;
+ this.maxEnqueuedMessages = 1000;
+ }
+
+ public Builder minReconnectionDelayMs(long minReconnectionDelayMs) {
+ this.minReconnectionDelayMs = minReconnectionDelayMs;
+ return this;
+ }
+
+ public Builder maxReconnectionDelayMs(long maxReconnectionDelayMs) {
+ this.maxReconnectionDelayMs = maxReconnectionDelayMs;
+ return this;
+ }
+
+ public Builder reconnectionDelayGrowFactor(double reconnectionDelayGrowFactor) {
+ this.reconnectionDelayGrowFactor = reconnectionDelayGrowFactor;
+ return this;
+ }
+
+ public Builder maxRetries(int maxRetries) {
+ this.maxRetries = maxRetries;
+ return this;
+ }
+
+ public Builder maxEnqueuedMessages(int maxEnqueuedMessages) {
+ this.maxEnqueuedMessages = maxEnqueuedMessages;
+ return this;
+ }
+
+ /**
+ * Builds the ReconnectOptions with validation.
+ *
+ * Validates that:
+ * - All delay values are positive
+ * - minReconnectionDelayMs <= maxReconnectionDelayMs
+ * - reconnectionDelayGrowFactor >= 1.0
+ * - maxRetries and maxEnqueuedMessages are non-negative
+ *
+ * @return The validated ReconnectOptions instance
+ * @throws IllegalArgumentException if configuration is invalid
+ */
+ public ReconnectOptions build() {
+ if (minReconnectionDelayMs <= 0) {
+ throw new IllegalArgumentException("minReconnectionDelayMs must be positive");
+ }
+ if (maxReconnectionDelayMs <= 0) {
+ throw new IllegalArgumentException("maxReconnectionDelayMs must be positive");
+ }
+ if (minReconnectionDelayMs > maxReconnectionDelayMs) {
+ throw new IllegalArgumentException("minReconnectionDelayMs (" + minReconnectionDelayMs
+ + ") must not exceed maxReconnectionDelayMs (" + maxReconnectionDelayMs + ")");
+ }
+ if (reconnectionDelayGrowFactor < 1.0) {
+ throw new IllegalArgumentException("reconnectionDelayGrowFactor must be >= 1.0");
+ }
+ if (maxRetries < 0) {
+ throw new IllegalArgumentException("maxRetries must be non-negative");
+ }
+ if (maxEnqueuedMessages < 0) {
+ throw new IllegalArgumentException("maxEnqueuedMessages must be non-negative");
+ }
+ return new ReconnectOptions(this);
+ }
+ }
+ }
+}
diff --git a/src/main/java/com/deepgram/core/RequestOptions.java b/src/main/java/com/deepgram/core/RequestOptions.java
new file mode 100644
index 0000000..d0e615a
--- /dev/null
+++ b/src/main/java/com/deepgram/core/RequestOptions.java
@@ -0,0 +1,138 @@
+/**
+ * This file was auto-generated by Fern from our API Definition.
+ */
+package com.deepgram.core;
+
+import java.util.HashMap;
+import java.util.Map;
+import java.util.Optional;
+import java.util.concurrent.TimeUnit;
+import java.util.function.Supplier;
+
+public final class RequestOptions {
+ private final String apiKey;
+
+ private final Optional timeout;
+
+ private final TimeUnit timeoutTimeUnit;
+
+ private final Map headers;
+
+ private final Map> headerSuppliers;
+
+ private final Map queryParameters;
+
+ private final Map> queryParameterSuppliers;
+
+ private RequestOptions(
+ String apiKey,
+ Optional timeout,
+ TimeUnit timeoutTimeUnit,
+ Map headers,
+ Map> headerSuppliers,
+ Map queryParameters,
+ Map> queryParameterSuppliers) {
+ this.apiKey = apiKey;
+ this.timeout = timeout;
+ this.timeoutTimeUnit = timeoutTimeUnit;
+ this.headers = headers;
+ this.headerSuppliers = headerSuppliers;
+ this.queryParameters = queryParameters;
+ this.queryParameterSuppliers = queryParameterSuppliers;
+ }
+
+ public Optional getTimeout() {
+ return timeout;
+ }
+
+ public TimeUnit getTimeoutTimeUnit() {
+ return timeoutTimeUnit;
+ }
+
+ public Map getHeaders() {
+ Map headers = new HashMap<>();
+ if (this.apiKey != null) {
+ headers.put("Authorization", "Token " + this.apiKey);
+ }
+ headers.putAll(this.headers);
+ this.headerSuppliers.forEach((key, supplier) -> {
+ headers.put(key, supplier.get());
+ });
+ return headers;
+ }
+
+ public Map getQueryParameters() {
+ Map queryParameters = new HashMap<>(this.queryParameters);
+ this.queryParameterSuppliers.forEach((key, supplier) -> {
+ queryParameters.put(key, supplier.get());
+ });
+ return queryParameters;
+ }
+
+ public static Builder builder() {
+ return new Builder();
+ }
+
+ public static class Builder {
+ private String apiKey = null;
+
+ private Optional timeout = Optional.empty();
+
+ private TimeUnit timeoutTimeUnit = TimeUnit.SECONDS;
+
+ private final Map headers = new HashMap<>();
+
+ private final Map> headerSuppliers = new HashMap<>();
+
+ private final Map queryParameters = new HashMap<>();
+
+ private final Map> queryParameterSuppliers = new HashMap<>();
+
+ public Builder apiKey(String apiKey) {
+ this.apiKey = apiKey;
+ return this;
+ }
+
+ public Builder timeout(Integer timeout) {
+ this.timeout = Optional.of(timeout);
+ return this;
+ }
+
+ public Builder timeout(Integer timeout, TimeUnit timeoutTimeUnit) {
+ this.timeout = Optional.of(timeout);
+ this.timeoutTimeUnit = timeoutTimeUnit;
+ return this;
+ }
+
+ public Builder addHeader(String key, String value) {
+ this.headers.put(key, value);
+ return this;
+ }
+
+ public Builder addHeader(String key, Supplier value) {
+ this.headerSuppliers.put(key, value);
+ return this;
+ }
+
+ public Builder addQueryParameter(String key, String value) {
+ this.queryParameters.put(key, value);
+ return this;
+ }
+
+ public Builder addQueryParameter(String key, Supplier value) {
+ this.queryParameterSuppliers.put(key, value);
+ return this;
+ }
+
+ public RequestOptions build() {
+ return new RequestOptions(
+ apiKey,
+ timeout,
+ timeoutTimeUnit,
+ headers,
+ headerSuppliers,
+ queryParameters,
+ queryParameterSuppliers);
+ }
+ }
+}
diff --git a/src/main/java/com/deepgram/core/ResponseBodyInputStream.java b/src/main/java/com/deepgram/core/ResponseBodyInputStream.java
new file mode 100644
index 0000000..2bdff70
--- /dev/null
+++ b/src/main/java/com/deepgram/core/ResponseBodyInputStream.java
@@ -0,0 +1,45 @@
+/**
+ * This file was auto-generated by Fern from our API Definition.
+ */
+package com.deepgram.core;
+
+import java.io.FilterInputStream;
+import java.io.IOException;
+import okhttp3.Response;
+
+/**
+ * A custom InputStream that wraps the InputStream from the OkHttp Response and ensures that the
+ * OkHttp Response object is properly closed when the stream is closed.
+ *
+ * This class extends FilterInputStream and takes an OkHttp Response object as a parameter.
+ * It retrieves the InputStream from the Response and overrides the close method to close
+ * both the InputStream and the Response object, ensuring proper resource management and preventing
+ * premature closure of the underlying HTTP connection.
+ */
+public class ResponseBodyInputStream extends FilterInputStream {
+ private final Response response;
+
+ /**
+ * Constructs a ResponseBodyInputStream that wraps the InputStream from the given OkHttp
+ * Response object.
+ *
+ * @param response the OkHttp Response object from which the InputStream is retrieved
+ * @throws IOException if an I/O error occurs while retrieving the InputStream
+ */
+ public ResponseBodyInputStream(Response response) throws IOException {
+ super(response.body().byteStream());
+ this.response = response;
+ }
+
+ /**
+ * Closes the InputStream and the associated OkHttp Response object. This ensures that the
+ * underlying HTTP connection is properly closed after the stream is no longer needed.
+ *
+ * @throws IOException if an I/O error occurs
+ */
+ @Override
+ public void close() throws IOException {
+ super.close();
+ response.close(); // Ensure the response is closed when the stream is closed
+ }
+}
diff --git a/src/main/java/com/deepgram/core/ResponseBodyReader.java b/src/main/java/com/deepgram/core/ResponseBodyReader.java
new file mode 100644
index 0000000..e820acf
--- /dev/null
+++ b/src/main/java/com/deepgram/core/ResponseBodyReader.java
@@ -0,0 +1,44 @@
+/**
+ * This file was auto-generated by Fern from our API Definition.
+ */
+package com.deepgram.core;
+
+import java.io.FilterReader;
+import java.io.IOException;
+import okhttp3.Response;
+
+/**
+ * A custom Reader that wraps the Reader from the OkHttp Response and ensures that the
+ * OkHttp Response object is properly closed when the reader is closed.
+ *
+ * This class extends FilterReader and takes an OkHttp Response object as a parameter.
+ * It retrieves the Reader from the Response and overrides the close method to close
+ * both the Reader and the Response object, ensuring proper resource management and preventing
+ * premature closure of the underlying HTTP connection.
+ */
+public class ResponseBodyReader extends FilterReader {
+ private final Response response;
+
+ /**
+ * Constructs a ResponseBodyReader that wraps the Reader from the given OkHttp Response object.
+ *
+ * @param response the OkHttp Response object from which the Reader is retrieved
+ * @throws IOException if an I/O error occurs while retrieving the Reader
+ */
+ public ResponseBodyReader(Response response) throws IOException {
+ super(response.body().charStream());
+ this.response = response;
+ }
+
+ /**
+ * Closes the Reader and the associated OkHttp Response object. This ensures that the
+ * underlying HTTP connection is properly closed after the reader is no longer needed.
+ *
+ * @throws IOException if an I/O error occurs
+ */
+ @Override
+ public void close() throws IOException {
+ super.close();
+ response.close(); // Ensure the response is closed when the reader is closed
+ }
+}
diff --git a/src/main/java/com/deepgram/core/RetryInterceptor.java b/src/main/java/com/deepgram/core/RetryInterceptor.java
new file mode 100644
index 0000000..b48acb7
--- /dev/null
+++ b/src/main/java/com/deepgram/core/RetryInterceptor.java
@@ -0,0 +1,180 @@
+/**
+ * This file was auto-generated by Fern from our API Definition.
+ */
+package com.deepgram.core;
+
+import java.io.IOException;
+import java.time.Duration;
+import java.time.ZonedDateTime;
+import java.time.format.DateTimeFormatter;
+import java.time.format.DateTimeParseException;
+import java.util.Optional;
+import java.util.Random;
+import okhttp3.Interceptor;
+import okhttp3.Response;
+
+public class RetryInterceptor implements Interceptor {
+
+ private static final Duration INITIAL_RETRY_DELAY = Duration.ofMillis(1000);
+ private static final Duration MAX_RETRY_DELAY = Duration.ofMillis(60000);
+ private static final double JITTER_FACTOR = 0.2;
+
+ private final ExponentialBackoff backoff;
+ private final Random random = new Random();
+
+ public RetryInterceptor(int maxRetries) {
+ this.backoff = new ExponentialBackoff(maxRetries);
+ }
+
+ @Override
+ public Response intercept(Chain chain) throws IOException {
+ Response response = chain.proceed(chain.request());
+
+ if (shouldRetry(response.code())) {
+ return retryChain(response, chain);
+ }
+
+ return response;
+ }
+
+ private Response retryChain(Response response, Chain chain) throws IOException {
+ Optional nextBackoff = this.backoff.nextBackoff(response);
+ while (nextBackoff.isPresent()) {
+ try {
+ Thread.sleep(nextBackoff.get().toMillis());
+ } catch (InterruptedException e) {
+ throw new IOException("Interrupted while trying request", e);
+ }
+ response.close();
+ response = chain.proceed(chain.request());
+ if (shouldRetry(response.code())) {
+ nextBackoff = this.backoff.nextBackoff(response);
+ } else {
+ return response;
+ }
+ }
+
+ return response;
+ }
+
+ /**
+ * Calculates the retry delay from response headers, with fallback to exponential backoff.
+ * Priority: Retry-After > X-RateLimit-Reset > Exponential Backoff
+ */
+ private Duration getRetryDelayFromHeaders(Response response, int retryAttempt) {
+ // Check for Retry-After header first (RFC 7231), with no jitter
+ String retryAfter = response.header("Retry-After");
+ if (retryAfter != null) {
+ // Parse as number of seconds...
+ Optional secondsDelay = tryParseLong(retryAfter)
+ .map(seconds -> seconds * 1000)
+ .filter(delayMs -> delayMs > 0)
+ .map(delayMs -> Math.min(delayMs, MAX_RETRY_DELAY.toMillis()))
+ .map(Duration::ofMillis);
+ if (secondsDelay.isPresent()) {
+ return secondsDelay.get();
+ }
+
+ // ...or as an HTTP date; both are valid
+ Optional dateDelay = tryParseHttpDate(retryAfter)
+ .map(resetTime -> resetTime.toInstant().toEpochMilli() - System.currentTimeMillis())
+ .filter(delayMs -> delayMs > 0)
+ .map(delayMs -> Math.min(delayMs, MAX_RETRY_DELAY.toMillis()))
+ .map(Duration::ofMillis);
+ if (dateDelay.isPresent()) {
+ return dateDelay.get();
+ }
+ }
+
+ // Then check for industry-standard X-RateLimit-Reset header, with positive jitter
+ String rateLimitReset = response.header("X-RateLimit-Reset");
+ if (rateLimitReset != null) {
+ // Assume Unix timestamp in epoch seconds
+ Optional rateLimitDelay = tryParseLong(rateLimitReset)
+ .map(resetTimeSeconds -> (resetTimeSeconds * 1000) - System.currentTimeMillis())
+ .filter(delayMs -> delayMs > 0)
+ .map(delayMs -> Math.min(delayMs, MAX_RETRY_DELAY.toMillis()))
+ .map(this::addPositiveJitter)
+ .map(Duration::ofMillis);
+ if (rateLimitDelay.isPresent()) {
+ return rateLimitDelay.get();
+ }
+ }
+
+ // Fall back to exponential backoff, with symmetric jitter
+ long baseDelay = INITIAL_RETRY_DELAY.toMillis() * (1L << retryAttempt); // 2^retryAttempt
+ long cappedDelay = Math.min(baseDelay, MAX_RETRY_DELAY.toMillis());
+ return Duration.ofMillis(addSymmetricJitter(cappedDelay));
+ }
+
+ /**
+ * Attempts to parse a string as a long, returning empty Optional on failure.
+ */
+ private Optional tryParseLong(String value) {
+ if (value == null) {
+ return Optional.empty();
+ }
+ try {
+ return Optional.of(Long.parseLong(value));
+ } catch (NumberFormatException e) {
+ return Optional.empty();
+ }
+ }
+
+ /**
+ * Attempts to parse a string as an HTTP date (RFC 1123), returning empty Optional on failure.
+ */
+ private Optional tryParseHttpDate(String value) {
+ if (value == null) {
+ return Optional.empty();
+ }
+ try {
+ return Optional.of(ZonedDateTime.parse(value, DateTimeFormatter.RFC_1123_DATE_TIME));
+ } catch (DateTimeParseException e) {
+ return Optional.empty();
+ }
+ }
+
+ /**
+ * Adds positive jitter (100-120% of original value) to prevent thundering herd.
+ * Used for X-RateLimit-Reset header delays.
+ */
+ private long addPositiveJitter(long delayMs) {
+ double jitterMultiplier = 1.0 + (random.nextDouble() * JITTER_FACTOR);
+ return (long) (delayMs * jitterMultiplier);
+ }
+
+ /**
+ * Adds symmetric jitter (90-110% of original value) to prevent thundering herd.
+ * Used for exponential backoff delays.
+ */
+ private long addSymmetricJitter(long delayMs) {
+ double jitterMultiplier = 1.0 + ((random.nextDouble() - 0.5) * JITTER_FACTOR);
+ return (long) (delayMs * jitterMultiplier);
+ }
+
+ private static boolean shouldRetry(int statusCode) {
+ return statusCode == 408 || statusCode == 429 || statusCode >= 500;
+ }
+
+ private final class ExponentialBackoff {
+
+ private final int maxNumRetries;
+
+ private int retryNumber = 0;
+
+ ExponentialBackoff(int maxNumRetries) {
+ this.maxNumRetries = maxNumRetries;
+ }
+
+ public Optional nextBackoff(Response response) {
+ if (retryNumber >= maxNumRetries) {
+ return Optional.empty();
+ }
+
+ Duration delay = getRetryDelayFromHeaders(response, retryNumber);
+ retryNumber += 1;
+ return Optional.of(delay);
+ }
+ }
+}
diff --git a/src/main/java/com/deepgram/core/Rfc2822DateTimeDeserializer.java b/src/main/java/com/deepgram/core/Rfc2822DateTimeDeserializer.java
new file mode 100644
index 0000000..d76bdc1
--- /dev/null
+++ b/src/main/java/com/deepgram/core/Rfc2822DateTimeDeserializer.java
@@ -0,0 +1,25 @@
+/**
+ * This file was auto-generated by Fern from our API Definition.
+ */
+package com.deepgram.core;
+
+import com.fasterxml.jackson.core.JsonParser;
+import com.fasterxml.jackson.databind.DeserializationContext;
+import com.fasterxml.jackson.databind.JsonDeserializer;
+import java.io.IOException;
+import java.time.OffsetDateTime;
+import java.time.ZonedDateTime;
+import java.time.format.DateTimeFormatter;
+
+/**
+ * Custom deserializer that handles converting RFC 2822 (RFC 1123) dates into {@link OffsetDateTime} objects.
+ * This is used for fields with format "date-time-rfc-2822", such as Twilio's dateCreated, dateSent, dateUpdated.
+ */
+public class Rfc2822DateTimeDeserializer extends JsonDeserializer {
+
+ @Override
+ public OffsetDateTime deserialize(JsonParser parser, DeserializationContext context) throws IOException {
+ String raw = parser.getValueAsString();
+ return ZonedDateTime.parse(raw, DateTimeFormatter.RFC_1123_DATE_TIME).toOffsetDateTime();
+ }
+}
diff --git a/src/main/java/com/deepgram/core/SseEvent.java b/src/main/java/com/deepgram/core/SseEvent.java
new file mode 100644
index 0000000..96abbe0
--- /dev/null
+++ b/src/main/java/com/deepgram/core/SseEvent.java
@@ -0,0 +1,114 @@
+/**
+ * This file was auto-generated by Fern from our API Definition.
+ */
+package com.deepgram.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/deepgram/core/SseEventParser.java b/src/main/java/com/deepgram/core/SseEventParser.java
new file mode 100644
index 0000000..06f6057
--- /dev/null
+++ b/src/main/java/com/deepgram/core/SseEventParser.java
@@ -0,0 +1,228 @@
+/**
+ * This file was auto-generated by Fern from our API Definition.
+ */
+package com.deepgram.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