[additional-formatter-args...]"
+ echo "The file should contain one file path per line"
+ exit 1
+fi
+
+FILE_LIST="$1"
+
+echo "Looking for file: $FILE_LIST"
+
+if [ ! -f "$FILE_LIST" ]; then
+ echo "Error: File '$FILE_LIST' not found"
+ exit 1
+fi
+
+if ! command -v ktfmt &> /dev/null; then
+ echo "Error: ktfmt not found"
+ exit 1
+fi
+
+# Process Kotlin files
+echo "==> Looking for Kotlin files"
+kt_files=$(grep -E '\.kt$' "$FILE_LIST" | grep -v './buildSrc/build/' || true)
+echo "==> Done looking for Kotlin files"
+
+if [[ -n "$kt_files" ]]; then
+ echo "==> will format Kotlin files"
+ echo "$kt_files" | tr '\n' '\0' | xargs -0 ktfmt --kotlinlang-style "$@"
+else
+ echo "No Kotlin files to format -- expected outcome during incremental formatting"
+fi
+
+# TODO(mbudayr): support palantir-java-format
+# Process Java files
+# grep -E '\.java$' "$FILE_LIST" | grep -v './buildSrc/build/' | tr '\n' '\0' | xargs -0 -r palantir-java-format --palantir --replace "$@"
diff --git a/scripts/format b/scripts/format
new file mode 100755
index 0000000..65db176
--- /dev/null
+++ b/scripts/format
@@ -0,0 +1,21 @@
+#!/usr/bin/env bash
+
+set -e
+
+cd "$(dirname "$0")/.."
+
+if command -v ktfmt &> /dev/null; then
+ echo "==> Running ktfmt"
+ ./scripts/kotlin-format
+else
+ echo "==> Running gradlew formatKotlin"
+ ./gradlew formatKotlin
+fi
+
+if command -v palantir-java-format &> /dev/null; then
+ echo "==> Running palantir-java-format"
+ ./scripts/java-format
+else
+ echo "==> Running gradlew formatJava"
+ ./gradlew formatJava
+fi
diff --git a/scripts/java-format b/scripts/java-format
new file mode 100755
index 0000000..ad5febc
--- /dev/null
+++ b/scripts/java-format
@@ -0,0 +1,7 @@
+#!/usr/bin/env bash
+
+set -e
+
+cd "$(dirname "$0")/.."
+
+find . -name "*.java" -not -path "./buildSrc/build/*" -print0 | xargs -0 -r palantir-java-format --palantir --replace "$@"
diff --git a/scripts/kotlin-format b/scripts/kotlin-format
new file mode 100755
index 0000000..3b8be9e
--- /dev/null
+++ b/scripts/kotlin-format
@@ -0,0 +1,7 @@
+#!/usr/bin/env bash
+
+set -e
+
+cd "$(dirname "$0")/.."
+
+find . -name "*.kt" -not -path "./buildSrc/build/*" -print0 | xargs -0 -r ktfmt --kotlinlang-style "$@"
diff --git a/scripts/lint b/scripts/lint
new file mode 100755
index 0000000..dbc8f77
--- /dev/null
+++ b/scripts/lint
@@ -0,0 +1,23 @@
+#!/usr/bin/env bash
+
+set -e
+
+cd "$(dirname "$0")/.."
+
+echo "==> Running lints"
+
+if command -v ktfmt &> /dev/null; then
+ echo "==> Checking ktfmt"
+ ./scripts/kotlin-format --dry-run --set-exit-if-changed
+else
+ echo "==> Running gradlew lintKotlin"
+ ./gradlew lintKotlin
+fi
+
+if command -v palantir-java-format &> /dev/null; then
+ echo "==> Checking palantir-java-format"
+ ./scripts/java-format --dry-run --set-exit-if-changed
+else
+ echo "==> Running gradlew lintJava"
+ ./gradlew lintJava
+fi
diff --git a/scripts/test b/scripts/test
new file mode 100755
index 0000000..904aea6
--- /dev/null
+++ b/scripts/test
@@ -0,0 +1,10 @@
+#!/usr/bin/env bash
+
+set -e
+
+cd "$(dirname "$0")/.."
+
+
+
+echo "==> Running tests"
+./gradlew test "$@"
diff --git a/scripts/upload-artifacts b/scripts/upload-artifacts
new file mode 100755
index 0000000..10f3c70
--- /dev/null
+++ b/scripts/upload-artifacts
@@ -0,0 +1,193 @@
+#!/usr/bin/env bash
+
+set -euo pipefail
+
+# ANSI Color Codes
+GREEN='\033[32m'
+RED='\033[31m'
+NC='\033[0m' # No Color
+
+MAVEN_REPO_PATH="./build/local-maven-repo"
+
+log_error() {
+ local msg="$1"
+ local headers="$2"
+ local body="$3"
+ echo -e "${RED}${msg}${NC}"
+ [[ -f "$headers" ]] && echo -e "${RED}Headers:$(cat "$headers")${NC}"
+ echo -e "${RED}Body: ${body}${NC}"
+ exit 1
+}
+
+upload_file() {
+ local file_name="$1"
+ local tmp_headers
+ tmp_headers=$(mktemp)
+
+ if [ -f "$file_name" ]; then
+ echo -e "${GREEN}Processing file: $file_name${NC}"
+ pkg_file_name="mvn${file_name#"${MAVEN_REPO_PATH}"}"
+
+ # Get signed URL for uploading artifact file
+ signed_url_response=$(curl -X POST -G "$URL" \
+ -sS --retry 5 \
+ -D "$tmp_headers" \
+ --data-urlencode "filename=$pkg_file_name" \
+ -H "Authorization: Bearer $AUTH" \
+ -H "Content-Type: application/json")
+
+ # Validate JSON and extract URL
+ if ! signed_url=$(echo "$signed_url_response" | jq -e -r '.url' 2>/dev/null) || [[ "$signed_url" == "null" ]]; then
+ log_error "Failed to get valid signed URL" "$tmp_headers" "$signed_url_response"
+ fi
+
+ # Set content-type based on file extension
+ local extension="${file_name##*.}"
+ local content_type
+ case "$extension" in
+ jar) content_type="application/java-archive" ;;
+ md5|sha1|sha256|sha512) content_type="text/plain" ;;
+ module) content_type="application/json" ;;
+ pom|xml) content_type="application/xml" ;;
+ html) content_type="text/html" ;;
+ *) content_type="application/octet-stream" ;;
+ esac
+
+ # Upload file
+ upload_response=$(curl -v -X PUT \
+ --retry 5 \
+ --retry-all-errors \
+ -D "$tmp_headers" \
+ -H "Content-Type: $content_type" \
+ --data-binary "@${file_name}" "$signed_url" 2>&1)
+
+ if ! echo "$upload_response" | grep -q "HTTP/[0-9.]* 200"; then
+ log_error "Failed to upload artifact file" "$tmp_headers" "$upload_response"
+ fi
+
+ # Insert small throttle to reduce rate limiting risk
+ sleep 0.1
+ fi
+}
+
+walk_tree() {
+ local current_dir="$1"
+
+ for entry in "$current_dir"/*; do
+ # Check that entry is valid
+ [ -e "$entry" ] || [ -h "$entry" ] || continue
+
+ if [ -d "$entry" ]; then
+ walk_tree "$entry"
+ else
+ upload_file "$entry"
+ fi
+ done
+}
+
+generate_instructions() {
+ cat << EOF > "$MAVEN_REPO_PATH/index.html"
+
+
+
+ Maven Repo
+
+
+ Stainless SDK Maven Repository
+ This is the Maven repository for your Stainless Java SDK build.
+
+ Project configuration
+
+ The details depend on whether you're using Maven or Gradle as your build tool.
+
+ Maven
+
+ Add the following to your project's pom.xml:
+ <repositories>
+ <repository>
+ <id>stainless-sdk-repo</id>
+ <url>https://pkg.stainless.com/s/${PROJECT}/${SHA}/mvn</url>
+ </repository>
+</repositories>
+
+ Gradle
+ Add the following to your build.gradle file:
+ repositories {
+ maven {
+ url "https://pkg.stainless.com/s/${PROJECT}/${SHA}/mvn"
+ }
+}
+
+
+ Configuring authentication (if required)
+
+ Some accounts may require authentication to access the repository. If so, use the
+ following instructions, replacing YOUR_STAINLESS_API_TOKEN with your actual token.
+
+ Maven with authentication
+
+ First, ensure you have the following in your Maven settings.xml for repo authentication:
+ <servers>
+ <server>
+ <id>stainless-sdk-repo</id>
+ <configuration>
+ <httpHeaders>
+ <property>
+ <name>Authorization</name>
+ <value>Bearer YOUR_STAINLESS_API_TOKEN</value>
+ </property>
+ </httpHeaders>
+ </configuration>
+ </server>
+</servers>
+
+ Then, add the following to your project's pom.xml:
+ <repositories>
+ <repository>
+ <id>stainless-sdk-repo</id>
+ <url>https://pkg.stainless.com/s/${PROJECT}/${SHA}/mvn</url>
+ </repository>
+</repositories>
+
+ Gradle with authentication
+ Add the following to your build.gradle file:
+ repositories {
+ maven {
+ url "https://pkg.stainless.com/s/${PROJECT}/${SHA}/mvn"
+ credentials(HttpHeaderCredentials) {
+ name = "Authorization"
+ value = "Bearer YOUR_STAINLESS_API_TOKEN"
+ }
+ authentication {
+ header(HttpHeaderAuthentication)
+ }
+ }
+}
+
+
+ Using the repository
+ Once you've configured the repository, you can include dependencies from it as usual. See your
+ project README
+ for more details.
+
+
+EOF
+ upload_file "${MAVEN_REPO_PATH}/index.html"
+
+ echo "Configure maven or gradle to use the repo located at 'https://pkg.stainless.com/s/${PROJECT}/${SHA}/mvn'"
+ echo "For more details, see the directions in https://pkg.stainless.com/s/${PROJECT}/${SHA}/mvn/index.html"
+}
+
+cd "$(dirname "$0")/.."
+
+echo "::group::Creating local Maven content"
+./gradlew publishMavenPublicationToLocalFileSystemRepository -PpublishLocal
+echo "::endgroup::"
+
+echo "::group::Uploading to pkg.stainless.com"
+walk_tree "$MAVEN_REPO_PATH"
+echo "::endgroup::"
+
+echo "::group::Generating instructions"
+generate_instructions
+echo "::endgroup::"
diff --git a/settings.gradle.kts b/settings.gradle.kts
new file mode 100644
index 0000000..02623dd
--- /dev/null
+++ b/settings.gradle.kts
@@ -0,0 +1,14 @@
+rootProject.name = "stagehand-java-root"
+
+val projectNames = rootDir.listFiles()
+ ?.asSequence()
+ .orEmpty()
+ .filter { file ->
+ file.isDirectory &&
+ file.name.startsWith("stagehand-java") &&
+ file.listFiles()?.asSequence().orEmpty().any { it.name == "build.gradle.kts" }
+ }
+ .map { it.name }
+ .toList()
+println("projects: $projectNames")
+projectNames.forEach { include(it) }
diff --git a/stagehand-java-client-okhttp/build.gradle.kts b/stagehand-java-client-okhttp/build.gradle.kts
new file mode 100644
index 0000000..be765aa
--- /dev/null
+++ b/stagehand-java-client-okhttp/build.gradle.kts
@@ -0,0 +1,14 @@
+plugins {
+ id("stagehand.kotlin")
+ id("stagehand.publish")
+}
+
+dependencies {
+ api(project(":stagehand-java-core"))
+
+ implementation("com.squareup.okhttp3:okhttp:4.12.0")
+
+ testImplementation(kotlin("test"))
+ testImplementation("org.assertj:assertj-core:3.27.7")
+ testImplementation("com.github.tomakehurst:wiremock-jre8:2.35.2")
+}
diff --git a/stagehand-java-client-okhttp/src/main/kotlin/com/browserbase/api/client/okhttp/OkHttpClient.kt b/stagehand-java-client-okhttp/src/main/kotlin/com/browserbase/api/client/okhttp/OkHttpClient.kt
new file mode 100644
index 0000000..155ae2b
--- /dev/null
+++ b/stagehand-java-client-okhttp/src/main/kotlin/com/browserbase/api/client/okhttp/OkHttpClient.kt
@@ -0,0 +1,356 @@
+package com.browserbase.api.client.okhttp
+
+import com.browserbase.api.core.RequestOptions
+import com.browserbase.api.core.Timeout
+import com.browserbase.api.core.http.Headers
+import com.browserbase.api.core.http.HttpClient
+import com.browserbase.api.core.http.HttpMethod
+import com.browserbase.api.core.http.HttpRequest
+import com.browserbase.api.core.http.HttpRequestBody
+import com.browserbase.api.core.http.HttpResponse
+import com.browserbase.api.core.http.ProxyAuthenticator
+import com.browserbase.api.errors.StagehandIoException
+import java.io.IOException
+import java.io.InputStream
+import java.io.OutputStream
+import java.net.Proxy
+import java.time.Duration
+import java.util.concurrent.CancellationException
+import java.util.concurrent.CompletableFuture
+import java.util.concurrent.ExecutorService
+import java.util.concurrent.TimeUnit
+import javax.net.ssl.HostnameVerifier
+import javax.net.ssl.SSLSocketFactory
+import javax.net.ssl.X509TrustManager
+import kotlin.jvm.optionals.getOrNull
+import okhttp3.Call
+import okhttp3.Callback
+import okhttp3.ConnectionPool
+import okhttp3.Dispatcher
+import okhttp3.HttpUrl
+import okhttp3.HttpUrl.Companion.toHttpUrl
+import okhttp3.MediaType
+import okhttp3.MediaType.Companion.toMediaType
+import okhttp3.Request
+import okhttp3.RequestBody
+import okhttp3.RequestBody.Companion.toRequestBody
+import okhttp3.Response
+import okio.BufferedSink
+import okio.buffer
+import okio.sink
+
+class OkHttpClient
+internal constructor(@JvmSynthetic internal val okHttpClient: okhttp3.OkHttpClient) : HttpClient {
+
+ override fun execute(request: HttpRequest, requestOptions: RequestOptions): HttpResponse {
+ val call = newCall(request, requestOptions)
+
+ return try {
+ call.execute().toHttpResponse()
+ } catch (e: IOException) {
+ throw StagehandIoException("Request failed", e)
+ } finally {
+ request.body?.close()
+ }
+ }
+
+ override fun executeAsync(
+ request: HttpRequest,
+ requestOptions: RequestOptions,
+ ): CompletableFuture {
+ val future = CompletableFuture()
+
+ val call = newCall(request, requestOptions)
+ call.enqueue(
+ object : Callback {
+ override fun onResponse(call: Call, response: Response) {
+ future.complete(response.toHttpResponse())
+ }
+
+ override fun onFailure(call: Call, e: IOException) {
+ future.completeExceptionally(StagehandIoException("Request failed", e))
+ }
+ }
+ )
+
+ future.whenComplete { _, e ->
+ if (e is CancellationException) {
+ call.cancel()
+ }
+ request.body?.close()
+ }
+
+ return future
+ }
+
+ override fun close() {
+ okHttpClient.dispatcher.executorService.shutdown()
+ okHttpClient.connectionPool.evictAll()
+ okHttpClient.cache?.close()
+ }
+
+ private fun newCall(request: HttpRequest, requestOptions: RequestOptions): Call {
+ val clientBuilder = okHttpClient.newBuilder()
+
+ requestOptions.timeout?.let {
+ clientBuilder
+ .connectTimeout(it.connect())
+ .readTimeout(it.read())
+ .writeTimeout(it.write())
+ .callTimeout(it.request())
+ }
+
+ val client = clientBuilder.build()
+ return client.newCall(request.toRequest(client))
+ }
+
+ companion object {
+ @JvmStatic fun builder() = Builder()
+ }
+
+ class Builder internal constructor() {
+
+ private var timeout: Timeout = Timeout.default()
+ private var proxy: Proxy? = null
+ private var proxyAuthenticator: ProxyAuthenticator? = null
+ private var maxIdleConnections: Int? = null
+ private var keepAliveDuration: Duration? = null
+ private var dispatcherExecutorService: ExecutorService? = null
+ private var sslSocketFactory: SSLSocketFactory? = null
+ private var trustManager: X509TrustManager? = null
+ private var hostnameVerifier: HostnameVerifier? = null
+
+ fun timeout(timeout: Timeout) = apply { this.timeout = timeout }
+
+ fun timeout(timeout: Duration) = timeout(Timeout.builder().request(timeout).build())
+
+ fun proxy(proxy: Proxy?) = apply { this.proxy = proxy }
+
+ fun proxyAuthenticator(proxyAuthenticator: ProxyAuthenticator?) = apply {
+ this.proxyAuthenticator = proxyAuthenticator
+ }
+
+ /**
+ * Sets the maximum number of idle connections kept by the underlying [ConnectionPool].
+ *
+ * If this is set, then [keepAliveDuration] must also be set.
+ *
+ * If unset, then OkHttp's default is used.
+ */
+ fun maxIdleConnections(maxIdleConnections: Int?) = apply {
+ this.maxIdleConnections = maxIdleConnections
+ }
+
+ /**
+ * Sets the keep-alive duration for idle connections in the underlying [ConnectionPool].
+ *
+ * If this is set, then [maxIdleConnections] must also be set.
+ *
+ * If unset, then OkHttp's default is used.
+ */
+ fun keepAliveDuration(keepAliveDuration: Duration?) = apply {
+ this.keepAliveDuration = keepAliveDuration
+ }
+
+ fun dispatcherExecutorService(dispatcherExecutorService: ExecutorService?) = apply {
+ this.dispatcherExecutorService = dispatcherExecutorService
+ }
+
+ fun sslSocketFactory(sslSocketFactory: SSLSocketFactory?) = apply {
+ this.sslSocketFactory = sslSocketFactory
+ }
+
+ fun trustManager(trustManager: X509TrustManager?) = apply {
+ this.trustManager = trustManager
+ }
+
+ fun hostnameVerifier(hostnameVerifier: HostnameVerifier?) = apply {
+ this.hostnameVerifier = hostnameVerifier
+ }
+
+ fun build(): OkHttpClient =
+ OkHttpClient(
+ okhttp3.OkHttpClient.Builder()
+ // `RetryingHttpClient` handles retries if the user enabled them.
+ .retryOnConnectionFailure(false)
+ .connectTimeout(timeout.connect())
+ .readTimeout(timeout.read())
+ .writeTimeout(timeout.write())
+ .callTimeout(timeout.request())
+ .proxy(proxy)
+ .apply {
+ proxyAuthenticator?.let { auth ->
+ proxyAuthenticator { route, response ->
+ auth
+ .authenticate(
+ route?.proxy ?: Proxy.NO_PROXY,
+ response.request.toHttpRequest(),
+ response.toHttpResponse(),
+ )
+ .getOrNull()
+ ?.toRequest(client = null)
+ }
+ }
+
+ dispatcherExecutorService?.let { dispatcher(Dispatcher(it)) }
+
+ val maxIdleConnections = maxIdleConnections
+ val keepAliveDuration = keepAliveDuration
+ if (maxIdleConnections != null && keepAliveDuration != null) {
+ connectionPool(
+ ConnectionPool(
+ maxIdleConnections,
+ keepAliveDuration.toNanos(),
+ TimeUnit.NANOSECONDS,
+ )
+ )
+ } else {
+ check((maxIdleConnections != null) == (keepAliveDuration != null)) {
+ "Both or none of `maxIdleConnections` and `keepAliveDuration` must be set, but only one was set"
+ }
+ }
+
+ val sslSocketFactory = sslSocketFactory
+ val trustManager = trustManager
+ if (sslSocketFactory != null && trustManager != null) {
+ sslSocketFactory(sslSocketFactory, trustManager)
+ } else {
+ check((sslSocketFactory != null) == (trustManager != null)) {
+ "Both or none of `sslSocketFactory` and `trustManager` must be set, but only one was set"
+ }
+ }
+
+ hostnameVerifier?.let(::hostnameVerifier)
+ }
+ .build()
+ .apply {
+ // We usually make all our requests to the same host so it makes sense to
+ // raise the per-host limit to the overall limit.
+ dispatcher.maxRequestsPerHost = dispatcher.maxRequests
+ }
+ )
+ }
+}
+
+private fun HttpRequest.toRequest(client: okhttp3.OkHttpClient?): Request {
+ var body: RequestBody? = body?.toRequestBody()
+ if (body == null && requiresBody(method)) {
+ body = "".toRequestBody()
+ }
+
+ val builder = Request.Builder().url(toUrl()).method(method.name, body)
+ headers.names().forEach { name -> headers.values(name).forEach { builder.addHeader(name, it) } }
+
+ if (client != null) {
+ if (
+ !headers.names().contains("X-Stainless-Read-Timeout") && client.readTimeoutMillis != 0
+ ) {
+ builder.addHeader(
+ "X-Stainless-Read-Timeout",
+ Duration.ofMillis(client.readTimeoutMillis.toLong()).seconds.toString(),
+ )
+ }
+ if (!headers.names().contains("X-Stainless-Timeout") && client.callTimeoutMillis != 0) {
+ builder.addHeader(
+ "X-Stainless-Timeout",
+ Duration.ofMillis(client.callTimeoutMillis.toLong()).seconds.toString(),
+ )
+ }
+ }
+
+ return builder.build()
+}
+
+/** `OkHttpClient` always requires a request body for some methods. */
+private fun requiresBody(method: HttpMethod): Boolean =
+ when (method) {
+ HttpMethod.POST,
+ HttpMethod.PUT,
+ HttpMethod.PATCH -> true
+ else -> false
+ }
+
+private fun HttpRequest.toUrl(): String {
+ val builder = baseUrl.toHttpUrl().newBuilder()
+ pathSegments.forEach(builder::addPathSegment)
+ queryParams.keys().forEach { key ->
+ queryParams.values(key).forEach { builder.addQueryParameter(key, it) }
+ }
+
+ return builder.toString()
+}
+
+private fun HttpRequestBody.toRequestBody(): RequestBody {
+ val mediaType = contentType()?.toMediaType()
+ val length = contentLength()
+
+ return object : RequestBody() {
+ override fun contentType(): MediaType? = mediaType
+
+ override fun contentLength(): Long = length
+
+ override fun isOneShot(): Boolean = !repeatable()
+
+ override fun writeTo(sink: BufferedSink) = writeTo(sink.outputStream())
+ }
+}
+
+private fun Request.toHttpRequest(): HttpRequest {
+ val builder = HttpRequest.builder().method(HttpMethod.valueOf(method)).baseUrl(url.toBaseUrl())
+ url.pathSegments.forEach(builder::addPathSegment)
+ url.queryParameterNames.forEach { name ->
+ url.queryParameterValues(name).filterNotNull().forEach { builder.putQueryParam(name, it) }
+ }
+ headers.forEach { (name, value) -> builder.putHeader(name, value) }
+ body?.let { builder.body(it.toHttpRequestBody()) }
+ return builder.build()
+}
+
+private fun HttpUrl.toBaseUrl(): String = buildString {
+ append(scheme).append("://").append(host)
+ if (port != HttpUrl.defaultPort(scheme)) {
+ append(":").append(port)
+ }
+}
+
+private fun RequestBody.toHttpRequestBody(): HttpRequestBody {
+ val mediaType = contentType()?.toString()
+ val length = contentLength()
+ val isOneShot = isOneShot()
+ val source = this
+ return object : HttpRequestBody {
+ override fun contentType(): String? = mediaType
+
+ override fun contentLength(): Long = length
+
+ override fun repeatable(): Boolean = !isOneShot
+
+ override fun writeTo(outputStream: OutputStream) {
+ val sink = outputStream.sink().buffer()
+ source.writeTo(sink)
+ sink.flush()
+ }
+
+ override fun close() {}
+ }
+}
+
+private fun Response.toHttpResponse(): HttpResponse {
+ val headers = headers.toHeaders()
+
+ return object : HttpResponse {
+ override fun statusCode(): Int = code
+
+ override fun headers(): Headers = headers
+
+ override fun body(): InputStream = body!!.byteStream()
+
+ override fun close() = body!!.close()
+ }
+}
+
+private fun okhttp3.Headers.toHeaders(): Headers {
+ val headersBuilder = Headers.builder()
+ forEach { (name, value) -> headersBuilder.put(name, value) }
+ return headersBuilder.build()
+}
diff --git a/stagehand-java-client-okhttp/src/main/kotlin/com/browserbase/api/client/okhttp/StagehandOkHttpClient.kt b/stagehand-java-client-okhttp/src/main/kotlin/com/browserbase/api/client/okhttp/StagehandOkHttpClient.kt
new file mode 100644
index 0000000..36e0fe3
--- /dev/null
+++ b/stagehand-java-client-okhttp/src/main/kotlin/com/browserbase/api/client/okhttp/StagehandOkHttpClient.kt
@@ -0,0 +1,437 @@
+// File generated from our OpenAPI spec by Stainless.
+
+package com.browserbase.api.client.okhttp
+
+import com.browserbase.api.client.StagehandClient
+import com.browserbase.api.client.StagehandClientImpl
+import com.browserbase.api.core.ClientOptions
+import com.browserbase.api.core.LogLevel
+import com.browserbase.api.core.Sleeper
+import com.browserbase.api.core.Timeout
+import com.browserbase.api.core.http.AsyncStreamResponse
+import com.browserbase.api.core.http.Headers
+import com.browserbase.api.core.http.HttpClient
+import com.browserbase.api.core.http.ProxyAuthenticator
+import com.browserbase.api.core.http.QueryParams
+import com.browserbase.api.core.jsonMapper
+import com.fasterxml.jackson.databind.json.JsonMapper
+import java.net.Proxy
+import java.time.Clock
+import java.time.Duration
+import java.util.Optional
+import java.util.concurrent.Executor
+import java.util.concurrent.ExecutorService
+import javax.net.ssl.HostnameVerifier
+import javax.net.ssl.SSLSocketFactory
+import javax.net.ssl.X509TrustManager
+import kotlin.jvm.optionals.getOrNull
+
+/**
+ * A class that allows building an instance of [StagehandClient] with [OkHttpClient] as the
+ * underlying [HttpClient].
+ */
+class StagehandOkHttpClient private constructor() {
+
+ companion object {
+
+ /** Returns a mutable builder for constructing an instance of [StagehandClient]. */
+ @JvmStatic fun builder() = Builder()
+
+ /**
+ * Returns a client configured using system properties and environment variables.
+ *
+ * @see ClientOptions.Builder.fromEnv
+ */
+ @JvmStatic fun fromEnv(): StagehandClient = builder().fromEnv().build()
+ }
+
+ /** A builder for [StagehandOkHttpClient]. */
+ class Builder internal constructor() {
+
+ private var clientOptions: ClientOptions.Builder = ClientOptions.builder()
+ private var dispatcherExecutorService: ExecutorService? = null
+ private var proxy: Proxy? = null
+ private var proxyAuthenticator: ProxyAuthenticator? = null
+ private var maxIdleConnections: Int? = null
+ private var keepAliveDuration: Duration? = null
+ private var sslSocketFactory: SSLSocketFactory? = null
+ private var trustManager: X509TrustManager? = null
+ private var hostnameVerifier: HostnameVerifier? = null
+
+ /**
+ * The executor service to use for running HTTP requests.
+ *
+ * Defaults to OkHttp's
+ * [default executor service](https://github.com/square/okhttp/blob/ace792f443b2ffb17974f5c0d1cecdf589309f26/okhttp/src/commonJvmAndroid/kotlin/okhttp3/Dispatcher.kt#L98-L104).
+ *
+ * This class takes ownership of the executor service and shuts it down when closed.
+ */
+ fun dispatcherExecutorService(dispatcherExecutorService: ExecutorService?) = apply {
+ this.dispatcherExecutorService = dispatcherExecutorService
+ }
+
+ /**
+ * Alias for calling [Builder.dispatcherExecutorService] with
+ * `dispatcherExecutorService.orElse(null)`.
+ */
+ fun dispatcherExecutorService(dispatcherExecutorService: Optional) =
+ dispatcherExecutorService(dispatcherExecutorService.getOrNull())
+
+ fun proxy(proxy: Proxy?) = apply { this.proxy = proxy }
+
+ /** Alias for calling [Builder.proxy] with `proxy.orElse(null)`. */
+ fun proxy(proxy: Optional) = proxy(proxy.getOrNull())
+
+ /**
+ * Provides credentials when an HTTP proxy responds with `407 Proxy Authentication
+ * Required`.
+ */
+ fun proxyAuthenticator(proxyAuthenticator: ProxyAuthenticator?) = apply {
+ this.proxyAuthenticator = proxyAuthenticator
+ }
+
+ /**
+ * Alias for calling [Builder.proxyAuthenticator] with `proxyAuthenticator.orElse(null)`.
+ */
+ fun proxyAuthenticator(proxyAuthenticator: Optional) =
+ proxyAuthenticator(proxyAuthenticator.getOrNull())
+
+ /**
+ * The maximum number of idle connections kept by the underlying OkHttp connection pool.
+ *
+ * If this is set, then [keepAliveDuration] must also be set.
+ *
+ * If unset, then OkHttp's default is used.
+ */
+ fun maxIdleConnections(maxIdleConnections: Int?) = apply {
+ this.maxIdleConnections = maxIdleConnections
+ }
+
+ /**
+ * Alias for [Builder.maxIdleConnections].
+ *
+ * This unboxed primitive overload exists for backwards compatibility.
+ */
+ fun maxIdleConnections(maxIdleConnections: Int) =
+ maxIdleConnections(maxIdleConnections as Int?)
+
+ /**
+ * Alias for calling [Builder.maxIdleConnections] with `maxIdleConnections.orElse(null)`.
+ */
+ fun maxIdleConnections(maxIdleConnections: Optional) =
+ maxIdleConnections(maxIdleConnections.getOrNull())
+
+ /**
+ * The keep-alive duration for idle connections in the underlying OkHttp connection pool.
+ *
+ * If this is set, then [maxIdleConnections] must also be set.
+ *
+ * If unset, then OkHttp's default is used.
+ */
+ fun keepAliveDuration(keepAliveDuration: Duration?) = apply {
+ this.keepAliveDuration = keepAliveDuration
+ }
+
+ /** Alias for calling [Builder.keepAliveDuration] with `keepAliveDuration.orElse(null)`. */
+ fun keepAliveDuration(keepAliveDuration: Optional) =
+ keepAliveDuration(keepAliveDuration.getOrNull())
+
+ /**
+ * The socket factory used to secure HTTPS connections.
+ *
+ * If this is set, then [trustManager] must also be set.
+ *
+ * If unset, then the system default is used. Most applications should not call this method,
+ * and instead use the system default. The default include special optimizations that can be
+ * lost if the implementation is modified.
+ */
+ fun sslSocketFactory(sslSocketFactory: SSLSocketFactory?) = apply {
+ this.sslSocketFactory = sslSocketFactory
+ }
+
+ /** Alias for calling [Builder.sslSocketFactory] with `sslSocketFactory.orElse(null)`. */
+ fun sslSocketFactory(sslSocketFactory: Optional) =
+ sslSocketFactory(sslSocketFactory.getOrNull())
+
+ /**
+ * The trust manager used to secure HTTPS connections.
+ *
+ * If this is set, then [sslSocketFactory] must also be set.
+ *
+ * If unset, then the system default is used. Most applications should not call this method,
+ * and instead use the system default. The default include special optimizations that can be
+ * lost if the implementation is modified.
+ */
+ fun trustManager(trustManager: X509TrustManager?) = apply {
+ this.trustManager = trustManager
+ }
+
+ /** Alias for calling [Builder.trustManager] with `trustManager.orElse(null)`. */
+ fun trustManager(trustManager: Optional) =
+ trustManager(trustManager.getOrNull())
+
+ /**
+ * The verifier used to confirm that response certificates apply to requested hostnames for
+ * HTTPS connections.
+ *
+ * If unset, then a default hostname verifier is used.
+ */
+ fun hostnameVerifier(hostnameVerifier: HostnameVerifier?) = apply {
+ this.hostnameVerifier = hostnameVerifier
+ }
+
+ /** Alias for calling [Builder.hostnameVerifier] with `hostnameVerifier.orElse(null)`. */
+ fun hostnameVerifier(hostnameVerifier: Optional) =
+ hostnameVerifier(hostnameVerifier.getOrNull())
+
+ /**
+ * Whether to throw an exception if any of the Jackson versions detected at runtime are
+ * incompatible with the SDK's minimum supported Jackson version (2.13.4).
+ *
+ * Defaults to true. Use extreme caution when disabling this option. There is no guarantee
+ * that the SDK will work correctly when using an incompatible Jackson version.
+ */
+ fun checkJacksonVersionCompatibility(checkJacksonVersionCompatibility: Boolean) = apply {
+ clientOptions.checkJacksonVersionCompatibility(checkJacksonVersionCompatibility)
+ }
+
+ /**
+ * The Jackson JSON mapper to use for serializing and deserializing JSON.
+ *
+ * Defaults to [com.browserbase.api.core.jsonMapper]. The default is usually sufficient and
+ * rarely needs to be overridden.
+ */
+ fun jsonMapper(jsonMapper: JsonMapper) = apply { clientOptions.jsonMapper(jsonMapper) }
+
+ /**
+ * The executor to use for running [AsyncStreamResponse.Handler] callbacks.
+ *
+ * Defaults to a dedicated cached thread pool.
+ *
+ * This class takes ownership of the executor and shuts it down, if possible, when closed.
+ */
+ fun streamHandlerExecutor(streamHandlerExecutor: Executor) = apply {
+ clientOptions.streamHandlerExecutor(streamHandlerExecutor)
+ }
+
+ /**
+ * The interface to use for delaying execution, like during retries.
+ *
+ * This is primarily useful for using fake delays in tests.
+ *
+ * Defaults to real execution delays.
+ *
+ * This class takes ownership of the sleeper and closes it when closed.
+ */
+ fun sleeper(sleeper: Sleeper) = apply { clientOptions.sleeper(sleeper) }
+
+ /**
+ * The clock to use for operations that require timing, like retries.
+ *
+ * This is primarily useful for using a fake clock in tests.
+ *
+ * Defaults to [Clock.systemUTC].
+ */
+ fun clock(clock: Clock) = apply { clientOptions.clock(clock) }
+
+ /**
+ * The base URL to use for every request.
+ *
+ * Defaults to the production environment: `https://api.stagehand.browserbase.com`.
+ */
+ fun baseUrl(baseUrl: String?) = apply { clientOptions.baseUrl(baseUrl) }
+
+ /** Alias for calling [Builder.baseUrl] with `baseUrl.orElse(null)`. */
+ fun baseUrl(baseUrl: Optional) = baseUrl(baseUrl.getOrNull())
+
+ /**
+ * Whether to call `validate` on every response before returning it.
+ *
+ * Setting this to `true` is _not_ forwards compatible with new types from the API for
+ * existing fields.
+ *
+ * Defaults to false, which means the shape of the response will not be validated upfront.
+ * Instead, validation will only occur for the parts of the response that are accessed.
+ */
+ fun responseValidation(responseValidation: Boolean) = apply {
+ clientOptions.responseValidation(responseValidation)
+ }
+
+ /**
+ * Sets the maximum time allowed for various parts of an HTTP call's lifecycle, excluding
+ * retries.
+ *
+ * Defaults to [Timeout.default].
+ */
+ fun timeout(timeout: Timeout) = apply { clientOptions.timeout(timeout) }
+
+ /**
+ * Sets the maximum time allowed for a complete HTTP call, not including retries.
+ *
+ * See [Timeout.request] for more details.
+ *
+ * For fine-grained control, pass a [Timeout] object.
+ */
+ fun timeout(timeout: Duration) = apply { clientOptions.timeout(timeout) }
+
+ /**
+ * The maximum number of times to retry failed requests, with a short exponential backoff
+ * between requests.
+ *
+ * Only the following error types are retried:
+ * - Connection errors (for example, due to a network connectivity problem)
+ * - 408 Request Timeout
+ * - 409 Conflict
+ * - 429 Rate Limit
+ * - 5xx Internal
+ *
+ * The API may also explicitly instruct the SDK to retry or not retry a request.
+ *
+ * Defaults to 2.
+ */
+ fun maxRetries(maxRetries: Int) = apply { clientOptions.maxRetries(maxRetries) }
+
+ /**
+ * The level at which to log request and response information.
+ *
+ * [fromEnv] will set the level from environment variables. See [LogLevel.fromEnv].
+ *
+ * Defaults to [LogLevel.fromEnv].
+ */
+ fun logLevel(logLevel: LogLevel) = apply { clientOptions.logLevel(logLevel) }
+
+ /** Your [Browserbase API Key](https://www.browserbase.com/settings) */
+ fun browserbaseApiKey(browserbaseApiKey: String) = apply {
+ clientOptions.browserbaseApiKey(browserbaseApiKey)
+ }
+
+ /**
+ * Deprecated. Browserbase API keys are now project-scoped, so this value is no longer
+ * required.
+ */
+ fun browserbaseProjectId(browserbaseProjectId: String?) = apply {
+ clientOptions.browserbaseProjectId(browserbaseProjectId)
+ }
+
+ /**
+ * Alias for calling [Builder.browserbaseProjectId] with
+ * `browserbaseProjectId.orElse(null)`.
+ */
+ fun browserbaseProjectId(browserbaseProjectId: Optional) =
+ browserbaseProjectId(browserbaseProjectId.getOrNull())
+
+ /** Your LLM provider API key (e.g. OPENAI_API_KEY, ANTHROPIC_API_KEY, etc.) */
+ fun modelApiKey(modelApiKey: String) = apply { clientOptions.modelApiKey(modelApiKey) }
+
+ fun headers(headers: Headers) = apply { clientOptions.headers(headers) }
+
+ fun headers(headers: Map>) = apply {
+ clientOptions.headers(headers)
+ }
+
+ fun putHeader(name: String, value: String) = apply { clientOptions.putHeader(name, value) }
+
+ fun putHeaders(name: String, values: Iterable) = apply {
+ clientOptions.putHeaders(name, values)
+ }
+
+ fun putAllHeaders(headers: Headers) = apply { clientOptions.putAllHeaders(headers) }
+
+ fun putAllHeaders(headers: Map>) = apply {
+ clientOptions.putAllHeaders(headers)
+ }
+
+ fun replaceHeaders(name: String, value: String) = apply {
+ clientOptions.replaceHeaders(name, value)
+ }
+
+ fun replaceHeaders(name: String, values: Iterable) = apply {
+ clientOptions.replaceHeaders(name, values)
+ }
+
+ fun replaceAllHeaders(headers: Headers) = apply { clientOptions.replaceAllHeaders(headers) }
+
+ fun replaceAllHeaders(headers: Map>) = apply {
+ clientOptions.replaceAllHeaders(headers)
+ }
+
+ fun removeHeaders(name: String) = apply { clientOptions.removeHeaders(name) }
+
+ fun removeAllHeaders(names: Set) = apply { clientOptions.removeAllHeaders(names) }
+
+ fun queryParams(queryParams: QueryParams) = apply { clientOptions.queryParams(queryParams) }
+
+ fun queryParams(queryParams: Map>) = apply {
+ clientOptions.queryParams(queryParams)
+ }
+
+ fun putQueryParam(key: String, value: String) = apply {
+ clientOptions.putQueryParam(key, value)
+ }
+
+ fun putQueryParams(key: String, values: Iterable) = apply {
+ clientOptions.putQueryParams(key, values)
+ }
+
+ fun putAllQueryParams(queryParams: QueryParams) = apply {
+ clientOptions.putAllQueryParams(queryParams)
+ }
+
+ fun putAllQueryParams(queryParams: Map>) = apply {
+ clientOptions.putAllQueryParams(queryParams)
+ }
+
+ fun replaceQueryParams(key: String, value: String) = apply {
+ clientOptions.replaceQueryParams(key, value)
+ }
+
+ fun replaceQueryParams(key: String, values: Iterable) = apply {
+ clientOptions.replaceQueryParams(key, values)
+ }
+
+ fun replaceAllQueryParams(queryParams: QueryParams) = apply {
+ clientOptions.replaceAllQueryParams(queryParams)
+ }
+
+ fun replaceAllQueryParams(queryParams: Map>) = apply {
+ clientOptions.replaceAllQueryParams(queryParams)
+ }
+
+ fun removeQueryParams(key: String) = apply { clientOptions.removeQueryParams(key) }
+
+ fun removeAllQueryParams(keys: Set) = apply {
+ clientOptions.removeAllQueryParams(keys)
+ }
+
+ /**
+ * Updates configuration using system properties and environment variables.
+ *
+ * @see ClientOptions.Builder.fromEnv
+ */
+ fun fromEnv() = apply { clientOptions.fromEnv() }
+
+ /**
+ * Returns an immutable instance of [StagehandClient].
+ *
+ * Further updates to this [Builder] will not mutate the returned instance.
+ */
+ fun build(): StagehandClient =
+ StagehandClientImpl(
+ clientOptions
+ .httpClient(
+ OkHttpClient.builder()
+ .timeout(clientOptions.timeout())
+ .proxy(proxy)
+ .proxyAuthenticator(proxyAuthenticator)
+ .maxIdleConnections(maxIdleConnections)
+ .keepAliveDuration(keepAliveDuration)
+ .dispatcherExecutorService(dispatcherExecutorService)
+ .sslSocketFactory(sslSocketFactory)
+ .trustManager(trustManager)
+ .hostnameVerifier(hostnameVerifier)
+ .build()
+ )
+ .build()
+ )
+ }
+}
diff --git a/stagehand-java-client-okhttp/src/main/kotlin/com/browserbase/api/client/okhttp/StagehandOkHttpClientAsync.kt b/stagehand-java-client-okhttp/src/main/kotlin/com/browserbase/api/client/okhttp/StagehandOkHttpClientAsync.kt
new file mode 100644
index 0000000..83bb57e
--- /dev/null
+++ b/stagehand-java-client-okhttp/src/main/kotlin/com/browserbase/api/client/okhttp/StagehandOkHttpClientAsync.kt
@@ -0,0 +1,437 @@
+// File generated from our OpenAPI spec by Stainless.
+
+package com.browserbase.api.client.okhttp
+
+import com.browserbase.api.client.StagehandClientAsync
+import com.browserbase.api.client.StagehandClientAsyncImpl
+import com.browserbase.api.core.ClientOptions
+import com.browserbase.api.core.LogLevel
+import com.browserbase.api.core.Sleeper
+import com.browserbase.api.core.Timeout
+import com.browserbase.api.core.http.AsyncStreamResponse
+import com.browserbase.api.core.http.Headers
+import com.browserbase.api.core.http.HttpClient
+import com.browserbase.api.core.http.ProxyAuthenticator
+import com.browserbase.api.core.http.QueryParams
+import com.browserbase.api.core.jsonMapper
+import com.fasterxml.jackson.databind.json.JsonMapper
+import java.net.Proxy
+import java.time.Clock
+import java.time.Duration
+import java.util.Optional
+import java.util.concurrent.Executor
+import java.util.concurrent.ExecutorService
+import javax.net.ssl.HostnameVerifier
+import javax.net.ssl.SSLSocketFactory
+import javax.net.ssl.X509TrustManager
+import kotlin.jvm.optionals.getOrNull
+
+/**
+ * A class that allows building an instance of [StagehandClientAsync] with [OkHttpClient] as the
+ * underlying [HttpClient].
+ */
+class StagehandOkHttpClientAsync private constructor() {
+
+ companion object {
+
+ /** Returns a mutable builder for constructing an instance of [StagehandClientAsync]. */
+ @JvmStatic fun builder() = Builder()
+
+ /**
+ * Returns a client configured using system properties and environment variables.
+ *
+ * @see ClientOptions.Builder.fromEnv
+ */
+ @JvmStatic fun fromEnv(): StagehandClientAsync = builder().fromEnv().build()
+ }
+
+ /** A builder for [StagehandOkHttpClientAsync]. */
+ class Builder internal constructor() {
+
+ private var clientOptions: ClientOptions.Builder = ClientOptions.builder()
+ private var dispatcherExecutorService: ExecutorService? = null
+ private var proxy: Proxy? = null
+ private var proxyAuthenticator: ProxyAuthenticator? = null
+ private var maxIdleConnections: Int? = null
+ private var keepAliveDuration: Duration? = null
+ private var sslSocketFactory: SSLSocketFactory? = null
+ private var trustManager: X509TrustManager? = null
+ private var hostnameVerifier: HostnameVerifier? = null
+
+ /**
+ * The executor service to use for running HTTP requests.
+ *
+ * Defaults to OkHttp's
+ * [default executor service](https://github.com/square/okhttp/blob/ace792f443b2ffb17974f5c0d1cecdf589309f26/okhttp/src/commonJvmAndroid/kotlin/okhttp3/Dispatcher.kt#L98-L104).
+ *
+ * This class takes ownership of the executor service and shuts it down when closed.
+ */
+ fun dispatcherExecutorService(dispatcherExecutorService: ExecutorService?) = apply {
+ this.dispatcherExecutorService = dispatcherExecutorService
+ }
+
+ /**
+ * Alias for calling [Builder.dispatcherExecutorService] with
+ * `dispatcherExecutorService.orElse(null)`.
+ */
+ fun dispatcherExecutorService(dispatcherExecutorService: Optional) =
+ dispatcherExecutorService(dispatcherExecutorService.getOrNull())
+
+ fun proxy(proxy: Proxy?) = apply { this.proxy = proxy }
+
+ /** Alias for calling [Builder.proxy] with `proxy.orElse(null)`. */
+ fun proxy(proxy: Optional) = proxy(proxy.getOrNull())
+
+ /**
+ * Provides credentials when an HTTP proxy responds with `407 Proxy Authentication
+ * Required`.
+ */
+ fun proxyAuthenticator(proxyAuthenticator: ProxyAuthenticator?) = apply {
+ this.proxyAuthenticator = proxyAuthenticator
+ }
+
+ /**
+ * Alias for calling [Builder.proxyAuthenticator] with `proxyAuthenticator.orElse(null)`.
+ */
+ fun proxyAuthenticator(proxyAuthenticator: Optional) =
+ proxyAuthenticator(proxyAuthenticator.getOrNull())
+
+ /**
+ * The maximum number of idle connections kept by the underlying OkHttp connection pool.
+ *
+ * If this is set, then [keepAliveDuration] must also be set.
+ *
+ * If unset, then OkHttp's default is used.
+ */
+ fun maxIdleConnections(maxIdleConnections: Int?) = apply {
+ this.maxIdleConnections = maxIdleConnections
+ }
+
+ /**
+ * Alias for [Builder.maxIdleConnections].
+ *
+ * This unboxed primitive overload exists for backwards compatibility.
+ */
+ fun maxIdleConnections(maxIdleConnections: Int) =
+ maxIdleConnections(maxIdleConnections as Int?)
+
+ /**
+ * Alias for calling [Builder.maxIdleConnections] with `maxIdleConnections.orElse(null)`.
+ */
+ fun maxIdleConnections(maxIdleConnections: Optional) =
+ maxIdleConnections(maxIdleConnections.getOrNull())
+
+ /**
+ * The keep-alive duration for idle connections in the underlying OkHttp connection pool.
+ *
+ * If this is set, then [maxIdleConnections] must also be set.
+ *
+ * If unset, then OkHttp's default is used.
+ */
+ fun keepAliveDuration(keepAliveDuration: Duration?) = apply {
+ this.keepAliveDuration = keepAliveDuration
+ }
+
+ /** Alias for calling [Builder.keepAliveDuration] with `keepAliveDuration.orElse(null)`. */
+ fun keepAliveDuration(keepAliveDuration: Optional) =
+ keepAliveDuration(keepAliveDuration.getOrNull())
+
+ /**
+ * The socket factory used to secure HTTPS connections.
+ *
+ * If this is set, then [trustManager] must also be set.
+ *
+ * If unset, then the system default is used. Most applications should not call this method,
+ * and instead use the system default. The default include special optimizations that can be
+ * lost if the implementation is modified.
+ */
+ fun sslSocketFactory(sslSocketFactory: SSLSocketFactory?) = apply {
+ this.sslSocketFactory = sslSocketFactory
+ }
+
+ /** Alias for calling [Builder.sslSocketFactory] with `sslSocketFactory.orElse(null)`. */
+ fun sslSocketFactory(sslSocketFactory: Optional) =
+ sslSocketFactory(sslSocketFactory.getOrNull())
+
+ /**
+ * The trust manager used to secure HTTPS connections.
+ *
+ * If this is set, then [sslSocketFactory] must also be set.
+ *
+ * If unset, then the system default is used. Most applications should not call this method,
+ * and instead use the system default. The default include special optimizations that can be
+ * lost if the implementation is modified.
+ */
+ fun trustManager(trustManager: X509TrustManager?) = apply {
+ this.trustManager = trustManager
+ }
+
+ /** Alias for calling [Builder.trustManager] with `trustManager.orElse(null)`. */
+ fun trustManager(trustManager: Optional) =
+ trustManager(trustManager.getOrNull())
+
+ /**
+ * The verifier used to confirm that response certificates apply to requested hostnames for
+ * HTTPS connections.
+ *
+ * If unset, then a default hostname verifier is used.
+ */
+ fun hostnameVerifier(hostnameVerifier: HostnameVerifier?) = apply {
+ this.hostnameVerifier = hostnameVerifier
+ }
+
+ /** Alias for calling [Builder.hostnameVerifier] with `hostnameVerifier.orElse(null)`. */
+ fun hostnameVerifier(hostnameVerifier: Optional) =
+ hostnameVerifier(hostnameVerifier.getOrNull())
+
+ /**
+ * Whether to throw an exception if any of the Jackson versions detected at runtime are
+ * incompatible with the SDK's minimum supported Jackson version (2.13.4).
+ *
+ * Defaults to true. Use extreme caution when disabling this option. There is no guarantee
+ * that the SDK will work correctly when using an incompatible Jackson version.
+ */
+ fun checkJacksonVersionCompatibility(checkJacksonVersionCompatibility: Boolean) = apply {
+ clientOptions.checkJacksonVersionCompatibility(checkJacksonVersionCompatibility)
+ }
+
+ /**
+ * The Jackson JSON mapper to use for serializing and deserializing JSON.
+ *
+ * Defaults to [com.browserbase.api.core.jsonMapper]. The default is usually sufficient and
+ * rarely needs to be overridden.
+ */
+ fun jsonMapper(jsonMapper: JsonMapper) = apply { clientOptions.jsonMapper(jsonMapper) }
+
+ /**
+ * The executor to use for running [AsyncStreamResponse.Handler] callbacks.
+ *
+ * Defaults to a dedicated cached thread pool.
+ *
+ * This class takes ownership of the executor and shuts it down, if possible, when closed.
+ */
+ fun streamHandlerExecutor(streamHandlerExecutor: Executor) = apply {
+ clientOptions.streamHandlerExecutor(streamHandlerExecutor)
+ }
+
+ /**
+ * The interface to use for delaying execution, like during retries.
+ *
+ * This is primarily useful for using fake delays in tests.
+ *
+ * Defaults to real execution delays.
+ *
+ * This class takes ownership of the sleeper and closes it when closed.
+ */
+ fun sleeper(sleeper: Sleeper) = apply { clientOptions.sleeper(sleeper) }
+
+ /**
+ * The clock to use for operations that require timing, like retries.
+ *
+ * This is primarily useful for using a fake clock in tests.
+ *
+ * Defaults to [Clock.systemUTC].
+ */
+ fun clock(clock: Clock) = apply { clientOptions.clock(clock) }
+
+ /**
+ * The base URL to use for every request.
+ *
+ * Defaults to the production environment: `https://api.stagehand.browserbase.com`.
+ */
+ fun baseUrl(baseUrl: String?) = apply { clientOptions.baseUrl(baseUrl) }
+
+ /** Alias for calling [Builder.baseUrl] with `baseUrl.orElse(null)`. */
+ fun baseUrl(baseUrl: Optional) = baseUrl(baseUrl.getOrNull())
+
+ /**
+ * Whether to call `validate` on every response before returning it.
+ *
+ * Setting this to `true` is _not_ forwards compatible with new types from the API for
+ * existing fields.
+ *
+ * Defaults to false, which means the shape of the response will not be validated upfront.
+ * Instead, validation will only occur for the parts of the response that are accessed.
+ */
+ fun responseValidation(responseValidation: Boolean) = apply {
+ clientOptions.responseValidation(responseValidation)
+ }
+
+ /**
+ * Sets the maximum time allowed for various parts of an HTTP call's lifecycle, excluding
+ * retries.
+ *
+ * Defaults to [Timeout.default].
+ */
+ fun timeout(timeout: Timeout) = apply { clientOptions.timeout(timeout) }
+
+ /**
+ * Sets the maximum time allowed for a complete HTTP call, not including retries.
+ *
+ * See [Timeout.request] for more details.
+ *
+ * For fine-grained control, pass a [Timeout] object.
+ */
+ fun timeout(timeout: Duration) = apply { clientOptions.timeout(timeout) }
+
+ /**
+ * The maximum number of times to retry failed requests, with a short exponential backoff
+ * between requests.
+ *
+ * Only the following error types are retried:
+ * - Connection errors (for example, due to a network connectivity problem)
+ * - 408 Request Timeout
+ * - 409 Conflict
+ * - 429 Rate Limit
+ * - 5xx Internal
+ *
+ * The API may also explicitly instruct the SDK to retry or not retry a request.
+ *
+ * Defaults to 2.
+ */
+ fun maxRetries(maxRetries: Int) = apply { clientOptions.maxRetries(maxRetries) }
+
+ /**
+ * The level at which to log request and response information.
+ *
+ * [fromEnv] will set the level from environment variables. See [LogLevel.fromEnv].
+ *
+ * Defaults to [LogLevel.fromEnv].
+ */
+ fun logLevel(logLevel: LogLevel) = apply { clientOptions.logLevel(logLevel) }
+
+ /** Your [Browserbase API Key](https://www.browserbase.com/settings) */
+ fun browserbaseApiKey(browserbaseApiKey: String) = apply {
+ clientOptions.browserbaseApiKey(browserbaseApiKey)
+ }
+
+ /**
+ * Deprecated. Browserbase API keys are now project-scoped, so this value is no longer
+ * required.
+ */
+ fun browserbaseProjectId(browserbaseProjectId: String?) = apply {
+ clientOptions.browserbaseProjectId(browserbaseProjectId)
+ }
+
+ /**
+ * Alias for calling [Builder.browserbaseProjectId] with
+ * `browserbaseProjectId.orElse(null)`.
+ */
+ fun browserbaseProjectId(browserbaseProjectId: Optional) =
+ browserbaseProjectId(browserbaseProjectId.getOrNull())
+
+ /** Your LLM provider API key (e.g. OPENAI_API_KEY, ANTHROPIC_API_KEY, etc.) */
+ fun modelApiKey(modelApiKey: String) = apply { clientOptions.modelApiKey(modelApiKey) }
+
+ fun headers(headers: Headers) = apply { clientOptions.headers(headers) }
+
+ fun headers(headers: Map>) = apply {
+ clientOptions.headers(headers)
+ }
+
+ fun putHeader(name: String, value: String) = apply { clientOptions.putHeader(name, value) }
+
+ fun putHeaders(name: String, values: Iterable) = apply {
+ clientOptions.putHeaders(name, values)
+ }
+
+ fun putAllHeaders(headers: Headers) = apply { clientOptions.putAllHeaders(headers) }
+
+ fun putAllHeaders(headers: Map>) = apply {
+ clientOptions.putAllHeaders(headers)
+ }
+
+ fun replaceHeaders(name: String, value: String) = apply {
+ clientOptions.replaceHeaders(name, value)
+ }
+
+ fun replaceHeaders(name: String, values: Iterable) = apply {
+ clientOptions.replaceHeaders(name, values)
+ }
+
+ fun replaceAllHeaders(headers: Headers) = apply { clientOptions.replaceAllHeaders(headers) }
+
+ fun replaceAllHeaders(headers: Map>) = apply {
+ clientOptions.replaceAllHeaders(headers)
+ }
+
+ fun removeHeaders(name: String) = apply { clientOptions.removeHeaders(name) }
+
+ fun removeAllHeaders(names: Set) = apply { clientOptions.removeAllHeaders(names) }
+
+ fun queryParams(queryParams: QueryParams) = apply { clientOptions.queryParams(queryParams) }
+
+ fun queryParams(queryParams: Map>) = apply {
+ clientOptions.queryParams(queryParams)
+ }
+
+ fun putQueryParam(key: String, value: String) = apply {
+ clientOptions.putQueryParam(key, value)
+ }
+
+ fun putQueryParams(key: String, values: Iterable) = apply {
+ clientOptions.putQueryParams(key, values)
+ }
+
+ fun putAllQueryParams(queryParams: QueryParams) = apply {
+ clientOptions.putAllQueryParams(queryParams)
+ }
+
+ fun putAllQueryParams(queryParams: Map>) = apply {
+ clientOptions.putAllQueryParams(queryParams)
+ }
+
+ fun replaceQueryParams(key: String, value: String) = apply {
+ clientOptions.replaceQueryParams(key, value)
+ }
+
+ fun replaceQueryParams(key: String, values: Iterable) = apply {
+ clientOptions.replaceQueryParams(key, values)
+ }
+
+ fun replaceAllQueryParams(queryParams: QueryParams) = apply {
+ clientOptions.replaceAllQueryParams(queryParams)
+ }
+
+ fun replaceAllQueryParams(queryParams: Map>) = apply {
+ clientOptions.replaceAllQueryParams(queryParams)
+ }
+
+ fun removeQueryParams(key: String) = apply { clientOptions.removeQueryParams(key) }
+
+ fun removeAllQueryParams(keys: Set) = apply {
+ clientOptions.removeAllQueryParams(keys)
+ }
+
+ /**
+ * Updates configuration using system properties and environment variables.
+ *
+ * @see ClientOptions.Builder.fromEnv
+ */
+ fun fromEnv() = apply { clientOptions.fromEnv() }
+
+ /**
+ * Returns an immutable instance of [StagehandClientAsync].
+ *
+ * Further updates to this [Builder] will not mutate the returned instance.
+ */
+ fun build(): StagehandClientAsync =
+ StagehandClientAsyncImpl(
+ clientOptions
+ .httpClient(
+ OkHttpClient.builder()
+ .timeout(clientOptions.timeout())
+ .proxy(proxy)
+ .proxyAuthenticator(proxyAuthenticator)
+ .maxIdleConnections(maxIdleConnections)
+ .keepAliveDuration(keepAliveDuration)
+ .dispatcherExecutorService(dispatcherExecutorService)
+ .sslSocketFactory(sslSocketFactory)
+ .trustManager(trustManager)
+ .hostnameVerifier(hostnameVerifier)
+ .build()
+ )
+ .build()
+ )
+ }
+}
diff --git a/stagehand-java-client-okhttp/src/test/kotlin/com/browserbase/api/client/okhttp/OkHttpClientTest.kt b/stagehand-java-client-okhttp/src/test/kotlin/com/browserbase/api/client/okhttp/OkHttpClientTest.kt
new file mode 100644
index 0000000..3a39e9e
--- /dev/null
+++ b/stagehand-java-client-okhttp/src/test/kotlin/com/browserbase/api/client/okhttp/OkHttpClientTest.kt
@@ -0,0 +1,44 @@
+package com.browserbase.api.client.okhttp
+
+import com.browserbase.api.core.http.HttpMethod
+import com.browserbase.api.core.http.HttpRequest
+import com.github.tomakehurst.wiremock.client.WireMock.*
+import com.github.tomakehurst.wiremock.junit5.WireMockRuntimeInfo
+import com.github.tomakehurst.wiremock.junit5.WireMockTest
+import org.assertj.core.api.Assertions.assertThat
+import org.junit.jupiter.api.BeforeEach
+import org.junit.jupiter.api.Test
+import org.junit.jupiter.api.parallel.ResourceLock
+
+@WireMockTest
+@ResourceLock("https://github.com/wiremock/wiremock/issues/169")
+internal class OkHttpClientTest {
+
+ private lateinit var baseUrl: String
+ private lateinit var httpClient: OkHttpClient
+
+ @BeforeEach
+ fun beforeEach(wmRuntimeInfo: WireMockRuntimeInfo) {
+ baseUrl = wmRuntimeInfo.httpBaseUrl
+ httpClient = OkHttpClient.builder().build()
+ }
+
+ @Test
+ fun executeAsync_whenFutureCancelled_cancelsUnderlyingCall() {
+ stubFor(post(urlPathEqualTo("/something")).willReturn(ok()))
+ val responseFuture =
+ httpClient.executeAsync(
+ HttpRequest.builder()
+ .method(HttpMethod.POST)
+ .baseUrl(baseUrl)
+ .addPathSegment("something")
+ .build()
+ )
+ val call = httpClient.okHttpClient.dispatcher.runningCalls().single()
+
+ responseFuture.cancel(false)
+
+ // Should have cancelled the underlying call
+ assertThat(call.isCanceled()).isTrue()
+ }
+}
diff --git a/stagehand-java-core/build.gradle.kts b/stagehand-java-core/build.gradle.kts
new file mode 100644
index 0000000..55e80f4
--- /dev/null
+++ b/stagehand-java-core/build.gradle.kts
@@ -0,0 +1,41 @@
+plugins {
+ id("stagehand.kotlin")
+ id("stagehand.publish")
+}
+
+configurations.all {
+ resolutionStrategy {
+ // Compile and test against a lower Jackson version to ensure we're compatible with it. Note that
+ // we generally support 2.13.4, but test against 2.14.0 because 2.13.4 has some annoying (but
+ // niche) bugs (users should upgrade if they encounter them). We publish with a higher version
+ // (see below) to ensure users depend on a secure version by default.
+ force("com.fasterxml.jackson.core:jackson-core:2.14.0")
+ force("com.fasterxml.jackson.core:jackson-databind:2.14.0")
+ force("com.fasterxml.jackson.core:jackson-annotations:2.14.0")
+ force("com.fasterxml.jackson.datatype:jackson-datatype-jdk8:2.14.0")
+ force("com.fasterxml.jackson.datatype:jackson-datatype-jsr310:2.14.0")
+ force("com.fasterxml.jackson.module:jackson-module-kotlin:2.14.0")
+ }
+}
+
+dependencies {
+ api("com.fasterxml.jackson.core:jackson-core:2.18.2")
+ api("com.fasterxml.jackson.core:jackson-databind:2.18.2")
+ api("com.google.errorprone:error_prone_annotations:2.33.0")
+
+ implementation("com.fasterxml.jackson.core:jackson-annotations:2.18.2")
+ implementation("com.fasterxml.jackson.datatype:jackson-datatype-jdk8:2.18.2")
+ implementation("com.fasterxml.jackson.datatype:jackson-datatype-jsr310:2.18.2")
+ implementation("com.fasterxml.jackson.module:jackson-module-kotlin:2.18.2")
+
+ testImplementation(kotlin("test"))
+ testImplementation(project(":stagehand-java-client-okhttp"))
+ testImplementation("com.github.tomakehurst:wiremock-jre8:2.35.2")
+ testImplementation("org.assertj:assertj-core:3.27.7")
+ testImplementation("org.junit.jupiter:junit-jupiter-api:5.9.3")
+ testImplementation("org.junit.jupiter:junit-jupiter-params:5.9.3")
+ testImplementation("org.junit-pioneer:junit-pioneer:1.9.1")
+ testImplementation("org.mockito:mockito-core:5.14.2")
+ testImplementation("org.mockito:mockito-junit-jupiter:5.14.2")
+ testImplementation("org.mockito.kotlin:mockito-kotlin:4.1.0")
+}
diff --git a/stagehand-java-core/src/main/kotlin/com/browserbase/api/client/StagehandClient.kt b/stagehand-java-core/src/main/kotlin/com/browserbase/api/client/StagehandClient.kt
new file mode 100644
index 0000000..a5c8c6c
--- /dev/null
+++ b/stagehand-java-core/src/main/kotlin/com/browserbase/api/client/StagehandClient.kt
@@ -0,0 +1,72 @@
+// File generated from our OpenAPI spec by Stainless.
+
+package com.browserbase.api.client
+
+import com.browserbase.api.core.ClientOptions
+import com.browserbase.api.services.blocking.SessionService
+import java.util.function.Consumer
+
+/**
+ * A client for interacting with the Stagehand REST API synchronously. You can also switch to
+ * asynchronous execution via the [async] method.
+ *
+ * This client performs best when you create a single instance and reuse it for all interactions
+ * with the REST API. This is because each client holds its own connection pool and thread pools.
+ * Reusing connections and threads reduces latency and saves memory. The client also handles rate
+ * limiting per client. This means that creating and using multiple instances at the same time will
+ * not respect rate limits.
+ *
+ * The threads and connections that are held will be released automatically if they remain idle. But
+ * if you are writing an application that needs to aggressively release unused resources, then you
+ * may call [close].
+ */
+interface StagehandClient {
+
+ /**
+ * Returns a version of this client that uses asynchronous execution.
+ *
+ * The returned client shares its resources, like its connection pool and thread pools, with
+ * this client.
+ */
+ fun async(): StagehandClientAsync
+
+ /**
+ * Returns a view of this service that provides access to raw HTTP responses for each method.
+ */
+ fun withRawResponse(): WithRawResponse
+
+ /**
+ * Returns a view of this service with the given option modifications applied.
+ *
+ * The original service is not modified.
+ */
+ fun withOptions(modifier: Consumer): StagehandClient
+
+ fun sessions(): SessionService
+
+ /**
+ * Closes this client, relinquishing any underlying resources.
+ *
+ * This is purposefully not inherited from [AutoCloseable] because the client is long-lived and
+ * usually should not be synchronously closed via try-with-resources.
+ *
+ * It's also usually not necessary to call this method at all. the default HTTP client
+ * automatically releases threads and connections if they remain idle, but if you are writing an
+ * application that needs to aggressively release unused resources, then you may call this
+ * method.
+ */
+ fun close()
+
+ /** A view of [StagehandClient] that provides access to raw HTTP responses for each method. */
+ interface WithRawResponse {
+
+ /**
+ * Returns a view of this service with the given option modifications applied.
+ *
+ * The original service is not modified.
+ */
+ fun withOptions(modifier: Consumer): StagehandClient.WithRawResponse
+
+ fun sessions(): SessionService.WithRawResponse
+ }
+}
diff --git a/stagehand-java-core/src/main/kotlin/com/browserbase/api/client/StagehandClientAsync.kt b/stagehand-java-core/src/main/kotlin/com/browserbase/api/client/StagehandClientAsync.kt
new file mode 100644
index 0000000..27fd2a9
--- /dev/null
+++ b/stagehand-java-core/src/main/kotlin/com/browserbase/api/client/StagehandClientAsync.kt
@@ -0,0 +1,76 @@
+// File generated from our OpenAPI spec by Stainless.
+
+package com.browserbase.api.client
+
+import com.browserbase.api.core.ClientOptions
+import com.browserbase.api.services.async.SessionServiceAsync
+import java.util.function.Consumer
+
+/**
+ * A client for interacting with the Stagehand REST API asynchronously. You can also switch to
+ * synchronous execution via the [sync] method.
+ *
+ * This client performs best when you create a single instance and reuse it for all interactions
+ * with the REST API. This is because each client holds its own connection pool and thread pools.
+ * Reusing connections and threads reduces latency and saves memory. The client also handles rate
+ * limiting per client. This means that creating and using multiple instances at the same time will
+ * not respect rate limits.
+ *
+ * The threads and connections that are held will be released automatically if they remain idle. But
+ * if you are writing an application that needs to aggressively release unused resources, then you
+ * may call [close].
+ */
+interface StagehandClientAsync {
+
+ /**
+ * Returns a version of this client that uses synchronous execution.
+ *
+ * The returned client shares its resources, like its connection pool and thread pools, with
+ * this client.
+ */
+ fun sync(): StagehandClient
+
+ /**
+ * Returns a view of this service that provides access to raw HTTP responses for each method.
+ */
+ fun withRawResponse(): WithRawResponse
+
+ /**
+ * Returns a view of this service with the given option modifications applied.
+ *
+ * The original service is not modified.
+ */
+ fun withOptions(modifier: Consumer): StagehandClientAsync
+
+ fun sessions(): SessionServiceAsync
+
+ /**
+ * Closes this client, relinquishing any underlying resources.
+ *
+ * This is purposefully not inherited from [AutoCloseable] because the client is long-lived and
+ * usually should not be synchronously closed via try-with-resources.
+ *
+ * It's also usually not necessary to call this method at all. the default HTTP client
+ * automatically releases threads and connections if they remain idle, but if you are writing an
+ * application that needs to aggressively release unused resources, then you may call this
+ * method.
+ */
+ fun close()
+
+ /**
+ * A view of [StagehandClientAsync] that provides access to raw HTTP responses for each method.
+ */
+ interface WithRawResponse {
+
+ /**
+ * Returns a view of this service with the given option modifications applied.
+ *
+ * The original service is not modified.
+ */
+ fun withOptions(
+ modifier: Consumer
+ ): StagehandClientAsync.WithRawResponse
+
+ fun sessions(): SessionServiceAsync.WithRawResponse
+ }
+}
diff --git a/stagehand-java-core/src/main/kotlin/com/browserbase/api/client/StagehandClientAsyncImpl.kt b/stagehand-java-core/src/main/kotlin/com/browserbase/api/client/StagehandClientAsyncImpl.kt
new file mode 100644
index 0000000..2a0b813
--- /dev/null
+++ b/stagehand-java-core/src/main/kotlin/com/browserbase/api/client/StagehandClientAsyncImpl.kt
@@ -0,0 +1,59 @@
+// File generated from our OpenAPI spec by Stainless.
+
+package com.browserbase.api.client
+
+import com.browserbase.api.core.ClientOptions
+import com.browserbase.api.core.getPackageVersion
+import com.browserbase.api.services.async.SessionServiceAsync
+import com.browserbase.api.services.async.SessionServiceAsyncImpl
+import java.util.function.Consumer
+
+class StagehandClientAsyncImpl(private val clientOptions: ClientOptions) : StagehandClientAsync {
+
+ private val clientOptionsWithUserAgent =
+ if (clientOptions.headers.names().contains("User-Agent")) clientOptions
+ else
+ clientOptions
+ .toBuilder()
+ .putHeader("User-Agent", "${javaClass.simpleName}/Java ${getPackageVersion()}")
+ .build()
+
+ // Pass the original clientOptions so that this client sets its own User-Agent.
+ private val sync: StagehandClient by lazy { StagehandClientImpl(clientOptions) }
+
+ private val withRawResponse: StagehandClientAsync.WithRawResponse by lazy {
+ WithRawResponseImpl(clientOptions)
+ }
+
+ private val sessions: SessionServiceAsync by lazy {
+ SessionServiceAsyncImpl(clientOptionsWithUserAgent)
+ }
+
+ override fun sync(): StagehandClient = sync
+
+ override fun withRawResponse(): StagehandClientAsync.WithRawResponse = withRawResponse
+
+ override fun withOptions(modifier: Consumer): StagehandClientAsync =
+ StagehandClientAsyncImpl(clientOptions.toBuilder().apply(modifier::accept).build())
+
+ override fun sessions(): SessionServiceAsync = sessions
+
+ override fun close() = clientOptions.close()
+
+ class WithRawResponseImpl internal constructor(private val clientOptions: ClientOptions) :
+ StagehandClientAsync.WithRawResponse {
+
+ private val sessions: SessionServiceAsync.WithRawResponse by lazy {
+ SessionServiceAsyncImpl.WithRawResponseImpl(clientOptions)
+ }
+
+ override fun withOptions(
+ modifier: Consumer
+ ): StagehandClientAsync.WithRawResponse =
+ StagehandClientAsyncImpl.WithRawResponseImpl(
+ clientOptions.toBuilder().apply(modifier::accept).build()
+ )
+
+ override fun sessions(): SessionServiceAsync.WithRawResponse = sessions
+ }
+}
diff --git a/stagehand-java-core/src/main/kotlin/com/browserbase/api/client/StagehandClientImpl.kt b/stagehand-java-core/src/main/kotlin/com/browserbase/api/client/StagehandClientImpl.kt
new file mode 100644
index 0000000..8ea5907
--- /dev/null
+++ b/stagehand-java-core/src/main/kotlin/com/browserbase/api/client/StagehandClientImpl.kt
@@ -0,0 +1,57 @@
+// File generated from our OpenAPI spec by Stainless.
+
+package com.browserbase.api.client
+
+import com.browserbase.api.core.ClientOptions
+import com.browserbase.api.core.getPackageVersion
+import com.browserbase.api.services.blocking.SessionService
+import com.browserbase.api.services.blocking.SessionServiceImpl
+import java.util.function.Consumer
+
+class StagehandClientImpl(private val clientOptions: ClientOptions) : StagehandClient {
+
+ private val clientOptionsWithUserAgent =
+ if (clientOptions.headers.names().contains("User-Agent")) clientOptions
+ else
+ clientOptions
+ .toBuilder()
+ .putHeader("User-Agent", "${javaClass.simpleName}/Java ${getPackageVersion()}")
+ .build()
+
+ // Pass the original clientOptions so that this client sets its own User-Agent.
+ private val async: StagehandClientAsync by lazy { StagehandClientAsyncImpl(clientOptions) }
+
+ private val withRawResponse: StagehandClient.WithRawResponse by lazy {
+ WithRawResponseImpl(clientOptions)
+ }
+
+ private val sessions: SessionService by lazy { SessionServiceImpl(clientOptionsWithUserAgent) }
+
+ override fun async(): StagehandClientAsync = async
+
+ override fun withRawResponse(): StagehandClient.WithRawResponse = withRawResponse
+
+ override fun withOptions(modifier: Consumer): StagehandClient =
+ StagehandClientImpl(clientOptions.toBuilder().apply(modifier::accept).build())
+
+ override fun sessions(): SessionService = sessions
+
+ override fun close() = clientOptions.close()
+
+ class WithRawResponseImpl internal constructor(private val clientOptions: ClientOptions) :
+ StagehandClient.WithRawResponse {
+
+ private val sessions: SessionService.WithRawResponse by lazy {
+ SessionServiceImpl.WithRawResponseImpl(clientOptions)
+ }
+
+ override fun withOptions(
+ modifier: Consumer
+ ): StagehandClient.WithRawResponse =
+ StagehandClientImpl.WithRawResponseImpl(
+ clientOptions.toBuilder().apply(modifier::accept).build()
+ )
+
+ override fun sessions(): SessionService.WithRawResponse = sessions
+ }
+}
diff --git a/stagehand-java-core/src/main/kotlin/com/browserbase/api/core/BaseDeserializer.kt b/stagehand-java-core/src/main/kotlin/com/browserbase/api/core/BaseDeserializer.kt
new file mode 100644
index 0000000..dc07be5
--- /dev/null
+++ b/stagehand-java-core/src/main/kotlin/com/browserbase/api/core/BaseDeserializer.kt
@@ -0,0 +1,44 @@
+package com.browserbase.api.core
+
+import com.fasterxml.jackson.core.JsonParser
+import com.fasterxml.jackson.core.ObjectCodec
+import com.fasterxml.jackson.core.type.TypeReference
+import com.fasterxml.jackson.databind.BeanProperty
+import com.fasterxml.jackson.databind.DeserializationContext
+import com.fasterxml.jackson.databind.JavaType
+import com.fasterxml.jackson.databind.JsonDeserializer
+import com.fasterxml.jackson.databind.JsonNode
+import com.fasterxml.jackson.databind.deser.ContextualDeserializer
+import com.fasterxml.jackson.databind.deser.std.StdDeserializer
+import kotlin.reflect.KClass
+
+abstract class BaseDeserializer(type: KClass) :
+ StdDeserializer(type.java), ContextualDeserializer {
+
+ override fun createContextual(
+ context: DeserializationContext,
+ property: BeanProperty?,
+ ): JsonDeserializer {
+ return this
+ }
+
+ override fun deserialize(parser: JsonParser, context: DeserializationContext): T {
+ return parser.codec.deserialize(parser.readValueAsTree())
+ }
+
+ protected abstract fun ObjectCodec.deserialize(node: JsonNode): T
+
+ protected fun ObjectCodec.tryDeserialize(node: JsonNode, type: TypeReference): T? =
+ try {
+ readValue(treeAsTokens(node), type)
+ } catch (e: Exception) {
+ null
+ }
+
+ protected fun ObjectCodec.tryDeserialize(node: JsonNode, type: JavaType): T? =
+ try {
+ readValue(treeAsTokens(node), type)
+ } catch (e: Exception) {
+ null
+ }
+}
diff --git a/stagehand-java-core/src/main/kotlin/com/browserbase/api/core/BaseSerializer.kt b/stagehand-java-core/src/main/kotlin/com/browserbase/api/core/BaseSerializer.kt
new file mode 100644
index 0000000..9142bfe
--- /dev/null
+++ b/stagehand-java-core/src/main/kotlin/com/browserbase/api/core/BaseSerializer.kt
@@ -0,0 +1,6 @@
+package com.browserbase.api.core
+
+import com.fasterxml.jackson.databind.ser.std.StdSerializer
+import kotlin.reflect.KClass
+
+abstract class BaseSerializer(type: KClass) : StdSerializer(type.java)
diff --git a/stagehand-java-core/src/main/kotlin/com/browserbase/api/core/Check.kt b/stagehand-java-core/src/main/kotlin/com/browserbase/api/core/Check.kt
new file mode 100644
index 0000000..0eb0c57
--- /dev/null
+++ b/stagehand-java-core/src/main/kotlin/com/browserbase/api/core/Check.kt
@@ -0,0 +1,96 @@
+@file:JvmName("Check")
+
+package com.browserbase.api.core
+
+import com.fasterxml.jackson.core.Version
+import com.fasterxml.jackson.core.util.VersionUtil
+
+fun checkRequired(name: String, condition: Boolean) =
+ check(condition) { "`$name` is required, but was not set" }
+
+fun checkRequired(name: String, value: T?): T =
+ checkNotNull(value) { "`$name` is required, but was not set" }
+
+@JvmSynthetic
+internal fun checkKnown(name: String, value: JsonField): T =
+ value.asKnown().orElseThrow {
+ IllegalStateException("`$name` is not a known type: ${value.javaClass.simpleName}")
+ }
+
+@JvmSynthetic
+internal fun checkKnown(name: String, value: MultipartField): T =
+ value.value.asKnown().orElseThrow {
+ IllegalStateException("`$name` is not a known type: ${value.javaClass.simpleName}")
+ }
+
+@JvmSynthetic
+internal fun checkLength(name: String, value: String, length: Int): String =
+ value.also {
+ check(it.length == length) { "`$name` must have length $length, but was ${it.length}" }
+ }
+
+@JvmSynthetic
+internal fun checkMinLength(name: String, value: String, minLength: Int): String =
+ value.also {
+ check(it.length >= minLength) {
+ if (minLength == 1) "`$name` must be non-empty, but was empty"
+ else "`$name` must have at least length $minLength, but was ${it.length}"
+ }
+ }
+
+@JvmSynthetic
+internal fun checkMaxLength(name: String, value: String, maxLength: Int): String =
+ value.also {
+ check(it.length <= maxLength) {
+ "`$name` must have at most length $maxLength, but was ${it.length}"
+ }
+ }
+
+@JvmSynthetic
+internal fun checkJacksonVersionCompatibility() {
+ val incompatibleJacksonVersions =
+ RUNTIME_JACKSON_VERSIONS.mapNotNull {
+ val badVersionReason = BAD_JACKSON_VERSIONS[it.toString()]
+ when {
+ it.majorVersion != MINIMUM_JACKSON_VERSION.majorVersion ->
+ it to "incompatible major version"
+ it.minorVersion < MINIMUM_JACKSON_VERSION.minorVersion ->
+ it to "minor version too low"
+ it.minorVersion == MINIMUM_JACKSON_VERSION.minorVersion &&
+ it.patchLevel < MINIMUM_JACKSON_VERSION.patchLevel ->
+ it to "patch version too low"
+ badVersionReason != null -> it to badVersionReason
+ else -> null
+ }
+ }
+ check(incompatibleJacksonVersions.isEmpty()) {
+ """
+This SDK requires a minimum Jackson version of $MINIMUM_JACKSON_VERSION, but the following incompatible Jackson versions were detected at runtime:
+
+${incompatibleJacksonVersions.asSequence().map { (version, incompatibilityReason) ->
+ "- `${version.toFullString().replace("/", ":")}` ($incompatibilityReason)"
+}.joinToString("\n")}
+
+This can happen if you are either:
+1. Directly depending on different Jackson versions
+2. Depending on some library that depends on different Jackson versions, potentially transitively
+
+Double-check that you are depending on compatible Jackson versions.
+
+See https://www.github.com/browserbase/stagehand-java#jackson for more information.
+ """
+ .trimIndent()
+ }
+}
+
+private val MINIMUM_JACKSON_VERSION: Version = VersionUtil.parseVersion("2.13.4", null, null)
+private val BAD_JACKSON_VERSIONS: Map =
+ mapOf("2.18.1" to "due to https://github.com/FasterXML/jackson-databind/issues/4639")
+private val RUNTIME_JACKSON_VERSIONS: List =
+ listOf(
+ com.fasterxml.jackson.core.json.PackageVersion.VERSION,
+ com.fasterxml.jackson.databind.cfg.PackageVersion.VERSION,
+ com.fasterxml.jackson.datatype.jdk8.PackageVersion.VERSION,
+ com.fasterxml.jackson.datatype.jsr310.PackageVersion.VERSION,
+ com.fasterxml.jackson.module.kotlin.PackageVersion.VERSION,
+ )
diff --git a/stagehand-java-core/src/main/kotlin/com/browserbase/api/core/ClientOptions.kt b/stagehand-java-core/src/main/kotlin/com/browserbase/api/core/ClientOptions.kt
new file mode 100644
index 0000000..12d3001
--- /dev/null
+++ b/stagehand-java-core/src/main/kotlin/com/browserbase/api/core/ClientOptions.kt
@@ -0,0 +1,601 @@
+// File generated from our OpenAPI spec by Stainless.
+
+package com.browserbase.api.core
+
+import com.browserbase.api.core.http.AsyncStreamResponse
+import com.browserbase.api.core.http.Headers
+import com.browserbase.api.core.http.HttpClient
+import com.browserbase.api.core.http.LoggingHttpClient
+import com.browserbase.api.core.http.PhantomReachableClosingHttpClient
+import com.browserbase.api.core.http.QueryParams
+import com.browserbase.api.core.http.RetryingHttpClient
+import com.fasterxml.jackson.databind.json.JsonMapper
+import java.time.Clock
+import java.time.Duration
+import java.util.Optional
+import java.util.concurrent.Executor
+import java.util.concurrent.ExecutorService
+import java.util.concurrent.Executors
+import java.util.concurrent.ThreadFactory
+import java.util.concurrent.atomic.AtomicLong
+import kotlin.jvm.optionals.getOrNull
+
+/** A class representing the SDK client configuration. */
+class ClientOptions
+private constructor(
+ private val originalHttpClient: HttpClient,
+ /**
+ * The HTTP client to use in the SDK.
+ *
+ * Use the one published in `stagehand-java-client-okhttp` or implement your own.
+ *
+ * This class takes ownership of the client and closes it when closed.
+ */
+ @get:JvmName("httpClient") val httpClient: HttpClient,
+ /**
+ * Whether to throw an exception if any of the Jackson versions detected at runtime are
+ * incompatible with the SDK's minimum supported Jackson version (2.13.4).
+ *
+ * Defaults to true. Use extreme caution when disabling this option. There is no guarantee that
+ * the SDK will work correctly when using an incompatible Jackson version.
+ */
+ @get:JvmName("checkJacksonVersionCompatibility") val checkJacksonVersionCompatibility: Boolean,
+ /**
+ * The Jackson JSON mapper to use for serializing and deserializing JSON.
+ *
+ * Defaults to [com.browserbase.api.core.jsonMapper]. The default is usually sufficient and
+ * rarely needs to be overridden.
+ */
+ @get:JvmName("jsonMapper") val jsonMapper: JsonMapper,
+ /**
+ * The executor to use for running [AsyncStreamResponse.Handler] callbacks.
+ *
+ * Defaults to a dedicated cached thread pool.
+ *
+ * This class takes ownership of the executor and shuts it down, if possible, when closed.
+ */
+ @get:JvmName("streamHandlerExecutor") val streamHandlerExecutor: Executor,
+ /**
+ * The interface to use for delaying execution, like during retries.
+ *
+ * This is primarily useful for using fake delays in tests.
+ *
+ * Defaults to real execution delays.
+ *
+ * This class takes ownership of the sleeper and closes it when closed.
+ */
+ @get:JvmName("sleeper") val sleeper: Sleeper,
+ /**
+ * The clock to use for operations that require timing, like retries.
+ *
+ * This is primarily useful for using a fake clock in tests.
+ *
+ * Defaults to [Clock.systemUTC].
+ */
+ @get:JvmName("clock") val clock: Clock,
+ private val baseUrl: String?,
+ /** Headers to send with the request. */
+ @get:JvmName("headers") val headers: Headers,
+ /** Query params to send with the request. */
+ @get:JvmName("queryParams") val queryParams: QueryParams,
+ /**
+ * Whether to call `validate` on every response before returning it.
+ *
+ * Setting this to `true` is _not_ forwards compatible with new types from the API for existing
+ * fields.
+ *
+ * Defaults to false, which means the shape of the response will not be validated upfront.
+ * Instead, validation will only occur for the parts of the response that are accessed.
+ */
+ @get:JvmName("responseValidation") val responseValidation: Boolean,
+ /**
+ * Sets the maximum time allowed for various parts of an HTTP call's lifecycle, excluding
+ * retries.
+ *
+ * Defaults to [Timeout.default].
+ */
+ @get:JvmName("timeout") val timeout: Timeout,
+ /**
+ * The maximum number of times to retry failed requests, with a short exponential backoff
+ * between requests.
+ *
+ * Only the following error types are retried:
+ * - Connection errors (for example, due to a network connectivity problem)
+ * - 408 Request Timeout
+ * - 409 Conflict
+ * - 429 Rate Limit
+ * - 5xx Internal
+ *
+ * The API may also explicitly instruct the SDK to retry or not retry a request.
+ *
+ * Defaults to 2.
+ */
+ @get:JvmName("maxRetries") val maxRetries: Int,
+ /**
+ * The level at which to log request and response information.
+ *
+ * [fromEnv] will set the level from environment variables. See [LogLevel.fromEnv].
+ *
+ * Defaults to [LogLevel.fromEnv].
+ */
+ @get:JvmName("logLevel") val logLevel: LogLevel,
+ /** Your [Browserbase API Key](https://www.browserbase.com/settings) */
+ @get:JvmName("browserbaseApiKey") val browserbaseApiKey: String,
+ private val browserbaseProjectId: String?,
+ /** Your LLM provider API key (e.g. OPENAI_API_KEY, ANTHROPIC_API_KEY, etc.) */
+ @get:JvmName("modelApiKey") val modelApiKey: String,
+) {
+
+ init {
+ if (checkJacksonVersionCompatibility) {
+ checkJacksonVersionCompatibility()
+ }
+ }
+
+ /**
+ * The base URL to use for every request.
+ *
+ * Defaults to the production environment: `https://api.stagehand.browserbase.com`.
+ */
+ fun baseUrl(): String = baseUrl ?: PRODUCTION_URL
+
+ /**
+ * Deprecated. Browserbase API keys are now project-scoped, so this value is no longer required.
+ */
+ fun browserbaseProjectId(): Optional = Optional.ofNullable(browserbaseProjectId)
+
+ fun toBuilder() = Builder().from(this)
+
+ companion object {
+
+ const val PRODUCTION_URL = "https://api.stagehand.browserbase.com"
+
+ /**
+ * Returns a mutable builder for constructing an instance of [ClientOptions].
+ *
+ * The following fields are required:
+ * ```java
+ * .httpClient()
+ * .browserbaseApiKey()
+ * .modelApiKey()
+ * ```
+ */
+ @JvmStatic fun builder() = Builder()
+
+ /**
+ * Returns options configured using system properties and environment variables.
+ *
+ * @see Builder.fromEnv
+ */
+ @JvmStatic fun fromEnv(): ClientOptions = builder().fromEnv().build()
+ }
+
+ /** A builder for [ClientOptions]. */
+ class Builder internal constructor() {
+
+ private var httpClient: HttpClient? = null
+ private var checkJacksonVersionCompatibility: Boolean = true
+ private var jsonMapper: JsonMapper = jsonMapper()
+ private var streamHandlerExecutor: Executor? = null
+ private var sleeper: Sleeper? = null
+ private var clock: Clock = Clock.systemUTC()
+ private var baseUrl: String? = null
+ private var headers: Headers.Builder = Headers.builder()
+ private var queryParams: QueryParams.Builder = QueryParams.builder()
+ private var responseValidation: Boolean = false
+ private var timeout: Timeout = Timeout.default()
+ private var maxRetries: Int = 2
+ private var logLevel: LogLevel = LogLevel.fromEnv()
+ private var browserbaseApiKey: String? = null
+ private var browserbaseProjectId: String? = null
+ private var modelApiKey: String? = null
+
+ @JvmSynthetic
+ internal fun from(clientOptions: ClientOptions) = apply {
+ httpClient = clientOptions.originalHttpClient
+ checkJacksonVersionCompatibility = clientOptions.checkJacksonVersionCompatibility
+ jsonMapper = clientOptions.jsonMapper
+ streamHandlerExecutor = clientOptions.streamHandlerExecutor
+ sleeper = clientOptions.sleeper
+ clock = clientOptions.clock
+ baseUrl = clientOptions.baseUrl
+ headers = clientOptions.headers.toBuilder()
+ queryParams = clientOptions.queryParams.toBuilder()
+ responseValidation = clientOptions.responseValidation
+ timeout = clientOptions.timeout
+ maxRetries = clientOptions.maxRetries
+ logLevel = clientOptions.logLevel
+ browserbaseApiKey = clientOptions.browserbaseApiKey
+ browserbaseProjectId = clientOptions.browserbaseProjectId
+ modelApiKey = clientOptions.modelApiKey
+ }
+
+ /**
+ * The HTTP client to use in the SDK.
+ *
+ * Use the one published in `stagehand-java-client-okhttp` or implement your own.
+ *
+ * This class takes ownership of the client and closes it when closed.
+ */
+ fun httpClient(httpClient: HttpClient) = apply {
+ this.httpClient = PhantomReachableClosingHttpClient(httpClient)
+ }
+
+ /**
+ * Whether to throw an exception if any of the Jackson versions detected at runtime are
+ * incompatible with the SDK's minimum supported Jackson version (2.13.4).
+ *
+ * Defaults to true. Use extreme caution when disabling this option. There is no guarantee
+ * that the SDK will work correctly when using an incompatible Jackson version.
+ */
+ fun checkJacksonVersionCompatibility(checkJacksonVersionCompatibility: Boolean) = apply {
+ this.checkJacksonVersionCompatibility = checkJacksonVersionCompatibility
+ }
+
+ /**
+ * The Jackson JSON mapper to use for serializing and deserializing JSON.
+ *
+ * Defaults to [com.browserbase.api.core.jsonMapper]. The default is usually sufficient and
+ * rarely needs to be overridden.
+ */
+ fun jsonMapper(jsonMapper: JsonMapper) = apply { this.jsonMapper = jsonMapper }
+
+ /**
+ * The executor to use for running [AsyncStreamResponse.Handler] callbacks.
+ *
+ * Defaults to a dedicated cached thread pool.
+ *
+ * This class takes ownership of the executor and shuts it down, if possible, when closed.
+ */
+ fun streamHandlerExecutor(streamHandlerExecutor: Executor) = apply {
+ this.streamHandlerExecutor =
+ if (streamHandlerExecutor is ExecutorService)
+ PhantomReachableExecutorService(streamHandlerExecutor)
+ else streamHandlerExecutor
+ }
+
+ /**
+ * The interface to use for delaying execution, like during retries.
+ *
+ * This is primarily useful for using fake delays in tests.
+ *
+ * Defaults to real execution delays.
+ *
+ * This class takes ownership of the sleeper and closes it when closed.
+ */
+ fun sleeper(sleeper: Sleeper) = apply { this.sleeper = PhantomReachableSleeper(sleeper) }
+
+ /**
+ * The clock to use for operations that require timing, like retries.
+ *
+ * This is primarily useful for using a fake clock in tests.
+ *
+ * Defaults to [Clock.systemUTC].
+ */
+ fun clock(clock: Clock) = apply { this.clock = clock }
+
+ /**
+ * The base URL to use for every request.
+ *
+ * Defaults to the production environment: `https://api.stagehand.browserbase.com`.
+ */
+ fun baseUrl(baseUrl: String?) = apply { this.baseUrl = baseUrl }
+
+ /** Alias for calling [Builder.baseUrl] with `baseUrl.orElse(null)`. */
+ fun baseUrl(baseUrl: Optional) = baseUrl(baseUrl.getOrNull())
+
+ /**
+ * Whether to call `validate` on every response before returning it.
+ *
+ * Setting this to `true` is _not_ forwards compatible with new types from the API for
+ * existing fields.
+ *
+ * Defaults to false, which means the shape of the response will not be validated upfront.
+ * Instead, validation will only occur for the parts of the response that are accessed.
+ */
+ fun responseValidation(responseValidation: Boolean) = apply {
+ this.responseValidation = responseValidation
+ }
+
+ /**
+ * Sets the maximum time allowed for various parts of an HTTP call's lifecycle, excluding
+ * retries.
+ *
+ * Defaults to [Timeout.default].
+ */
+ fun timeout(timeout: Timeout) = apply { this.timeout = timeout }
+
+ /**
+ * Sets the maximum time allowed for a complete HTTP call, not including retries.
+ *
+ * See [Timeout.request] for more details.
+ *
+ * For fine-grained control, pass a [Timeout] object.
+ */
+ fun timeout(timeout: Duration) = timeout(Timeout.builder().request(timeout).build())
+
+ /**
+ * The maximum number of times to retry failed requests, with a short exponential backoff
+ * between requests.
+ *
+ * Only the following error types are retried:
+ * - Connection errors (for example, due to a network connectivity problem)
+ * - 408 Request Timeout
+ * - 409 Conflict
+ * - 429 Rate Limit
+ * - 5xx Internal
+ *
+ * The API may also explicitly instruct the SDK to retry or not retry a request.
+ *
+ * Defaults to 2.
+ */
+ fun maxRetries(maxRetries: Int) = apply { this.maxRetries = maxRetries }
+
+ /**
+ * The level at which to log request and response information.
+ *
+ * [fromEnv] will set the level from environment variables. See [LogLevel.fromEnv].
+ *
+ * Defaults to [LogLevel.fromEnv].
+ */
+ fun logLevel(logLevel: LogLevel) = apply { this.logLevel = logLevel }
+
+ /** Your [Browserbase API Key](https://www.browserbase.com/settings) */
+ fun browserbaseApiKey(browserbaseApiKey: String) = apply {
+ this.browserbaseApiKey = browserbaseApiKey
+ }
+
+ /**
+ * Deprecated. Browserbase API keys are now project-scoped, so this value is no longer
+ * required.
+ */
+ fun browserbaseProjectId(browserbaseProjectId: String?) = apply {
+ this.browserbaseProjectId = browserbaseProjectId
+ }
+
+ /**
+ * Alias for calling [Builder.browserbaseProjectId] with
+ * `browserbaseProjectId.orElse(null)`.
+ */
+ fun browserbaseProjectId(browserbaseProjectId: Optional) =
+ browserbaseProjectId(browserbaseProjectId.getOrNull())
+
+ /** Your LLM provider API key (e.g. OPENAI_API_KEY, ANTHROPIC_API_KEY, etc.) */
+ fun modelApiKey(modelApiKey: String) = apply { this.modelApiKey = modelApiKey }
+
+ fun headers(headers: Headers) = apply {
+ this.headers.clear()
+ putAllHeaders(headers)
+ }
+
+ fun headers(headers: Map>) = apply {
+ this.headers.clear()
+ putAllHeaders(headers)
+ }
+
+ fun putHeader(name: String, value: String) = apply { headers.put(name, value) }
+
+ fun putHeaders(name: String, values: Iterable) = apply { headers.put(name, values) }
+
+ fun putAllHeaders(headers: Headers) = apply { this.headers.putAll(headers) }
+
+ fun putAllHeaders(headers: Map>) = apply {
+ this.headers.putAll(headers)
+ }
+
+ fun replaceHeaders(name: String, value: String) = apply { headers.replace(name, value) }
+
+ fun replaceHeaders(name: String, values: Iterable) = apply {
+ headers.replace(name, values)
+ }
+
+ fun replaceAllHeaders(headers: Headers) = apply { this.headers.replaceAll(headers) }
+
+ fun replaceAllHeaders(headers: Map>) = apply {
+ this.headers.replaceAll(headers)
+ }
+
+ fun removeHeaders(name: String) = apply { headers.remove(name) }
+
+ fun removeAllHeaders(names: Set) = apply { headers.removeAll(names) }
+
+ fun queryParams(queryParams: QueryParams) = apply {
+ this.queryParams.clear()
+ putAllQueryParams(queryParams)
+ }
+
+ fun queryParams(queryParams: Map>) = apply {
+ this.queryParams.clear()
+ putAllQueryParams(queryParams)
+ }
+
+ fun putQueryParam(key: String, value: String) = apply { queryParams.put(key, value) }
+
+ fun putQueryParams(key: String, values: Iterable) = apply {
+ queryParams.put(key, values)
+ }
+
+ fun putAllQueryParams(queryParams: QueryParams) = apply {
+ this.queryParams.putAll(queryParams)
+ }
+
+ fun putAllQueryParams(queryParams: Map>) = apply {
+ this.queryParams.putAll(queryParams)
+ }
+
+ fun replaceQueryParams(key: String, value: String) = apply {
+ queryParams.replace(key, value)
+ }
+
+ fun replaceQueryParams(key: String, values: Iterable) = apply {
+ queryParams.replace(key, values)
+ }
+
+ fun replaceAllQueryParams(queryParams: QueryParams) = apply {
+ this.queryParams.replaceAll(queryParams)
+ }
+
+ fun replaceAllQueryParams(queryParams: Map>) = apply {
+ this.queryParams.replaceAll(queryParams)
+ }
+
+ fun removeQueryParams(key: String) = apply { queryParams.remove(key) }
+
+ fun removeAllQueryParams(keys: Set) = apply { queryParams.removeAll(keys) }
+
+ fun timeout(): Timeout = timeout
+
+ /**
+ * Updates configuration using system properties and environment variables.
+ *
+ * See this table for the available options:
+ *
+ * |Setter |System property |Environment variable |Required|Default value |
+ * |----------------------|--------------------------------|------------------------|--------|-----------------------------------------|
+ * |`browserbaseApiKey` |`stagehand.browserbaseApiKey` |`BROWSERBASE_API_KEY` |true |- |
+ * |`browserbaseProjectId`|`stagehand.browserbaseProjectId`|`BROWSERBASE_PROJECT_ID`|false |- |
+ * |`modelApiKey` |`stagehand.modelApiKey` |`MODEL_API_KEY` |true |- |
+ * |`baseUrl` |`stagehand.baseUrl` |`STAGEHAND_BASE_URL` |true |`"https://api.stagehand.browserbase.com"`|
+ *
+ * System properties take precedence over environment variables.
+ */
+ fun fromEnv() = apply {
+ logLevel(LogLevel.fromEnv())
+ (System.getProperty("stagehand.baseUrl") ?: System.getenv("STAGEHAND_BASE_URL"))?.let {
+ baseUrl(it)
+ }
+ (System.getProperty("stagehand.browserbaseApiKey")
+ ?: System.getenv("BROWSERBASE_API_KEY"))
+ ?.let { browserbaseApiKey(it) }
+ (System.getProperty("stagehand.browserbaseProjectId")
+ ?: System.getenv("BROWSERBASE_PROJECT_ID"))
+ ?.let { browserbaseProjectId(it) }
+ (System.getProperty("stagehand.modelApiKey") ?: System.getenv("MODEL_API_KEY"))?.let {
+ modelApiKey(it)
+ }
+ System.getenv("STAGEHAND_CUSTOM_HEADERS")?.let { customHeadersEnv ->
+ for (line in customHeadersEnv.split("\n")) {
+ val colon = line.indexOf(':')
+ if (colon >= 0) {
+ putHeader(line.substring(0, colon).trim(), line.substring(colon + 1).trim())
+ }
+ }
+ }
+ }
+
+ /**
+ * Returns an immutable instance of [ClientOptions].
+ *
+ * Further updates to this [Builder] will not mutate the returned instance.
+ *
+ * The following fields are required:
+ * ```java
+ * .httpClient()
+ * .browserbaseApiKey()
+ * .modelApiKey()
+ * ```
+ *
+ * @throws IllegalStateException if any required field is unset.
+ */
+ fun build(): ClientOptions {
+ val httpClient = checkRequired("httpClient", httpClient)
+ val streamHandlerExecutor =
+ streamHandlerExecutor
+ ?: PhantomReachableExecutorService(
+ Executors.newCachedThreadPool(
+ object : ThreadFactory {
+
+ private val threadFactory: ThreadFactory =
+ Executors.defaultThreadFactory()
+ private val count = AtomicLong(0)
+
+ override fun newThread(runnable: Runnable): Thread =
+ threadFactory.newThread(runnable).also {
+ it.name =
+ "stagehand-stream-handler-thread-${count.getAndIncrement()}"
+ }
+ }
+ )
+ )
+ val sleeper = sleeper ?: PhantomReachableSleeper(DefaultSleeper())
+ val browserbaseApiKey = checkRequired("browserbaseApiKey", browserbaseApiKey)
+ val modelApiKey = checkRequired("modelApiKey", modelApiKey)
+
+ val headers = Headers.builder()
+ val queryParams = QueryParams.builder()
+ headers.put("X-Stainless-Lang", "java")
+ headers.put("X-Stainless-Arch", getOsArch())
+ headers.put("X-Stainless-OS", getOsName())
+ headers.put("X-Stainless-OS-Version", getOsVersion())
+ headers.put("X-Stainless-Package-Version", getPackageVersion())
+ headers.put("X-Stainless-Runtime", "JRE")
+ headers.put("X-Stainless-Runtime-Version", getJavaVersion())
+ headers.put("X-Stainless-Kotlin-Version", KotlinVersion.CURRENT.toString())
+ // We replace after all the default headers to allow end-users to overwrite them.
+ headers.replaceAll(this.headers.build())
+ queryParams.replaceAll(this.queryParams.build())
+ browserbaseApiKey.let {
+ if (!it.isEmpty()) {
+ headers.replace("x-bb-api-key", it)
+ }
+ }
+ browserbaseProjectId?.let {
+ if (!it.isEmpty()) {
+ headers.replace("x-bb-project-id", it)
+ }
+ }
+ modelApiKey.let {
+ if (!it.isEmpty()) {
+ headers.replace("x-model-api-key", it)
+ }
+ }
+
+ return ClientOptions(
+ httpClient,
+ RetryingHttpClient.builder()
+ .httpClient(
+ LoggingHttpClient.builder()
+ .httpClient(httpClient)
+ .clock(clock)
+ .level(logLevel)
+ .build()
+ )
+ .sleeper(sleeper)
+ .clock(clock)
+ .maxRetries(maxRetries)
+ .build(),
+ checkJacksonVersionCompatibility,
+ jsonMapper,
+ streamHandlerExecutor,
+ sleeper,
+ clock,
+ baseUrl,
+ headers.build(),
+ queryParams.build(),
+ responseValidation,
+ timeout,
+ maxRetries,
+ logLevel,
+ browserbaseApiKey,
+ browserbaseProjectId,
+ modelApiKey,
+ )
+ }
+ }
+
+ /**
+ * Closes these client options, relinquishing any underlying resources.
+ *
+ * This is purposefully not inherited from [AutoCloseable] because the client options are
+ * long-lived and usually should not be synchronously closed via try-with-resources.
+ *
+ * It's also usually not necessary to call this method at all. the default client automatically
+ * releases threads and connections if they remain idle, but if you are writing an application
+ * that needs to aggressively release unused resources, then you may call this method.
+ */
+ fun close() {
+ httpClient.close()
+ (streamHandlerExecutor as? ExecutorService)?.shutdown()
+ sleeper.close()
+ }
+}
diff --git a/stagehand-java-core/src/main/kotlin/com/browserbase/api/core/DefaultSleeper.kt b/stagehand-java-core/src/main/kotlin/com/browserbase/api/core/DefaultSleeper.kt
new file mode 100644
index 0000000..7ea0449
--- /dev/null
+++ b/stagehand-java-core/src/main/kotlin/com/browserbase/api/core/DefaultSleeper.kt
@@ -0,0 +1,28 @@
+package com.browserbase.api.core
+
+import java.time.Duration
+import java.util.Timer
+import java.util.TimerTask
+import java.util.concurrent.CompletableFuture
+
+class DefaultSleeper : Sleeper {
+
+ private val timer = Timer("DefaultSleeper", true)
+
+ override fun sleep(duration: Duration) = Thread.sleep(duration.toMillis())
+
+ override fun sleepAsync(duration: Duration): CompletableFuture {
+ val future = CompletableFuture()
+ timer.schedule(
+ object : TimerTask() {
+ override fun run() {
+ future.complete(null)
+ }
+ },
+ duration.toMillis(),
+ )
+ return future
+ }
+
+ override fun close() = timer.cancel()
+}
diff --git a/stagehand-java-core/src/main/kotlin/com/browserbase/api/core/LogLevel.kt b/stagehand-java-core/src/main/kotlin/com/browserbase/api/core/LogLevel.kt
new file mode 100644
index 0000000..f5a07a1
--- /dev/null
+++ b/stagehand-java-core/src/main/kotlin/com/browserbase/api/core/LogLevel.kt
@@ -0,0 +1,33 @@
+// File generated from our OpenAPI spec by Stainless.
+
+package com.browserbase.api.core
+
+/** The level at which to log request and response information. */
+enum class LogLevel {
+ /** No logging. */
+ OFF,
+ /** Minimal request and response summary logs. No headers or bodies are logged. */
+ INFO,
+ /** [INFO] logs plus details about request failures. */
+ ERROR,
+ /**
+ * Full request and response logs. Sensitive headers are redacted, but sensitive data in request
+ * and response bodies may still be visible.
+ */
+ DEBUG;
+
+ /** Returns whether this level is at or higher than the given [level]. */
+ fun shouldLog(level: LogLevel): Boolean = ordinal >= level.ordinal
+
+ companion object {
+
+ /** Returns a [LogLevel] based on the `STAGEHAND_LOG` environment variable. */
+ fun fromEnv() =
+ when (System.getenv("STAGEHAND_LOG")?.lowercase()) {
+ "info" -> INFO
+ "error" -> ERROR
+ "debug" -> DEBUG
+ else -> OFF
+ }
+ }
+}
diff --git a/stagehand-java-core/src/main/kotlin/com/browserbase/api/core/ObjectMappers.kt b/stagehand-java-core/src/main/kotlin/com/browserbase/api/core/ObjectMappers.kt
new file mode 100644
index 0000000..65b7e72
--- /dev/null
+++ b/stagehand-java-core/src/main/kotlin/com/browserbase/api/core/ObjectMappers.kt
@@ -0,0 +1,180 @@
+@file:JvmName("ObjectMappers")
+
+package com.browserbase.api.core
+
+import com.fasterxml.jackson.annotation.JsonInclude
+import com.fasterxml.jackson.core.JsonGenerator
+import com.fasterxml.jackson.core.JsonParseException
+import com.fasterxml.jackson.core.JsonParser
+import com.fasterxml.jackson.databind.DeserializationContext
+import com.fasterxml.jackson.databind.DeserializationFeature
+import com.fasterxml.jackson.databind.MapperFeature
+import com.fasterxml.jackson.databind.SerializationFeature
+import com.fasterxml.jackson.databind.SerializerProvider
+import com.fasterxml.jackson.databind.cfg.CoercionAction
+import com.fasterxml.jackson.databind.cfg.CoercionInputShape
+import com.fasterxml.jackson.databind.deser.std.StdDeserializer
+import com.fasterxml.jackson.databind.json.JsonMapper
+import com.fasterxml.jackson.databind.module.SimpleModule
+import com.fasterxml.jackson.databind.type.LogicalType
+import com.fasterxml.jackson.datatype.jdk8.Jdk8Module
+import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule
+import com.fasterxml.jackson.module.kotlin.kotlinModule
+import java.io.InputStream
+import java.time.DateTimeException
+import java.time.LocalDate
+import java.time.LocalDateTime
+import java.time.OffsetDateTime
+import java.time.ZoneId
+import java.time.format.DateTimeFormatter
+import java.time.temporal.ChronoField
+
+fun jsonMapper(): JsonMapper = JSON_MAPPER
+
+private val JSON_MAPPER: JsonMapper =
+ JsonMapper.builder()
+ .addModule(kotlinModule())
+ .addModule(Jdk8Module())
+ .addModule(JavaTimeModule())
+ .addModule(
+ SimpleModule()
+ .addSerializer(InputStreamSerializer)
+ .addDeserializer(OffsetDateTime::class.java, LenientOffsetDateTimeDeserializer())
+ )
+ .withCoercionConfig(LogicalType.Boolean) {
+ it.setCoercion(CoercionInputShape.Integer, CoercionAction.Fail)
+ .setCoercion(CoercionInputShape.Float, CoercionAction.Fail)
+ .setCoercion(CoercionInputShape.String, CoercionAction.Fail)
+ .setCoercion(CoercionInputShape.Array, CoercionAction.Fail)
+ .setCoercion(CoercionInputShape.Object, CoercionAction.Fail)
+ }
+ .withCoercionConfig(LogicalType.Integer) {
+ it.setCoercion(CoercionInputShape.Boolean, CoercionAction.Fail)
+ .setCoercion(CoercionInputShape.Float, CoercionAction.Fail)
+ .setCoercion(CoercionInputShape.String, CoercionAction.Fail)
+ .setCoercion(CoercionInputShape.Array, CoercionAction.Fail)
+ .setCoercion(CoercionInputShape.Object, CoercionAction.Fail)
+ }
+ .withCoercionConfig(LogicalType.Float) {
+ it.setCoercion(CoercionInputShape.Boolean, CoercionAction.Fail)
+ .setCoercion(CoercionInputShape.String, CoercionAction.Fail)
+ .setCoercion(CoercionInputShape.Array, CoercionAction.Fail)
+ .setCoercion(CoercionInputShape.Object, CoercionAction.Fail)
+ }
+ .withCoercionConfig(LogicalType.Textual) {
+ it.setCoercion(CoercionInputShape.Boolean, CoercionAction.Fail)
+ .setCoercion(CoercionInputShape.Integer, CoercionAction.Fail)
+ .setCoercion(CoercionInputShape.Float, CoercionAction.Fail)
+ .setCoercion(CoercionInputShape.Array, CoercionAction.Fail)
+ .setCoercion(CoercionInputShape.Object, CoercionAction.Fail)
+ }
+ .withCoercionConfig(LogicalType.DateTime) {
+ it.setCoercion(CoercionInputShape.Integer, CoercionAction.Fail)
+ .setCoercion(CoercionInputShape.Float, CoercionAction.Fail)
+ .setCoercion(CoercionInputShape.Array, CoercionAction.Fail)
+ .setCoercion(CoercionInputShape.Object, CoercionAction.Fail)
+ }
+ .withCoercionConfig(LogicalType.Array) {
+ it.setCoercion(CoercionInputShape.Boolean, CoercionAction.Fail)
+ .setCoercion(CoercionInputShape.Integer, CoercionAction.Fail)
+ .setCoercion(CoercionInputShape.Float, CoercionAction.Fail)
+ .setCoercion(CoercionInputShape.String, CoercionAction.Fail)
+ .setCoercion(CoercionInputShape.Object, CoercionAction.Fail)
+ }
+ .withCoercionConfig(LogicalType.Collection) {
+ it.setCoercion(CoercionInputShape.Boolean, CoercionAction.Fail)
+ .setCoercion(CoercionInputShape.Integer, CoercionAction.Fail)
+ .setCoercion(CoercionInputShape.Float, CoercionAction.Fail)
+ .setCoercion(CoercionInputShape.String, CoercionAction.Fail)
+ .setCoercion(CoercionInputShape.Object, CoercionAction.Fail)
+ }
+ .withCoercionConfig(LogicalType.Map) {
+ it.setCoercion(CoercionInputShape.Boolean, CoercionAction.Fail)
+ .setCoercion(CoercionInputShape.Integer, CoercionAction.Fail)
+ .setCoercion(CoercionInputShape.Float, CoercionAction.Fail)
+ .setCoercion(CoercionInputShape.String, CoercionAction.Fail)
+ .setCoercion(CoercionInputShape.Object, CoercionAction.Fail)
+ }
+ .withCoercionConfig(LogicalType.POJO) {
+ it.setCoercion(CoercionInputShape.Boolean, CoercionAction.Fail)
+ .setCoercion(CoercionInputShape.Integer, CoercionAction.Fail)
+ .setCoercion(CoercionInputShape.Float, CoercionAction.Fail)
+ .setCoercion(CoercionInputShape.String, CoercionAction.Fail)
+ .setCoercion(CoercionInputShape.Array, CoercionAction.Fail)
+ }
+ .serializationInclusion(JsonInclude.Include.NON_ABSENT)
+ .disable(DeserializationFeature.ADJUST_DATES_TO_CONTEXT_TIME_ZONE)
+ .disable(SerializationFeature.FLUSH_AFTER_WRITE_VALUE)
+ .disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS)
+ .disable(SerializationFeature.WRITE_DURATIONS_AS_TIMESTAMPS)
+ .disable(MapperFeature.ALLOW_COERCION_OF_SCALARS)
+ .disable(MapperFeature.AUTO_DETECT_CREATORS)
+ .disable(MapperFeature.AUTO_DETECT_FIELDS)
+ .disable(MapperFeature.AUTO_DETECT_GETTERS)
+ .disable(MapperFeature.AUTO_DETECT_IS_GETTERS)
+ .disable(MapperFeature.AUTO_DETECT_SETTERS)
+ .build()
+
+/** A serializer that serializes [InputStream] to bytes. */
+private object InputStreamSerializer : BaseSerializer(InputStream::class) {
+
+ private fun readResolve(): Any = InputStreamSerializer
+
+ override fun serialize(
+ value: InputStream?,
+ gen: JsonGenerator?,
+ serializers: SerializerProvider?,
+ ) {
+ if (value == null) {
+ gen?.writeNull()
+ } else {
+ value.use { gen?.writeBinary(it.readBytes()) }
+ }
+ }
+}
+
+/**
+ * A deserializer that can deserialize [OffsetDateTime] from datetimes, dates, and zoned datetimes.
+ */
+private class LenientOffsetDateTimeDeserializer :
+ StdDeserializer(OffsetDateTime::class.java) {
+
+ companion object {
+
+ private val DATE_TIME_FORMATTERS =
+ listOf(
+ DateTimeFormatter.ISO_LOCAL_DATE_TIME,
+ DateTimeFormatter.ISO_LOCAL_DATE,
+ DateTimeFormatter.ISO_ZONED_DATE_TIME,
+ )
+ }
+
+ override fun logicalType(): LogicalType = LogicalType.DateTime
+
+ override fun deserialize(p: JsonParser, context: DeserializationContext): OffsetDateTime {
+ val exceptions = mutableListOf()
+
+ for (formatter in DATE_TIME_FORMATTERS) {
+ try {
+ val temporal = formatter.parse(p.text)
+
+ return when {
+ !temporal.isSupported(ChronoField.HOUR_OF_DAY) ->
+ LocalDate.from(temporal)
+ .atStartOfDay()
+ .atZone(ZoneId.of("UTC"))
+ .toOffsetDateTime()
+ !temporal.isSupported(ChronoField.OFFSET_SECONDS) ->
+ LocalDateTime.from(temporal).atZone(ZoneId.of("UTC")).toOffsetDateTime()
+ else -> OffsetDateTime.from(temporal)
+ }
+ } catch (e: DateTimeException) {
+ exceptions.add(e)
+ }
+ }
+
+ throw JsonParseException(p, "Cannot parse `OffsetDateTime` from value: ${p.text}").apply {
+ exceptions.forEach { addSuppressed(it) }
+ }
+ }
+}
diff --git a/stagehand-java-core/src/main/kotlin/com/browserbase/api/core/Params.kt b/stagehand-java-core/src/main/kotlin/com/browserbase/api/core/Params.kt
new file mode 100644
index 0000000..74e0357
--- /dev/null
+++ b/stagehand-java-core/src/main/kotlin/com/browserbase/api/core/Params.kt
@@ -0,0 +1,16 @@
+package com.browserbase.api.core
+
+import com.browserbase.api.core.http.Headers
+import com.browserbase.api.core.http.QueryParams
+
+/** An interface representing parameters passed to a service method. */
+interface Params {
+ /** The full set of headers in the parameters, including both fixed and additional headers. */
+ fun _headers(): Headers
+
+ /**
+ * The full set of query params in the parameters, including both fixed and additional query
+ * params.
+ */
+ fun _queryParams(): QueryParams
+}
diff --git a/stagehand-java-core/src/main/kotlin/com/browserbase/api/core/PhantomReachable.kt b/stagehand-java-core/src/main/kotlin/com/browserbase/api/core/PhantomReachable.kt
new file mode 100644
index 0000000..12cba3f
--- /dev/null
+++ b/stagehand-java-core/src/main/kotlin/com/browserbase/api/core/PhantomReachable.kt
@@ -0,0 +1,56 @@
+@file:JvmName("PhantomReachable")
+
+package com.browserbase.api.core
+
+import com.browserbase.api.errors.StagehandException
+import java.lang.reflect.InvocationTargetException
+
+/**
+ * Closes [closeable] when [observed] becomes only phantom reachable.
+ *
+ * This is a wrapper around a Java 9+ [java.lang.ref.Cleaner], or a no-op in older Java versions.
+ */
+@JvmSynthetic
+internal fun closeWhenPhantomReachable(observed: Any, closeable: AutoCloseable) {
+ check(observed !== closeable) {
+ "`observed` cannot be the same object as `closeable` because it would never become phantom reachable"
+ }
+ closeWhenPhantomReachable(observed, closeable::close)
+}
+
+/**
+ * Calls [close] when [observed] becomes only phantom reachable.
+ *
+ * This is a wrapper around a Java 9+ [java.lang.ref.Cleaner], or a no-op in older Java versions.
+ */
+@JvmSynthetic
+internal fun closeWhenPhantomReachable(observed: Any, close: () -> Unit) {
+ closeWhenPhantomReachable?.let { it(observed, close) }
+}
+
+private val closeWhenPhantomReachable: ((Any, () -> Unit) -> Unit)? by lazy {
+ try {
+ val cleanerClass = Class.forName("java.lang.ref.Cleaner")
+ val cleanerCreate = cleanerClass.getMethod("create")
+ val cleanerRegister =
+ cleanerClass.getMethod("register", Any::class.java, Runnable::class.java)
+ val cleanerObject = cleanerCreate.invoke(null);
+
+ { observed, close ->
+ try {
+ cleanerRegister.invoke(cleanerObject, observed, Runnable { close() })
+ } catch (e: ReflectiveOperationException) {
+ if (e is InvocationTargetException) {
+ when (val cause = e.cause) {
+ is RuntimeException,
+ is Error -> throw cause
+ }
+ }
+ throw StagehandException("Unexpected reflective invocation failure", e)
+ }
+ }
+ } catch (e: ReflectiveOperationException) {
+ // We're running Java 8, which has no Cleaner.
+ null
+ }
+}
diff --git a/stagehand-java-core/src/main/kotlin/com/browserbase/api/core/PhantomReachableExecutorService.kt b/stagehand-java-core/src/main/kotlin/com/browserbase/api/core/PhantomReachableExecutorService.kt
new file mode 100644
index 0000000..7fb24a5
--- /dev/null
+++ b/stagehand-java-core/src/main/kotlin/com/browserbase/api/core/PhantomReachableExecutorService.kt
@@ -0,0 +1,58 @@
+package com.browserbase.api.core
+
+import java.util.concurrent.Callable
+import java.util.concurrent.ExecutorService
+import java.util.concurrent.Future
+import java.util.concurrent.TimeUnit
+
+/**
+ * A delegating wrapper around an [ExecutorService] that shuts it down once it's only phantom
+ * reachable.
+ *
+ * This class ensures the [ExecutorService] is shut down even if the user forgets to do it.
+ */
+internal class PhantomReachableExecutorService(private val executorService: ExecutorService) :
+ ExecutorService {
+ init {
+ closeWhenPhantomReachable(this) { executorService.shutdown() }
+ }
+
+ override fun execute(command: Runnable) = executorService.execute(command)
+
+ override fun shutdown() = executorService.shutdown()
+
+ override fun shutdownNow(): MutableList = executorService.shutdownNow()
+
+ override fun isShutdown(): Boolean = executorService.isShutdown
+
+ override fun isTerminated(): Boolean = executorService.isTerminated
+
+ override fun awaitTermination(timeout: Long, unit: TimeUnit): Boolean =
+ executorService.awaitTermination(timeout, unit)
+
+ override fun submit(task: Callable): Future = executorService.submit(task)
+
+ override fun submit(task: Runnable, result: T): Future =
+ executorService.submit(task, result)
+
+ override fun submit(task: Runnable): Future<*> = executorService.submit(task)
+
+ override fun invokeAll(
+ tasks: MutableCollection>
+ ): MutableList> = executorService.invokeAll(tasks)
+
+ override fun invokeAll(
+ tasks: MutableCollection>,
+ timeout: Long,
+ unit: TimeUnit,
+ ): MutableList> = executorService.invokeAll(tasks, timeout, unit)
+
+ override fun invokeAny(tasks: MutableCollection>): T =
+ executorService.invokeAny(tasks)
+
+ override fun invokeAny(
+ tasks: MutableCollection>,
+ timeout: Long,
+ unit: TimeUnit,
+ ): T = executorService.invokeAny(tasks, timeout, unit)
+}
diff --git a/stagehand-java-core/src/main/kotlin/com/browserbase/api/core/PhantomReachableSleeper.kt b/stagehand-java-core/src/main/kotlin/com/browserbase/api/core/PhantomReachableSleeper.kt
new file mode 100644
index 0000000..172760d
--- /dev/null
+++ b/stagehand-java-core/src/main/kotlin/com/browserbase/api/core/PhantomReachableSleeper.kt
@@ -0,0 +1,23 @@
+package com.browserbase.api.core
+
+import java.time.Duration
+import java.util.concurrent.CompletableFuture
+
+/**
+ * A delegating wrapper around a [Sleeper] that closes it once it's only phantom reachable.
+ *
+ * This class ensures the [Sleeper] is closed even if the user forgets to do it.
+ */
+internal class PhantomReachableSleeper(private val sleeper: Sleeper) : Sleeper {
+
+ init {
+ closeWhenPhantomReachable(this, sleeper)
+ }
+
+ override fun sleep(duration: Duration) = sleeper.sleep(duration)
+
+ override fun sleepAsync(duration: Duration): CompletableFuture =
+ sleeper.sleepAsync(duration)
+
+ override fun close() = sleeper.close()
+}
diff --git a/stagehand-java-core/src/main/kotlin/com/browserbase/api/core/PrepareRequest.kt b/stagehand-java-core/src/main/kotlin/com/browserbase/api/core/PrepareRequest.kt
new file mode 100644
index 0000000..89ffa2d
--- /dev/null
+++ b/stagehand-java-core/src/main/kotlin/com/browserbase/api/core/PrepareRequest.kt
@@ -0,0 +1,24 @@
+@file:JvmName("PrepareRequest")
+
+package com.browserbase.api.core
+
+import com.browserbase.api.core.http.HttpRequest
+import java.util.concurrent.CompletableFuture
+
+@JvmSynthetic
+internal fun HttpRequest.prepare(clientOptions: ClientOptions, params: Params): HttpRequest =
+ toBuilder()
+ .putAllQueryParams(clientOptions.queryParams)
+ .replaceAllQueryParams(params._queryParams())
+ .putAllHeaders(clientOptions.headers)
+ .replaceAllHeaders(params._headers())
+ .build()
+
+@JvmSynthetic
+internal fun HttpRequest.prepareAsync(
+ clientOptions: ClientOptions,
+ params: Params,
+): CompletableFuture =
+ // This async version exists to make it easier to add async specific preparation logic in the
+ // future.
+ CompletableFuture.completedFuture(prepare(clientOptions, params))
diff --git a/stagehand-java-core/src/main/kotlin/com/browserbase/api/core/Properties.kt b/stagehand-java-core/src/main/kotlin/com/browserbase/api/core/Properties.kt
new file mode 100644
index 0000000..936e15a
--- /dev/null
+++ b/stagehand-java-core/src/main/kotlin/com/browserbase/api/core/Properties.kt
@@ -0,0 +1,42 @@
+@file:JvmName("Properties")
+
+package com.browserbase.api.core
+
+import com.browserbase.api.client.StagehandClient
+
+fun getOsArch(): String {
+ val osArch = System.getProperty("os.arch")
+
+ return when (osArch) {
+ null -> "unknown"
+ "i386",
+ "x32",
+ "x86" -> "x32"
+ "amd64",
+ "x86_64" -> "x64"
+ "arm" -> "arm"
+ "aarch64" -> "arm64"
+ else -> "other:$osArch"
+ }
+}
+
+fun getOsName(): String {
+ val osName = System.getProperty("os.name")
+ val vendorUrl = System.getProperty("java.vendor.url")
+
+ return when {
+ osName == null -> "Unknown"
+ osName.startsWith("Linux") && vendorUrl == "http://www.android.com/" -> "Android"
+ osName.startsWith("Linux") -> "Linux"
+ osName.startsWith("Mac OS") -> "MacOS"
+ osName.startsWith("Windows") -> "Windows"
+ else -> "Other:$osName"
+ }
+}
+
+fun getOsVersion(): String = System.getProperty("os.version", "unknown") ?: "unknown"
+
+fun getPackageVersion(): String =
+ StagehandClient::class.java.`package`?.implementationVersion ?: "unknown"
+
+fun getJavaVersion(): String = System.getProperty("java.version", "unknown") ?: "unknown"
diff --git a/stagehand-java-core/src/main/kotlin/com/browserbase/api/core/RequestOptions.kt b/stagehand-java-core/src/main/kotlin/com/browserbase/api/core/RequestOptions.kt
new file mode 100644
index 0000000..795cdd5
--- /dev/null
+++ b/stagehand-java-core/src/main/kotlin/com/browserbase/api/core/RequestOptions.kt
@@ -0,0 +1,55 @@
+package com.browserbase.api.core
+
+import java.time.Duration
+
+class RequestOptions private constructor(val responseValidation: Boolean?, val timeout: Timeout?) {
+
+ companion object {
+
+ private val NONE = builder().build()
+
+ @JvmStatic fun none() = NONE
+
+ @JvmSynthetic
+ internal fun from(clientOptions: ClientOptions): RequestOptions =
+ builder()
+ .responseValidation(clientOptions.responseValidation)
+ .timeout(clientOptions.timeout)
+ .build()
+
+ @JvmStatic fun builder() = Builder()
+ }
+
+ fun applyDefaults(options: RequestOptions): RequestOptions =
+ RequestOptions(
+ responseValidation = responseValidation ?: options.responseValidation,
+ timeout =
+ if (options.timeout != null && timeout != null) timeout.assign(options.timeout)
+ else timeout ?: options.timeout,
+ )
+
+ class Builder internal constructor() {
+
+ private var responseValidation: Boolean? = null
+ private var timeout: Timeout? = null
+
+ /**
+ * Whether to call `validate` on the response before returning it.
+ *
+ * Setting this to `true` is _not_ forwards compatible with new types from the API for
+ * existing fields.
+ *
+ * Defaults to false, which means the shape of the response will not be validated upfront.
+ * Instead, validation will only occur for the parts of the response that are accessed.
+ */
+ fun responseValidation(responseValidation: Boolean) = apply {
+ this.responseValidation = responseValidation
+ }
+
+ fun timeout(timeout: Timeout) = apply { this.timeout = timeout }
+
+ fun timeout(timeout: Duration) = timeout(Timeout.builder().request(timeout).build())
+
+ fun build(): RequestOptions = RequestOptions(responseValidation, timeout)
+ }
+}
diff --git a/stagehand-java-core/src/main/kotlin/com/browserbase/api/core/Sleeper.kt b/stagehand-java-core/src/main/kotlin/com/browserbase/api/core/Sleeper.kt
new file mode 100644
index 0000000..7c5e93a
--- /dev/null
+++ b/stagehand-java-core/src/main/kotlin/com/browserbase/api/core/Sleeper.kt
@@ -0,0 +1,21 @@
+package com.browserbase.api.core
+
+import java.time.Duration
+import java.util.concurrent.CompletableFuture
+
+/**
+ * An interface for delaying execution for a specified amount of time.
+ *
+ * Useful for testing and cleaning up resources.
+ */
+interface Sleeper : AutoCloseable {
+
+ /** Synchronously pauses execution for the given [duration]. */
+ fun sleep(duration: Duration)
+
+ /** Asynchronously pauses execution for the given [duration]. */
+ fun sleepAsync(duration: Duration): CompletableFuture
+
+ /** Overridden from [AutoCloseable] to not have a checked exception in its signature. */
+ override fun close()
+}
diff --git a/stagehand-java-core/src/main/kotlin/com/browserbase/api/core/Timeout.kt b/stagehand-java-core/src/main/kotlin/com/browserbase/api/core/Timeout.kt
new file mode 100644
index 0000000..d5618ce
--- /dev/null
+++ b/stagehand-java-core/src/main/kotlin/com/browserbase/api/core/Timeout.kt
@@ -0,0 +1,171 @@
+// File generated from our OpenAPI spec by Stainless.
+
+package com.browserbase.api.core
+
+import java.time.Duration
+import java.util.Objects
+import java.util.Optional
+import kotlin.jvm.optionals.getOrNull
+
+/** A class containing timeouts for various processing phases of a request. */
+class Timeout
+private constructor(
+ private val connect: Duration?,
+ private val read: Duration?,
+ private val write: Duration?,
+ private val request: Duration?,
+) {
+
+ /**
+ * The maximum time allowed to establish a connection with a host.
+ *
+ * A value of [Duration.ZERO] means there's no timeout.
+ *
+ * Defaults to `Duration.ofMinutes(1)`.
+ */
+ fun connect(): Duration = connect ?: Duration.ofMinutes(1)
+
+ /**
+ * The maximum time allowed between two data packets when waiting for the server’s response.
+ *
+ * A value of [Duration.ZERO] means there's no timeout.
+ *
+ * Defaults to `request()`.
+ */
+ fun read(): Duration = read ?: request()
+
+ /**
+ * The maximum time allowed between two data packets when sending the request to the server.
+ *
+ * A value of [Duration.ZERO] means there's no timeout.
+ *
+ * Defaults to `request()`.
+ */
+ fun write(): Duration = write ?: request()
+
+ /**
+ * The maximum time allowed for a complete HTTP call, not including retries.
+ *
+ * This includes resolving DNS, connecting, writing the request body, server processing, as well
+ * as reading the response body.
+ *
+ * A value of [Duration.ZERO] means there's no timeout.
+ *
+ * Defaults to `Duration.ofMinutes(1)`.
+ */
+ fun request(): Duration = request ?: Duration.ofMinutes(1)
+
+ fun toBuilder() = Builder().from(this)
+
+ companion object {
+
+ @JvmStatic fun default() = builder().build()
+
+ /** Returns a mutable builder for constructing an instance of [Timeout]. */
+ @JvmStatic fun builder() = Builder()
+ }
+
+ /** A builder for [Timeout]. */
+ class Builder internal constructor() {
+
+ private var connect: Duration? = null
+ private var read: Duration? = null
+ private var write: Duration? = null
+ private var request: Duration? = null
+
+ @JvmSynthetic
+ internal fun from(timeout: Timeout) = apply {
+ connect = timeout.connect
+ read = timeout.read
+ write = timeout.write
+ request = timeout.request
+ }
+
+ /**
+ * The maximum time allowed to establish a connection with a host.
+ *
+ * A value of [Duration.ZERO] means there's no timeout.
+ *
+ * Defaults to `Duration.ofMinutes(1)`.
+ */
+ fun connect(connect: Duration?) = apply { this.connect = connect }
+
+ /** Alias for calling [Builder.connect] with `connect.orElse(null)`. */
+ fun connect(connect: Optional) = connect(connect.getOrNull())
+
+ /**
+ * The maximum time allowed between two data packets when waiting for the server’s response.
+ *
+ * A value of [Duration.ZERO] means there's no timeout.
+ *
+ * Defaults to `request()`.
+ */
+ fun read(read: Duration?) = apply { this.read = read }
+
+ /** Alias for calling [Builder.read] with `read.orElse(null)`. */
+ fun read(read: Optional) = read(read.getOrNull())
+
+ /**
+ * The maximum time allowed between two data packets when sending the request to the server.
+ *
+ * A value of [Duration.ZERO] means there's no timeout.
+ *
+ * Defaults to `request()`.
+ */
+ fun write(write: Duration?) = apply { this.write = write }
+
+ /** Alias for calling [Builder.write] with `write.orElse(null)`. */
+ fun write(write: Optional) = write(write.getOrNull())
+
+ /**
+ * The maximum time allowed for a complete HTTP call, not including retries.
+ *
+ * This includes resolving DNS, connecting, writing the request body, server processing, as
+ * well as reading the response body.
+ *
+ * A value of [Duration.ZERO] means there's no timeout.
+ *
+ * Defaults to `Duration.ofMinutes(1)`.
+ */
+ fun request(request: Duration?) = apply { this.request = request }
+
+ /** Alias for calling [Builder.request] with `request.orElse(null)`. */
+ fun request(request: Optional) = request(request.getOrNull())
+
+ /**
+ * Returns an immutable instance of [Timeout].
+ *
+ * Further updates to this [Builder] will not mutate the returned instance.
+ */
+ fun build(): Timeout = Timeout(connect, read, write, request)
+ }
+
+ @JvmSynthetic
+ internal fun assign(target: Timeout): Timeout =
+ target
+ .toBuilder()
+ .apply {
+ connect?.let(this::connect)
+ read?.let(this::read)
+ write?.let(this::write)
+ request?.let(this::request)
+ }
+ .build()
+
+ override fun equals(other: Any?): Boolean {
+ if (this === other) {
+ return true
+ }
+
+ return other is Timeout &&
+ connect == other.connect &&
+ read == other.read &&
+ write == other.write &&
+ request == other.request
+ }
+
+ override fun hashCode(): Int = Objects.hash(connect, read, write, request)
+
+ override fun toString() =
+ "Timeout{connect=$connect, read=$read, write=$write, request=$request}"
+}
diff --git a/stagehand-java-core/src/main/kotlin/com/browserbase/api/core/Utils.kt b/stagehand-java-core/src/main/kotlin/com/browserbase/api/core/Utils.kt
new file mode 100644
index 0000000..ee3da60
--- /dev/null
+++ b/stagehand-java-core/src/main/kotlin/com/browserbase/api/core/Utils.kt
@@ -0,0 +1,121 @@
+@file:JvmName("Utils")
+
+package com.browserbase.api.core
+
+import com.browserbase.api.errors.StagehandInvalidDataException
+import java.util.Collections
+import java.util.SortedMap
+import java.util.SortedSet
+import java.util.concurrent.CompletableFuture
+import java.util.concurrent.locks.Lock
+
+@JvmSynthetic
+internal fun T?.getOrThrow(name: String): T =
+ this ?: throw StagehandInvalidDataException("`${name}` is not present")
+
+@JvmSynthetic
+internal fun List.toImmutable(): List =
+ if (isEmpty()) Collections.emptyList() else Collections.unmodifiableList(toList())
+
+@JvmSynthetic
+internal fun > SortedSet.toImmutable(): SortedSet =
+ if (isEmpty()) Collections.emptySortedSet()
+ else Collections.unmodifiableSortedSet(toSortedSet(comparator() ?: Comparator.naturalOrder()))
+
+@JvmSynthetic
+internal fun Map.toImmutable(): Map =
+ if (isEmpty()) immutableEmptyMap() else Collections.unmodifiableMap(toMap())
+
+@JvmSynthetic internal fun immutableEmptyMap(): Map = Collections.emptyMap()
+
+@JvmSynthetic
+internal fun , V> SortedMap.toImmutable(): SortedMap =
+ if (isEmpty()) Collections.emptySortedMap()
+ else Collections.unmodifiableSortedMap(toSortedMap(comparator()))
+
+/**
+ * Returns all elements that yield the largest value for the given function, or an empty list if
+ * there are zero elements.
+ *
+ * This is similar to [Sequence.maxByOrNull] except it returns _all_ elements that yield the largest
+ * value; not just the first one.
+ */
+@JvmSynthetic
+internal fun > Sequence.allMaxBy(selector: (T) -> R): List {
+ var maxValue: R? = null
+ val maxElements = mutableListOf()
+
+ val iterator = iterator()
+ while (iterator.hasNext()) {
+ val element = iterator.next()
+ val value = selector(element)
+ if (maxValue == null || value > maxValue) {
+ maxValue = value
+ maxElements.clear()
+ maxElements.add(element)
+ } else if (value == maxValue) {
+ maxElements.add(element)
+ }
+ }
+
+ return maxElements
+}
+
+/**
+ * Returns whether [this] is equal to [other].
+ *
+ * This differs from [Object.equals] because it also deeply equates arrays based on their contents,
+ * even when there are arrays directly nested within other arrays.
+ */
+@JvmSynthetic
+internal infix fun Any?.contentEquals(other: Any?): Boolean =
+ arrayOf(this).contentDeepEquals(arrayOf(other))
+
+/**
+ * Returns a hash of the given sequence of [values].
+ *
+ * This differs from [java.util.Objects.hash] because it also deeply hashes arrays based on their
+ * contents, even when there are arrays directly nested within other arrays.
+ */
+@JvmSynthetic internal fun contentHash(vararg values: Any?): Int = values.contentDeepHashCode()
+
+/**
+ * Returns a [String] representation of [this].
+ *
+ * This differs from [Object.toString] because it also deeply stringifies arrays based on their
+ * contents, even when there are arrays directly nested within other arrays.
+ */
+@JvmSynthetic
+internal fun Any?.contentToString(): String {
+ var string = arrayOf(this).contentDeepToString()
+ if (string.startsWith('[')) {
+ string = string.substring(1)
+ }
+ if (string.endsWith(']')) {
+ string = string.substring(0, string.length - 1)
+ }
+ return string
+}
+
+internal interface Enum
+
+/**
+ * Executes the given [action] while holding the lock, returning a [CompletableFuture] with the
+ * result.
+ *
+ * @param action The asynchronous action to execute while holding the lock
+ * @return A [CompletableFuture] that completes with the result of the action
+ */
+@JvmSynthetic
+internal fun Lock.withLockAsync(action: () -> CompletableFuture): CompletableFuture {
+ lock()
+ val future =
+ try {
+ action()
+ } catch (e: Throwable) {
+ unlock()
+ throw e
+ }
+ future.whenComplete { _, _ -> unlock() }
+ return future
+}
diff --git a/stagehand-java-core/src/main/kotlin/com/browserbase/api/core/Values.kt b/stagehand-java-core/src/main/kotlin/com/browserbase/api/core/Values.kt
new file mode 100644
index 0000000..db0102a
--- /dev/null
+++ b/stagehand-java-core/src/main/kotlin/com/browserbase/api/core/Values.kt
@@ -0,0 +1,723 @@
+package com.browserbase.api.core
+
+import com.browserbase.api.errors.StagehandInvalidDataException
+import com.fasterxml.jackson.annotation.JacksonAnnotationsInside
+import com.fasterxml.jackson.annotation.JsonCreator
+import com.fasterxml.jackson.annotation.JsonInclude
+import com.fasterxml.jackson.core.JsonGenerator
+import com.fasterxml.jackson.core.ObjectCodec
+import com.fasterxml.jackson.core.type.TypeReference
+import com.fasterxml.jackson.databind.BeanProperty
+import com.fasterxml.jackson.databind.DeserializationContext
+import com.fasterxml.jackson.databind.JavaType
+import com.fasterxml.jackson.databind.JsonDeserializer
+import com.fasterxml.jackson.databind.JsonNode
+import com.fasterxml.jackson.databind.SerializerProvider
+import com.fasterxml.jackson.databind.annotation.JsonDeserialize
+import com.fasterxml.jackson.databind.annotation.JsonSerialize
+import com.fasterxml.jackson.databind.node.JsonNodeType.ARRAY
+import com.fasterxml.jackson.databind.node.JsonNodeType.BINARY
+import com.fasterxml.jackson.databind.node.JsonNodeType.BOOLEAN
+import com.fasterxml.jackson.databind.node.JsonNodeType.MISSING
+import com.fasterxml.jackson.databind.node.JsonNodeType.NULL
+import com.fasterxml.jackson.databind.node.JsonNodeType.NUMBER
+import com.fasterxml.jackson.databind.node.JsonNodeType.OBJECT
+import com.fasterxml.jackson.databind.node.JsonNodeType.POJO
+import com.fasterxml.jackson.databind.node.JsonNodeType.STRING
+import com.fasterxml.jackson.databind.ser.std.NullSerializer
+import java.io.InputStream
+import java.util.Objects
+import java.util.Optional
+
+/**
+ * A class representing a serializable JSON field.
+ *
+ * It can either be a [KnownValue] value of type [T], matching the type the SDK expects, or an
+ * arbitrary JSON value that bypasses the type system (via [JsonValue]).
+ */
+@JsonDeserialize(using = JsonField.Deserializer::class)
+sealed class JsonField {
+
+ /**
+ * Returns whether this field is missing, which means it will be omitted from the serialized
+ * JSON entirely.
+ */
+ fun isMissing(): Boolean = this is JsonMissing
+
+ /** Whether this field is explicitly set to `null`. */
+ fun isNull(): Boolean = this is JsonNull
+
+ /**
+ * Returns an [Optional] containing this field's "known" value, meaning it matches the type the
+ * SDK expects, or an empty [Optional] if this field contains an arbitrary [JsonValue].
+ *
+ * This is the opposite of [asUnknown].
+ */
+ fun asKnown():
+ Optional<
+ // Safe because `Optional` is effectively covariant, but Kotlin doesn't know that.
+ @UnsafeVariance
+ T
+ > = Optional.ofNullable((this as? KnownValue)?.value)
+
+ /**
+ * Returns an [Optional] containing this field's arbitrary [JsonValue], meaning it mismatches
+ * the type the SDK expects, or an empty [Optional] if this field contains a "known" value.
+ *
+ * This is the opposite of [asKnown].
+ */
+ fun asUnknown(): Optional = Optional.ofNullable(this as? JsonValue)
+
+ /**
+ * Returns an [Optional] containing this field's boolean value, or an empty [Optional] if it
+ * doesn't contain a boolean.
+ *
+ * This method checks for both a [KnownValue] containing a boolean and for [JsonBoolean].
+ */
+ fun asBoolean(): Optional =
+ when (this) {
+ is JsonBoolean -> Optional.of(value)
+ is KnownValue -> Optional.ofNullable(value as? Boolean)
+ else -> Optional.empty()
+ }
+
+ /**
+ * Returns an [Optional] containing this field's numerical value, or an empty [Optional] if it
+ * doesn't contain a number.
+ *
+ * This method checks for both a [KnownValue] containing a number and for [JsonNumber].
+ */
+ fun asNumber(): Optional =
+ when (this) {
+ is JsonNumber -> Optional.of(value)
+ is KnownValue -> Optional.ofNullable(value as? Number)
+ else -> Optional.empty()
+ }
+
+ /**
+ * Returns an [Optional] containing this field's string value, or an empty [Optional] if it
+ * doesn't contain a string.
+ *
+ * This method checks for both a [KnownValue] containing a string and for [JsonString].
+ */
+ fun asString(): Optional =
+ when (this) {
+ is JsonString -> Optional.of(value)
+ is KnownValue -> Optional.ofNullable(value as? String)
+ else -> Optional.empty()
+ }
+
+ fun asStringOrThrow(): String =
+ asString().orElseThrow { StagehandInvalidDataException("Value is not a string") }
+
+ /**
+ * Returns an [Optional] containing this field's list value, or an empty [Optional] if it
+ * doesn't contain a list.
+ *
+ * This method checks for both a [KnownValue] containing a list and for [JsonArray].
+ */
+ fun asArray(): Optional> =
+ when (this) {
+ is JsonArray -> Optional.of(values)
+ is KnownValue ->
+ Optional.ofNullable(
+ (value as? List<*>)?.map {
+ try {
+ JsonValue.from(it)
+ } catch (e: IllegalArgumentException) {
+ // The known value is a list, but not all values are convertible to
+ // `JsonValue`.
+ return Optional.empty()
+ }
+ }
+ )
+ else -> Optional.empty()
+ }
+
+ /**
+ * Returns an [Optional] containing this field's map value, or an empty [Optional] if it doesn't
+ * contain a map.
+ *
+ * This method checks for both a [KnownValue] containing a map and for [JsonObject].
+ */
+ fun asObject(): Optional