diff --git a/docs/reference/configuration.md b/docs/reference/configuration.md index efe2073..d5366e2 100644 --- a/docs/reference/configuration.md +++ b/docs/reference/configuration.md @@ -82,6 +82,39 @@ Custom handler for streaming requests. The handler receives a PSR-7 `RequestInte You only need this for HTTP clients other than Guzzle or Symfony. Both of those are detected automatically and handled without any extra configuration. +## Beta features + +Anthropic's beta features are opt-in via the `anthropic-beta` header. This SDK lets you enable them per request by passing a `betas` array alongside your normal parameters: + +```php +$response = $client->messages()->create([ + 'model' => 'claude-opus-4-6', + 'max_tokens' => 1024, + 'messages' => [ + ['role' => 'user', 'content' => 'Hello!'], + ], + 'betas' => [ + 'interleaved-thinking-2025-05-14', + 'extended-cache-ttl-2025-04-11', + ], +]); +``` + +The SDK pulls `betas` out of the parameters before serialization, so nothing leaks into the JSON body or query string. It becomes a comma-separated `anthropic-beta` header on that one request only. + +If some beta is enabled for every request in your app, set it globally on the factory and skip the per-call array: + +```php +$client = Anthropic::factory() + ->withApiKey('your-api-key') + ->withHttpHeader('anthropic-beta', 'interleaved-thinking-2025-05-14') + ->make(); +``` + +Global and per-request combine. If you set `anthropic-beta: interleaved-thinking-2025-05-14` globally and pass `betas: ['files-api-2025-04-14']` on one call, that call sends both, de-duplicated. + +Some resources require a specific beta header to work at all. The SDK auto-injects those for you, so you never have to type the version string for the resource you're already using. You only pass `betas` when you want to add additional ones on top. + ## HTTP client setup ### Guzzle diff --git a/src/ValueObjects/Transporter/Payload.php b/src/ValueObjects/Transporter/Payload.php index e9aac72..bd19c43 100644 --- a/src/ValueObjects/Transporter/Payload.php +++ b/src/ValueObjects/Transporter/Payload.php @@ -22,12 +22,14 @@ final class Payload * Creates a new Request value object. * * @param array $parameters + * @param list $betas */ private function __construct( private readonly ContentType $contentType, private readonly Method $method, private readonly ResourceUri $uri, private readonly array $parameters = [], + private readonly array $betas = [], ) { // .. } @@ -39,11 +41,9 @@ private function __construct( */ public static function list(string $resource, array $parameters = []): self { - $contentType = ContentType::JSON; - $method = Method::GET; - $uri = ResourceUri::list($resource); + [$parameters, $betas] = self::splitBetas($parameters); - return new self($contentType, $method, $uri, $parameters); + return new self(ContentType::JSON, Method::GET, ResourceUri::list($resource), $parameters, $betas); } /** @@ -53,11 +53,9 @@ public static function list(string $resource, array $parameters = []): self */ public static function retrieve(string $resource, string $id, string $suffix = '', array $parameters = []): self { - $contentType = ContentType::JSON; - $method = Method::GET; - $uri = ResourceUri::retrieve($resource, $id, $suffix); + [$parameters, $betas] = self::splitBetas($parameters); - return new self($contentType, $method, $uri, $parameters); + return new self(ContentType::JSON, Method::GET, ResourceUri::retrieve($resource, $id, $suffix), $parameters, $betas); } /** @@ -67,11 +65,9 @@ public static function retrieve(string $resource, string $id, string $suffix = ' */ public static function modify(string $resource, string $id, array $parameters = []): self { - $contentType = ContentType::JSON; - $method = Method::POST; - $uri = ResourceUri::modify($resource, $id); + [$parameters, $betas] = self::splitBetas($parameters); - return new self($contentType, $method, $uri, $parameters); + return new self(ContentType::JSON, Method::POST, ResourceUri::modify($resource, $id), $parameters, $betas); } /** @@ -79,11 +75,7 @@ public static function modify(string $resource, string $id, array $parameters = */ public static function retrieveContent(string $resource, string $id): self { - $contentType = ContentType::JSON; - $method = Method::GET; - $uri = ResourceUri::retrieveContent($resource, $id); - - return new self($contentType, $method, $uri); + return new self(ContentType::JSON, Method::GET, ResourceUri::retrieveContent($resource, $id)); } /** @@ -93,11 +85,9 @@ public static function retrieveContent(string $resource, string $id): self */ public static function create(string $resource, array $parameters): self { - $contentType = ContentType::JSON; - $method = Method::POST; - $uri = ResourceUri::create($resource); + [$parameters, $betas] = self::splitBetas($parameters); - return new self($contentType, $method, $uri, $parameters); + return new self(ContentType::JSON, Method::POST, ResourceUri::create($resource), $parameters, $betas); } /** @@ -107,11 +97,9 @@ public static function create(string $resource, array $parameters): self */ public static function upload(string $resource, array $parameters): self { - $contentType = ContentType::MULTIPART; - $method = Method::POST; - $uri = ResourceUri::upload($resource); + [$parameters, $betas] = self::splitBetas($parameters); - return new self($contentType, $method, $uri, $parameters); + return new self(ContentType::MULTIPART, Method::POST, ResourceUri::upload($resource), $parameters, $betas); } /** @@ -119,11 +107,7 @@ public static function upload(string $resource, array $parameters): self */ public static function cancel(string $resource, string $id): self { - $contentType = ContentType::JSON; - $method = Method::POST; - $uri = ResourceUri::cancel($resource, $id); - - return new self($contentType, $method, $uri); + return new self(ContentType::JSON, Method::POST, ResourceUri::cancel($resource, $id)); } /** @@ -131,11 +115,25 @@ public static function cancel(string $resource, string $id): self */ public static function delete(string $resource, string $id): self { - $contentType = ContentType::JSON; - $method = Method::DELETE; - $uri = ResourceUri::delete($resource, $id); + return new self(ContentType::JSON, Method::DELETE, ResourceUri::delete($resource, $id)); + } - return new self($contentType, $method, $uri); + /** + * Returns a new Payload with the given betas merged into this one. + * + * Resources use this to auto-inject the beta header they need (e.g. `files-api-2025-04-14`) while keeping any betas the user already passed via the `betas` parameter. + * + * @param list $betas + */ + public function withBetas(array $betas): self + { + return new self( + $this->contentType, + $this->method, + $this->uri, + $this->parameters, + self::dedupeBetas([...$this->betas, ...$betas]), + ); } /** @@ -201,6 +199,65 @@ public function toRequest(BaseUri $baseUri, Headers $headers, QueryParams $query $request = $request->withHeader($name, $value); } + if ($this->betas !== []) { + $request = $request->withHeader('anthropic-beta', self::mergeBetaHeader($request->getHeaderLine('anthropic-beta'), $this->betas)); + } + return $request; } + + /** + * Splits out the `betas` parameter from the request body, turning it into a per-request header. + * + * @param array $parameters + * @return array{0: array, 1: list} + */ + private static function splitBetas(array $parameters): array + { + if (! isset($parameters['betas']) || ! is_array($parameters['betas'])) { + return [$parameters, []]; + } + + /** @var array $rawBetas */ + $rawBetas = $parameters['betas']; + unset($parameters['betas']); + + $betas = []; + foreach ($rawBetas as $beta) { + if (is_string($beta) && trim($beta) !== '') { + $betas[] = trim($beta); + } + } + + return [$parameters, self::dedupeBetas($betas)]; + } + + /** + * Merges a comma-separated beta header string with additional betas, de-duplicating in order of appearance. + * + * @param list $betas + */ + private static function mergeBetaHeader(string $existing, array $betas): string + { + $existingList = []; + if ($existing !== '') { + foreach (explode(',', $existing) as $item) { + $trimmed = trim($item); + if ($trimmed !== '') { + $existingList[] = $trimmed; + } + } + } + + return implode(',', self::dedupeBetas([...$existingList, ...$betas])); + } + + /** + * @param list $betas + * @return list + */ + private static function dedupeBetas(array $betas): array + { + return array_values(array_unique($betas)); + } } diff --git a/tests/ValueObjects/Transporter/Payload.php b/tests/ValueObjects/Transporter/Payload.php index c01cf75..f196601 100644 --- a/tests/ValueObjects/Transporter/Payload.php +++ b/tests/ValueObjects/Transporter/Payload.php @@ -57,3 +57,160 @@ 'name' => 'test', ])); }); + +test('betas in create params become an anthropic-beta header and are stripped from the body', function () { + $payload = Payload::create('messages', [ + 'model' => 'claude-opus-4-6', + 'betas' => ['files-api-2025-04-14', 'extended-cache-ttl-2025-04-11'], + ]); + + $baseUri = BaseUri::from('api.anthropic.com/v1'); + $headers = Headers::withAuthorization(ApiKey::from('foo')); + $queryParams = QueryParams::create(); + + $request = $payload->toRequest($baseUri, $headers, $queryParams); + + expect($request->getHeaderLine('anthropic-beta')) + ->toBe('files-api-2025-04-14,extended-cache-ttl-2025-04-11'); + + expect($request->getBody()->getContents()) + ->toBe(json_encode(['model' => 'claude-opus-4-6'])); +}); + +test('betas in list params become a header and do not leak into the query string', function () { + $payload = Payload::list('messages/batches', [ + 'limit' => 10, + 'betas' => ['message-batches-2024-09-24'], + ]); + + $baseUri = BaseUri::from('api.anthropic.com/v1'); + $headers = Headers::withAuthorization(ApiKey::from('foo')); + $queryParams = QueryParams::create(); + + $request = $payload->toRequest($baseUri, $headers, $queryParams); + + expect($request->getHeaderLine('anthropic-beta'))->toBe('message-batches-2024-09-24'); + expect($request->getUri()->getQuery())->toBe('limit=10'); +}); + +test('betas in retrieve params become a header', function () { + $payload = Payload::retrieve('files', 'file_123', '', [ + 'betas' => ['files-api-2025-04-14'], + ]); + + $baseUri = BaseUri::from('api.anthropic.com/v1'); + $headers = Headers::withAuthorization(ApiKey::from('foo')); + $queryParams = QueryParams::create(); + + $request = $payload->toRequest($baseUri, $headers, $queryParams); + + expect($request->getHeaderLine('anthropic-beta'))->toBe('files-api-2025-04-14'); + expect($request->getUri()->getQuery())->toBe(''); +}); + +test('betas in upload params become a header and are stripped from the multipart body', function () { + $payload = Payload::upload('files', [ + 'file' => 'bytes', + 'betas' => ['files-api-2025-04-14'], + ]); + + $baseUri = BaseUri::from('api.anthropic.com/v1'); + $headers = Headers::withAuthorization(ApiKey::from('foo')); + $queryParams = QueryParams::create(); + + $request = $payload->toRequest($baseUri, $headers, $queryParams); + + expect($request->getHeaderLine('anthropic-beta'))->toBe('files-api-2025-04-14'); + expect($request->getBody()->getContents())->not->toContain('files-api-2025-04-14'); +}); + +test('withBetas merges with betas already extracted from parameters, de-duplicating', function () { + $payload = Payload::create('files', [ + 'betas' => ['files-api-2025-04-14'], + ])->withBetas(['files-api-2025-04-14', 'extended-cache-ttl-2025-04-11']); + + $baseUri = BaseUri::from('api.anthropic.com/v1'); + $headers = Headers::withAuthorization(ApiKey::from('foo')); + $queryParams = QueryParams::create(); + + $request = $payload->toRequest($baseUri, $headers, $queryParams); + + expect($request->getHeaderLine('anthropic-beta')) + ->toBe('files-api-2025-04-14,extended-cache-ttl-2025-04-11'); +}); + +test('per-request betas merge with a globally configured anthropic-beta header', function () { + $payload = Payload::create('messages', [ + 'betas' => ['files-api-2025-04-14'], + ]); + + $baseUri = BaseUri::from('api.anthropic.com/v1'); + $headers = Headers::withAuthorization(ApiKey::from('foo')) + ->withCustomHeader('anthropic-beta', 'interleaved-thinking-2025-05-14'); + $queryParams = QueryParams::create(); + + $request = $payload->toRequest($baseUri, $headers, $queryParams); + + expect($request->getHeaderLine('anthropic-beta')) + ->toBe('interleaved-thinking-2025-05-14,files-api-2025-04-14'); +}); + +test('duplicate betas across global header and per-request are de-duplicated', function () { + $payload = Payload::create('messages', [ + 'betas' => ['files-api-2025-04-14', 'interleaved-thinking-2025-05-14'], + ]); + + $baseUri = BaseUri::from('api.anthropic.com/v1'); + $headers = Headers::withAuthorization(ApiKey::from('foo')) + ->withCustomHeader('anthropic-beta', 'interleaved-thinking-2025-05-14'); + $queryParams = QueryParams::create(); + + $request = $payload->toRequest($baseUri, $headers, $queryParams); + + expect($request->getHeaderLine('anthropic-beta')) + ->toBe('interleaved-thinking-2025-05-14,files-api-2025-04-14'); +}); + +test('empty betas array is a no-op and the anthropic-beta header is not set', function () { + $payload = Payload::create('messages', [ + 'model' => 'claude-opus-4-6', + 'betas' => [], + ]); + + $baseUri = BaseUri::from('api.anthropic.com/v1'); + $headers = Headers::withAuthorization(ApiKey::from('foo')); + $queryParams = QueryParams::create(); + + $request = $payload->toRequest($baseUri, $headers, $queryParams); + + expect($request->hasHeader('anthropic-beta'))->toBeFalse(); + expect($request->getBody()->getContents()) + ->toBe(json_encode(['model' => 'claude-opus-4-6'])); +}); + +test('non-string beta entries are ignored', function () { + $payload = Payload::create('messages', [ + 'betas' => ['files-api-2025-04-14', 42, null, '', ' '], + ]); + + $baseUri = BaseUri::from('api.anthropic.com/v1'); + $headers = Headers::withAuthorization(ApiKey::from('foo')); + $queryParams = QueryParams::create(); + + $request = $payload->toRequest($baseUri, $headers, $queryParams); + + expect($request->getHeaderLine('anthropic-beta'))->toBe('files-api-2025-04-14'); +}); + +test('withBetas on a payload with no user-provided betas still sets the header', function () { + $payload = Payload::create('files', []) + ->withBetas(['files-api-2025-04-14']); + + $baseUri = BaseUri::from('api.anthropic.com/v1'); + $headers = Headers::withAuthorization(ApiKey::from('foo')); + $queryParams = QueryParams::create(); + + $request = $payload->toRequest($baseUri, $headers, $queryParams); + + expect($request->getHeaderLine('anthropic-beta'))->toBe('files-api-2025-04-14'); +});