diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 27fda9e..a8d4c5a 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -1,12 +1,14 @@
name: CI
on:
push:
- branches-ignore:
- - 'generated'
- - 'codegen/**'
- - 'integrated/**'
- - 'stl-preview-head/**'
- - 'stl-preview-base/**'
+ branches:
+ - '**'
+ - '!integrated/**'
+ - '!stl-preview-head/**'
+ - '!stl-preview-base/**'
+ - '!generated'
+ - '!codegen/**'
+ - 'codegen/stl/**'
pull_request:
branches-ignore:
- 'stl-preview-head/**'
@@ -17,7 +19,7 @@ jobs:
timeout-minutes: 15
name: lint
runs-on: ${{ github.repository == 'stainless-sdks/stagehand-java' && 'depot-ubuntu-24.04' || 'ubuntu-latest' }}
- if: github.event_name == 'push' || github.event.pull_request.head.repo.fork
+ if: (github.event_name == 'push' || github.event.pull_request.head.repo.fork) && (github.event_name != 'push' || github.event.head_commit.message != 'codegen metadata')
steps:
- uses: actions/checkout@v6
@@ -44,7 +46,7 @@ jobs:
contents: read
id-token: write
runs-on: ${{ github.repository == 'stainless-sdks/stagehand-java' && 'depot-ubuntu-24.04' || 'ubuntu-latest' }}
- if: github.event_name == 'push' || github.event.pull_request.head.repo.fork
+ if: (github.event_name == 'push' || github.event.pull_request.head.repo.fork) && (github.event_name != 'push' || github.event.head_commit.message != 'codegen metadata')
steps:
- uses: actions/checkout@v6
@@ -65,14 +67,18 @@ jobs:
run: ./scripts/build
- name: Get GitHub OIDC Token
- if: github.repository == 'stainless-sdks/stagehand-java'
+ if: |-
+ github.repository == 'stainless-sdks/stagehand-java' &&
+ !startsWith(github.ref, 'refs/heads/stl/')
id: github-oidc
uses: actions/github-script@v8
with:
script: core.setOutput('github_token', await core.getIDToken());
- name: Build and upload Maven artifacts
- if: github.repository == 'stainless-sdks/stagehand-java'
+ if: |-
+ github.repository == 'stainless-sdks/stagehand-java' &&
+ !startsWith(github.ref, 'refs/heads/stl/')
env:
URL: https://pkg.stainless.com/s
AUTH: ${{ steps.github-oidc.outputs.github_token }}
diff --git a/.gitignore b/.gitignore
index a65171e..dfd2d4b 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,4 +1,5 @@
.prism.log
+.stdy.log
.gradle
.idea
.kotlin
diff --git a/.release-please-manifest.json b/.release-please-manifest.json
index 1bc5713..df3292b 100644
--- a/.release-please-manifest.json
+++ b/.release-please-manifest.json
@@ -1,3 +1,3 @@
{
- ".": "0.7.1"
+ ".": "3.18.0"
}
\ No newline at end of file
diff --git a/.stats.yml b/.stats.yml
index c61740e..129faad 100644
--- a/.stats.yml
+++ b/.stats.yml
@@ -1,4 +1,4 @@
configured_endpoints: 8
-openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/browserbase%2Fstagehand-a4e672f457dd99336f4b2a113fd7c7c6c9db0941b38d57cff6e3641549a6c4ed.yml
-openapi_spec_hash: eae9c8561e420db8e4d238c1e59617fb
-config_hash: 2a565ad6662259a2e90fa5f1f5095525
+openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/browserbase%2Fstagehand-bc309fd00fe0507f4cbe3bc77fa27d0fbffeaa6e71998778da34de42608a67e8.yml
+openapi_spec_hash: 1db1af5c1b068bba1d652102f4454668
+config_hash: d6c6f623d03971bdba921650e5eb7e5f
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 168eb3d..d4c566e 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,36 @@
# Changelog
+## 3.18.0 (2026-03-25)
+
+Full Changelog: [v0.7.1...v3.18.0](https://github.com/browserbase/stagehand-java/compare/v0.7.1...v3.18.0)
+
+### Features
+
+* [STG-1607] Yield finished SSE event instead of silently dropping it ([72fa653](https://github.com/browserbase/stagehand-java/commit/72fa6531d9996c0653fa610b5b05e8027c3a450b))
+* Add explicit SSE event names for local v3 streaming ([6f7efad](https://github.com/browserbase/stagehand-java/commit/6f7efad3e19a4fdb911b2c478afb02ad2b63f8e9))
+* Add missing cdpHeaders field to v3 server openapi spec ([5a9506d](https://github.com/browserbase/stagehand-java/commit/5a9506d5dfeb1ae172834941aad3bbd9e96f2973))
+* Include LLM headers in ModelConfig ([dbf7424](https://github.com/browserbase/stagehand-java/commit/dbf74248557129e0e82067e7ce44bcbc4813174c))
+* Revert broken finished SSE yield config ([f404196](https://github.com/browserbase/stagehand-java/commit/f4041968aefba0e64380ca1d14ff20e5f93f9494))
+* variables for observe ([b10e2ad](https://github.com/browserbase/stagehand-java/commit/b10e2ad429ebac12464ac332eb32c2d11035c1ff))
+
+
+### Bug Fixes
+
+* **client:** allow updating header/query affecting fields in `toBuilder()` ([173adb9](https://github.com/browserbase/stagehand-java/commit/173adb9d14281cf3cb1eccedd850f5da1f95f41f))
+* **client:** incorrect `Retry-After` parsing ([76ea3fc](https://github.com/browserbase/stagehand-java/commit/76ea3fc583bfe9ca7295856db85378b44d765b0f))
+
+
+### Chores
+
+* **ci:** skip lint on metadata-only changes ([db9534f](https://github.com/browserbase/stagehand-java/commit/db9534f195b6045ad9679b08243c36d55340569b))
+* **ci:** skip uploading artifacts on stainless-internal branches ([4a1399c](https://github.com/browserbase/stagehand-java/commit/4a1399c7a535d9bf8934e9d142878e57a1537b8d))
+* **internal:** bump ktfmt ([d6758da](https://github.com/browserbase/stagehand-java/commit/d6758da0fe85d048b4f8fcd740be7c37901bb253))
+* **internal:** bump palantir-java-format ([3bf036a](https://github.com/browserbase/stagehand-java/commit/3bf036aed5ba8fb90fe4bf5bc6b62a49f09f7dcc))
+* **internal:** codegen related update ([f94c8c9](https://github.com/browserbase/stagehand-java/commit/f94c8c9a86af4592fba5c42645ccc1099a8ab1fe))
+* **internal:** tweak CI branches ([1d88e38](https://github.com/browserbase/stagehand-java/commit/1d88e3802216d33841d3595d3d9fa024189dfc28))
+* **internal:** update gitignore ([690533c](https://github.com/browserbase/stagehand-java/commit/690533ceee3c5d482a17d67c36a7bceb62a63ec2))
+* **internal:** update retry delay tests ([53bdc6b](https://github.com/browserbase/stagehand-java/commit/53bdc6ba4804aca38996fe28d5eeff412a10d189))
+
## 0.7.1 (2026-03-04)
Full Changelog: [v0.7.0...v0.7.1](https://github.com/browserbase/stagehand-java/compare/v0.7.0...v0.7.1)
diff --git a/README.md b/README.md
index 726fa64..4cb92e4 100644
--- a/README.md
+++ b/README.md
@@ -2,8 +2,8 @@
-[](https://central.sonatype.com/artifact/com.browserbase.api/stagehand-java/0.7.1)
-[](https://javadoc.io/doc/com.browserbase.api/stagehand-java/0.7.1)
+[](https://central.sonatype.com/artifact/com.browserbase.api/stagehand-java/3.18.0)
+[](https://javadoc.io/doc/com.browserbase.api/stagehand-java/3.18.0)
@@ -85,7 +85,7 @@ Most existing browser automation tools either require you to write low-level cod
### Gradle
```kotlin
-implementation("com.browserbase.api:stagehand-java:0.7.1")
+implementation("com.browserbase.api:stagehand-java:3.18.0")
```
### Maven
@@ -94,7 +94,7 @@ implementation("com.browserbase.api:stagehand-java:0.7.1")
com.browserbase.api
stagehand-java
- 0.7.1
+ 3.18.0
```
diff --git a/build.gradle.kts b/build.gradle.kts
index 79fb854..e4d25aa 100644
--- a/build.gradle.kts
+++ b/build.gradle.kts
@@ -8,7 +8,7 @@ repositories {
allprojects {
group = "com.browserbase.api"
- version = "0.7.1" // x-release-please-version
+ version = "3.18.0" // x-release-please-version
}
subprojects {
diff --git a/buildSrc/src/main/kotlin/stagehand.java.gradle.kts b/buildSrc/src/main/kotlin/stagehand.java.gradle.kts
index 70fc33f..8f4f902 100644
--- a/buildSrc/src/main/kotlin/stagehand.java.gradle.kts
+++ b/buildSrc/src/main/kotlin/stagehand.java.gradle.kts
@@ -45,7 +45,7 @@ tasks.withType().configureEach {
val palantir by configurations.creating
dependencies {
- palantir("com.palantir.javaformat:palantir-java-format:2.73.0")
+ palantir("com.palantir.javaformat:palantir-java-format:2.89.0")
}
fun registerPalantir(
diff --git a/buildSrc/src/main/kotlin/stagehand.kotlin.gradle.kts b/buildSrc/src/main/kotlin/stagehand.kotlin.gradle.kts
index be6cf65..2e59660 100644
--- a/buildSrc/src/main/kotlin/stagehand.kotlin.gradle.kts
+++ b/buildSrc/src/main/kotlin/stagehand.kotlin.gradle.kts
@@ -40,7 +40,7 @@ tasks.withType().configureEach {
val ktfmt by configurations.creating
dependencies {
- ktfmt("com.facebook:ktfmt:0.56")
+ ktfmt("com.facebook:ktfmt:0.61")
}
fun registerKtfmt(
diff --git a/scripts/fast-format b/scripts/fast-format
index 1b3bc47..35a1dee 100755
--- a/scripts/fast-format
+++ b/scripts/fast-format
@@ -24,8 +24,8 @@ if [ ! -f "$FILE_LIST" ]; then
exit 1
fi
-if ! command -v ktfmt-fast-format &> /dev/null; then
- echo "Error: ktfmt-fast-format not found"
+if ! command -v ktfmt &> /dev/null; then
+ echo "Error: ktfmt not found"
exit 1
fi
@@ -36,7 +36,7 @@ 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-fast-format --kotlinlang-style "$@"
+ echo "$kt_files" | tr '\n' '\0' | xargs -0 ktfmt --kotlinlang-style "$@"
else
echo "No Kotlin files to format -- expected outcome during incremental formatting"
fi
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
index 8b1c7f0..ac6a5ef 100644
--- 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
@@ -485,23 +485,24 @@ private constructor(
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.put("x-bb-api-key", it)
+ headers.replace("x-bb-api-key", it)
}
}
browserbaseProjectId.let {
if (!it.isEmpty()) {
- headers.put("x-bb-project-id", it)
+ headers.replace("x-bb-project-id", it)
}
}
modelApiKey.let {
if (!it.isEmpty()) {
- headers.put("x-model-api-key", it)
+ headers.replace("x-model-api-key", it)
}
}
- headers.replaceAll(this.headers.build())
- queryParams.replaceAll(this.queryParams.build())
return ClientOptions(
httpClient,
diff --git a/stagehand-java-core/src/main/kotlin/com/browserbase/api/core/handlers/SseHandler.kt b/stagehand-java-core/src/main/kotlin/com/browserbase/api/core/handlers/SseHandler.kt
index f8e870e..d0ddde2 100644
--- a/stagehand-java-core/src/main/kotlin/com/browserbase/api/core/handlers/SseHandler.kt
+++ b/stagehand-java-core/src/main/kotlin/com/browserbase/api/core/handlers/SseHandler.kt
@@ -18,23 +18,11 @@ import com.fasterxml.jackson.module.kotlin.jacksonTypeRef
internal fun sseHandler(jsonMapper: JsonMapper): Handler> =
streamHandler { response, lines ->
val state = SseState(jsonMapper)
- var done = false
for (line in lines) {
- // Stop emitting messages, but iterate through the full stream.
- if (done) {
- continue
- }
-
val message = state.decode(line) ?: continue
- when {
- message.data.startsWith("{\"data\":{\"status\":\"finished\"") -> {
- // In this case we don't break because we still want to iterate through the full
- // stream.
- done = true
- continue
- }
- message.data.startsWith("error") -> {
+ when (message.event) {
+ "error" -> {
throw SseException.builder()
.statusCode(response.statusCode())
.headers(response.headers())
@@ -47,10 +35,10 @@ internal fun sseHandler(jsonMapper: JsonMapper): Handler yield(message)
}
}
}
diff --git a/stagehand-java-core/src/main/kotlin/com/browserbase/api/core/http/RetryingHttpClient.kt b/stagehand-java-core/src/main/kotlin/com/browserbase/api/core/http/RetryingHttpClient.kt
index ab5bd0b..381b36f 100644
--- a/stagehand-java-core/src/main/kotlin/com/browserbase/api/core/http/RetryingHttpClient.kt
+++ b/stagehand-java-core/src/main/kotlin/com/browserbase/api/core/http/RetryingHttpClient.kt
@@ -201,7 +201,7 @@ private constructor(
?: headers.values("Retry-After").getOrNull(0)?.let { retryAfter ->
retryAfter.toFloatOrNull()?.times(TimeUnit.SECONDS.toNanos(1))
?: try {
- ChronoUnit.MILLIS.between(
+ ChronoUnit.NANOS.between(
OffsetDateTime.now(clock),
OffsetDateTime.parse(
retryAfter,
@@ -214,13 +214,8 @@ private constructor(
}
}
?.let { retryAfterNanos ->
- // If the API asks us to wait a certain amount of time (and it's a reasonable
- // amount), just
- // do what it says.
- val retryAfter = Duration.ofNanos(retryAfterNanos.toLong())
- if (retryAfter in Duration.ofNanos(0)..Duration.ofMinutes(1)) {
- return retryAfter
- }
+ // If the API asks us to wait a certain amount of time, do what it says.
+ return Duration.ofNanos(retryAfterNanos.toLong())
}
// Apply exponential backoff, but not more than the max.
diff --git a/stagehand-java-core/src/main/kotlin/com/browserbase/api/models/sessions/ModelConfig.kt b/stagehand-java-core/src/main/kotlin/com/browserbase/api/models/sessions/ModelConfig.kt
index 1334949..4e46131 100644
--- a/stagehand-java-core/src/main/kotlin/com/browserbase/api/models/sessions/ModelConfig.kt
+++ b/stagehand-java-core/src/main/kotlin/com/browserbase/api/models/sessions/ModelConfig.kt
@@ -8,6 +8,7 @@ import com.browserbase.api.core.JsonField
import com.browserbase.api.core.JsonMissing
import com.browserbase.api.core.JsonValue
import com.browserbase.api.core.checkRequired
+import com.browserbase.api.core.toImmutable
import com.browserbase.api.errors.StagehandInvalidDataException
import com.fasterxml.jackson.annotation.JsonAnyGetter
import com.fasterxml.jackson.annotation.JsonAnySetter
@@ -24,6 +25,7 @@ private constructor(
private val modelName: JsonField,
private val apiKey: JsonField,
private val baseUrl: JsonField,
+ private val headers: JsonField,
private val provider: JsonField,
private val additionalProperties: MutableMap,
) {
@@ -33,8 +35,9 @@ private constructor(
@JsonProperty("modelName") @ExcludeMissing modelName: JsonField = JsonMissing.of(),
@JsonProperty("apiKey") @ExcludeMissing apiKey: JsonField = JsonMissing.of(),
@JsonProperty("baseURL") @ExcludeMissing baseUrl: JsonField = JsonMissing.of(),
+ @JsonProperty("headers") @ExcludeMissing headers: JsonField = JsonMissing.of(),
@JsonProperty("provider") @ExcludeMissing provider: JsonField = JsonMissing.of(),
- ) : this(modelName, apiKey, baseUrl, provider, mutableMapOf())
+ ) : this(modelName, apiKey, baseUrl, headers, provider, mutableMapOf())
/**
* Model name string with provider prefix (e.g., 'openai/gpt-5-nano')
@@ -60,6 +63,14 @@ private constructor(
*/
fun baseUrl(): Optional = baseUrl.getOptional("baseURL")
+ /**
+ * Custom headers sent with every request to the model provider
+ *
+ * @throws StagehandInvalidDataException if the JSON field has an unexpected type (e.g. if the
+ * server responded with an unexpected value).
+ */
+ fun headers(): Optional = headers.getOptional("headers")
+
/**
* AI provider for the model (or provide a baseURL endpoint instead)
*
@@ -89,6 +100,13 @@ private constructor(
*/
@JsonProperty("baseURL") @ExcludeMissing fun _baseUrl(): JsonField = baseUrl
+ /**
+ * Returns the raw JSON value of [headers].
+ *
+ * Unlike [headers], this method doesn't throw if the JSON field has an unexpected type.
+ */
+ @JsonProperty("headers") @ExcludeMissing fun _headers(): JsonField = headers
+
/**
* Returns the raw JSON value of [provider].
*
@@ -127,6 +145,7 @@ private constructor(
private var modelName: JsonField? = null
private var apiKey: JsonField = JsonMissing.of()
private var baseUrl: JsonField = JsonMissing.of()
+ private var headers: JsonField = JsonMissing.of()
private var provider: JsonField = JsonMissing.of()
private var additionalProperties: MutableMap = mutableMapOf()
@@ -135,6 +154,7 @@ private constructor(
modelName = modelConfig.modelName
apiKey = modelConfig.apiKey
baseUrl = modelConfig.baseUrl
+ headers = modelConfig.headers
provider = modelConfig.provider
additionalProperties = modelConfig.additionalProperties.toMutableMap()
}
@@ -173,6 +193,17 @@ private constructor(
*/
fun baseUrl(baseUrl: JsonField) = apply { this.baseUrl = baseUrl }
+ /** Custom headers sent with every request to the model provider */
+ fun headers(headers: Headers) = headers(JsonField.of(headers))
+
+ /**
+ * Sets [Builder.headers] to an arbitrary JSON value.
+ *
+ * You should usually call [Builder.headers] with a well-typed [Headers] value instead. This
+ * method is primarily for setting the field to an undocumented or not yet supported value.
+ */
+ fun headers(headers: JsonField) = apply { this.headers = headers }
+
/** AI provider for the model (or provide a baseURL endpoint instead) */
fun provider(provider: Provider) = provider(JsonField.of(provider))
@@ -221,6 +252,7 @@ private constructor(
checkRequired("modelName", modelName),
apiKey,
baseUrl,
+ headers,
provider,
additionalProperties.toMutableMap(),
)
@@ -236,6 +268,7 @@ private constructor(
modelName()
apiKey()
baseUrl()
+ headers().ifPresent { it.validate() }
provider().ifPresent { it.validate() }
validated = true
}
@@ -258,8 +291,109 @@ private constructor(
(if (modelName.asKnown().isPresent) 1 else 0) +
(if (apiKey.asKnown().isPresent) 1 else 0) +
(if (baseUrl.asKnown().isPresent) 1 else 0) +
+ (headers.asKnown().getOrNull()?.validity() ?: 0) +
(provider.asKnown().getOrNull()?.validity() ?: 0)
+ /** Custom headers sent with every request to the model provider */
+ class Headers
+ @JsonCreator
+ private constructor(
+ @com.fasterxml.jackson.annotation.JsonValue
+ private val additionalProperties: Map
+ ) {
+
+ @JsonAnyGetter
+ @ExcludeMissing
+ fun _additionalProperties(): Map = additionalProperties
+
+ fun toBuilder() = Builder().from(this)
+
+ companion object {
+
+ /** Returns a mutable builder for constructing an instance of [Headers]. */
+ @JvmStatic fun builder() = Builder()
+ }
+
+ /** A builder for [Headers]. */
+ class Builder internal constructor() {
+
+ private var additionalProperties: MutableMap = mutableMapOf()
+
+ @JvmSynthetic
+ internal fun from(headers: Headers) = apply {
+ additionalProperties = headers.additionalProperties.toMutableMap()
+ }
+
+ fun additionalProperties(additionalProperties: Map) = apply {
+ this.additionalProperties.clear()
+ putAllAdditionalProperties(additionalProperties)
+ }
+
+ fun putAdditionalProperty(key: String, value: JsonValue) = apply {
+ additionalProperties.put(key, value)
+ }
+
+ fun putAllAdditionalProperties(additionalProperties: Map) = apply {
+ this.additionalProperties.putAll(additionalProperties)
+ }
+
+ fun removeAdditionalProperty(key: String) = apply { additionalProperties.remove(key) }
+
+ fun removeAllAdditionalProperties(keys: Set) = apply {
+ keys.forEach(::removeAdditionalProperty)
+ }
+
+ /**
+ * Returns an immutable instance of [Headers].
+ *
+ * Further updates to this [Builder] will not mutate the returned instance.
+ */
+ fun build(): Headers = Headers(additionalProperties.toImmutable())
+ }
+
+ private var validated: Boolean = false
+
+ fun validate(): Headers = apply {
+ if (validated) {
+ return@apply
+ }
+
+ validated = true
+ }
+
+ fun isValid(): Boolean =
+ try {
+ validate()
+ true
+ } catch (e: StagehandInvalidDataException) {
+ false
+ }
+
+ /**
+ * Returns a score indicating how many valid values are contained in this object
+ * recursively.
+ *
+ * Used for best match union deserialization.
+ */
+ @JvmSynthetic
+ internal fun validity(): Int =
+ additionalProperties.count { (_, value) -> !value.isNull() && !value.isMissing() }
+
+ override fun equals(other: Any?): Boolean {
+ if (this === other) {
+ return true
+ }
+
+ return other is Headers && additionalProperties == other.additionalProperties
+ }
+
+ private val hashCode: Int by lazy { Objects.hash(additionalProperties) }
+
+ override fun hashCode(): Int = hashCode
+
+ override fun toString() = "Headers{additionalProperties=$additionalProperties}"
+ }
+
/** AI provider for the model (or provide a baseURL endpoint instead) */
class Provider @JsonCreator private constructor(private val value: JsonField) : Enum {
@@ -415,16 +549,17 @@ private constructor(
modelName == other.modelName &&
apiKey == other.apiKey &&
baseUrl == other.baseUrl &&
+ headers == other.headers &&
provider == other.provider &&
additionalProperties == other.additionalProperties
}
private val hashCode: Int by lazy {
- Objects.hash(modelName, apiKey, baseUrl, provider, additionalProperties)
+ Objects.hash(modelName, apiKey, baseUrl, headers, provider, additionalProperties)
}
override fun hashCode(): Int = hashCode
override fun toString() =
- "ModelConfig{modelName=$modelName, apiKey=$apiKey, baseUrl=$baseUrl, provider=$provider, additionalProperties=$additionalProperties}"
+ "ModelConfig{modelName=$modelName, apiKey=$apiKey, baseUrl=$baseUrl, headers=$headers, provider=$provider, additionalProperties=$additionalProperties}"
}
diff --git a/stagehand-java-core/src/main/kotlin/com/browserbase/api/models/sessions/SessionActParams.kt b/stagehand-java-core/src/main/kotlin/com/browserbase/api/models/sessions/SessionActParams.kt
index 3445d78..6d92b39 100644
--- a/stagehand-java-core/src/main/kotlin/com/browserbase/api/models/sessions/SessionActParams.kt
+++ b/stagehand-java-core/src/main/kotlin/com/browserbase/api/models/sessions/SessionActParams.kt
@@ -805,7 +805,8 @@ private constructor(
fun timeout(): Optional = timeout.getOptional("timeout")
/**
- * Variables to substitute in the action instruction
+ * Variables to substitute in the action instruction. Accepts flat primitives or { value,
+ * description? } objects.
*
* @throws StagehandInvalidDataException if the JSON field has an unexpected type (e.g. if
* the server responded with an unexpected value).
@@ -899,7 +900,10 @@ private constructor(
*/
fun timeout(timeout: JsonField) = apply { this.timeout = timeout }
- /** Variables to substitute in the action instruction */
+ /**
+ * Variables to substitute in the action instruction. Accepts flat primitives or {
+ * value, description? } objects.
+ */
fun variables(variables: Variables) = variables(JsonField.of(variables))
/**
@@ -1144,7 +1148,10 @@ private constructor(
}
}
- /** Variables to substitute in the action instruction */
+ /**
+ * Variables to substitute in the action instruction. Accepts flat primitives or { value,
+ * description? } objects.
+ */
class Variables
@JsonCreator
private constructor(
diff --git a/stagehand-java-core/src/main/kotlin/com/browserbase/api/models/sessions/SessionExecuteParams.kt b/stagehand-java-core/src/main/kotlin/com/browserbase/api/models/sessions/SessionExecuteParams.kt
index 65fd5a6..f68232a 100644
--- a/stagehand-java-core/src/main/kotlin/com/browserbase/api/models/sessions/SessionExecuteParams.kt
+++ b/stagehand-java-core/src/main/kotlin/com/browserbase/api/models/sessions/SessionExecuteParams.kt
@@ -1686,6 +1686,8 @@ private constructor(
private val instruction: JsonField,
private val highlightCursor: JsonField,
private val maxSteps: JsonField,
+ private val toolTimeout: JsonField,
+ private val useSearch: JsonField,
private val additionalProperties: MutableMap,
) {
@@ -1697,8 +1699,16 @@ private constructor(
@JsonProperty("highlightCursor")
@ExcludeMissing
highlightCursor: JsonField = JsonMissing.of(),
- @JsonProperty("maxSteps") @ExcludeMissing maxSteps: JsonField = JsonMissing.of(),
- ) : this(instruction, highlightCursor, maxSteps, mutableMapOf())
+ @JsonProperty("maxSteps")
+ @ExcludeMissing
+ maxSteps: JsonField = JsonMissing.of(),
+ @JsonProperty("toolTimeout")
+ @ExcludeMissing
+ toolTimeout: JsonField = JsonMissing.of(),
+ @JsonProperty("useSearch")
+ @ExcludeMissing
+ useSearch: JsonField = JsonMissing.of(),
+ ) : this(instruction, highlightCursor, maxSteps, toolTimeout, useSearch, mutableMapOf())
/**
* Natural language instruction for the agent
@@ -1724,6 +1734,22 @@ private constructor(
*/
fun maxSteps(): Optional = maxSteps.getOptional("maxSteps")
+ /**
+ * Timeout in milliseconds for each agent tool call
+ *
+ * @throws StagehandInvalidDataException if the JSON field has an unexpected type (e.g. if
+ * the server responded with an unexpected value).
+ */
+ fun toolTimeout(): Optional = toolTimeout.getOptional("toolTimeout")
+
+ /**
+ * Whether to enable the web search tool powered by Browserbase Search API
+ *
+ * @throws StagehandInvalidDataException if the JSON field has an unexpected type (e.g. if
+ * the server responded with an unexpected value).
+ */
+ fun useSearch(): Optional = useSearch.getOptional("useSearch")
+
/**
* Returns the raw JSON value of [instruction].
*
@@ -1750,6 +1776,22 @@ private constructor(
*/
@JsonProperty("maxSteps") @ExcludeMissing fun _maxSteps(): JsonField = maxSteps
+ /**
+ * Returns the raw JSON value of [toolTimeout].
+ *
+ * Unlike [toolTimeout], this method doesn't throw if the JSON field has an unexpected type.
+ */
+ @JsonProperty("toolTimeout")
+ @ExcludeMissing
+ fun _toolTimeout(): JsonField = toolTimeout
+
+ /**
+ * Returns the raw JSON value of [useSearch].
+ *
+ * Unlike [useSearch], this method doesn't throw if the JSON field has an unexpected type.
+ */
+ @JsonProperty("useSearch") @ExcludeMissing fun _useSearch(): JsonField = useSearch
+
@JsonAnySetter
private fun putAdditionalProperty(key: String, value: JsonValue) {
additionalProperties.put(key, value)
@@ -1781,6 +1823,8 @@ private constructor(
private var instruction: JsonField? = null
private var highlightCursor: JsonField = JsonMissing.of()
private var maxSteps: JsonField = JsonMissing.of()
+ private var toolTimeout: JsonField = JsonMissing.of()
+ private var useSearch: JsonField = JsonMissing.of()
private var additionalProperties: MutableMap = mutableMapOf()
@JvmSynthetic
@@ -1788,6 +1832,8 @@ private constructor(
instruction = executeOptions.instruction
highlightCursor = executeOptions.highlightCursor
maxSteps = executeOptions.maxSteps
+ toolTimeout = executeOptions.toolTimeout
+ useSearch = executeOptions.useSearch
additionalProperties = executeOptions.additionalProperties.toMutableMap()
}
@@ -1832,6 +1878,32 @@ private constructor(
*/
fun maxSteps(maxSteps: JsonField) = apply { this.maxSteps = maxSteps }
+ /** Timeout in milliseconds for each agent tool call */
+ fun toolTimeout(toolTimeout: Double) = toolTimeout(JsonField.of(toolTimeout))
+
+ /**
+ * Sets [Builder.toolTimeout] to an arbitrary JSON value.
+ *
+ * You should usually call [Builder.toolTimeout] with a well-typed [Double] value
+ * instead. This method is primarily for setting the field to an undocumented or not yet
+ * supported value.
+ */
+ fun toolTimeout(toolTimeout: JsonField) = apply {
+ this.toolTimeout = toolTimeout
+ }
+
+ /** Whether to enable the web search tool powered by Browserbase Search API */
+ fun useSearch(useSearch: Boolean) = useSearch(JsonField.of(useSearch))
+
+ /**
+ * Sets [Builder.useSearch] to an arbitrary JSON value.
+ *
+ * You should usually call [Builder.useSearch] with a well-typed [Boolean] value
+ * instead. This method is primarily for setting the field to an undocumented or not yet
+ * supported value.
+ */
+ fun useSearch(useSearch: JsonField) = apply { this.useSearch = useSearch }
+
fun additionalProperties(additionalProperties: Map) = apply {
this.additionalProperties.clear()
putAllAdditionalProperties(additionalProperties)
@@ -1868,6 +1940,8 @@ private constructor(
checkRequired("instruction", instruction),
highlightCursor,
maxSteps,
+ toolTimeout,
+ useSearch,
additionalProperties.toMutableMap(),
)
}
@@ -1882,6 +1956,8 @@ private constructor(
instruction()
highlightCursor()
maxSteps()
+ toolTimeout()
+ useSearch()
validated = true
}
@@ -1903,7 +1979,9 @@ private constructor(
internal fun validity(): Int =
(if (instruction.asKnown().isPresent) 1 else 0) +
(if (highlightCursor.asKnown().isPresent) 1 else 0) +
- (if (maxSteps.asKnown().isPresent) 1 else 0)
+ (if (maxSteps.asKnown().isPresent) 1 else 0) +
+ (if (toolTimeout.asKnown().isPresent) 1 else 0) +
+ (if (useSearch.asKnown().isPresent) 1 else 0)
override fun equals(other: Any?): Boolean {
if (this === other) {
@@ -1914,17 +1992,26 @@ private constructor(
instruction == other.instruction &&
highlightCursor == other.highlightCursor &&
maxSteps == other.maxSteps &&
+ toolTimeout == other.toolTimeout &&
+ useSearch == other.useSearch &&
additionalProperties == other.additionalProperties
}
private val hashCode: Int by lazy {
- Objects.hash(instruction, highlightCursor, maxSteps, additionalProperties)
+ Objects.hash(
+ instruction,
+ highlightCursor,
+ maxSteps,
+ toolTimeout,
+ useSearch,
+ additionalProperties,
+ )
}
override fun hashCode(): Int = hashCode
override fun toString() =
- "ExecuteOptions{instruction=$instruction, highlightCursor=$highlightCursor, maxSteps=$maxSteps, additionalProperties=$additionalProperties}"
+ "ExecuteOptions{instruction=$instruction, highlightCursor=$highlightCursor, maxSteps=$maxSteps, toolTimeout=$toolTimeout, useSearch=$useSearch, additionalProperties=$additionalProperties}"
}
/** Whether to stream the response via SSE */
diff --git a/stagehand-java-core/src/main/kotlin/com/browserbase/api/models/sessions/SessionObserveParams.kt b/stagehand-java-core/src/main/kotlin/com/browserbase/api/models/sessions/SessionObserveParams.kt
index 695025f..d18eb4a 100644
--- a/stagehand-java-core/src/main/kotlin/com/browserbase/api/models/sessions/SessionObserveParams.kt
+++ b/stagehand-java-core/src/main/kotlin/com/browserbase/api/models/sessions/SessionObserveParams.kt
@@ -14,6 +14,7 @@ import com.browserbase.api.core.allMaxBy
import com.browserbase.api.core.getOrThrow
import com.browserbase.api.core.http.Headers
import com.browserbase.api.core.http.QueryParams
+import com.browserbase.api.core.toImmutable
import com.browserbase.api.errors.StagehandInvalidDataException
import com.fasterxml.jackson.annotation.JsonAnyGetter
import com.fasterxml.jackson.annotation.JsonAnySetter
@@ -566,6 +567,7 @@ private constructor(
private val model: JsonField,
private val selector: JsonField,
private val timeout: JsonField,
+ private val variables: JsonField,
private val additionalProperties: MutableMap,
) {
@@ -576,7 +578,10 @@ private constructor(
@ExcludeMissing
selector: JsonField = JsonMissing.of(),
@JsonProperty("timeout") @ExcludeMissing timeout: JsonField = JsonMissing.of(),
- ) : this(model, selector, timeout, mutableMapOf())
+ @JsonProperty("variables")
+ @ExcludeMissing
+ variables: JsonField = JsonMissing.of(),
+ ) : this(model, selector, timeout, variables, mutableMapOf())
/**
* Model configuration object or model name string (e.g., 'openai/gpt-5-nano')
@@ -602,6 +607,16 @@ private constructor(
*/
fun timeout(): Optional = timeout.getOptional("timeout")
+ /**
+ * Variables whose names are exposed to the model so observe() returns %variableName%
+ * placeholders in suggested action arguments instead of literal values. Accepts flat
+ * primitives or { value, description? } objects.
+ *
+ * @throws StagehandInvalidDataException if the JSON field has an unexpected type (e.g. if
+ * the server responded with an unexpected value).
+ */
+ fun variables(): Optional = variables.getOptional("variables")
+
/**
* Returns the raw JSON value of [model].
*
@@ -623,6 +638,15 @@ private constructor(
*/
@JsonProperty("timeout") @ExcludeMissing fun _timeout(): JsonField = timeout
+ /**
+ * Returns the raw JSON value of [variables].
+ *
+ * Unlike [variables], this method doesn't throw if the JSON field has an unexpected type.
+ */
+ @JsonProperty("variables")
+ @ExcludeMissing
+ fun _variables(): JsonField = variables
+
@JsonAnySetter
private fun putAdditionalProperty(key: String, value: JsonValue) {
additionalProperties.put(key, value)
@@ -647,6 +671,7 @@ private constructor(
private var model: JsonField = JsonMissing.of()
private var selector: JsonField = JsonMissing.of()
private var timeout: JsonField = JsonMissing.of()
+ private var variables: JsonField = JsonMissing.of()
private var additionalProperties: MutableMap = mutableMapOf()
@JvmSynthetic
@@ -654,6 +679,7 @@ private constructor(
model = options.model
selector = options.selector
timeout = options.timeout
+ variables = options.variables
additionalProperties = options.additionalProperties.toMutableMap()
}
@@ -699,6 +725,22 @@ private constructor(
*/
fun timeout(timeout: JsonField) = apply { this.timeout = timeout }
+ /**
+ * Variables whose names are exposed to the model so observe() returns %variableName%
+ * placeholders in suggested action arguments instead of literal values. Accepts flat
+ * primitives or { value, description? } objects.
+ */
+ fun variables(variables: Variables) = variables(JsonField.of(variables))
+
+ /**
+ * Sets [Builder.variables] to an arbitrary JSON value.
+ *
+ * You should usually call [Builder.variables] with a well-typed [Variables] value
+ * instead. This method is primarily for setting the field to an undocumented or not yet
+ * supported value.
+ */
+ fun variables(variables: JsonField) = apply { this.variables = variables }
+
fun additionalProperties(additionalProperties: Map) = apply {
this.additionalProperties.clear()
putAllAdditionalProperties(additionalProperties)
@@ -724,7 +766,7 @@ private constructor(
* Further updates to this [Builder] will not mutate the returned instance.
*/
fun build(): Options =
- Options(model, selector, timeout, additionalProperties.toMutableMap())
+ Options(model, selector, timeout, variables, additionalProperties.toMutableMap())
}
private var validated: Boolean = false
@@ -737,6 +779,7 @@ private constructor(
model().ifPresent { it.validate() }
selector()
timeout()
+ variables().ifPresent { it.validate() }
validated = true
}
@@ -758,7 +801,8 @@ private constructor(
internal fun validity(): Int =
(model.asKnown().getOrNull()?.validity() ?: 0) +
(if (selector.asKnown().isPresent) 1 else 0) +
- (if (timeout.asKnown().isPresent) 1 else 0)
+ (if (timeout.asKnown().isPresent) 1 else 0) +
+ (variables.asKnown().getOrNull()?.validity() ?: 0)
/** Model configuration object or model name string (e.g., 'openai/gpt-5-nano') */
@JsonDeserialize(using = Model.Deserializer::class)
@@ -932,6 +976,113 @@ private constructor(
}
}
+ /**
+ * Variables whose names are exposed to the model so observe() returns %variableName%
+ * placeholders in suggested action arguments instead of literal values. Accepts flat
+ * primitives or { value, description? } objects.
+ */
+ class Variables
+ @JsonCreator
+ private constructor(
+ @com.fasterxml.jackson.annotation.JsonValue
+ private val additionalProperties: Map
+ ) {
+
+ @JsonAnyGetter
+ @ExcludeMissing
+ fun _additionalProperties(): Map = additionalProperties
+
+ fun toBuilder() = Builder().from(this)
+
+ companion object {
+
+ /** Returns a mutable builder for constructing an instance of [Variables]. */
+ @JvmStatic fun builder() = Builder()
+ }
+
+ /** A builder for [Variables]. */
+ class Builder internal constructor() {
+
+ private var additionalProperties: MutableMap = mutableMapOf()
+
+ @JvmSynthetic
+ internal fun from(variables: Variables) = apply {
+ additionalProperties = variables.additionalProperties.toMutableMap()
+ }
+
+ fun additionalProperties(additionalProperties: Map) = apply {
+ this.additionalProperties.clear()
+ putAllAdditionalProperties(additionalProperties)
+ }
+
+ fun putAdditionalProperty(key: String, value: JsonValue) = apply {
+ additionalProperties.put(key, value)
+ }
+
+ fun putAllAdditionalProperties(additionalProperties: Map) =
+ apply {
+ this.additionalProperties.putAll(additionalProperties)
+ }
+
+ fun removeAdditionalProperty(key: String) = apply {
+ additionalProperties.remove(key)
+ }
+
+ fun removeAllAdditionalProperties(keys: Set) = apply {
+ keys.forEach(::removeAdditionalProperty)
+ }
+
+ /**
+ * Returns an immutable instance of [Variables].
+ *
+ * Further updates to this [Builder] will not mutate the returned instance.
+ */
+ fun build(): Variables = Variables(additionalProperties.toImmutable())
+ }
+
+ private var validated: Boolean = false
+
+ fun validate(): Variables = apply {
+ if (validated) {
+ return@apply
+ }
+
+ validated = true
+ }
+
+ fun isValid(): Boolean =
+ try {
+ validate()
+ true
+ } catch (e: StagehandInvalidDataException) {
+ false
+ }
+
+ /**
+ * Returns a score indicating how many valid values are contained in this object
+ * recursively.
+ *
+ * Used for best match union deserialization.
+ */
+ @JvmSynthetic
+ internal fun validity(): Int =
+ additionalProperties.count { (_, value) -> !value.isNull() && !value.isMissing() }
+
+ override fun equals(other: Any?): Boolean {
+ if (this === other) {
+ return true
+ }
+
+ return other is Variables && additionalProperties == other.additionalProperties
+ }
+
+ private val hashCode: Int by lazy { Objects.hash(additionalProperties) }
+
+ override fun hashCode(): Int = hashCode
+
+ override fun toString() = "Variables{additionalProperties=$additionalProperties}"
+ }
+
override fun equals(other: Any?): Boolean {
if (this === other) {
return true
@@ -941,17 +1092,18 @@ private constructor(
model == other.model &&
selector == other.selector &&
timeout == other.timeout &&
+ variables == other.variables &&
additionalProperties == other.additionalProperties
}
private val hashCode: Int by lazy {
- Objects.hash(model, selector, timeout, additionalProperties)
+ Objects.hash(model, selector, timeout, variables, additionalProperties)
}
override fun hashCode(): Int = hashCode
override fun toString() =
- "Options{model=$model, selector=$selector, timeout=$timeout, additionalProperties=$additionalProperties}"
+ "Options{model=$model, selector=$selector, timeout=$timeout, variables=$variables, additionalProperties=$additionalProperties}"
}
/** Whether to stream the response via SSE */
diff --git a/stagehand-java-core/src/main/kotlin/com/browserbase/api/models/sessions/SessionStartParams.kt b/stagehand-java-core/src/main/kotlin/com/browserbase/api/models/sessions/SessionStartParams.kt
index f177a4e..5e09071 100644
--- a/stagehand-java-core/src/main/kotlin/com/browserbase/api/models/sessions/SessionStartParams.kt
+++ b/stagehand-java-core/src/main/kotlin/com/browserbase/api/models/sessions/SessionStartParams.kt
@@ -1372,6 +1372,7 @@ private constructor(
private constructor(
private val acceptDownloads: JsonField,
private val args: JsonField>,
+ private val cdpHeaders: JsonField,
private val cdpUrl: JsonField,
private val chromiumSandbox: JsonField,
private val connectTimeoutMs: JsonField,
@@ -1400,6 +1401,9 @@ private constructor(
@JsonProperty("args")
@ExcludeMissing
args: JsonField> = JsonMissing.of(),
+ @JsonProperty("cdpHeaders")
+ @ExcludeMissing
+ cdpHeaders: JsonField = JsonMissing.of(),
@JsonProperty("cdpUrl")
@ExcludeMissing
cdpUrl: JsonField = JsonMissing.of(),
@@ -1450,6 +1454,7 @@ private constructor(
) : this(
acceptDownloads,
args,
+ cdpHeaders,
cdpUrl,
chromiumSandbox,
connectTimeoutMs,
@@ -1483,6 +1488,12 @@ private constructor(
*/
fun args(): Optional> = args.getOptional("args")
+ /**
+ * @throws StagehandInvalidDataException if the JSON field has an unexpected type (e.g.
+ * if the server responded with an unexpected value).
+ */
+ fun cdpHeaders(): Optional = cdpHeaders.getOptional("cdpHeaders")
+
/**
* @throws StagehandInvalidDataException if the JSON field has an unexpected type (e.g.
* if the server responded with an unexpected value).
@@ -1608,6 +1619,16 @@ private constructor(
*/
@JsonProperty("args") @ExcludeMissing fun _args(): JsonField> = args
+ /**
+ * Returns the raw JSON value of [cdpHeaders].
+ *
+ * Unlike [cdpHeaders], this method doesn't throw if the JSON field has an unexpected
+ * type.
+ */
+ @JsonProperty("cdpHeaders")
+ @ExcludeMissing
+ fun _cdpHeaders(): JsonField = cdpHeaders
+
/**
* Returns the raw JSON value of [cdpUrl].
*
@@ -1783,6 +1804,7 @@ private constructor(
private var acceptDownloads: JsonField = JsonMissing.of()
private var args: JsonField>? = null
+ private var cdpHeaders: JsonField = JsonMissing.of()
private var cdpUrl: JsonField = JsonMissing.of()
private var chromiumSandbox: JsonField = JsonMissing.of()
private var connectTimeoutMs: JsonField = JsonMissing.of()
@@ -1806,6 +1828,7 @@ private constructor(
internal fun from(launchOptions: LaunchOptions) = apply {
acceptDownloads = launchOptions.acceptDownloads
args = launchOptions.args.map { it.toMutableList() }
+ cdpHeaders = launchOptions.cdpHeaders
cdpUrl = launchOptions.cdpUrl
chromiumSandbox = launchOptions.chromiumSandbox
connectTimeoutMs = launchOptions.connectTimeoutMs
@@ -1865,6 +1888,19 @@ private constructor(
}
}
+ fun cdpHeaders(cdpHeaders: CdpHeaders) = cdpHeaders(JsonField.of(cdpHeaders))
+
+ /**
+ * Sets [Builder.cdpHeaders] to an arbitrary JSON value.
+ *
+ * You should usually call [Builder.cdpHeaders] with a well-typed [CdpHeaders] value
+ * instead. This method is primarily for setting the field to an undocumented or not
+ * yet supported value.
+ */
+ fun cdpHeaders(cdpHeaders: JsonField) = apply {
+ this.cdpHeaders = cdpHeaders
+ }
+
fun cdpUrl(cdpUrl: String) = cdpUrl(JsonField.of(cdpUrl))
/**
@@ -2120,6 +2156,7 @@ private constructor(
LaunchOptions(
acceptDownloads,
(args ?: JsonMissing.of()).map { it.toImmutable() },
+ cdpHeaders,
cdpUrl,
chromiumSandbox,
connectTimeoutMs,
@@ -2150,6 +2187,7 @@ private constructor(
acceptDownloads()
args()
+ cdpHeaders().ifPresent { it.validate() }
cdpUrl()
chromiumSandbox()
connectTimeoutMs()
@@ -2188,6 +2226,7 @@ private constructor(
internal fun validity(): Int =
(if (acceptDownloads.asKnown().isPresent) 1 else 0) +
(args.asKnown().getOrNull()?.size ?: 0) +
+ (cdpHeaders.asKnown().getOrNull()?.validity() ?: 0) +
(if (cdpUrl.asKnown().isPresent) 1 else 0) +
(if (chromiumSandbox.asKnown().isPresent) 1 else 0) +
(if (connectTimeoutMs.asKnown().isPresent) 1 else 0) +
@@ -2206,6 +2245,110 @@ private constructor(
(if (userDataDir.asKnown().isPresent) 1 else 0) +
(viewport.asKnown().getOrNull()?.validity() ?: 0)
+ class CdpHeaders
+ @JsonCreator
+ private constructor(
+ @com.fasterxml.jackson.annotation.JsonValue
+ private val additionalProperties: Map
+ ) {
+
+ @JsonAnyGetter
+ @ExcludeMissing
+ fun _additionalProperties(): Map = additionalProperties
+
+ fun toBuilder() = Builder().from(this)
+
+ companion object {
+
+ /** Returns a mutable builder for constructing an instance of [CdpHeaders]. */
+ @JvmStatic fun builder() = Builder()
+ }
+
+ /** A builder for [CdpHeaders]. */
+ class Builder internal constructor() {
+
+ private var additionalProperties: MutableMap = mutableMapOf()
+
+ @JvmSynthetic
+ internal fun from(cdpHeaders: CdpHeaders) = apply {
+ additionalProperties = cdpHeaders.additionalProperties.toMutableMap()
+ }
+
+ fun additionalProperties(additionalProperties: Map) = apply {
+ this.additionalProperties.clear()
+ putAllAdditionalProperties(additionalProperties)
+ }
+
+ fun putAdditionalProperty(key: String, value: JsonValue) = apply {
+ additionalProperties.put(key, value)
+ }
+
+ fun putAllAdditionalProperties(additionalProperties: Map) =
+ apply {
+ this.additionalProperties.putAll(additionalProperties)
+ }
+
+ fun removeAdditionalProperty(key: String) = apply {
+ additionalProperties.remove(key)
+ }
+
+ fun removeAllAdditionalProperties(keys: Set) = apply {
+ keys.forEach(::removeAdditionalProperty)
+ }
+
+ /**
+ * Returns an immutable instance of [CdpHeaders].
+ *
+ * Further updates to this [Builder] will not mutate the returned instance.
+ */
+ fun build(): CdpHeaders = CdpHeaders(additionalProperties.toImmutable())
+ }
+
+ private var validated: Boolean = false
+
+ fun validate(): CdpHeaders = apply {
+ if (validated) {
+ return@apply
+ }
+
+ validated = true
+ }
+
+ fun isValid(): Boolean =
+ try {
+ validate()
+ true
+ } catch (e: StagehandInvalidDataException) {
+ false
+ }
+
+ /**
+ * Returns a score indicating how many valid values are contained in this object
+ * recursively.
+ *
+ * Used for best match union deserialization.
+ */
+ @JvmSynthetic
+ internal fun validity(): Int =
+ additionalProperties.count { (_, value) ->
+ !value.isNull() && !value.isMissing()
+ }
+
+ override fun equals(other: Any?): Boolean {
+ if (this === other) {
+ return true
+ }
+
+ return other is CdpHeaders && additionalProperties == other.additionalProperties
+ }
+
+ private val hashCode: Int by lazy { Objects.hash(additionalProperties) }
+
+ override fun hashCode(): Int = hashCode
+
+ override fun toString() = "CdpHeaders{additionalProperties=$additionalProperties}"
+ }
+
@JsonDeserialize(using = IgnoreDefaultArgs.Deserializer::class)
@JsonSerialize(using = IgnoreDefaultArgs.Serializer::class)
class IgnoreDefaultArgs
@@ -2872,6 +3015,7 @@ private constructor(
return other is LaunchOptions &&
acceptDownloads == other.acceptDownloads &&
args == other.args &&
+ cdpHeaders == other.cdpHeaders &&
cdpUrl == other.cdpUrl &&
chromiumSandbox == other.chromiumSandbox &&
connectTimeoutMs == other.connectTimeoutMs &&
@@ -2896,6 +3040,7 @@ private constructor(
Objects.hash(
acceptDownloads,
args,
+ cdpHeaders,
cdpUrl,
chromiumSandbox,
connectTimeoutMs,
@@ -2920,7 +3065,7 @@ private constructor(
override fun hashCode(): Int = hashCode
override fun toString() =
- "LaunchOptions{acceptDownloads=$acceptDownloads, args=$args, cdpUrl=$cdpUrl, chromiumSandbox=$chromiumSandbox, connectTimeoutMs=$connectTimeoutMs, deviceScaleFactor=$deviceScaleFactor, devtools=$devtools, downloadsPath=$downloadsPath, executablePath=$executablePath, hasTouch=$hasTouch, headless=$headless, ignoreDefaultArgs=$ignoreDefaultArgs, ignoreHttpsErrors=$ignoreHttpsErrors, locale=$locale, port=$port, preserveUserDataDir=$preserveUserDataDir, proxy=$proxy, userDataDir=$userDataDir, viewport=$viewport, additionalProperties=$additionalProperties}"
+ "LaunchOptions{acceptDownloads=$acceptDownloads, args=$args, cdpHeaders=$cdpHeaders, cdpUrl=$cdpUrl, chromiumSandbox=$chromiumSandbox, connectTimeoutMs=$connectTimeoutMs, deviceScaleFactor=$deviceScaleFactor, devtools=$devtools, downloadsPath=$downloadsPath, executablePath=$executablePath, hasTouch=$hasTouch, headless=$headless, ignoreDefaultArgs=$ignoreDefaultArgs, ignoreHttpsErrors=$ignoreHttpsErrors, locale=$locale, port=$port, preserveUserDataDir=$preserveUserDataDir, proxy=$proxy, userDataDir=$userDataDir, viewport=$viewport, additionalProperties=$additionalProperties}"
}
/** Browser type to use */
diff --git a/stagehand-java-core/src/main/kotlin/com/browserbase/api/models/sessions/StreamEvent.kt b/stagehand-java-core/src/main/kotlin/com/browserbase/api/models/sessions/StreamEvent.kt
index 443104b..4584374 100644
--- a/stagehand-java-core/src/main/kotlin/com/browserbase/api/models/sessions/StreamEvent.kt
+++ b/stagehand-java-core/src/main/kotlin/com/browserbase/api/models/sessions/StreamEvent.kt
@@ -30,8 +30,8 @@ import java.util.Optional
import kotlin.jvm.optionals.getOrNull
/**
- * Server-Sent Event emitted during streaming responses. Events are sent as `data: \n\n`. Key
- * order: data (with status first), type, id.
+ * Server-Sent Event emitted during streaming responses. Events are sent as `event: \ndata:
+ * \n\n`, where the JSON payload has the shape `{ data, type, id }`.
*/
class StreamEvent
@JsonCreator(mode = JsonCreator.Mode.DISABLED)
diff --git a/stagehand-java-core/src/test/kotlin/com/browserbase/api/core/ClientOptionsTest.kt b/stagehand-java-core/src/test/kotlin/com/browserbase/api/core/ClientOptionsTest.kt
index 75d56e8..1bd88a6 100644
--- a/stagehand-java-core/src/test/kotlin/com/browserbase/api/core/ClientOptionsTest.kt
+++ b/stagehand-java-core/src/test/kotlin/com/browserbase/api/core/ClientOptionsTest.kt
@@ -16,6 +16,37 @@ internal class ClientOptionsTest {
private val httpClient = mock()
+ @Test
+ fun putHeader_canOverwriteDefaultHeader() {
+ val clientOptions =
+ ClientOptions.builder()
+ .httpClient(httpClient)
+ .putHeader("User-Agent", "My User Agent")
+ .browserbaseApiKey("My Browserbase API Key")
+ .browserbaseProjectId("My Browserbase Project ID")
+ .modelApiKey("My Model API Key")
+ .build()
+
+ assertThat(clientOptions.headers.values("User-Agent")).containsExactly("My User Agent")
+ }
+
+ @Test
+ fun toBuilder_bbApiKeyAuthCanBeUpdated() {
+ var clientOptions =
+ ClientOptions.builder()
+ .httpClient(httpClient)
+ .browserbaseApiKey("My Browserbase API Key")
+ .browserbaseProjectId("My Browserbase Project ID")
+ .modelApiKey("My Model API Key")
+ .build()
+
+ clientOptions =
+ clientOptions.toBuilder().browserbaseApiKey("another My Browserbase API Key").build()
+
+ assertThat(clientOptions.headers.values("x-bb-api-key"))
+ .containsExactly("another My Browserbase API Key")
+ }
+
@Test
fun toBuilder_whenOriginalClientOptionsGarbageCollected_doesNotCloseOriginalClient() {
var clientOptions =
diff --git a/stagehand-java-core/src/test/kotlin/com/browserbase/api/core/handlers/SseHandlerTest.kt b/stagehand-java-core/src/test/kotlin/com/browserbase/api/core/handlers/SseHandlerTest.kt
index 29105f3..2e4dfb0 100644
--- a/stagehand-java-core/src/test/kotlin/com/browserbase/api/core/handlers/SseHandlerTest.kt
+++ b/stagehand-java-core/src/test/kotlin/com/browserbase/api/core/handlers/SseHandlerTest.kt
@@ -2,10 +2,12 @@
package com.browserbase.api.core.handlers
+import com.browserbase.api.core.JsonValue
import com.browserbase.api.core.http.Headers
import com.browserbase.api.core.http.HttpResponse
import com.browserbase.api.core.http.SseMessage
import com.browserbase.api.core.jsonMapper
+import com.browserbase.api.errors.SseException
import java.io.InputStream
import java.util.stream.Collectors.toList
import org.assertj.core.api.Assertions.assertThat
@@ -21,64 +23,105 @@ internal class SseHandlerTest {
internal val expectedMessages: List? = null,
internal val expectedException: Exception? = null,
) {
- DATA_MISSING_EVENT(
+ EVENT_AND_DATA(
buildString {
+ append("event: starting\n")
append("data: {\"foo\":true}\n")
append("\n")
},
- listOf(sseMessageBuilder().data("{\"foo\":true}").build()),
+ listOf(sseMessageBuilder().event("starting").data("{\"foo\":true}").build()),
),
- MULTIPLE_DATA_MISSING_EVENT(
+ EVENT_MISSING_DATA(
buildString {
+ append("event: starting\n")
+ append("\n")
+ },
+ listOf(sseMessageBuilder().event("starting").build()),
+ ),
+ MULTIPLE_EVENTS_AND_DATA(
+ buildString {
+ append("event: starting\n")
append("data: {\"foo\":true}\n")
append("\n")
+ append("event: connected\n")
append("data: {\"bar\":false}\n")
append("\n")
},
listOf(
- sseMessageBuilder().data("{\"foo\":true}").build(),
- sseMessageBuilder().data("{\"bar\":false}").build(),
+ sseMessageBuilder().event("starting").data("{\"foo\":true}").build(),
+ sseMessageBuilder().event("connected").data("{\"bar\":false}").build(),
+ ),
+ ),
+ MULTIPLE_EVENTS_MISSING_DATA(
+ buildString {
+ append("event: starting\n")
+ append("\n")
+ append("event: connected\n")
+ append("\n")
+ },
+ listOf(
+ sseMessageBuilder().event("starting").build(),
+ sseMessageBuilder().event("connected").build(),
),
),
DATA_JSON_ESCAPED_DOUBLE_NEW_LINE(
buildString {
+ append("event: starting\n")
append("data: {\n")
append("data: \"foo\":\n")
append("data: true}\n")
append("\n\n")
},
- listOf(sseMessageBuilder().data("{\n\"foo\":\ntrue}").build()),
+ listOf(sseMessageBuilder().event("starting").data("{\n\"foo\":\ntrue}").build()),
),
MULTIPLE_DATA_LINES(
buildString {
+ append("event: starting\n")
append("data: {\n")
append("data: \"foo\":\n")
append("data: true}\n")
append("\n\n")
},
- listOf(sseMessageBuilder().data("{\n\"foo\":\ntrue}").build()),
+ listOf(sseMessageBuilder().event("starting").data("{\n\"foo\":\ntrue}").build()),
),
SPECIAL_NEW_LINE_CHARACTER(
buildString {
+ append("event: starting\n")
append("data: {\"content\":\" culpa\"}\n")
append("\n")
+ append("event: connected\n")
append("data: {\"content\":\" \u2028\"}\n")
append("\n")
+ append("event: starting\n")
append("data: {\"content\":\"foo\"}\n")
append("\n")
},
listOf(
- sseMessageBuilder().data("{\"content\":\" culpa\"}").build(),
- sseMessageBuilder().data("{\"content\":\" \u2028\"}").build(),
- sseMessageBuilder().data("{\"content\":\"foo\"}").build(),
+ sseMessageBuilder().event("starting").data("{\"content\":\" culpa\"}").build(),
+ sseMessageBuilder().event("connected").data("{\"content\":\" \u2028\"}").build(),
+ sseMessageBuilder().event("starting").data("{\"content\":\"foo\"}").build(),
),
),
MULTI_BYTE_CHARACTER(
buildString {
+ append("event: starting\n")
append("data: {\"content\":\"\u0438\u0437\u0432\u0435\u0441\u0442\u043d\u0438\"}\n")
append("\n")
},
- listOf(sseMessageBuilder().data("{\"content\":\"известни\"}").build()),
+ listOf(sseMessageBuilder().event("starting").data("{\"content\":\"известни\"}").build()),
+ ),
+ ERROR_EVENT(
+ buildString {
+ append("event: error\n")
+ append("data: {\"errorProperty\":\"42\"}\n")
+ append("\n")
+ },
+ expectedException =
+ SseException.builder()
+ .statusCode(0)
+ .headers(Headers.builder().build())
+ .body(JsonValue.from(mapOf("errorProperty" to "42")))
+ .build(),
),
}
diff --git a/stagehand-java-core/src/test/kotlin/com/browserbase/api/core/http/RetryingHttpClientTest.kt b/stagehand-java-core/src/test/kotlin/com/browserbase/api/core/http/RetryingHttpClientTest.kt
index ce14c91..49e0138 100644
--- a/stagehand-java-core/src/test/kotlin/com/browserbase/api/core/http/RetryingHttpClientTest.kt
+++ b/stagehand-java-core/src/test/kotlin/com/browserbase/api/core/http/RetryingHttpClientTest.kt
@@ -20,7 +20,11 @@ import com.github.tomakehurst.wiremock.junit5.WireMockRuntimeInfo
import com.github.tomakehurst.wiremock.junit5.WireMockTest
import com.github.tomakehurst.wiremock.stubbing.Scenario
import java.io.InputStream
+import java.time.Clock
import java.time.Duration
+import java.time.OffsetDateTime
+import java.time.ZoneOffset
+import java.time.format.DateTimeFormatter
import java.util.concurrent.CompletableFuture
import org.assertj.core.api.Assertions.assertThat
import org.junit.jupiter.api.BeforeEach
@@ -36,6 +40,21 @@ internal class RetryingHttpClientTest {
private lateinit var baseUrl: String
private lateinit var httpClient: HttpClient
+ private class RecordingSleeper : Sleeper {
+ val durations = mutableListOf()
+
+ override fun sleep(duration: Duration) {
+ durations.add(duration)
+ }
+
+ override fun sleepAsync(duration: Duration): CompletableFuture {
+ durations.add(duration)
+ return CompletableFuture.completedFuture(null)
+ }
+
+ override fun close() {}
+ }
+
@BeforeEach
fun beforeEach(wmRuntimeInfo: WireMockRuntimeInfo) {
baseUrl = wmRuntimeInfo.httpBaseUrl
@@ -86,7 +105,8 @@ internal class RetryingHttpClientTest {
@ValueSource(booleans = [false, true])
fun execute(async: Boolean) {
stubFor(post(urlPathEqualTo("/something")).willReturn(ok()))
- val retryingClient = retryingHttpClientBuilder().build()
+ val sleeper = RecordingSleeper()
+ val retryingClient = retryingHttpClientBuilder(sleeper).build()
val response =
retryingClient.execute(
@@ -100,6 +120,7 @@ internal class RetryingHttpClientTest {
assertThat(response.statusCode()).isEqualTo(200)
verify(1, postRequestedFor(urlPathEqualTo("/something")))
+ assertThat(sleeper.durations).isEmpty()
assertNoResponseLeaks()
}
@@ -111,8 +132,12 @@ internal class RetryingHttpClientTest {
.withHeader("X-Some-Header", matching("stainless-java-retry-.+"))
.willReturn(ok())
)
+ val sleeper = RecordingSleeper()
val retryingClient =
- retryingHttpClientBuilder().maxRetries(2).idempotencyHeader("X-Some-Header").build()
+ retryingHttpClientBuilder(sleeper)
+ .maxRetries(2)
+ .idempotencyHeader("X-Some-Header")
+ .build()
val response =
retryingClient.execute(
@@ -126,20 +151,20 @@ internal class RetryingHttpClientTest {
assertThat(response.statusCode()).isEqualTo(200)
verify(1, postRequestedFor(urlPathEqualTo("/something")))
+ assertThat(sleeper.durations).isEmpty()
assertNoResponseLeaks()
}
@ParameterizedTest
@ValueSource(booleans = [false, true])
fun execute_withRetryAfterHeader(async: Boolean) {
+ val retryAfterDate = "Wed, 21 Oct 2015 07:28:00 GMT"
stubFor(
post(urlPathEqualTo("/something"))
// First we fail with a retry after header given as a date
.inScenario("foo")
.whenScenarioStateIs(Scenario.STARTED)
- .willReturn(
- serviceUnavailable().withHeader("Retry-After", "Wed, 21 Oct 2015 07:28:00 GMT")
- )
+ .willReturn(serviceUnavailable().withHeader("Retry-After", retryAfterDate))
.willSetStateTo("RETRY_AFTER_DATE")
)
stubFor(
@@ -158,7 +183,13 @@ internal class RetryingHttpClientTest {
.willReturn(ok())
.willSetStateTo("COMPLETED")
)
- val retryingClient = retryingHttpClientBuilder().maxRetries(2).build()
+ // Fix the clock to 5 seconds before the Retry-After date so the date-based backoff is
+ // deterministic.
+ val retryAfterDateTime =
+ OffsetDateTime.parse(retryAfterDate, DateTimeFormatter.RFC_1123_DATE_TIME)
+ val clock = Clock.fixed(retryAfterDateTime.minusSeconds(5).toInstant(), ZoneOffset.UTC)
+ val sleeper = RecordingSleeper()
+ val retryingClient = retryingHttpClientBuilder(sleeper, clock).maxRetries(2).build()
val response =
retryingClient.execute(
@@ -186,19 +217,20 @@ internal class RetryingHttpClientTest {
postRequestedFor(urlPathEqualTo("/something"))
.withHeader("x-stainless-retry-count", equalTo("2")),
)
+ assertThat(sleeper.durations)
+ .containsExactly(Duration.ofSeconds(5), Duration.ofMillis(1234))
assertNoResponseLeaks()
}
@ParameterizedTest
@ValueSource(booleans = [false, true])
fun execute_withOverwrittenRetryCountHeader(async: Boolean) {
+ val retryAfterDate = "Wed, 21 Oct 2015 07:28:00 GMT"
stubFor(
post(urlPathEqualTo("/something"))
.inScenario("foo") // first we fail with a retry after header given as a date
.whenScenarioStateIs(Scenario.STARTED)
- .willReturn(
- serviceUnavailable().withHeader("Retry-After", "Wed, 21 Oct 2015 07:28:00 GMT")
- )
+ .willReturn(serviceUnavailable().withHeader("Retry-After", retryAfterDate))
.willSetStateTo("RETRY_AFTER_DATE")
)
stubFor(
@@ -208,7 +240,11 @@ internal class RetryingHttpClientTest {
.willReturn(ok())
.willSetStateTo("COMPLETED")
)
- val retryingClient = retryingHttpClientBuilder().maxRetries(2).build()
+ val retryAfterDateTime =
+ OffsetDateTime.parse(retryAfterDate, DateTimeFormatter.RFC_1123_DATE_TIME)
+ val clock = Clock.fixed(retryAfterDateTime.minusSeconds(5).toInstant(), ZoneOffset.UTC)
+ val sleeper = RecordingSleeper()
+ val retryingClient = retryingHttpClientBuilder(sleeper, clock).maxRetries(2).build()
val response =
retryingClient.execute(
@@ -227,6 +263,7 @@ internal class RetryingHttpClientTest {
postRequestedFor(urlPathEqualTo("/something"))
.withHeader("x-stainless-retry-count", equalTo("42")),
)
+ assertThat(sleeper.durations).containsExactly(Duration.ofSeconds(5))
assertNoResponseLeaks()
}
@@ -247,7 +284,8 @@ internal class RetryingHttpClientTest {
.willReturn(ok())
.willSetStateTo("COMPLETED")
)
- val retryingClient = retryingHttpClientBuilder().maxRetries(1).build()
+ val sleeper = RecordingSleeper()
+ val retryingClient = retryingHttpClientBuilder(sleeper).maxRetries(1).build()
val response =
retryingClient.execute(
@@ -261,6 +299,7 @@ internal class RetryingHttpClientTest {
assertThat(response.statusCode()).isEqualTo(200)
verify(2, postRequestedFor(urlPathEqualTo("/something")))
+ assertThat(sleeper.durations).containsExactly(Duration.ofMillis(10))
assertNoResponseLeaks()
}
@@ -301,21 +340,12 @@ internal class RetryingHttpClientTest {
override fun close() = httpClient.close()
}
+ val sleeper = RecordingSleeper()
val retryingClient =
RetryingHttpClient.builder()
.httpClient(failingHttpClient)
.maxRetries(2)
- .sleeper(
- object : Sleeper {
-
- override fun sleep(duration: Duration) {}
-
- override fun sleepAsync(duration: Duration): CompletableFuture =
- CompletableFuture.completedFuture(null)
-
- override fun close() {}
- }
- )
+ .sleeper(sleeper)
.build()
val response =
@@ -339,25 +369,153 @@ internal class RetryingHttpClientTest {
postRequestedFor(urlPathEqualTo("/something"))
.withHeader("x-stainless-retry-count", equalTo("0")),
)
+ // Exponential backoff with jitter: 0.5s * jitter where jitter is in [0.75, 1.0].
+ assertThat(sleeper.durations).hasSize(1)
+ assertThat(sleeper.durations[0]).isBetween(Duration.ofMillis(375), Duration.ofMillis(500))
assertNoResponseLeaks()
}
- private fun retryingHttpClientBuilder() =
- RetryingHttpClient.builder()
- .httpClient(httpClient)
- // Use a no-op `Sleeper` to make the test fast.
- .sleeper(
- object : Sleeper {
+ @ParameterizedTest
+ @ValueSource(booleans = [false, true])
+ fun execute_withExponentialBackoff(async: Boolean) {
+ stubFor(post(urlPathEqualTo("/something")).willReturn(serviceUnavailable()))
+ val sleeper = RecordingSleeper()
+ val retryingClient = retryingHttpClientBuilder(sleeper).maxRetries(3).build()
+
+ val response =
+ retryingClient.execute(
+ HttpRequest.builder()
+ .method(HttpMethod.POST)
+ .baseUrl(baseUrl)
+ .addPathSegment("something")
+ .build(),
+ async,
+ )
- override fun sleep(duration: Duration) {}
+ // All retries exhausted; the last 503 response is returned.
+ assertThat(response.statusCode()).isEqualTo(503)
+ verify(4, postRequestedFor(urlPathEqualTo("/something")))
+ // Exponential backoff with jitter: backoff = min(0.5 * 2^(retries-1), 8) * jitter where
+ // jitter is in [0.75, 1.0].
+ assertThat(sleeper.durations).hasSize(3)
+ // retries=1: 0.5s * [0.75, 1.0]
+ assertThat(sleeper.durations[0]).isBetween(Duration.ofMillis(375), Duration.ofMillis(500))
+ // retries=2: 1s * [0.75, 1.0]
+ assertThat(sleeper.durations[1]).isBetween(Duration.ofMillis(750), Duration.ofMillis(1000))
+ // retries=3: 2s * [0.75, 1.0]
+ assertThat(sleeper.durations[2]).isBetween(Duration.ofMillis(1500), Duration.ofMillis(2000))
+ assertNoResponseLeaks()
+ }
- override fun sleepAsync(duration: Duration): CompletableFuture =
- CompletableFuture.completedFuture(null)
+ @ParameterizedTest
+ @ValueSource(booleans = [false, true])
+ fun execute_withExponentialBackoffCap(async: Boolean) {
+ stubFor(post(urlPathEqualTo("/something")).willReturn(serviceUnavailable()))
+ val sleeper = RecordingSleeper()
+ val retryingClient = retryingHttpClientBuilder(sleeper).maxRetries(6).build()
- override fun close() {}
- }
+ val response =
+ retryingClient.execute(
+ HttpRequest.builder()
+ .method(HttpMethod.POST)
+ .baseUrl(baseUrl)
+ .addPathSegment("something")
+ .build(),
+ async,
)
+ assertThat(response.statusCode()).isEqualTo(503)
+ verify(7, postRequestedFor(urlPathEqualTo("/something")))
+ assertThat(sleeper.durations).hasSize(6)
+ // retries=5: backoff hits the 8s cap * [0.75, 1.0]
+ assertThat(sleeper.durations[4]).isBetween(Duration.ofMillis(6000), Duration.ofMillis(8000))
+ // retries=6: still capped at 8s * [0.75, 1.0]
+ assertThat(sleeper.durations[5]).isBetween(Duration.ofMillis(6000), Duration.ofMillis(8000))
+ assertNoResponseLeaks()
+ }
+
+ @ParameterizedTest
+ @ValueSource(booleans = [false, true])
+ fun execute_withRetryAfterMsPriorityOverRetryAfter(async: Boolean) {
+ stubFor(
+ post(urlPathEqualTo("/something"))
+ .inScenario("foo")
+ .whenScenarioStateIs(Scenario.STARTED)
+ .willReturn(
+ serviceUnavailable()
+ .withHeader("Retry-After-Ms", "50")
+ .withHeader("Retry-After", "2")
+ )
+ .willSetStateTo("RETRY")
+ )
+ stubFor(
+ post(urlPathEqualTo("/something"))
+ .inScenario("foo")
+ .whenScenarioStateIs("RETRY")
+ .willReturn(ok())
+ .willSetStateTo("COMPLETED")
+ )
+ val sleeper = RecordingSleeper()
+ val retryingClient = retryingHttpClientBuilder(sleeper).maxRetries(1).build()
+
+ val response =
+ retryingClient.execute(
+ HttpRequest.builder()
+ .method(HttpMethod.POST)
+ .baseUrl(baseUrl)
+ .addPathSegment("something")
+ .build(),
+ async,
+ )
+
+ assertThat(response.statusCode()).isEqualTo(200)
+ // Retry-After-Ms (50ms) takes priority over Retry-After (2s).
+ assertThat(sleeper.durations).containsExactly(Duration.ofMillis(50))
+ assertNoResponseLeaks()
+ }
+
+ @ParameterizedTest
+ @ValueSource(booleans = [false, true])
+ fun execute_withRetryAfterUnparseable(async: Boolean) {
+ stubFor(
+ post(urlPathEqualTo("/something"))
+ .inScenario("foo")
+ .whenScenarioStateIs(Scenario.STARTED)
+ .willReturn(serviceUnavailable().withHeader("Retry-After", "not-a-date-or-number"))
+ .willSetStateTo("RETRY")
+ )
+ stubFor(
+ post(urlPathEqualTo("/something"))
+ .inScenario("foo")
+ .whenScenarioStateIs("RETRY")
+ .willReturn(ok())
+ .willSetStateTo("COMPLETED")
+ )
+ val sleeper = RecordingSleeper()
+ val retryingClient = retryingHttpClientBuilder(sleeper).maxRetries(1).build()
+
+ val response =
+ retryingClient.execute(
+ HttpRequest.builder()
+ .method(HttpMethod.POST)
+ .baseUrl(baseUrl)
+ .addPathSegment("something")
+ .build(),
+ async,
+ )
+
+ assertThat(response.statusCode()).isEqualTo(200)
+ // Unparseable Retry-After falls through to exponential backoff.
+ assertThat(sleeper.durations).hasSize(1)
+ assertThat(sleeper.durations[0]).isBetween(Duration.ofMillis(375), Duration.ofMillis(500))
+ assertNoResponseLeaks()
+ }
+
+ private fun retryingHttpClientBuilder(
+ sleeper: RecordingSleeper,
+ clock: Clock = Clock.systemUTC(),
+ ) = RetryingHttpClient.builder().httpClient(httpClient).sleeper(sleeper).clock(clock)
+
private fun HttpClient.execute(request: HttpRequest, async: Boolean): HttpResponse =
if (async) executeAsync(request).get() else execute(request)
diff --git a/stagehand-java-core/src/test/kotlin/com/browserbase/api/models/sessions/ModelConfigTest.kt b/stagehand-java-core/src/test/kotlin/com/browserbase/api/models/sessions/ModelConfigTest.kt
index cd7f6b7..ee341a1 100644
--- a/stagehand-java-core/src/test/kotlin/com/browserbase/api/models/sessions/ModelConfigTest.kt
+++ b/stagehand-java-core/src/test/kotlin/com/browserbase/api/models/sessions/ModelConfigTest.kt
@@ -2,6 +2,7 @@
package com.browserbase.api.models.sessions
+import com.browserbase.api.core.JsonValue
import com.browserbase.api.core.jsonMapper
import com.fasterxml.jackson.module.kotlin.jacksonTypeRef
import org.assertj.core.api.Assertions.assertThat
@@ -16,12 +17,23 @@ internal class ModelConfigTest {
.modelName("openai/gpt-5-nano")
.apiKey("sk-some-openai-api-key")
.baseUrl("https://api.openai.com/v1")
+ .headers(
+ ModelConfig.Headers.builder()
+ .putAdditionalProperty("foo", JsonValue.from("string"))
+ .build()
+ )
.provider(ModelConfig.Provider.OPENAI)
.build()
assertThat(modelConfig.modelName()).isEqualTo("openai/gpt-5-nano")
assertThat(modelConfig.apiKey()).contains("sk-some-openai-api-key")
assertThat(modelConfig.baseUrl()).contains("https://api.openai.com/v1")
+ assertThat(modelConfig.headers())
+ .contains(
+ ModelConfig.Headers.builder()
+ .putAdditionalProperty("foo", JsonValue.from("string"))
+ .build()
+ )
assertThat(modelConfig.provider()).contains(ModelConfig.Provider.OPENAI)
}
@@ -33,6 +45,11 @@ internal class ModelConfigTest {
.modelName("openai/gpt-5-nano")
.apiKey("sk-some-openai-api-key")
.baseUrl("https://api.openai.com/v1")
+ .headers(
+ ModelConfig.Headers.builder()
+ .putAdditionalProperty("foo", JsonValue.from("string"))
+ .build()
+ )
.provider(ModelConfig.Provider.OPENAI)
.build()
diff --git a/stagehand-java-core/src/test/kotlin/com/browserbase/api/models/sessions/SessionActParamsTest.kt b/stagehand-java-core/src/test/kotlin/com/browserbase/api/models/sessions/SessionActParamsTest.kt
index 9d1e452..86e9f29 100644
--- a/stagehand-java-core/src/test/kotlin/com/browserbase/api/models/sessions/SessionActParamsTest.kt
+++ b/stagehand-java-core/src/test/kotlin/com/browserbase/api/models/sessions/SessionActParamsTest.kt
@@ -23,6 +23,11 @@ internal class SessionActParamsTest {
.modelName("openai/gpt-5-nano")
.apiKey("sk-some-openai-api-key")
.baseUrl("https://api.openai.com/v1")
+ .headers(
+ ModelConfig.Headers.builder()
+ .putAdditionalProperty("foo", JsonValue.from("string"))
+ .build()
+ )
.provider(ModelConfig.Provider.OPENAI)
.build()
)
@@ -30,6 +35,15 @@ internal class SessionActParamsTest {
.variables(
SessionActParams.Options.Variables.builder()
.putAdditionalProperty("username", JsonValue.from("john_doe"))
+ .putAdditionalProperty(
+ "password",
+ JsonValue.from(
+ mapOf(
+ "value" to "secret123",
+ "description" to "The login password",
+ )
+ ),
+ )
.build()
)
.build()
@@ -65,6 +79,11 @@ internal class SessionActParamsTest {
.modelName("openai/gpt-5-nano")
.apiKey("sk-some-openai-api-key")
.baseUrl("https://api.openai.com/v1")
+ .headers(
+ ModelConfig.Headers.builder()
+ .putAdditionalProperty("foo", JsonValue.from("string"))
+ .build()
+ )
.provider(ModelConfig.Provider.OPENAI)
.build()
)
@@ -72,6 +91,15 @@ internal class SessionActParamsTest {
.variables(
SessionActParams.Options.Variables.builder()
.putAdditionalProperty("username", JsonValue.from("john_doe"))
+ .putAdditionalProperty(
+ "password",
+ JsonValue.from(
+ mapOf(
+ "value" to "secret123",
+ "description" to "The login password",
+ )
+ ),
+ )
.build()
)
.build()
@@ -111,6 +139,11 @@ internal class SessionActParamsTest {
.modelName("openai/gpt-5-nano")
.apiKey("sk-some-openai-api-key")
.baseUrl("https://api.openai.com/v1")
+ .headers(
+ ModelConfig.Headers.builder()
+ .putAdditionalProperty("foo", JsonValue.from("string"))
+ .build()
+ )
.provider(ModelConfig.Provider.OPENAI)
.build()
)
@@ -118,6 +151,15 @@ internal class SessionActParamsTest {
.variables(
SessionActParams.Options.Variables.builder()
.putAdditionalProperty("username", JsonValue.from("john_doe"))
+ .putAdditionalProperty(
+ "password",
+ JsonValue.from(
+ mapOf(
+ "value" to "secret123",
+ "description" to "The login password",
+ )
+ ),
+ )
.build()
)
.build()
@@ -137,6 +179,11 @@ internal class SessionActParamsTest {
.modelName("openai/gpt-5-nano")
.apiKey("sk-some-openai-api-key")
.baseUrl("https://api.openai.com/v1")
+ .headers(
+ ModelConfig.Headers.builder()
+ .putAdditionalProperty("foo", JsonValue.from("string"))
+ .build()
+ )
.provider(ModelConfig.Provider.OPENAI)
.build()
)
@@ -144,6 +191,15 @@ internal class SessionActParamsTest {
.variables(
SessionActParams.Options.Variables.builder()
.putAdditionalProperty("username", JsonValue.from("john_doe"))
+ .putAdditionalProperty(
+ "password",
+ JsonValue.from(
+ mapOf(
+ "value" to "secret123",
+ "description" to "The login password",
+ )
+ ),
+ )
.build()
)
.build()
diff --git a/stagehand-java-core/src/test/kotlin/com/browserbase/api/models/sessions/SessionExecuteParamsTest.kt b/stagehand-java-core/src/test/kotlin/com/browserbase/api/models/sessions/SessionExecuteParamsTest.kt
index cb26cb2..dc042b7 100644
--- a/stagehand-java-core/src/test/kotlin/com/browserbase/api/models/sessions/SessionExecuteParamsTest.kt
+++ b/stagehand-java-core/src/test/kotlin/com/browserbase/api/models/sessions/SessionExecuteParamsTest.kt
@@ -2,6 +2,7 @@
package com.browserbase.api.models.sessions
+import com.browserbase.api.core.JsonValue
import com.browserbase.api.core.http.Headers
import org.assertj.core.api.Assertions.assertThat
import org.junit.jupiter.api.Test
@@ -21,6 +22,11 @@ internal class SessionExecuteParamsTest {
.modelName("openai/gpt-5-nano")
.apiKey("sk-some-openai-api-key")
.baseUrl("https://api.openai.com/v1")
+ .headers(
+ ModelConfig.Headers.builder()
+ .putAdditionalProperty("foo", JsonValue.from("string"))
+ .build()
+ )
.provider(ModelConfig.Provider.OPENAI)
.build()
)
@@ -30,6 +36,11 @@ internal class SessionExecuteParamsTest {
.modelName("openai/gpt-5-nano")
.apiKey("sk-some-openai-api-key")
.baseUrl("https://api.openai.com/v1")
+ .headers(
+ ModelConfig.Headers.builder()
+ .putAdditionalProperty("foo", JsonValue.from("string"))
+ .build()
+ )
.provider(ModelConfig.Provider.OPENAI)
.build()
)
@@ -44,6 +55,8 @@ internal class SessionExecuteParamsTest {
)
.highlightCursor(true)
.maxSteps(20.0)
+ .toolTimeout(30000.0)
+ .useSearch(true)
.build()
)
.frameId("frameId")
@@ -85,6 +98,11 @@ internal class SessionExecuteParamsTest {
.modelName("openai/gpt-5-nano")
.apiKey("sk-some-openai-api-key")
.baseUrl("https://api.openai.com/v1")
+ .headers(
+ ModelConfig.Headers.builder()
+ .putAdditionalProperty("foo", JsonValue.from("string"))
+ .build()
+ )
.provider(ModelConfig.Provider.OPENAI)
.build()
)
@@ -94,6 +112,11 @@ internal class SessionExecuteParamsTest {
.modelName("openai/gpt-5-nano")
.apiKey("sk-some-openai-api-key")
.baseUrl("https://api.openai.com/v1")
+ .headers(
+ ModelConfig.Headers.builder()
+ .putAdditionalProperty("foo", JsonValue.from("string"))
+ .build()
+ )
.provider(ModelConfig.Provider.OPENAI)
.build()
)
@@ -108,6 +131,8 @@ internal class SessionExecuteParamsTest {
)
.highlightCursor(true)
.maxSteps(20.0)
+ .toolTimeout(30000.0)
+ .useSearch(true)
.build()
)
.frameId("frameId")
@@ -153,6 +178,11 @@ internal class SessionExecuteParamsTest {
.modelName("openai/gpt-5-nano")
.apiKey("sk-some-openai-api-key")
.baseUrl("https://api.openai.com/v1")
+ .headers(
+ ModelConfig.Headers.builder()
+ .putAdditionalProperty("foo", JsonValue.from("string"))
+ .build()
+ )
.provider(ModelConfig.Provider.OPENAI)
.build()
)
@@ -162,6 +192,11 @@ internal class SessionExecuteParamsTest {
.modelName("openai/gpt-5-nano")
.apiKey("sk-some-openai-api-key")
.baseUrl("https://api.openai.com/v1")
+ .headers(
+ ModelConfig.Headers.builder()
+ .putAdditionalProperty("foo", JsonValue.from("string"))
+ .build()
+ )
.provider(ModelConfig.Provider.OPENAI)
.build()
)
@@ -176,6 +211,8 @@ internal class SessionExecuteParamsTest {
)
.highlightCursor(true)
.maxSteps(20.0)
+ .toolTimeout(30000.0)
+ .useSearch(true)
.build()
)
.frameId("frameId")
@@ -193,6 +230,11 @@ internal class SessionExecuteParamsTest {
.modelName("openai/gpt-5-nano")
.apiKey("sk-some-openai-api-key")
.baseUrl("https://api.openai.com/v1")
+ .headers(
+ ModelConfig.Headers.builder()
+ .putAdditionalProperty("foo", JsonValue.from("string"))
+ .build()
+ )
.provider(ModelConfig.Provider.OPENAI)
.build()
)
@@ -202,6 +244,11 @@ internal class SessionExecuteParamsTest {
.modelName("openai/gpt-5-nano")
.apiKey("sk-some-openai-api-key")
.baseUrl("https://api.openai.com/v1")
+ .headers(
+ ModelConfig.Headers.builder()
+ .putAdditionalProperty("foo", JsonValue.from("string"))
+ .build()
+ )
.provider(ModelConfig.Provider.OPENAI)
.build()
)
@@ -217,6 +264,8 @@ internal class SessionExecuteParamsTest {
)
.highlightCursor(true)
.maxSteps(20.0)
+ .toolTimeout(30000.0)
+ .useSearch(true)
.build()
)
assertThat(body.frameId()).contains("frameId")
diff --git a/stagehand-java-core/src/test/kotlin/com/browserbase/api/models/sessions/SessionExtractParamsTest.kt b/stagehand-java-core/src/test/kotlin/com/browserbase/api/models/sessions/SessionExtractParamsTest.kt
index 3061c8d..1418be5 100644
--- a/stagehand-java-core/src/test/kotlin/com/browserbase/api/models/sessions/SessionExtractParamsTest.kt
+++ b/stagehand-java-core/src/test/kotlin/com/browserbase/api/models/sessions/SessionExtractParamsTest.kt
@@ -23,6 +23,11 @@ internal class SessionExtractParamsTest {
.modelName("openai/gpt-5-nano")
.apiKey("sk-some-openai-api-key")
.baseUrl("https://api.openai.com/v1")
+ .headers(
+ ModelConfig.Headers.builder()
+ .putAdditionalProperty("foo", JsonValue.from("string"))
+ .build()
+ )
.provider(ModelConfig.Provider.OPENAI)
.build()
)
@@ -63,6 +68,11 @@ internal class SessionExtractParamsTest {
.modelName("openai/gpt-5-nano")
.apiKey("sk-some-openai-api-key")
.baseUrl("https://api.openai.com/v1")
+ .headers(
+ ModelConfig.Headers.builder()
+ .putAdditionalProperty("foo", JsonValue.from("string"))
+ .build()
+ )
.provider(ModelConfig.Provider.OPENAI)
.build()
)
@@ -107,6 +117,11 @@ internal class SessionExtractParamsTest {
.modelName("openai/gpt-5-nano")
.apiKey("sk-some-openai-api-key")
.baseUrl("https://api.openai.com/v1")
+ .headers(
+ ModelConfig.Headers.builder()
+ .putAdditionalProperty("foo", JsonValue.from("string"))
+ .build()
+ )
.provider(ModelConfig.Provider.OPENAI)
.build()
)
@@ -134,6 +149,11 @@ internal class SessionExtractParamsTest {
.modelName("openai/gpt-5-nano")
.apiKey("sk-some-openai-api-key")
.baseUrl("https://api.openai.com/v1")
+ .headers(
+ ModelConfig.Headers.builder()
+ .putAdditionalProperty("foo", JsonValue.from("string"))
+ .build()
+ )
.provider(ModelConfig.Provider.OPENAI)
.build()
)
diff --git a/stagehand-java-core/src/test/kotlin/com/browserbase/api/models/sessions/SessionObserveParamsTest.kt b/stagehand-java-core/src/test/kotlin/com/browserbase/api/models/sessions/SessionObserveParamsTest.kt
index f238c0e..1ed125e 100644
--- a/stagehand-java-core/src/test/kotlin/com/browserbase/api/models/sessions/SessionObserveParamsTest.kt
+++ b/stagehand-java-core/src/test/kotlin/com/browserbase/api/models/sessions/SessionObserveParamsTest.kt
@@ -2,6 +2,7 @@
package com.browserbase.api.models.sessions
+import com.browserbase.api.core.JsonValue
import com.browserbase.api.core.http.Headers
import org.assertj.core.api.Assertions.assertThat
import org.junit.jupiter.api.Test
@@ -22,11 +23,30 @@ internal class SessionObserveParamsTest {
.modelName("openai/gpt-5-nano")
.apiKey("sk-some-openai-api-key")
.baseUrl("https://api.openai.com/v1")
+ .headers(
+ ModelConfig.Headers.builder()
+ .putAdditionalProperty("foo", JsonValue.from("string"))
+ .build()
+ )
.provider(ModelConfig.Provider.OPENAI)
.build()
)
.selector("nav")
.timeout(30000.0)
+ .variables(
+ SessionObserveParams.Options.Variables.builder()
+ .putAdditionalProperty(
+ "username",
+ JsonValue.from(
+ mapOf(
+ "value" to "john@example.com",
+ "description" to "The login email",
+ )
+ ),
+ )
+ .putAdditionalProperty("rememberMe", JsonValue.from(true))
+ .build()
+ )
.build()
)
.build()
@@ -57,11 +77,30 @@ internal class SessionObserveParamsTest {
.modelName("openai/gpt-5-nano")
.apiKey("sk-some-openai-api-key")
.baseUrl("https://api.openai.com/v1")
+ .headers(
+ ModelConfig.Headers.builder()
+ .putAdditionalProperty("foo", JsonValue.from("string"))
+ .build()
+ )
.provider(ModelConfig.Provider.OPENAI)
.build()
)
.selector("nav")
.timeout(30000.0)
+ .variables(
+ SessionObserveParams.Options.Variables.builder()
+ .putAdditionalProperty(
+ "username",
+ JsonValue.from(
+ mapOf(
+ "value" to "john@example.com",
+ "description" to "The login email",
+ )
+ ),
+ )
+ .putAdditionalProperty("rememberMe", JsonValue.from(true))
+ .build()
+ )
.build()
)
.build()
@@ -96,11 +135,30 @@ internal class SessionObserveParamsTest {
.modelName("openai/gpt-5-nano")
.apiKey("sk-some-openai-api-key")
.baseUrl("https://api.openai.com/v1")
+ .headers(
+ ModelConfig.Headers.builder()
+ .putAdditionalProperty("foo", JsonValue.from("string"))
+ .build()
+ )
.provider(ModelConfig.Provider.OPENAI)
.build()
)
.selector("nav")
.timeout(30000.0)
+ .variables(
+ SessionObserveParams.Options.Variables.builder()
+ .putAdditionalProperty(
+ "username",
+ JsonValue.from(
+ mapOf(
+ "value" to "john@example.com",
+ "description" to "The login email",
+ )
+ ),
+ )
+ .putAdditionalProperty("rememberMe", JsonValue.from(true))
+ .build()
+ )
.build()
)
.build()
@@ -117,11 +175,30 @@ internal class SessionObserveParamsTest {
.modelName("openai/gpt-5-nano")
.apiKey("sk-some-openai-api-key")
.baseUrl("https://api.openai.com/v1")
+ .headers(
+ ModelConfig.Headers.builder()
+ .putAdditionalProperty("foo", JsonValue.from("string"))
+ .build()
+ )
.provider(ModelConfig.Provider.OPENAI)
.build()
)
.selector("nav")
.timeout(30000.0)
+ .variables(
+ SessionObserveParams.Options.Variables.builder()
+ .putAdditionalProperty(
+ "username",
+ JsonValue.from(
+ mapOf(
+ "value" to "john@example.com",
+ "description" to "The login email",
+ )
+ ),
+ )
+ .putAdditionalProperty("rememberMe", JsonValue.from(true))
+ .build()
+ )
.build()
)
}
diff --git a/stagehand-java-core/src/test/kotlin/com/browserbase/api/models/sessions/SessionStartParamsTest.kt b/stagehand-java-core/src/test/kotlin/com/browserbase/api/models/sessions/SessionStartParamsTest.kt
index e22e941..5b35aaa 100644
--- a/stagehand-java-core/src/test/kotlin/com/browserbase/api/models/sessions/SessionStartParamsTest.kt
+++ b/stagehand-java-core/src/test/kotlin/com/browserbase/api/models/sessions/SessionStartParamsTest.kt
@@ -22,6 +22,11 @@ internal class SessionStartParamsTest {
SessionStartParams.Browser.LaunchOptions.builder()
.acceptDownloads(true)
.addArg("string")
+ .cdpHeaders(
+ SessionStartParams.Browser.LaunchOptions.CdpHeaders.builder()
+ .putAdditionalProperty("foo", JsonValue.from("string"))
+ .build()
+ )
.cdpUrl("cdpUrl")
.chromiumSandbox(true)
.connectTimeoutMs(0.0)
@@ -168,6 +173,11 @@ internal class SessionStartParamsTest {
SessionStartParams.Browser.LaunchOptions.builder()
.acceptDownloads(true)
.addArg("string")
+ .cdpHeaders(
+ SessionStartParams.Browser.LaunchOptions.CdpHeaders.builder()
+ .putAdditionalProperty("foo", JsonValue.from("string"))
+ .build()
+ )
.cdpUrl("cdpUrl")
.chromiumSandbox(true)
.connectTimeoutMs(0.0)
@@ -331,6 +341,11 @@ internal class SessionStartParamsTest {
SessionStartParams.Browser.LaunchOptions.builder()
.acceptDownloads(true)
.addArg("string")
+ .cdpHeaders(
+ SessionStartParams.Browser.LaunchOptions.CdpHeaders.builder()
+ .putAdditionalProperty("foo", JsonValue.from("string"))
+ .build()
+ )
.cdpUrl("cdpUrl")
.chromiumSandbox(true)
.connectTimeoutMs(0.0)
@@ -478,6 +493,11 @@ internal class SessionStartParamsTest {
SessionStartParams.Browser.LaunchOptions.builder()
.acceptDownloads(true)
.addArg("string")
+ .cdpHeaders(
+ SessionStartParams.Browser.LaunchOptions.CdpHeaders.builder()
+ .putAdditionalProperty("foo", JsonValue.from("string"))
+ .build()
+ )
.cdpUrl("cdpUrl")
.chromiumSandbox(true)
.connectTimeoutMs(0.0)
diff --git a/stagehand-java-core/src/test/kotlin/com/browserbase/api/services/ErrorHandlingTest.kt b/stagehand-java-core/src/test/kotlin/com/browserbase/api/services/ErrorHandlingTest.kt
index a28b993..a123b5c 100644
--- a/stagehand-java-core/src/test/kotlin/com/browserbase/api/services/ErrorHandlingTest.kt
+++ b/stagehand-java-core/src/test/kotlin/com/browserbase/api/services/ErrorHandlingTest.kt
@@ -84,6 +84,15 @@ internal class ErrorHandlingTest {
SessionStartParams.Browser.LaunchOptions.builder()
.acceptDownloads(true)
.addArg("string")
+ .cdpHeaders(
+ SessionStartParams.Browser.LaunchOptions.CdpHeaders
+ .builder()
+ .putAdditionalProperty(
+ "foo",
+ JsonValue.from("string"),
+ )
+ .build()
+ )
.cdpUrl("cdpUrl")
.chromiumSandbox(true)
.connectTimeoutMs(0.0)
@@ -261,6 +270,15 @@ internal class ErrorHandlingTest {
SessionStartParams.Browser.LaunchOptions.builder()
.acceptDownloads(true)
.addArg("string")
+ .cdpHeaders(
+ SessionStartParams.Browser.LaunchOptions.CdpHeaders
+ .builder()
+ .putAdditionalProperty(
+ "foo",
+ JsonValue.from("string"),
+ )
+ .build()
+ )
.cdpUrl("cdpUrl")
.chromiumSandbox(true)
.connectTimeoutMs(0.0)
@@ -438,6 +456,15 @@ internal class ErrorHandlingTest {
SessionStartParams.Browser.LaunchOptions.builder()
.acceptDownloads(true)
.addArg("string")
+ .cdpHeaders(
+ SessionStartParams.Browser.LaunchOptions.CdpHeaders
+ .builder()
+ .putAdditionalProperty(
+ "foo",
+ JsonValue.from("string"),
+ )
+ .build()
+ )
.cdpUrl("cdpUrl")
.chromiumSandbox(true)
.connectTimeoutMs(0.0)
@@ -615,6 +642,15 @@ internal class ErrorHandlingTest {
SessionStartParams.Browser.LaunchOptions.builder()
.acceptDownloads(true)
.addArg("string")
+ .cdpHeaders(
+ SessionStartParams.Browser.LaunchOptions.CdpHeaders
+ .builder()
+ .putAdditionalProperty(
+ "foo",
+ JsonValue.from("string"),
+ )
+ .build()
+ )
.cdpUrl("cdpUrl")
.chromiumSandbox(true)
.connectTimeoutMs(0.0)
@@ -792,6 +828,15 @@ internal class ErrorHandlingTest {
SessionStartParams.Browser.LaunchOptions.builder()
.acceptDownloads(true)
.addArg("string")
+ .cdpHeaders(
+ SessionStartParams.Browser.LaunchOptions.CdpHeaders
+ .builder()
+ .putAdditionalProperty(
+ "foo",
+ JsonValue.from("string"),
+ )
+ .build()
+ )
.cdpUrl("cdpUrl")
.chromiumSandbox(true)
.connectTimeoutMs(0.0)
@@ -969,6 +1014,15 @@ internal class ErrorHandlingTest {
SessionStartParams.Browser.LaunchOptions.builder()
.acceptDownloads(true)
.addArg("string")
+ .cdpHeaders(
+ SessionStartParams.Browser.LaunchOptions.CdpHeaders
+ .builder()
+ .putAdditionalProperty(
+ "foo",
+ JsonValue.from("string"),
+ )
+ .build()
+ )
.cdpUrl("cdpUrl")
.chromiumSandbox(true)
.connectTimeoutMs(0.0)
@@ -1146,6 +1200,15 @@ internal class ErrorHandlingTest {
SessionStartParams.Browser.LaunchOptions.builder()
.acceptDownloads(true)
.addArg("string")
+ .cdpHeaders(
+ SessionStartParams.Browser.LaunchOptions.CdpHeaders
+ .builder()
+ .putAdditionalProperty(
+ "foo",
+ JsonValue.from("string"),
+ )
+ .build()
+ )
.cdpUrl("cdpUrl")
.chromiumSandbox(true)
.connectTimeoutMs(0.0)
@@ -1323,6 +1386,15 @@ internal class ErrorHandlingTest {
SessionStartParams.Browser.LaunchOptions.builder()
.acceptDownloads(true)
.addArg("string")
+ .cdpHeaders(
+ SessionStartParams.Browser.LaunchOptions.CdpHeaders
+ .builder()
+ .putAdditionalProperty(
+ "foo",
+ JsonValue.from("string"),
+ )
+ .build()
+ )
.cdpUrl("cdpUrl")
.chromiumSandbox(true)
.connectTimeoutMs(0.0)
@@ -1500,6 +1572,15 @@ internal class ErrorHandlingTest {
SessionStartParams.Browser.LaunchOptions.builder()
.acceptDownloads(true)
.addArg("string")
+ .cdpHeaders(
+ SessionStartParams.Browser.LaunchOptions.CdpHeaders
+ .builder()
+ .putAdditionalProperty(
+ "foo",
+ JsonValue.from("string"),
+ )
+ .build()
+ )
.cdpUrl("cdpUrl")
.chromiumSandbox(true)
.connectTimeoutMs(0.0)
@@ -1677,6 +1758,15 @@ internal class ErrorHandlingTest {
SessionStartParams.Browser.LaunchOptions.builder()
.acceptDownloads(true)
.addArg("string")
+ .cdpHeaders(
+ SessionStartParams.Browser.LaunchOptions.CdpHeaders
+ .builder()
+ .putAdditionalProperty(
+ "foo",
+ JsonValue.from("string"),
+ )
+ .build()
+ )
.cdpUrl("cdpUrl")
.chromiumSandbox(true)
.connectTimeoutMs(0.0)
@@ -1854,6 +1944,15 @@ internal class ErrorHandlingTest {
SessionStartParams.Browser.LaunchOptions.builder()
.acceptDownloads(true)
.addArg("string")
+ .cdpHeaders(
+ SessionStartParams.Browser.LaunchOptions.CdpHeaders
+ .builder()
+ .putAdditionalProperty(
+ "foo",
+ JsonValue.from("string"),
+ )
+ .build()
+ )
.cdpUrl("cdpUrl")
.chromiumSandbox(true)
.connectTimeoutMs(0.0)
@@ -2031,6 +2130,15 @@ internal class ErrorHandlingTest {
SessionStartParams.Browser.LaunchOptions.builder()
.acceptDownloads(true)
.addArg("string")
+ .cdpHeaders(
+ SessionStartParams.Browser.LaunchOptions.CdpHeaders
+ .builder()
+ .putAdditionalProperty(
+ "foo",
+ JsonValue.from("string"),
+ )
+ .build()
+ )
.cdpUrl("cdpUrl")
.chromiumSandbox(true)
.connectTimeoutMs(0.0)
@@ -2208,6 +2316,15 @@ internal class ErrorHandlingTest {
SessionStartParams.Browser.LaunchOptions.builder()
.acceptDownloads(true)
.addArg("string")
+ .cdpHeaders(
+ SessionStartParams.Browser.LaunchOptions.CdpHeaders
+ .builder()
+ .putAdditionalProperty(
+ "foo",
+ JsonValue.from("string"),
+ )
+ .build()
+ )
.cdpUrl("cdpUrl")
.chromiumSandbox(true)
.connectTimeoutMs(0.0)
@@ -2385,6 +2502,15 @@ internal class ErrorHandlingTest {
SessionStartParams.Browser.LaunchOptions.builder()
.acceptDownloads(true)
.addArg("string")
+ .cdpHeaders(
+ SessionStartParams.Browser.LaunchOptions.CdpHeaders
+ .builder()
+ .putAdditionalProperty(
+ "foo",
+ JsonValue.from("string"),
+ )
+ .build()
+ )
.cdpUrl("cdpUrl")
.chromiumSandbox(true)
.connectTimeoutMs(0.0)
@@ -2562,6 +2688,15 @@ internal class ErrorHandlingTest {
SessionStartParams.Browser.LaunchOptions.builder()
.acceptDownloads(true)
.addArg("string")
+ .cdpHeaders(
+ SessionStartParams.Browser.LaunchOptions.CdpHeaders
+ .builder()
+ .putAdditionalProperty(
+ "foo",
+ JsonValue.from("string"),
+ )
+ .build()
+ )
.cdpUrl("cdpUrl")
.chromiumSandbox(true)
.connectTimeoutMs(0.0)
@@ -2739,6 +2874,15 @@ internal class ErrorHandlingTest {
SessionStartParams.Browser.LaunchOptions.builder()
.acceptDownloads(true)
.addArg("string")
+ .cdpHeaders(
+ SessionStartParams.Browser.LaunchOptions.CdpHeaders
+ .builder()
+ .putAdditionalProperty(
+ "foo",
+ JsonValue.from("string"),
+ )
+ .build()
+ )
.cdpUrl("cdpUrl")
.chromiumSandbox(true)
.connectTimeoutMs(0.0)
@@ -2914,6 +3058,15 @@ internal class ErrorHandlingTest {
SessionStartParams.Browser.LaunchOptions.builder()
.acceptDownloads(true)
.addArg("string")
+ .cdpHeaders(
+ SessionStartParams.Browser.LaunchOptions.CdpHeaders
+ .builder()
+ .putAdditionalProperty(
+ "foo",
+ JsonValue.from("string"),
+ )
+ .build()
+ )
.cdpUrl("cdpUrl")
.chromiumSandbox(true)
.connectTimeoutMs(0.0)
diff --git a/stagehand-java-core/src/test/kotlin/com/browserbase/api/services/ServiceParamsTest.kt b/stagehand-java-core/src/test/kotlin/com/browserbase/api/services/ServiceParamsTest.kt
index f3a40b7..3f10c1a 100644
--- a/stagehand-java-core/src/test/kotlin/com/browserbase/api/services/ServiceParamsTest.kt
+++ b/stagehand-java-core/src/test/kotlin/com/browserbase/api/services/ServiceParamsTest.kt
@@ -58,6 +58,11 @@ internal class ServiceParamsTest {
SessionStartParams.Browser.LaunchOptions.builder()
.acceptDownloads(true)
.addArg("string")
+ .cdpHeaders(
+ SessionStartParams.Browser.LaunchOptions.CdpHeaders.builder()
+ .putAdditionalProperty("foo", JsonValue.from("string"))
+ .build()
+ )
.cdpUrl("cdpUrl")
.chromiumSandbox(true)
.connectTimeoutMs(0.0)
@@ -224,6 +229,11 @@ internal class ServiceParamsTest {
.modelName("openai/gpt-5-nano")
.apiKey("sk-some-openai-api-key")
.baseUrl("https://api.openai.com/v1")
+ .headers(
+ ModelConfig.Headers.builder()
+ .putAdditionalProperty("foo", JsonValue.from("string"))
+ .build()
+ )
.provider(ModelConfig.Provider.OPENAI)
.build()
)
@@ -231,6 +241,15 @@ internal class ServiceParamsTest {
.variables(
SessionActParams.Options.Variables.builder()
.putAdditionalProperty("username", JsonValue.from("john_doe"))
+ .putAdditionalProperty(
+ "password",
+ JsonValue.from(
+ mapOf(
+ "value" to "secret123",
+ "description" to "The login password",
+ )
+ ),
+ )
.build()
)
.build()
diff --git a/stagehand-java-core/src/test/kotlin/com/browserbase/api/services/async/SessionServiceAsyncTest.kt b/stagehand-java-core/src/test/kotlin/com/browserbase/api/services/async/SessionServiceAsyncTest.kt
index 2a0e601..471a7ac 100644
--- a/stagehand-java-core/src/test/kotlin/com/browserbase/api/services/async/SessionServiceAsyncTest.kt
+++ b/stagehand-java-core/src/test/kotlin/com/browserbase/api/services/async/SessionServiceAsyncTest.kt
@@ -43,6 +43,11 @@ internal class SessionServiceAsyncTest {
.modelName("openai/gpt-5-nano")
.apiKey("sk-some-openai-api-key")
.baseUrl("https://api.openai.com/v1")
+ .headers(
+ ModelConfig.Headers.builder()
+ .putAdditionalProperty("foo", JsonValue.from("string"))
+ .build()
+ )
.provider(ModelConfig.Provider.OPENAI)
.build()
)
@@ -50,6 +55,15 @@ internal class SessionServiceAsyncTest {
.variables(
SessionActParams.Options.Variables.builder()
.putAdditionalProperty("username", JsonValue.from("john_doe"))
+ .putAdditionalProperty(
+ "password",
+ JsonValue.from(
+ mapOf(
+ "value" to "secret123",
+ "description" to "The login password",
+ )
+ ),
+ )
.build()
)
.build()
@@ -86,6 +100,11 @@ internal class SessionServiceAsyncTest {
.modelName("openai/gpt-5-nano")
.apiKey("sk-some-openai-api-key")
.baseUrl("https://api.openai.com/v1")
+ .headers(
+ ModelConfig.Headers.builder()
+ .putAdditionalProperty("foo", JsonValue.from("string"))
+ .build()
+ )
.provider(ModelConfig.Provider.OPENAI)
.build()
)
@@ -93,6 +112,15 @@ internal class SessionServiceAsyncTest {
.variables(
SessionActParams.Options.Variables.builder()
.putAdditionalProperty("username", JsonValue.from("john_doe"))
+ .putAdditionalProperty(
+ "password",
+ JsonValue.from(
+ mapOf(
+ "value" to "secret123",
+ "description" to "The login password",
+ )
+ ),
+ )
.build()
)
.build()
@@ -152,6 +180,11 @@ internal class SessionServiceAsyncTest {
.modelName("openai/gpt-5-nano")
.apiKey("sk-some-openai-api-key")
.baseUrl("https://api.openai.com/v1")
+ .headers(
+ ModelConfig.Headers.builder()
+ .putAdditionalProperty("foo", JsonValue.from("string"))
+ .build()
+ )
.provider(ModelConfig.Provider.OPENAI)
.build()
)
@@ -161,6 +194,11 @@ internal class SessionServiceAsyncTest {
.modelName("openai/gpt-5-nano")
.apiKey("sk-some-openai-api-key")
.baseUrl("https://api.openai.com/v1")
+ .headers(
+ ModelConfig.Headers.builder()
+ .putAdditionalProperty("foo", JsonValue.from("string"))
+ .build()
+ )
.provider(ModelConfig.Provider.OPENAI)
.build()
)
@@ -175,6 +213,8 @@ internal class SessionServiceAsyncTest {
)
.highlightCursor(true)
.maxSteps(20.0)
+ .toolTimeout(30000.0)
+ .useSearch(true)
.build()
)
.frameId("frameId")
@@ -210,6 +250,11 @@ internal class SessionServiceAsyncTest {
.modelName("openai/gpt-5-nano")
.apiKey("sk-some-openai-api-key")
.baseUrl("https://api.openai.com/v1")
+ .headers(
+ ModelConfig.Headers.builder()
+ .putAdditionalProperty("foo", JsonValue.from("string"))
+ .build()
+ )
.provider(ModelConfig.Provider.OPENAI)
.build()
)
@@ -219,6 +264,11 @@ internal class SessionServiceAsyncTest {
.modelName("openai/gpt-5-nano")
.apiKey("sk-some-openai-api-key")
.baseUrl("https://api.openai.com/v1")
+ .headers(
+ ModelConfig.Headers.builder()
+ .putAdditionalProperty("foo", JsonValue.from("string"))
+ .build()
+ )
.provider(ModelConfig.Provider.OPENAI)
.build()
)
@@ -233,6 +283,8 @@ internal class SessionServiceAsyncTest {
)
.highlightCursor(true)
.maxSteps(20.0)
+ .toolTimeout(30000.0)
+ .useSearch(true)
.build()
)
.frameId("frameId")
@@ -270,6 +322,11 @@ internal class SessionServiceAsyncTest {
.modelName("openai/gpt-5-nano")
.apiKey("sk-some-openai-api-key")
.baseUrl("https://api.openai.com/v1")
+ .headers(
+ ModelConfig.Headers.builder()
+ .putAdditionalProperty("foo", JsonValue.from("string"))
+ .build()
+ )
.provider(ModelConfig.Provider.OPENAI)
.build()
)
@@ -314,6 +371,11 @@ internal class SessionServiceAsyncTest {
.modelName("openai/gpt-5-nano")
.apiKey("sk-some-openai-api-key")
.baseUrl("https://api.openai.com/v1")
+ .headers(
+ ModelConfig.Headers.builder()
+ .putAdditionalProperty("foo", JsonValue.from("string"))
+ .build()
+ )
.provider(ModelConfig.Provider.OPENAI)
.build()
)
@@ -392,11 +454,30 @@ internal class SessionServiceAsyncTest {
.modelName("openai/gpt-5-nano")
.apiKey("sk-some-openai-api-key")
.baseUrl("https://api.openai.com/v1")
+ .headers(
+ ModelConfig.Headers.builder()
+ .putAdditionalProperty("foo", JsonValue.from("string"))
+ .build()
+ )
.provider(ModelConfig.Provider.OPENAI)
.build()
)
.selector("nav")
.timeout(30000.0)
+ .variables(
+ SessionObserveParams.Options.Variables.builder()
+ .putAdditionalProperty(
+ "username",
+ JsonValue.from(
+ mapOf(
+ "value" to "john@example.com",
+ "description" to "The login email",
+ )
+ ),
+ )
+ .putAdditionalProperty("rememberMe", JsonValue.from(true))
+ .build()
+ )
.build()
)
.build()
@@ -431,11 +512,30 @@ internal class SessionServiceAsyncTest {
.modelName("openai/gpt-5-nano")
.apiKey("sk-some-openai-api-key")
.baseUrl("https://api.openai.com/v1")
+ .headers(
+ ModelConfig.Headers.builder()
+ .putAdditionalProperty("foo", JsonValue.from("string"))
+ .build()
+ )
.provider(ModelConfig.Provider.OPENAI)
.build()
)
.selector("nav")
.timeout(30000.0)
+ .variables(
+ SessionObserveParams.Options.Variables.builder()
+ .putAdditionalProperty(
+ "username",
+ JsonValue.from(
+ mapOf(
+ "value" to "john@example.com",
+ "description" to "The login email",
+ )
+ ),
+ )
+ .putAdditionalProperty("rememberMe", JsonValue.from(true))
+ .build()
+ )
.build()
)
.build()
@@ -493,6 +593,12 @@ internal class SessionServiceAsyncTest {
SessionStartParams.Browser.LaunchOptions.builder()
.acceptDownloads(true)
.addArg("string")
+ .cdpHeaders(
+ SessionStartParams.Browser.LaunchOptions.CdpHeaders
+ .builder()
+ .putAdditionalProperty("foo", JsonValue.from("string"))
+ .build()
+ )
.cdpUrl("cdpUrl")
.chromiumSandbox(true)
.connectTimeoutMs(0.0)
diff --git a/stagehand-java-core/src/test/kotlin/com/browserbase/api/services/blocking/SessionServiceTest.kt b/stagehand-java-core/src/test/kotlin/com/browserbase/api/services/blocking/SessionServiceTest.kt
index dedab31..e6c5cb5 100644
--- a/stagehand-java-core/src/test/kotlin/com/browserbase/api/services/blocking/SessionServiceTest.kt
+++ b/stagehand-java-core/src/test/kotlin/com/browserbase/api/services/blocking/SessionServiceTest.kt
@@ -43,6 +43,11 @@ internal class SessionServiceTest {
.modelName("openai/gpt-5-nano")
.apiKey("sk-some-openai-api-key")
.baseUrl("https://api.openai.com/v1")
+ .headers(
+ ModelConfig.Headers.builder()
+ .putAdditionalProperty("foo", JsonValue.from("string"))
+ .build()
+ )
.provider(ModelConfig.Provider.OPENAI)
.build()
)
@@ -50,6 +55,15 @@ internal class SessionServiceTest {
.variables(
SessionActParams.Options.Variables.builder()
.putAdditionalProperty("username", JsonValue.from("john_doe"))
+ .putAdditionalProperty(
+ "password",
+ JsonValue.from(
+ mapOf(
+ "value" to "secret123",
+ "description" to "The login password",
+ )
+ ),
+ )
.build()
)
.build()
@@ -85,6 +99,11 @@ internal class SessionServiceTest {
.modelName("openai/gpt-5-nano")
.apiKey("sk-some-openai-api-key")
.baseUrl("https://api.openai.com/v1")
+ .headers(
+ ModelConfig.Headers.builder()
+ .putAdditionalProperty("foo", JsonValue.from("string"))
+ .build()
+ )
.provider(ModelConfig.Provider.OPENAI)
.build()
)
@@ -92,6 +111,15 @@ internal class SessionServiceTest {
.variables(
SessionActParams.Options.Variables.builder()
.putAdditionalProperty("username", JsonValue.from("john_doe"))
+ .putAdditionalProperty(
+ "password",
+ JsonValue.from(
+ mapOf(
+ "value" to "secret123",
+ "description" to "The login password",
+ )
+ ),
+ )
.build()
)
.build()
@@ -150,6 +178,11 @@ internal class SessionServiceTest {
.modelName("openai/gpt-5-nano")
.apiKey("sk-some-openai-api-key")
.baseUrl("https://api.openai.com/v1")
+ .headers(
+ ModelConfig.Headers.builder()
+ .putAdditionalProperty("foo", JsonValue.from("string"))
+ .build()
+ )
.provider(ModelConfig.Provider.OPENAI)
.build()
)
@@ -159,6 +192,11 @@ internal class SessionServiceTest {
.modelName("openai/gpt-5-nano")
.apiKey("sk-some-openai-api-key")
.baseUrl("https://api.openai.com/v1")
+ .headers(
+ ModelConfig.Headers.builder()
+ .putAdditionalProperty("foo", JsonValue.from("string"))
+ .build()
+ )
.provider(ModelConfig.Provider.OPENAI)
.build()
)
@@ -173,6 +211,8 @@ internal class SessionServiceTest {
)
.highlightCursor(true)
.maxSteps(20.0)
+ .toolTimeout(30000.0)
+ .useSearch(true)
.build()
)
.frameId("frameId")
@@ -207,6 +247,11 @@ internal class SessionServiceTest {
.modelName("openai/gpt-5-nano")
.apiKey("sk-some-openai-api-key")
.baseUrl("https://api.openai.com/v1")
+ .headers(
+ ModelConfig.Headers.builder()
+ .putAdditionalProperty("foo", JsonValue.from("string"))
+ .build()
+ )
.provider(ModelConfig.Provider.OPENAI)
.build()
)
@@ -216,6 +261,11 @@ internal class SessionServiceTest {
.modelName("openai/gpt-5-nano")
.apiKey("sk-some-openai-api-key")
.baseUrl("https://api.openai.com/v1")
+ .headers(
+ ModelConfig.Headers.builder()
+ .putAdditionalProperty("foo", JsonValue.from("string"))
+ .build()
+ )
.provider(ModelConfig.Provider.OPENAI)
.build()
)
@@ -230,6 +280,8 @@ internal class SessionServiceTest {
)
.highlightCursor(true)
.maxSteps(20.0)
+ .toolTimeout(30000.0)
+ .useSearch(true)
.build()
)
.frameId("frameId")
@@ -267,6 +319,11 @@ internal class SessionServiceTest {
.modelName("openai/gpt-5-nano")
.apiKey("sk-some-openai-api-key")
.baseUrl("https://api.openai.com/v1")
+ .headers(
+ ModelConfig.Headers.builder()
+ .putAdditionalProperty("foo", JsonValue.from("string"))
+ .build()
+ )
.provider(ModelConfig.Provider.OPENAI)
.build()
)
@@ -310,6 +367,11 @@ internal class SessionServiceTest {
.modelName("openai/gpt-5-nano")
.apiKey("sk-some-openai-api-key")
.baseUrl("https://api.openai.com/v1")
+ .headers(
+ ModelConfig.Headers.builder()
+ .putAdditionalProperty("foo", JsonValue.from("string"))
+ .build()
+ )
.provider(ModelConfig.Provider.OPENAI)
.build()
)
@@ -387,11 +449,30 @@ internal class SessionServiceTest {
.modelName("openai/gpt-5-nano")
.apiKey("sk-some-openai-api-key")
.baseUrl("https://api.openai.com/v1")
+ .headers(
+ ModelConfig.Headers.builder()
+ .putAdditionalProperty("foo", JsonValue.from("string"))
+ .build()
+ )
.provider(ModelConfig.Provider.OPENAI)
.build()
)
.selector("nav")
.timeout(30000.0)
+ .variables(
+ SessionObserveParams.Options.Variables.builder()
+ .putAdditionalProperty(
+ "username",
+ JsonValue.from(
+ mapOf(
+ "value" to "john@example.com",
+ "description" to "The login email",
+ )
+ ),
+ )
+ .putAdditionalProperty("rememberMe", JsonValue.from(true))
+ .build()
+ )
.build()
)
.build()
@@ -425,11 +506,30 @@ internal class SessionServiceTest {
.modelName("openai/gpt-5-nano")
.apiKey("sk-some-openai-api-key")
.baseUrl("https://api.openai.com/v1")
+ .headers(
+ ModelConfig.Headers.builder()
+ .putAdditionalProperty("foo", JsonValue.from("string"))
+ .build()
+ )
.provider(ModelConfig.Provider.OPENAI)
.build()
)
.selector("nav")
.timeout(30000.0)
+ .variables(
+ SessionObserveParams.Options.Variables.builder()
+ .putAdditionalProperty(
+ "username",
+ JsonValue.from(
+ mapOf(
+ "value" to "john@example.com",
+ "description" to "The login email",
+ )
+ ),
+ )
+ .putAdditionalProperty("rememberMe", JsonValue.from(true))
+ .build()
+ )
.build()
)
.build()
@@ -486,6 +586,12 @@ internal class SessionServiceTest {
SessionStartParams.Browser.LaunchOptions.builder()
.acceptDownloads(true)
.addArg("string")
+ .cdpHeaders(
+ SessionStartParams.Browser.LaunchOptions.CdpHeaders
+ .builder()
+ .putAdditionalProperty("foo", JsonValue.from("string"))
+ .build()
+ )
.cdpUrl("cdpUrl")
.chromiumSandbox(true)
.connectTimeoutMs(0.0)