diff --git a/.github/workflows/publish-packagist.yml b/.github/workflows/publish-packagist.yml index c632370..6c6f68e 100644 --- a/.github/workflows/publish-packagist.yml +++ b/.github/workflows/publish-packagist.yml @@ -15,7 +15,7 @@ jobs: - name: Publish to Packagist run: |- - curl --fail-with-body -X POST -H 'Content-Type: application/json' "https://packagist.org/api/update-package?username=${PACKAGIST_USERNAME}&apiToken=${PACKAGIST_SAFE_KEY}" -d '{"repository":"https://www.github.com/browserbase/stagehand-php"}' + curl --fail-with-body -X POST -H 'Content-Type: application/json' "https://packagist.org/api/update-package?username=${PACKAGIST_USERNAME}&apiToken=${PACKAGIST_SAFE_KEY}" -d '{"repository":"https://github.com/browserbase/stagehand-php"}' env: PACKAGIST_USERNAME: ${{ secrets.STAGEHAND_PACKAGIST_USERNAME || secrets.PACKAGIST_USERNAME }} PACKAGIST_SAFE_KEY: ${{ secrets.STAGEHAND_PACKAGIST_SAFE_KEY || secrets.PACKAGIST_SAFE_KEY }} \ No newline at end of file diff --git a/.release-please-manifest.json b/.release-please-manifest.json index 911c623..d11c8fc 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -1,3 +1,3 @@ { - ".": "3.19.3" + ".": "3.20.0" } \ No newline at end of file diff --git a/.stats.yml b/.stats.yml index ac9e78a..391cde3 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-b969ce378479c79ee64c05127c0ed6c6ce2edbee017ecd037242fb618a5ebc9f.yml -openapi_spec_hash: a24aabaa5214effb679808b7f2be0ad4 -config_hash: a962ae71493deb11a1c903256fb25386 +openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/browserbase/stagehand-6f6bfb81d092f30a5e2005328c97d61b9ea36132bb19e9e79e55294b9534ce20.yml +openapi_spec_hash: f3fc1e3688a38dc2c28f7178f7d534e5 +config_hash: 1fb12ae9b478488bc1e56bfbdc210b01 diff --git a/AGENTS.md b/AGENTS.md index a49b682..c1e86c3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -32,7 +32,6 @@ composer require browserbase/stagehand # Set environment variables export BROWSERBASE_API_KEY="your-bb-api-key" -export BROWSERBASE_PROJECT_ID="your-bb-project-uuid" export MODEL_API_KEY="sk-proj-your-llm-api-key" # Run the example @@ -45,7 +44,6 @@ use Stagehand\Client; $client = new Client( browserbaseAPIKey: getenv('BROWSERBASE_API_KEY'), - browserbaseProjectID: getenv('BROWSERBASE_PROJECT_ID'), modelAPIKey: getenv('MODEL_API_KEY'), ); $startResponse = $client->sessions->start(model: 'openai/gpt-5-nano'); diff --git a/CHANGELOG.md b/CHANGELOG.md index bdc1f55..4ba66f7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,28 @@ # Changelog +## 3.20.0 (2026-05-06) + +Full Changelog: [v3.19.3...v3.20.0](https://github.com/browserbase/stagehand-php/compare/v3.19.3...v3.20.0) + +### Features + +* [feat]: add `ignoreSelectors` to `extract()` ([4d548b6](https://github.com/browserbase/stagehand-php/commit/4d548b6b71174972b12884840185357a30e30d8d)) +* [STG-1798] feat: support Browserbase verified sessions ([52c4636](https://github.com/browserbase/stagehand-php/commit/52c4636560e5dd556db9c061a3bb9c48ffa20e76)) +* [STG-1808] Deprecate Browserbase project ID ([5ce5e23](https://github.com/browserbase/stagehand-php/commit/5ce5e2347bb5700816910f4973b1d74487e2b92e)) +* Bedrock auth passthrough ([40cb10e](https://github.com/browserbase/stagehand-php/commit/40cb10eb39c7f53b9c3fd668018c1b3569650e5c)) +* remove experimental requirement on agent variables ([#2079](https://github.com/browserbase/stagehand-php/issues/2079)) ([7271c1e](https://github.com/browserbase/stagehand-php/commit/7271c1e3ee6a8c3e8fe425a77196c051c7dfa086)) +* 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)) +* support setting headers via env ([d6235c1](https://github.com/browserbase/stagehand-php/commit/d6235c1aa657237410c3bab8483b6a5f8ebf3eba)) + + +### 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)) +* **release:** use canonical GitHub URL in Packagist publish script ([eb093f7](https://github.com/browserbase/stagehand-php/commit/eb093f76a7e0999169970d79341fafa2871a6eee)) +* 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) diff --git a/README.md b/README.md index 1749137..574163f 100644 --- a/README.md +++ b/README.md @@ -80,7 +80,7 @@ The REST API documentation can be found on [docs.stagehand.dev](https://docs.sta ``` -composer require "browserbase/stagehand 3.19.3" +composer require "browserbase/stagehand 3.20.0" ``` @@ -113,14 +113,12 @@ if (file_exists(__DIR__ . '/../.env')) { // Initialize the Stagehand client with API keys from environment variables $client = new Client( browserbaseAPIKey: getenv('BROWSERBASE_API_KEY') ?: throw new Exception('BROWSERBASE_API_KEY environment variable is required'), - browserbaseProjectID: getenv('BROWSERBASE_PROJECT_ID') ?: throw new Exception('BROWSERBASE_PROJECT_ID environment variable is required'), modelAPIKey: getenv('MODEL_API_KEY') ?: throw new Exception('MODEL_API_KEY environment variable is required'), ); // Start a new session $startResponse = $client->sessions->start( browserbaseAPIKey: getenv('BROWSERBASE_API_KEY'), - browserbaseProjectID: getenv('BROWSERBASE_PROJECT_ID'), model: 'openai/gpt-4o', ); echo "Session started: {$startResponse->data->sessionID}\n"; @@ -217,12 +215,11 @@ echo "Session ended\n"; ### Running the Example -Set the required environment variables and run the example script: +Set the environment variables and run the example script: ```bash # Set your credentials export BROWSERBASE_API_KEY="your-browserbase-api-key" -export BROWSERBASE_PROJECT_ID="your-browserbase-project-id" export MODEL_API_KEY="your-openai-api-key" # Install dependencies and run @@ -248,9 +245,6 @@ use Stagehand\Client; $client = new Client( browserbaseAPIKey: getenv('BROWSERBASE_API_KEY') ?: 'My Browserbase API Key', - browserbaseProjectID: getenv( - 'BROWSERBASE_PROJECT_ID' - ) ?: 'My Browserbase Project ID', modelAPIKey: getenv('MODEL_API_KEY') ?: 'My Model API Key', ); diff --git a/examples/basic.php b/examples/basic.php index 51ee2b6..3f22180 100644 --- a/examples/basic.php +++ b/examples/basic.php @@ -18,14 +18,12 @@ // Initialize the Stagehand client with API keys from environment variables $client = new Client( browserbaseAPIKey: getenv('BROWSERBASE_API_KEY') ?: throw new Exception('BROWSERBASE_API_KEY environment variable is required'), - browserbaseProjectID: getenv('BROWSERBASE_PROJECT_ID') ?: throw new Exception('BROWSERBASE_PROJECT_ID environment variable is required'), modelAPIKey: getenv('MODEL_API_KEY') ?: throw new Exception('MODEL_API_KEY environment variable is required'), ); // Start a new session $startResponse = $client->sessions->start( browserbaseAPIKey: getenv('BROWSERBASE_API_KEY'), - browserbaseProjectID: getenv('BROWSERBASE_PROJECT_ID'), model: 'openai/gpt-4o', ); echo "Session started: {$startResponse->data->sessionID}\n"; diff --git a/src/Client.php b/src/Client.php index 267d660..c571bb6 100644 --- a/src/Client.php +++ b/src/Client.php @@ -18,6 +18,10 @@ class Client extends BaseClient { public string $browserbaseAPIKey; + /** + * Deprecated. Browserbase API keys are now project-scoped, so this value is no longer required. + * Accepted for backwards compatibility; it is ignored. + */ public string $browserbaseProjectID; public string $modelAPIKey; @@ -40,16 +44,14 @@ public function __construct( $this->browserbaseAPIKey = (string) ($browserbaseAPIKey ?? Util::getenv( 'BROWSERBASE_API_KEY' )); - $this->browserbaseProjectID = (string) ($browserbaseProjectID ?? Util::getenv( - 'BROWSERBASE_PROJECT_ID' - )); + $this->browserbaseProjectID = (string) $browserbaseProjectID; $this->modelAPIKey = (string) ($modelAPIKey ?? Util::getenv( '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( @@ -61,18 +63,31 @@ public function __construct( $requestOptions, ); + /** @var array $headers */ + $headers = [ + 'Content-Type' => 'application/json', + 'Accept' => 'application/json', + 'User-Agent' => sprintf('stagehand/PHP %s', VERSION), + 'X-Stainless-Lang' => 'php', + 'X-Stainless-Package-Version' => '3.19.3', + 'X-Stainless-Arch' => Util::machtype(), + 'X-Stainless-OS' => Util::ostype(), + 'X-Stainless-Runtime' => php_sapi_name(), + 'X-Stainless-Runtime-Version' => phpversion(), + ]; + + $customHeadersEnv = Util::getenv('STAGEHAND_CUSTOM_HEADERS'); + if (null !== $customHeadersEnv) { + foreach (explode("\n", $customHeadersEnv) as $line) { + $colon = strpos($line, ':'); + if (false !== $colon) { + $headers[trim(substr($line, 0, $colon))] = trim(substr($line, $colon + 1)); + } + } + } + parent::__construct( - headers: [ - 'Content-Type' => 'application/json', - 'Accept' => 'application/json', - 'User-Agent' => sprintf('stagehand/PHP %s', VERSION), - 'X-Stainless-Lang' => 'php', - 'X-Stainless-Package-Version' => '3.1.0', - 'X-Stainless-Arch' => Util::machtype(), - 'X-Stainless-OS' => Util::ostype(), - 'X-Stainless-Runtime' => php_sapi_name(), - 'X-Stainless-Runtime-Version' => phpversion(), - ], + headers: $headers, baseUrl: $baseUrl, options: $options ); @@ -85,7 +100,6 @@ protected function authHeaders(): array { return [ ...$this->bbAPIKeyAuth(), - ...$this->bbProjectIDAuth(), ...$this->llmModelAPIKeyAuth(), ]; } @@ -101,9 +115,7 @@ protected function bbAPIKeyAuth(): array /** @return array */ protected function bbProjectIDAuth(): array { - return $this->browserbaseProjectID ? [ - 'x-bb-project-id' => $this->browserbaseProjectID, - ] : []; + return []; } /** @return array */ diff --git a/src/Core/Attributes/Required.php b/src/Core/Attributes/Required.php index c8b5851..82791f6 100644 --- a/src/Core/Attributes/Required.php +++ b/src/Core/Attributes/Required.php @@ -25,9 +25,6 @@ class Required public readonly bool $nullable; - /** @var array */ - private static array $enumConverters = []; - /** * @param class-string|Converter|string|null $type * @param class-string<\BackedEnum>|Converter|null $enum @@ -52,7 +49,7 @@ 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; @@ -60,16 +57,4 @@ public function __construct( $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]; - } } diff --git a/src/Core/Conversion.php b/src/Core/Conversion.php index 1942914..363e47c 100644 --- a/src/Core/Conversion.php +++ b/src/Core/Conversion.php @@ -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 @@ -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); } @@ -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); } @@ -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); diff --git a/src/Core/Conversion/EnumOf.php b/src/Core/Conversion/EnumOf.php index 729a2e6..fbb7161 100644 --- a/src/Core/Conversion/EnumOf.php +++ b/src/Core/Conversion/EnumOf.php @@ -14,6 +14,9 @@ final class EnumOf implements Converter { private readonly string $type; + /** @var array, self> */ + private static array $cache = []; + /** * @param list $members */ @@ -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); @@ -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; diff --git a/src/Core/FileParam.php b/src/Core/FileParam.php new file mode 100644 index 0000000..bc52309 --- /dev/null +++ b/src/Core/FileParam.php @@ -0,0 +1,63 @@ +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); + } +} diff --git a/src/Core/Util.php b/src/Core/Util.php index 58e82aa..19a90c3 100644 --- a/src/Core/Util.php +++ b/src/Core/Util.php @@ -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 */ @@ -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)) { @@ -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 $closing + * + * @return \Generator + */ + 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|array|null $body * @@ -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"; diff --git a/src/Sessions/SessionExecuteParams/ExecuteOptions.php b/src/Sessions/SessionExecuteParams/ExecuteOptions.php index be26904..c66813d 100644 --- a/src/Sessions/SessionExecuteParams/ExecuteOptions.php +++ b/src/Sessions/SessionExecuteParams/ExecuteOptions.php @@ -8,14 +8,19 @@ use Stagehand\Core\Attributes\Required; use Stagehand\Core\Concerns\SdkModel; use Stagehand\Core\Contracts\BaseModel; +use Stagehand\Sessions\SessionExecuteParams\ExecuteOptions\Variable; /** + * @phpstan-import-type VariableVariants from \Stagehand\Sessions\SessionExecuteParams\ExecuteOptions\Variable + * @phpstan-import-type VariableShape from \Stagehand\Sessions\SessionExecuteParams\ExecuteOptions\Variable + * * @phpstan-type ExecuteOptionsShape = array{ * instruction: string, * highlightCursor?: bool|null, * maxSteps?: float|null, * toolTimeout?: float|null, * useSearch?: bool|null, + * variables?: array|null, * } */ final class ExecuteOptions implements BaseModel @@ -53,6 +58,14 @@ final class ExecuteOptions implements BaseModel #[Optional] public ?bool $useSearch; + /** + * Variables available to the agent via %variableName% syntax in supported tools. + * + * @var array|null $variables + */ + #[Optional(map: Variable::class)] + public ?array $variables; + /** * `new ExecuteOptions()` is missing required properties by the API. * @@ -76,6 +89,8 @@ public function __construct() * Construct an instance from the required parameters. * * You must use named parameters to construct any parameters with a default value. + * + * @param array|null $variables */ public static function with( string $instruction, @@ -83,6 +98,7 @@ public static function with( ?float $maxSteps = null, ?float $toolTimeout = null, ?bool $useSearch = null, + ?array $variables = null, ): self { $self = new self; @@ -92,6 +108,7 @@ public static function with( null !== $maxSteps && $self['maxSteps'] = $maxSteps; null !== $toolTimeout && $self['toolTimeout'] = $toolTimeout; null !== $useSearch && $self['useSearch'] = $useSearch; + null !== $variables && $self['variables'] = $variables; return $self; } @@ -150,4 +167,17 @@ public function withUseSearch(bool $useSearch): self return $self; } + + /** + * Variables available to the agent via %variableName% syntax in supported tools. + * + * @param array $variables + */ + public function withVariables(array $variables): self + { + $self = clone $this; + $self['variables'] = $variables; + + return $self; + } } diff --git a/src/Sessions/SessionExecuteParams/ExecuteOptions/Variable.php b/src/Sessions/SessionExecuteParams/ExecuteOptions/Variable.php new file mode 100644 index 0000000..8b2bd3d --- /dev/null +++ b/src/Sessions/SessionExecuteParams/ExecuteOptions/Variable.php @@ -0,0 +1,29 @@ +|array + */ + public static function variants(): array + { + return ['string', 'float', 'bool', UnionMember3::class]; + } +} diff --git a/src/Sessions/SessionExecuteParams/ExecuteOptions/Variable/UnionMember3.php b/src/Sessions/SessionExecuteParams/ExecuteOptions/Variable/UnionMember3.php new file mode 100644 index 0000000..15f3246 --- /dev/null +++ b/src/Sessions/SessionExecuteParams/ExecuteOptions/Variable/UnionMember3.php @@ -0,0 +1,89 @@ + */ + use SdkModel; + + /** @var ValueVariants $value */ + #[Required] + public string|float|bool $value; + + #[Optional] + public ?string $description; + + /** + * `new UnionMember3()` is missing required properties by the API. + * + * To enforce required parameters use + * ``` + * UnionMember3::with(value: ...) + * ``` + * + * Otherwise ensure the following setters are called + * + * ``` + * (new UnionMember3)->withValue(...) + * ``` + */ + public function __construct() + { + $this->initialize(); + } + + /** + * Construct an instance from the required parameters. + * + * You must use named parameters to construct any parameters with a default value. + * + * @param ValueShape $value + */ + public static function with( + string|float|bool $value, + ?string $description = null + ): self { + $self = new self; + + $self['value'] = $value; + + null !== $description && $self['description'] = $description; + + return $self; + } + + /** + * @param ValueShape $value + */ + public function withValue(string|float|bool $value): self + { + $self = clone $this; + $self['value'] = $value; + + return $self; + } + + public function withDescription(string $description): self + { + $self = clone $this; + $self['description'] = $description; + + return $self; + } +} diff --git a/src/Sessions/SessionExecuteParams/ExecuteOptions/Variable/UnionMember3/Value.php b/src/Sessions/SessionExecuteParams/ExecuteOptions/Variable/UnionMember3/Value.php new file mode 100644 index 0000000..a8b9cc0 --- /dev/null +++ b/src/Sessions/SessionExecuteParams/ExecuteOptions/Variable/UnionMember3/Value.php @@ -0,0 +1,26 @@ +|array + */ + public static function variants(): array + { + return ['string', 'float', 'bool']; + } +} diff --git a/src/Sessions/SessionExtractParams/Options.php b/src/Sessions/SessionExtractParams/Options.php index 835c3b1..0e8a6c3 100644 --- a/src/Sessions/SessionExtractParams/Options.php +++ b/src/Sessions/SessionExtractParams/Options.php @@ -14,7 +14,10 @@ * @phpstan-import-type ModelShape from \Stagehand\Sessions\SessionExtractParams\Options\Model * * @phpstan-type OptionsShape = array{ - * model?: ModelShape|null, selector?: string|null, timeout?: float|null + * ignoreSelectors?: list|null, + * model?: ModelShape|null, + * selector?: string|null, + * timeout?: float|null, * } */ final class Options implements BaseModel @@ -22,6 +25,14 @@ final class Options implements BaseModel /** @use SdkModel */ use SdkModel; + /** + * Selectors for elements and subtrees that should be excluded from extraction. + * + * @var list|null $ignoreSelectors + */ + #[Optional(list: 'string')] + public ?array $ignoreSelectors; + /** * Model configuration object or model name string (e.g., 'openai/gpt-5-nano'). * @@ -52,15 +63,18 @@ public function __construct() * * You must use named parameters to construct any parameters with a default value. * + * @param list|null $ignoreSelectors * @param ModelShape|null $model */ public static function with( + ?array $ignoreSelectors = null, string|ModelConfig|array|null $model = null, ?string $selector = null, ?float $timeout = null, ): self { $self = new self; + null !== $ignoreSelectors && $self['ignoreSelectors'] = $ignoreSelectors; null !== $model && $self['model'] = $model; null !== $selector && $self['selector'] = $selector; null !== $timeout && $self['timeout'] = $timeout; @@ -68,6 +82,19 @@ public static function with( return $self; } + /** + * Selectors for elements and subtrees that should be excluded from extraction. + * + * @param list $ignoreSelectors + */ + public function withIgnoreSelectors(array $ignoreSelectors): self + { + $self = clone $this; + $self['ignoreSelectors'] = $ignoreSelectors; + + return $self; + } + /** * Model configuration object or model name string (e.g., 'openai/gpt-5-nano'). * diff --git a/src/Sessions/SessionStartParams/BrowserbaseSessionCreateParams.php b/src/Sessions/SessionStartParams/BrowserbaseSessionCreateParams.php index 07b8956..948d65f 100644 --- a/src/Sessions/SessionStartParams/BrowserbaseSessionCreateParams.php +++ b/src/Sessions/SessionStartParams/BrowserbaseSessionCreateParams.php @@ -41,6 +41,11 @@ final class BrowserbaseSessionCreateParams implements BaseModel #[Optional] public ?bool $keepAlive; + /** + * @deprecated + * + * Deprecated. Browserbase API keys are now project-scoped, so this field is no longer required. + */ #[Optional('projectId')] public ?string $projectID; @@ -126,6 +131,9 @@ public function withKeepAlive(bool $keepAlive): self return $self; } + /** + * Deprecated. Browserbase API keys are now project-scoped, so this field is no longer required. + */ public function withProjectID(string $projectID): self { $self = clone $this; diff --git a/src/Sessions/SessionStartParams/BrowserbaseSessionCreateParams/BrowserSettings.php b/src/Sessions/SessionStartParams/BrowserbaseSessionCreateParams/BrowserSettings.php index 6014c60..9272ee9 100644 --- a/src/Sessions/SessionStartParams/BrowserbaseSessionCreateParams/BrowserSettings.php +++ b/src/Sessions/SessionStartParams/BrowserbaseSessionCreateParams/BrowserSettings.php @@ -9,6 +9,7 @@ use Stagehand\Core\Contracts\BaseModel; use Stagehand\Sessions\SessionStartParams\BrowserbaseSessionCreateParams\BrowserSettings\Context; use Stagehand\Sessions\SessionStartParams\BrowserbaseSessionCreateParams\BrowserSettings\Fingerprint; +use Stagehand\Sessions\SessionStartParams\BrowserbaseSessionCreateParams\BrowserSettings\Os; use Stagehand\Sessions\SessionStartParams\BrowserbaseSessionCreateParams\BrowserSettings\Viewport; /** @@ -19,12 +20,16 @@ * @phpstan-type BrowserSettingsShape = array{ * advancedStealth?: bool|null, * blockAds?: bool|null, + * captchaImageSelector?: string|null, + * captchaInputSelector?: string|null, * context?: null|Context|ContextShape, * extensionID?: string|null, * fingerprint?: null|Fingerprint|FingerprintShape, * logSession?: bool|null, + * os?: null|Os|value-of, * recordSession?: bool|null, * solveCaptchas?: bool|null, + * verified?: bool|null, * viewport?: null|Viewport|ViewportShape, * } */ @@ -39,6 +44,12 @@ final class BrowserSettings implements BaseModel #[Optional] public ?bool $blockAds; + #[Optional] + public ?string $captchaImageSelector; + + #[Optional] + public ?string $captchaInputSelector; + #[Optional] public ?Context $context; @@ -51,12 +62,19 @@ final class BrowserSettings implements BaseModel #[Optional] public ?bool $logSession; + /** @var value-of|null $os */ + #[Optional(enum: Os::class)] + public ?string $os; + #[Optional] public ?bool $recordSession; #[Optional] public ?bool $solveCaptchas; + #[Optional] + public ?bool $verified; + #[Optional] public ?Viewport $viewport; @@ -72,29 +90,38 @@ public function __construct() * * @param Context|ContextShape|null $context * @param Fingerprint|FingerprintShape|null $fingerprint + * @param Os|value-of|null $os * @param Viewport|ViewportShape|null $viewport */ public static function with( ?bool $advancedStealth = null, ?bool $blockAds = null, + ?string $captchaImageSelector = null, + ?string $captchaInputSelector = null, Context|array|null $context = null, ?string $extensionID = null, Fingerprint|array|null $fingerprint = null, ?bool $logSession = null, + Os|string|null $os = null, ?bool $recordSession = null, ?bool $solveCaptchas = null, + ?bool $verified = null, Viewport|array|null $viewport = null, ): self { $self = new self; null !== $advancedStealth && $self['advancedStealth'] = $advancedStealth; null !== $blockAds && $self['blockAds'] = $blockAds; + null !== $captchaImageSelector && $self['captchaImageSelector'] = $captchaImageSelector; + null !== $captchaInputSelector && $self['captchaInputSelector'] = $captchaInputSelector; null !== $context && $self['context'] = $context; null !== $extensionID && $self['extensionID'] = $extensionID; null !== $fingerprint && $self['fingerprint'] = $fingerprint; null !== $logSession && $self['logSession'] = $logSession; + null !== $os && $self['os'] = $os; null !== $recordSession && $self['recordSession'] = $recordSession; null !== $solveCaptchas && $self['solveCaptchas'] = $solveCaptchas; + null !== $verified && $self['verified'] = $verified; null !== $viewport && $self['viewport'] = $viewport; return $self; @@ -116,6 +143,22 @@ public function withBlockAds(bool $blockAds): self return $self; } + public function withCaptchaImageSelector(string $captchaImageSelector): self + { + $self = clone $this; + $self['captchaImageSelector'] = $captchaImageSelector; + + return $self; + } + + public function withCaptchaInputSelector(string $captchaInputSelector): self + { + $self = clone $this; + $self['captchaInputSelector'] = $captchaInputSelector; + + return $self; + } + /** * @param Context|ContextShape $context */ @@ -154,6 +197,17 @@ public function withLogSession(bool $logSession): self return $self; } + /** + * @param Os|value-of $os + */ + public function withOs(Os|string $os): self + { + $self = clone $this; + $self['os'] = $os; + + return $self; + } + public function withRecordSession(bool $recordSession): self { $self = clone $this; @@ -170,6 +224,14 @@ public function withSolveCaptchas(bool $solveCaptchas): self return $self; } + public function withVerified(bool $verified): self + { + $self = clone $this; + $self['verified'] = $verified; + + return $self; + } + /** * @param Viewport|ViewportShape $viewport */ diff --git a/src/Sessions/SessionStartParams/BrowserbaseSessionCreateParams/BrowserSettings/Os.php b/src/Sessions/SessionStartParams/BrowserbaseSessionCreateParams/BrowserSettings/Os.php new file mode 100644 index 0000000..745cef8 --- /dev/null +++ b/src/Sessions/SessionStartParams/BrowserbaseSessionCreateParams/BrowserSettings/Os.php @@ -0,0 +1,18 @@ +createResponse() - ->withStatus(200) - ->withHeader('Content-Type', 'application/json') - ->withBody(Psr17FactoryDiscovery::findStreamFactory()->createStream(json_encode([], flags: Util::JSON_ENCODE_FLAGS) ?: '')) - ; + $this->withEnv( + [ + 'STAGEHAND_API_URL' => 'http://localhost:5000/from-api-env', + 'STAGEHAND_BASE_URL' => 'http://localhost:5000/from-base-env', + ], + function (): void { + $transporter = $this->mockTransport(); - $transporter->setDefaultResponse($mockRsp); + $client = new \Stagehand\Client( + browserbaseAPIKey: 'My Browserbase API Key', + browserbaseProjectID: 'My Browserbase Project ID', + modelAPIKey: 'My Model API Key', + requestOptions: ['transporter' => $transporter], + ); + + $client->sessions->start(modelName: 'openai/gpt-5.4-mini'); + + $this->assertNotFalse($requested = $transporter->getRequests()[0] ?? false); + $this->assertSame( + 'http://localhost:5000/from-api-env/v1/sessions/start', + (string) $requested->getUri() + ); + }, + ); + } + + public function testBaseUrlUsesLegacyStagehandBaseUrlEnv(): void + { + $this->withEnv( + [ + 'STAGEHAND_API_URL' => null, + 'STAGEHAND_BASE_URL' => 'http://localhost:5000/from-base-env', + ], + function (): void { + $transporter = $this->mockTransport(); + + $client = new \Stagehand\Client( + browserbaseAPIKey: 'My Browserbase API Key', + browserbaseProjectID: 'My Browserbase Project ID', + modelAPIKey: 'My Model API Key', + requestOptions: ['transporter' => $transporter], + ); + + $client->sessions->start(modelName: 'openai/gpt-5.4-mini'); + + $this->assertNotFalse($requested = $transporter->getRequests()[0] ?? false); + $this->assertSame( + 'http://localhost:5000/from-base-env/v1/sessions/start', + (string) $requested->getUri() + ); + }, + ); + } + + public function testDefaultHeaders(): void + { + $transporter = $this->mockTransport(); $client = new \Stagehand\Client( baseUrl: 'http://localhost', @@ -43,4 +91,61 @@ public function testDefaultHeaders(): void $this->assertNotEmpty($sent); } } + + public function testProjectIDHeaderIsOmitted(): void + { + $transporter = $this->mockTransport(); + + $client = new \Stagehand\Client( + baseUrl: 'http://localhost', + browserbaseAPIKey: 'My Browserbase API Key', + browserbaseProjectID: 'My Browserbase Project ID', + modelAPIKey: 'My Model API Key', + requestOptions: ['transporter' => $transporter], + ); + + $client->sessions->start(modelName: 'openai/gpt-5.4-mini'); + + $this->assertNotFalse($requested = $transporter->getRequests()[0] ?? false); + $this->assertSame('', $requested->getHeaderLine('x-bb-project-id')); + } + + private function mockTransport(): Client + { + $transporter = new Client; + $mockRsp = Psr17FactoryDiscovery::findResponseFactory() + ->createResponse() + ->withStatus(200) + ->withHeader('Content-Type', 'application/json') + ->withBody(Psr17FactoryDiscovery::findStreamFactory()->createStream(json_encode([], flags: Util::JSON_ENCODE_FLAGS) ?: '')) + ; + + $transporter->setDefaultResponse($mockRsp); + + return $transporter; + } + + /** + * @param array $vars + */ + private function withEnv(array $vars, callable $callback): void + { + $oldValues = []; + foreach ($vars as $key => $_) { + $value = getenv($key); + $oldValues[$key] = false === $value ? null : $value; + } + + try { + foreach ($vars as $key => $value) { + null === $value ? putenv($key) : putenv("{$key}={$value}"); + } + + $callback(); + } finally { + foreach ($oldValues as $key => $value) { + null === $value ? putenv($key) : putenv("{$key}={$value}"); + } + } + } } diff --git a/tests/Services/SessionsTest.php b/tests/Services/SessionsTest.php index c0c7b57..5d00fc6 100644 --- a/tests/Services/SessionsTest.php +++ b/tests/Services/SessionsTest.php @@ -159,6 +159,7 @@ public function testExecuteWithOptionalParams(): void 'maxSteps' => 20, 'toolTimeout' => 30000, 'useSearch' => true, + 'variables' => ['foo' => 'string'], ], frameID: 'frameId', shouldCache: true, @@ -310,6 +311,8 @@ public function testStartWithOptionalParams(): void 'browserSettings' => [ 'advancedStealth' => true, 'blockAds' => true, + 'captchaImageSelector' => 'captchaImageSelector', + 'captchaInputSelector' => 'captchaInputSelector', 'context' => ['id' => 'id', 'persist' => true], 'extensionID' => 'extensionId', 'fingerprint' => [ @@ -326,8 +329,10 @@ public function testStartWithOptionalParams(): void ], ], 'logSession' => true, + 'os' => 'windows', 'recordSession' => true, 'solveCaptchas' => true, + 'verified' => true, 'viewport' => ['height' => 0, 'width' => 0], ], 'extensionID' => 'extensionId',