Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .release-please-manifest.json
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
{
".": "3.19.3"
".": "3.20.0"
}
4 changes: 2 additions & 2 deletions .stats.yml
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
configured_endpoints: 8
openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/browserbase%2Fstagehand-b969ce378479c79ee64c05127c0ed6c6ce2edbee017ecd037242fb618a5ebc9f.yml
openapi_spec_hash: a24aabaa5214effb679808b7f2be0ad4
openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/browserbase%2Fstagehand-1c6caa2891a7f3bdfc0caab143f285badc9145220c9b29cd5e4cf1a9b3ac11cf.yml
openapi_spec_hash: 28c4b734a5309067c39bb4c4b709b9ab
config_hash: a962ae71493deb11a1c903256fb25386
18 changes: 18 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,23 @@
# Changelog

## 3.20.0 (2026-04-30)

Full Changelog: [v3.19.3...v3.20.0](https://github.com/browserbase/stagehand-php/compare/v3.19.3...v3.20.0)

### Features

* [STG-1798] feat: support Browserbase verified sessions ([52c4636](https://github.com/browserbase/stagehand-php/commit/52c4636560e5dd556db9c061a3bb9c48ffa20e76))
* Bedrock auth passthrough ([40cb10e](https://github.com/browserbase/stagehand-php/commit/40cb10eb39c7f53b9c3fd668018c1b3569650e5c))
* Revert "[STG-1573] Add providerOptions for extensible model auth ([#1822](https://github.com/browserbase/stagehand-php/issues/1822))" ([1f99ac3](https://github.com/browserbase/stagehand-php/commit/1f99ac3957867970c6eaa968e8daaa83ae855b7b))


### Bug Fixes

* **client:** properly generate file params ([4a6d856](https://github.com/browserbase/stagehand-php/commit/4a6d85692b4bec4a518cf201999e99b2a7129263))
* **client:** resolve serialization issue with unions and enums ([17f1ece](https://github.com/browserbase/stagehand-php/commit/17f1ece3f13369ac5555a6df9af1bb4fdce3d307))
* populate enum-typed properties with enum instances ([0ba1b6f](https://github.com/browserbase/stagehand-php/commit/0ba1b6fda9bd4709e3665a3bd7659373b87b64d3))
* revert enum parsing change that lead to unconditional failure ([ca1dd7a](https://github.com/browserbase/stagehand-php/commit/ca1dd7a1dd2d8cf0b0b2d50f9cf317c98652cc89))

## 3.19.3 (2026-04-03)

Full Changelog: [v3.18.0...v3.19.3](https://github.com/browserbase/stagehand-php/compare/v3.18.0...v3.19.3)
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,7 @@ The REST API documentation can be found on [docs.stagehand.dev](https://docs.sta
<!-- x-release-please-start-version -->

```
composer require "browserbase/stagehand 3.19.3"
composer require "browserbase/stagehand 3.20.0"
```

<!-- x-release-please-end -->
Expand Down
6 changes: 3 additions & 3 deletions src/Client.php
Original file line number Diff line number Diff line change
Expand Up @@ -47,9 +47,9 @@ public function __construct(
'MODEL_API_KEY'
));

$baseUrl ??= Util::getenv(
'STAGEHAND_BASE_URL'
) ?: 'https://api.stagehand.browserbase.com';
$baseUrl ??= Util::getenv('STAGEHAND_API_URL')
?: Util::getenv('STAGEHAND_BASE_URL')
?: 'https://api.stagehand.browserbase.com';

$options = RequestOptions::parse(
RequestOptions::with(
Expand Down
17 changes: 1 addition & 16 deletions src/Core/Attributes/Required.php
Original file line number Diff line number Diff line change
Expand Up @@ -25,9 +25,6 @@ class Required

public readonly bool $nullable;

/** @var array<string,Converter> */
private static array $enumConverters = [];

/**
* @param class-string<ConverterSource>|Converter|string|null $type
* @param class-string<\BackedEnum>|Converter|null $enum
Expand All @@ -52,24 +49,12 @@ public function __construct(
$type ??= new MapOf($map);
}
if (null !== $enum) {
$type ??= $enum instanceof Converter ? $enum : self::enumConverter($enum);
$type ??= $enum instanceof Converter ? $enum : EnumOf::fromBackedEnum($enum);
}

$this->apiName = $apiName;
$this->type = $type;
$this->optional = false;
$this->nullable = $nullable;
}

/** @property class-string<\BackedEnum> $enum */
private static function enumConverter(string $enum): Converter
{
if (!isset(self::$enumConverters[$enum])) {
// @phpstan-ignore-next-line argument.type
$converter = new EnumOf(array_column($enum::cases(), column_key: 'value'));
self::$enumConverters[$enum] = $converter;
}

return self::$enumConverters[$enum];
}
}
19 changes: 19 additions & 0 deletions src/Core/Conversion.php
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
use Stagehand\Core\Conversion\Contracts\Converter;
use Stagehand\Core\Conversion\Contracts\ConverterSource;
use Stagehand\Core\Conversion\DumpState;
use Stagehand\Core\Conversion\EnumOf;

/**
* @internal
Expand All @@ -21,6 +22,10 @@ public static function dump_unknown(mixed $value, DumpState $state): mixed
}

if (is_object($value)) {
if ($value instanceof FileParam) {
return $value;
}

if (is_a($value, class: ConverterSource::class)) {
return $value::converter()->dump($value, state: $state);
}
Expand Down Expand Up @@ -61,6 +66,13 @@ public static function coerce(Converter|ConverterSource|string $target, mixed $v
return $target->coerce($value, state: $state);
}

// BackedEnum class-name targets: wrap in EnumOf so enum values are scored
// against the enum's cases. Without this, tryConvert's default case scores
// any class-name target as `no`, even when the value is a valid enum member.
if (is_a($target, class: \BackedEnum::class, allow_string: true)) {
return EnumOf::fromBackedEnum($target)->coerce($value, state: $state);
}

return self::tryConvert($target, value: $value, state: $state);
}

Expand All @@ -74,6 +86,13 @@ public static function dump(Converter|ConverterSource|string $target, mixed $val
return $target::converter()->dump($value, state: $state);
}

// BackedEnum class-name targets: wrap in EnumOf so enum values are scored
// against the enum's cases. Without this, tryConvert's default case scores
// any class-name target as `no`, even when the value is a valid enum member.
if (is_a($target, class: \BackedEnum::class, allow_string: true)) {
return EnumOf::fromBackedEnum($target)->dump($value, state: $state);
}

self::tryConvert($target, value: $value, state: $state);

return self::dump_unknown($value, state: $state);
Expand Down
15 changes: 13 additions & 2 deletions src/Core/Conversion/EnumOf.php
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,9 @@ final class EnumOf implements Converter
{
private readonly string $type;

/** @var array<class-string<\BackedEnum>, self> */
private static array $cache = [];

/**
* @param list<bool|float|int|string|null> $members
*/
Expand All @@ -26,6 +29,13 @@ public function __construct(private readonly array $members)
$this->type = $type;
}

/** @param class-string<\BackedEnum> $enum */
public static function fromBackedEnum(string $enum): self
{
// @phpstan-ignore-next-line argument.type
return self::$cache[$enum] ??= new self(array_column($enum::cases(), column_key: 'value'));
}

public function coerce(mixed $value, CoerceState $state): mixed
{
$this->tally($value, state: $state);
Expand All @@ -42,9 +52,10 @@ public function dump(mixed $value, DumpState $state): mixed

private function tally(mixed $value, CoerceState|DumpState $state): void
{
if (in_array($value, haystack: $this->members, strict: true)) {
$needle = $value instanceof \BackedEnum ? $value->value : $value;
if (in_array($needle, haystack: $this->members, strict: true)) {
++$state->yes;
} elseif ($this->type === gettype($value)) {
} elseif ($this->type === gettype($needle)) {
++$state->maybe;
} else {
++$state->no;
Expand Down
63 changes: 63 additions & 0 deletions src/Core/FileParam.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
<?php

declare(strict_types=1);

namespace Stagehand\Core;

/**
* Represents a file to upload in a multipart request.
*
* ```php
* // From a file on disk:
* $client->files->upload(file: FileParam::fromResource(fopen('data.csv', 'r')));
*
* // From a string:
* $client->files->upload(file: FileParam::fromString('csv data...', 'data.csv'));
* ```
*/
final class FileParam
{
public const DEFAULT_CONTENT_TYPE = 'application/octet-stream';

/**
* @param resource|string $data the file content as a resource or string
*/
private function __construct(
public readonly mixed $data,
public readonly string $filename,
public readonly string $contentType = self::DEFAULT_CONTENT_TYPE,
) {}

/**
* Create a FileParam from an open resource (e.g. from fopen()).
*
* @param resource $resource an open file resource
* @param string|null $filename Override the filename. Defaults to the resource URI basename.
* @param string $contentType override the content type
*/
public static function fromResource(mixed $resource, ?string $filename = null, string $contentType = self::DEFAULT_CONTENT_TYPE): self
{
if (!is_resource($resource)) {
throw new \InvalidArgumentException('Expected a resource, got '.get_debug_type($resource));
}

if (null === $filename) {
$meta = stream_get_meta_data($resource);
$filename = basename($meta['uri'] ?? 'upload');
}

return new self($resource, filename: $filename, contentType: $contentType);
}

/**
* Create a FileParam from a string.
*
* @param string $content the file content
* @param string $filename the filename for the Content-Disposition header
* @param string $contentType override the content type
*/
public static function fromString(string $content, string $filename, string $contentType = self::DEFAULT_CONTENT_TYPE): self
{
return new self($content, filename: $filename, contentType: $contentType);
}
}
60 changes: 47 additions & 13 deletions src/Core/Util.php
Original file line number Diff line number Diff line change
Expand Up @@ -283,7 +283,7 @@ public static function withSetBody(

if (preg_match('/^multipart\/form-data/', $contentType)) {
[$boundary, $gen] = self::encodeMultipartStreaming($body);
$encoded = implode('', iterator_to_array($gen));
$encoded = implode('', iterator_to_array($gen, preserve_keys: false));
$stream = $factory->createStream($encoded);

/** @var RequestInterface */
Expand Down Expand Up @@ -447,11 +447,18 @@ private static function writeMultipartContent(
): \Generator {
$contentLine = "Content-Type: %s\r\n\r\n";

if (is_resource($val)) {
yield sprintf($contentLine, $contentType ?? 'application/octet-stream');
while (!feof($val)) {
if ($read = fread($val, length: self::BUF_SIZE)) {
yield $read;
if ($val instanceof FileParam) {
$ct = $val->contentType ?? $contentType;

yield sprintf($contentLine, $ct);
$data = $val->data;
if (is_string($data)) {
yield $data;
} else { // resource
while (!feof($data)) {
if ($read = fread($data, length: self::BUF_SIZE)) {
yield $read;
}
}
}
} elseif (is_string($val) || is_numeric($val) || is_bool($val)) {
Expand Down Expand Up @@ -483,17 +490,48 @@ private static function writeMultipartChunk(
yield 'Content-Disposition: form-data';

if (!is_null($key)) {
$name = rawurlencode(self::strVal($key));
$name = str_replace(['"', "\r", "\n"], replace: '', subject: $key);

yield "; name=\"{$name}\"";
}

// File uploads require a filename in the Content-Disposition header,
// e.g. `Content-Disposition: form-data; name="file"; filename="data.csv"`
// Without this, many servers will reject the upload with a 400.
if ($val instanceof FileParam) {
$filename = str_replace(['"', "\r", "\n"], replace: '', subject: $val->filename);

yield "; filename=\"{$filename}\"";
}

yield "\r\n";
foreach (self::writeMultipartContent($val, closing: $closing) as $chunk) {
yield $chunk;
}
}

/**
* Expands list arrays into separate multipart parts, applying the configured array key format.
*
* @param list<callable> $closing
*
* @return \Generator<string>
*/
private static function writeMultipartField(
string $boundary,
?string $key,
mixed $val,
array &$closing
): \Generator {
if (is_array($val) && array_is_list($val)) {
foreach ($val as $item) {
yield from self::writeMultipartField(boundary: $boundary, key: $key, val: $item, closing: $closing);
}
} else {
yield from self::writeMultipartChunk(boundary: $boundary, key: $key, val: $val, closing: $closing);
}
}

/**
* @param bool|int|float|string|resource|\Traversable<mixed,>|array<string,mixed>|null $body
*
Expand All @@ -508,14 +546,10 @@ private static function encodeMultipartStreaming(mixed $body): array
try {
if (is_array($body) || is_object($body)) {
foreach ((array) $body as $key => $val) {
foreach (static::writeMultipartChunk(boundary: $boundary, key: $key, val: $val, closing: $closing) as $chunk) {
yield $chunk;
}
yield from static::writeMultipartField(boundary: $boundary, key: $key, val: $val, closing: $closing);
}
} else {
foreach (static::writeMultipartChunk(boundary: $boundary, key: null, val: $body, closing: $closing) as $chunk) {
yield $chunk;
}
yield from static::writeMultipartField(boundary: $boundary, key: null, val: $body, closing: $closing);
}

yield "--{$boundary}--\r\n";
Expand Down
Loading