diff --git a/README.md b/README.md index fa40286..a9e67bc 100644 --- a/README.md +++ b/README.md @@ -25,6 +25,7 @@ A community-maintained PHP SDK for the [Anthropic API](https://platform.claude.c - [Token Counting](https://mozex.dev/docs/anthropic-php/v1/usage/token-counting) - [Models](https://mozex.dev/docs/anthropic-php/v1/usage/models) - [Batches](https://mozex.dev/docs/anthropic-php/v1/usage/batches) + - [Files](https://mozex.dev/docs/anthropic-php/v1/usage/files) - [Completions](https://mozex.dev/docs/anthropic-php/v1/usage/completions) - Reference - [Configuration](https://mozex.dev/docs/anthropic-php/v1/reference/configuration) diff --git a/docs/reference/testing.md b/docs/reference/testing.md index 7206448..70d3223 100644 --- a/docs/reference/testing.md +++ b/docs/reference/testing.md @@ -51,6 +51,9 @@ Every response class has a `fake()` method: | `Batches\BatchListResponse::fake()` | Batch list | | `Batches\BatchResultResponse::fake()` | Batch results | | `Batches\DeletedBatchResponse::fake()` | Batch delete | +| `Files\FileResponse::fake()` | Files upload/retrieve metadata | +| `Files\FileListResponse::fake()` | Files list | +| `Files\DeletedFileResponse::fake()` | Files delete | ## Overriding response fields diff --git a/docs/usage/files.md b/docs/usage/files.md new file mode 100644 index 0000000..a19b4d4 --- /dev/null +++ b/docs/usage/files.md @@ -0,0 +1,187 @@ +--- +title: Files +weight: 10 +--- + +The Files API lets you upload documents once and reference them by `file_id` in any later Messages call. Anthropic currently flags this endpoint as beta on their side; the SDK sends the required header for you, so there's nothing to configure. + +## Uploading a file + +Pass any readable resource under the `file` key. The client streams it to `/v1/files` as `multipart/form-data` and gives you back the metadata: + +```php +$response = $client->files()->upload([ + 'file' => fopen('/path/to/document.pdf', 'r'), +]); + +$response->id; // 'file_011CNha8iCJcU1wXNR6q4V8w' +$response->type; // 'file' +$response->filename; // 'document.pdf' +$response->mimeType; // 'application/pdf' +$response->sizeBytes; // 1024000 +$response->createdAt; // '2025-01-01T00:00:00Z' +$response->downloadable; // false +``` + +Supported types map to content blocks like this: + +| File type | MIME | Use it in | +|-----------|------|-----------| +| PDF | `application/pdf` | `document` block | +| Plain text | `text/plain` | `document` block | +| Images | `image/jpeg`, `image/png`, `image/gif`, `image/webp` | `image` block | +| Code execution inputs | varies (CSV, XLSX, JSON, etc.) | `container_upload` block | + +Max file size is 500 MB. The storage ceiling is 500 GB per organization. + +Keep the `id` wherever you'd normally keep a file path. You'll hand it back to the Messages API as a `file_id`. + +## Referencing a file in a message + +Once you have an `id`, drop it into a content block. For a PDF or text file, that's a `document` block with `source.type` set to `file`. Note the `betas` key on the Messages call: the Messages endpoint only accepts `source.type: file` when the `files-api-2025-04-14` beta header is on that specific request. The SDK auto-injects it on `$client->files()` calls but doesn't know whether any given Messages call references a file_id, so you pass it explicitly here: + +```php +$response = $client->messages()->create([ + 'model' => 'claude-opus-4-6', + 'max_tokens' => 1024, + 'betas' => ['files-api-2025-04-14'], + 'messages' => [ + [ + 'role' => 'user', + 'content' => [ + ['type' => 'text', 'text' => 'Summarise this document.'], + [ + 'type' => 'document', + 'source' => [ + 'type' => 'file', + 'file_id' => 'file_011CNha8iCJcU1wXNR6q4V8w', + ], + ], + ], + ], + ], +]); +``` + +If every Messages call in your app references uploaded files, skip the per-call `betas` and set the header globally on the factory instead: + +```php +$client = Anthropic::factory() + ->withApiKey('your-api-key') + ->withHttpHeader('anthropic-beta', 'files-api-2025-04-14') + ->make(); +``` + +For images, swap `document` for `image`: + +```php +[ + 'type' => 'image', + 'source' => [ + 'type' => 'file', + 'file_id' => 'file_011CPMxVD3fHLUhvTqtsQA5w', + ], +] +``` + +Same file can be referenced from any number of requests, which is the point of the whole API. + +## Listing files + +`list()` returns a cursor-paginated page of files in the workspace tied to your API key: + +```php +$response = $client->files()->list(['limit' => 20]); + +foreach ($response->data as $file) { + $file->id; + $file->filename; + $file->sizeBytes; +} + +$response->firstId; // 'file_011CNha8iCJcU1wXNR6q4V8w' +$response->lastId; // 'file_011CPMxVD3fHLUhvTqtsQA5w' +$response->hasMore; // false +``` + +Pagination works like the Models and Batches endpoints. `limit` goes up to 1000 and defaults to 20. Use `after_id` with the previous page's `lastId` to walk forward, or `before_id` with `firstId` to walk backward: + +```php +$page1 = $client->files()->list(['limit' => 100]); + +while ($page1->hasMore) { + $page1 = $client->files()->list([ + 'limit' => 100, + 'after_id' => $page1->lastId, + ]); +} +``` + +There's also an optional `scope_id` parameter to filter by session scope, which matters if you're using files inside a session. + +## Retrieving metadata + +`retrieveMetadata()` is a GET on a single file. Same response shape as upload: + +```php +$file = $client->files()->retrieveMetadata('file_011CNha8iCJcU1wXNR6q4V8w'); + +$file->filename; // 'document.pdf' +$file->mimeType; // 'application/pdf' +$file->sizeBytes; // 1024000 +$file->downloadable; // false +``` + +For session-scoped files (created by the code execution tool or Skills API), `scope` tells you where it came from: + +```php +$file->scope?->type; // 'session' +$file->scope?->id; // 'session_01AbCdEfGhIjKlMnOpQrStUv' +``` + +## Downloading a file + +Only files created by the [code execution tool](https://platform.claude.com/docs/en/agents-and-tools/tool-use/code-execution-tool) or [Skills](https://platform.claude.com/docs/en/build-with-claude/skills-guide) can be downloaded. Files you upload yourself can't be read back. `downloadable` on the metadata tells you which is which. + +`download()` returns the raw bytes as a string: + +```php +$bytes = $client->files()->download('file_011CPMxVD3fHLUhvTqtsQA5w'); + +file_put_contents('output.png', $bytes); +``` + +For a large download, wrap the write in something that writes to disk as it goes rather than holding the whole thing in memory. If you're building on top of Guzzle or Symfony, you can swap the transporter out via the factory and stream directly. + +## Deleting a file + +```php +$response = $client->files()->delete('file_011CNha8iCJcU1wXNR6q4V8w'); + +$response->id; // 'file_011CNha8iCJcU1wXNR6q4V8w' +$response->type; // 'file_deleted' +``` + +Deletes are permanent. Files still being referenced in an in-flight Messages call may keep working briefly, but new requests using that `file_id` will fail with a 404. + +## Errors to expect + +The common ones, from the [Anthropic docs](https://platform.claude.com/docs/en/build-with-claude/files): + +- `404` if the `file_id` doesn't exist or belongs to another workspace. +- `400` if you use the wrong block type for the file (e.g., image file inside a `document` block). +- `400` if the filename breaks the rules (1 to 255 characters, no `< > : " | ? * \ /` or control characters). +- `413` if the file is over 500 MB. +- `403` if your organization is over the 500 GB total. + +All of these surface as `Anthropic\Exceptions\ErrorException` with the usual `message` and `type` on the payload. + +## Rate limits + +Anthropic caps file-related calls at roughly 100 per minute. Normal messages and token usage rate limits still apply on top. + +File operations themselves (upload, list, retrieve, download, delete) are free. Tokens are only charged when the file is actually used inside a Messages call. + +--- + +For the full API reference, see the [Files API guide](https://platform.claude.com/docs/en/build-with-claude/files) and the [Files endpoint reference](https://platform.claude.com/docs/en/api/files-list) on the Anthropic docs. diff --git a/src/Client.php b/src/Client.php index 39a3a91..9c3c694 100644 --- a/src/Client.php +++ b/src/Client.php @@ -8,6 +8,7 @@ use Anthropic\Contracts\TransporterContract; use Anthropic\Resources\Batches; use Anthropic\Resources\Completions; +use Anthropic\Resources\Files; use Anthropic\Resources\Messages; use Anthropic\Resources\Models; @@ -61,4 +62,14 @@ public function batches(): Batches { return new Batches($this->transporter); } + + /** + * Upload, list, retrieve, download, and delete files. + * + * @see https://platform.claude.com/docs/en/build-with-claude/files + */ + public function files(): Files + { + return new Files($this->transporter); + } } diff --git a/src/Contracts/ClientContract.php b/src/Contracts/ClientContract.php index 0b5e86b..abf163e 100644 --- a/src/Contracts/ClientContract.php +++ b/src/Contracts/ClientContract.php @@ -4,6 +4,7 @@ use Anthropic\Contracts\Resources\BatchesContract; use Anthropic\Contracts\Resources\CompletionsContract; +use Anthropic\Contracts\Resources\FilesContract; use Anthropic\Contracts\Resources\MessagesContract; use Anthropic\Contracts\Resources\ModelsContract; @@ -37,4 +38,11 @@ public function models(): ModelsContract; * @see https://platform.claude.com/docs/en/api/messages/batches/create */ public function batches(): BatchesContract; + + /** + * Upload, list, retrieve, download, and delete files. + * + * @see https://platform.claude.com/docs/en/build-with-claude/files + */ + public function files(): FilesContract; } diff --git a/src/Contracts/Resources/FilesContract.php b/src/Contracts/Resources/FilesContract.php new file mode 100644 index 0000000..44ec2a6 --- /dev/null +++ b/src/Contracts/Resources/FilesContract.php @@ -0,0 +1,49 @@ + $parameters + */ + public function upload(array $parameters): FileResponse; + + /** + * Lists files belonging to the workspace of the API key. + * + * @see https://platform.claude.com/docs/en/api/files-list + * + * @param array $parameters + */ + public function list(array $parameters = []): FileListResponse; + + /** + * Retrieves metadata for a specific file. + * + * @see https://platform.claude.com/docs/en/api/files-metadata + */ + public function retrieveMetadata(string $fileId): FileResponse; + + /** + * Downloads the contents of a file created by skills or the code execution tool. + * + * @see https://platform.claude.com/docs/en/api/files-content + */ + public function download(string $fileId): string; + + /** + * Deletes a file. + * + * @see https://platform.claude.com/docs/en/api/files-delete + */ + public function delete(string $fileId): DeletedFileResponse; +} diff --git a/src/Resources/Files.php b/src/Resources/Files.php new file mode 100644 index 0000000..13a73f8 --- /dev/null +++ b/src/Resources/Files.php @@ -0,0 +1,98 @@ + $parameters + */ + public function upload(array $parameters): FileResponse + { + $payload = Payload::upload('files', $parameters)->withBetas([self::BETA]); + + /** @var Response $response */ + $response = $this->transporter->requestObject($payload); + + return FileResponse::from($response->data(), $response->meta()); + } + + /** + * Lists files belonging to the workspace of the API key. + * + * @see https://platform.claude.com/docs/en/api/files-list + * + * @param array $parameters + */ + public function list(array $parameters = []): FileListResponse + { + $payload = Payload::list('files', $parameters)->withBetas([self::BETA]); + + /** @var Response, first_id?: ?string, last_id?: ?string, has_more?: bool}> $response */ + $response = $this->transporter->requestObject($payload); + + return FileListResponse::from($response->data(), $response->meta()); + } + + /** + * Retrieves metadata for a specific file. + * + * @see https://platform.claude.com/docs/en/api/files-metadata + */ + public function retrieveMetadata(string $fileId): FileResponse + { + $payload = Payload::retrieve('files', $fileId)->withBetas([self::BETA]); + + /** @var Response $response */ + $response = $this->transporter->requestObject($payload); + + return FileResponse::from($response->data(), $response->meta()); + } + + /** + * Downloads the contents of a file created by skills or the code execution tool. + * + * @see https://platform.claude.com/docs/en/api/files-content + */ + public function download(string $fileId): string + { + $payload = Payload::retrieveContent('files', $fileId)->withBetas([self::BETA]); + + return $this->transporter->requestContent($payload); + } + + /** + * Deletes a file. + * + * @see https://platform.claude.com/docs/en/api/files-delete + */ + public function delete(string $fileId): DeletedFileResponse + { + $payload = Payload::delete('files', $fileId)->withBetas([self::BETA]); + + /** @var Response $response */ + $response = $this->transporter->requestObject($payload); + + return DeletedFileResponse::from($response->data(), $response->meta()); + } +} diff --git a/src/Responses/Files/DeletedFileResponse.php b/src/Responses/Files/DeletedFileResponse.php new file mode 100644 index 0000000..95ed937 --- /dev/null +++ b/src/Responses/Files/DeletedFileResponse.php @@ -0,0 +1,57 @@ + + */ +final class DeletedFileResponse implements ResponseContract, ResponseHasMetaInformationContract +{ + /** + * @use ArrayAccessible + */ + use ArrayAccessible; + + use Fakeable; + use HasMetaInformation; + + private function __construct( + public readonly string $id, + public readonly string $type, + private readonly MetaInformation $meta, + ) {} + + /** + * Acts as static factory, and returns a new Response instance. + * + * @param array{id: string, type?: string} $attributes + */ + public static function from(array $attributes, MetaInformation $meta): self + { + return new self( + $attributes['id'], + $attributes['type'] ?? 'file_deleted', + $meta, + ); + } + + /** + * {@inheritDoc} + */ + public function toArray(): array + { + return [ + 'id' => $this->id, + 'type' => $this->type, + ]; + } +} diff --git a/src/Responses/Files/FileListResponse.php b/src/Responses/Files/FileListResponse.php new file mode 100644 index 0000000..4319e40 --- /dev/null +++ b/src/Responses/Files/FileListResponse.php @@ -0,0 +1,74 @@ +, first_id: ?string, last_id: ?string, has_more: bool}> + */ +final class FileListResponse implements ResponseContract, ResponseHasMetaInformationContract +{ + /** + * @use ArrayAccessible, first_id: ?string, last_id: ?string, has_more: bool}> + */ + use ArrayAccessible; + + use Fakeable; + use HasMetaInformation; + + /** + * @param array $data + */ + private function __construct( + public readonly array $data, + public readonly ?string $firstId, + public readonly ?string $lastId, + public readonly bool $hasMore, + private readonly MetaInformation $meta, + ) {} + + /** + * Acts as static factory, and returns a new Response instance. + * + * @param array{data: array, first_id?: ?string, last_id?: ?string, has_more?: bool} $attributes + */ + public static function from(array $attributes, MetaInformation $meta): self + { + $data = array_map( + fn (array $result): FileResponse => FileResponse::from($result, $meta), + $attributes['data'], + ); + + return new self( + $data, + $attributes['first_id'] ?? null, + $attributes['last_id'] ?? null, + $attributes['has_more'] ?? false, + $meta, + ); + } + + /** + * {@inheritDoc} + */ + public function toArray(): array + { + return [ + 'data' => array_map( + static fn (FileResponse $response): array => $response->toArray(), + $this->data, + ), + 'first_id' => $this->firstId, + 'last_id' => $this->lastId, + 'has_more' => $this->hasMore, + ]; + } +} diff --git a/src/Responses/Files/FileResponse.php b/src/Responses/Files/FileResponse.php new file mode 100644 index 0000000..99f0a7c --- /dev/null +++ b/src/Responses/Files/FileResponse.php @@ -0,0 +1,83 @@ + + */ +final class FileResponse implements ResponseContract, ResponseHasMetaInformationContract +{ + /** + * @use ArrayAccessible + */ + use ArrayAccessible; + + use Fakeable; + use HasMetaInformation; + + private function __construct( + public readonly string $id, + public readonly string $type, + public readonly string $filename, + public readonly string $mimeType, + public readonly int $sizeBytes, + public readonly string $createdAt, + public readonly ?bool $downloadable, + public readonly ?FileResponseScope $scope, + private readonly MetaInformation $meta, + ) {} + + /** + * Acts as static factory, and returns a new Response instance. + * + * @param array{id: string, type: string, filename: string, mime_type: string, size_bytes: int, created_at: string, downloadable?: bool, scope?: array{id: string, type: string}} $attributes + */ + public static function from(array $attributes, MetaInformation $meta): self + { + return new self( + $attributes['id'], + $attributes['type'], + $attributes['filename'], + $attributes['mime_type'], + $attributes['size_bytes'], + $attributes['created_at'], + $attributes['downloadable'] ?? null, + isset($attributes['scope']) ? FileResponseScope::from($attributes['scope']) : null, + $meta, + ); + } + + /** + * {@inheritDoc} + */ + public function toArray(): array + { + $result = [ + 'id' => $this->id, + 'type' => $this->type, + 'filename' => $this->filename, + 'mime_type' => $this->mimeType, + 'size_bytes' => $this->sizeBytes, + 'created_at' => $this->createdAt, + ]; + + if ($this->downloadable !== null) { + $result['downloadable'] = $this->downloadable; + } + + if ($this->scope !== null) { + $result['scope'] = $this->scope->toArray(); + } + + return $result; + } +} diff --git a/src/Responses/Files/FileResponseScope.php b/src/Responses/Files/FileResponseScope.php new file mode 100644 index 0000000..6e4ba58 --- /dev/null +++ b/src/Responses/Files/FileResponseScope.php @@ -0,0 +1,35 @@ + $this->id, + 'type' => $this->type, + ]; + } +} diff --git a/src/Testing/ClientFake.php b/src/Testing/ClientFake.php index 07e30a8..ce0b677 100644 --- a/src/Testing/ClientFake.php +++ b/src/Testing/ClientFake.php @@ -10,6 +10,7 @@ use Anthropic\Testing\Requests\TestRequest; use Anthropic\Testing\Resources\BatchesTestResource; use Anthropic\Testing\Resources\CompletionsTestResource; +use Anthropic\Testing\Resources\FilesTestResource; use Anthropic\Testing\Resources\MessagesTestResource; use Anthropic\Testing\Resources\ModelsTestResource; use PHPUnit\Framework\Assert as PHPUnit; @@ -140,4 +141,9 @@ public function batches(): BatchesTestResource { return new BatchesTestResource($this); } + + public function files(): FilesTestResource + { + return new FilesTestResource($this); + } } diff --git a/src/Testing/Resources/FilesTestResource.php b/src/Testing/Resources/FilesTestResource.php new file mode 100644 index 0000000..3df75c7 --- /dev/null +++ b/src/Testing/Resources/FilesTestResource.php @@ -0,0 +1,45 @@ +record(__FUNCTION__, func_get_args()); + } + + public function list(array $parameters = []): FileListResponse + { + return $this->record(__FUNCTION__, func_get_args()); + } + + public function retrieveMetadata(string $fileId): FileResponse + { + return $this->record(__FUNCTION__, func_get_args()); + } + + public function download(string $fileId): string + { + return $this->record(__FUNCTION__, func_get_args()); + } + + public function delete(string $fileId): DeletedFileResponse + { + return $this->record(__FUNCTION__, func_get_args()); + } +} diff --git a/src/Testing/Responses/Concerns/Files/Fakeable.php b/src/Testing/Responses/Concerns/Files/Fakeable.php new file mode 100644 index 0000000..7bc5f0e --- /dev/null +++ b/src/Testing/Responses/Concerns/Files/Fakeable.php @@ -0,0 +1,53 @@ + $override + */ + public static function fake( + array $override = [], + ?MetaInformation $meta = null, + OverrideStrategy $strategy = OverrideStrategy::Merge, + ): static { + $class = str_replace('Anthropic\\Responses\\', 'Anthropic\\Testing\\Responses\\Fixtures\\', static::class).'Fixture'; + + return static::from( + self::buildAttributes($class::ATTRIBUTES, $override, $strategy), + $meta ?? self::fakeResponseMetaInformation(), + ); + } + + /** + * @param array $original + * @param array $override + * @return array + */ + private static function buildAttributes(array $original, array $override, OverrideStrategy $strategy = OverrideStrategy::Merge): array + { + return match ($strategy) { + OverrideStrategy::Replace => array_replace($original, $override), + OverrideStrategy::Merge => array_replace_recursive($original, $override), + }; + } + + public static function fakeResponseMetaInformation(): MetaInformation + { + return MetaInformation::from([ + 'anthropic-ratelimit-requests-limit' => [5], + 'anthropic-ratelimit-requests-remaining' => [4], + 'anthropic-ratelimit-requests-reset' => ['2024-04-30T15:56:17Z'], + 'anthropic-ratelimit-tokens-limit' => [25000], + 'anthropic-ratelimit-tokens-remaining' => [25000], + 'anthropic-ratelimit-tokens-reset' => ['2024-04-30T15:56:17Z'], + 'request-id' => ['02c10373f63cf2954851197d75c0adab'], + ]); + } +} diff --git a/src/Testing/Responses/Fixtures/Files/DeletedFileResponseFixture.php b/src/Testing/Responses/Fixtures/Files/DeletedFileResponseFixture.php new file mode 100644 index 0000000..5f4803b --- /dev/null +++ b/src/Testing/Responses/Fixtures/Files/DeletedFileResponseFixture.php @@ -0,0 +1,11 @@ + 'file_011CNha8iCJcU1wXNR6q4V8w', + 'type' => 'file_deleted', + ]; +} diff --git a/src/Testing/Responses/Fixtures/Files/FileListResponseFixture.php b/src/Testing/Responses/Fixtures/Files/FileListResponseFixture.php new file mode 100644 index 0000000..996f4d0 --- /dev/null +++ b/src/Testing/Responses/Fixtures/Files/FileListResponseFixture.php @@ -0,0 +1,15 @@ + [ + FileResponseFixture::ATTRIBUTES, + ], + 'first_id' => 'file_011CNha8iCJcU1wXNR6q4V8w', + 'last_id' => 'file_011CNha8iCJcU1wXNR6q4V8w', + 'has_more' => false, + ]; +} diff --git a/src/Testing/Responses/Fixtures/Files/FileResponseFixture.php b/src/Testing/Responses/Fixtures/Files/FileResponseFixture.php new file mode 100644 index 0000000..cf2f032 --- /dev/null +++ b/src/Testing/Responses/Fixtures/Files/FileResponseFixture.php @@ -0,0 +1,16 @@ + 'file_011CNha8iCJcU1wXNR6q4V8w', + 'type' => 'file', + 'filename' => 'document.pdf', + 'mime_type' => 'application/pdf', + 'size_bytes' => 1024000, + 'created_at' => '2025-01-01T00:00:00Z', + 'downloadable' => false, + ]; +} diff --git a/src/ValueObjects/Transporter/Payload.php b/src/ValueObjects/Transporter/Payload.php index bd19c43..855896b 100644 --- a/src/ValueObjects/Transporter/Payload.php +++ b/src/ValueObjects/Transporter/Payload.php @@ -162,7 +162,7 @@ public function toRequest(BaseUri $baseUri, Headers $headers, QueryParams $query if ($this->contentType === ContentType::MULTIPART) { $streamBuilder = new MultipartStreamBuilder($psr17Factory); - /** @var array> $parameters */ + /** @var array> $parameters */ $parameters = $this->parameters; foreach ($parameters as $key => $value) { diff --git a/tests/Helpers/Files.php b/tests/Helpers/Files.php new file mode 100644 index 0000000..a841e75 --- /dev/null +++ b/tests/Helpers/Files.php @@ -0,0 +1,61 @@ + 'file_011CNha8iCJcU1wXNR6q4V8w', + 'type' => 'file', + 'filename' => 'document.pdf', + 'mime_type' => 'application/pdf', + 'size_bytes' => 1024000, + 'created_at' => '2025-01-01T00:00:00Z', + 'downloadable' => false, + ]; +} + +/** + * Composed from `fileResponse()` with a `scope` field added. The Anthropic docs define + * `BetaFileScope` as `{id: string, type: "session"}` but don't provide a concrete session + * ID example, so the `scope.id` value is illustrative. + * + * @return array{id: string, type: string, filename: string, mime_type: string, size_bytes: int, created_at: string, downloadable: bool, scope: array{id: string, type: string}} + */ +function fileScopedResponse(): array +{ + return [ + ...fileResponse(), + 'scope' => [ + 'id' => 'session_01AbCdEfGhIjKlMnOpQrStUv', + 'type' => 'session', + ], + ]; +} + +/** + * @return array{data: array, first_id: string, last_id: string, has_more: bool} + */ +function fileListResponse(): array +{ + return [ + 'data' => [ + fileResponse(), + ], + 'first_id' => 'file_011CNha8iCJcU1wXNR6q4V8w', + 'last_id' => 'file_011CNha8iCJcU1wXNR6q4V8w', + 'has_more' => false, + ]; +} + +/** + * @return array{id: string, type: string} + */ +function deletedFileResponse(): array +{ + return [ + 'id' => 'file_011CNha8iCJcU1wXNR6q4V8w', + 'type' => 'file_deleted', + ]; +} diff --git a/tests/Resources/Files.php b/tests/Resources/Files.php new file mode 100644 index 0000000..6546b29 --- /dev/null +++ b/tests/Resources/Files.php @@ -0,0 +1,239 @@ +files()->upload([ + 'file' => $handle, + ]); + + expect($result) + ->toBeInstanceOf(FileResponse::class) + ->id->toBe('file_011CNha8iCJcU1wXNR6q4V8w') + ->type->toBe('file') + ->filename->toBe('document.pdf') + ->mimeType->toBe('application/pdf') + ->sizeBytes->toBe(1024000) + ->createdAt->toBe('2025-01-01T00:00:00Z') + ->downloadable->toBeFalse() + ->scope->toBeNull(); + + expect($result->meta()) + ->toBeInstanceOf(MetaInformation::class); +}); + +test('list', function () { + $client = mockClient( + 'GET', + 'files', + [], + Response::from(fileListResponse(), metaHeaders()), + validateParams: false, + ); + + $result = $client->files()->list(); + + expect($result) + ->toBeInstanceOf(FileListResponse::class) + ->data->toBeArray()->toHaveCount(1) + ->data->each->toBeInstanceOf(FileResponse::class) + ->firstId->toBe('file_011CNha8iCJcU1wXNR6q4V8w') + ->lastId->toBe('file_011CNha8iCJcU1wXNR6q4V8w') + ->hasMore->toBeFalse(); + + expect($result->meta()) + ->toBeInstanceOf(MetaInformation::class); +}); + +test('list with pagination parameters', function () { + $client = mockClient( + 'GET', + 'files', + ['limit' => 5, 'after_id' => 'file_011CNha8iCJcU1wXNR6q4V8w'], + Response::from(fileListResponse(), metaHeaders()), + ); + + $result = $client->files()->list([ + 'limit' => 5, + 'after_id' => 'file_011CNha8iCJcU1wXNR6q4V8w', + ]); + + expect($result)->toBeInstanceOf(FileListResponse::class); +}); + +test('retrieve metadata', function () { + $client = mockClient( + 'GET', + 'files/file_011CNha8iCJcU1wXNR6q4V8w', + [], + Response::from(fileResponse(), metaHeaders()), + ); + + $result = $client->files()->retrieveMetadata('file_011CNha8iCJcU1wXNR6q4V8w'); + + expect($result) + ->toBeInstanceOf(FileResponse::class) + ->id->toBe('file_011CNha8iCJcU1wXNR6q4V8w') + ->filename->toBe('document.pdf') + ->mimeType->toBe('application/pdf'); + + expect($result->meta()) + ->toBeInstanceOf(MetaInformation::class); +}); + +test('retrieve metadata with scope', function () { + $client = mockClient( + 'GET', + 'files/file_011CNha8iCJcU1wXNR6q4V8w', + [], + Response::from(fileScopedResponse(), metaHeaders()), + ); + + $result = $client->files()->retrieveMetadata('file_011CNha8iCJcU1wXNR6q4V8w'); + + expect($result->scope) + ->not->toBeNull() + ->id->toBe('session_01AbCdEfGhIjKlMnOpQrStUv') + ->type->toBe('session'); +}); + +test('download', function () { + $client = mockContentClient( + 'GET', + 'files/file_011CPMxVD3fHLUhvTqtsQA5w/content', + [], + 'raw-file-bytes', + ); + + $result = $client->files()->download('file_011CPMxVD3fHLUhvTqtsQA5w'); + + expect($result)->toBe('raw-file-bytes'); +}); + +test('delete', function () { + $client = mockClient( + 'DELETE', + 'files/file_011CNha8iCJcU1wXNR6q4V8w', + [], + Response::from(deletedFileResponse(), metaHeaders()), + ); + + $result = $client->files()->delete('file_011CNha8iCJcU1wXNR6q4V8w'); + + expect($result) + ->toBeInstanceOf(DeletedFileResponse::class) + ->id->toBe('file_011CNha8iCJcU1wXNR6q4V8w') + ->type->toBe('file_deleted'); + + expect($result->meta()) + ->toBeInstanceOf(MetaInformation::class); +}); + +test('every files method auto-injects the files-api-2025-04-14 beta header', function ( + string $transporterMethod, + Closure $invoke, + Closure $responseFactory, +) { + $transporter = Mockery::mock(TransporterContract::class); + + $response = $responseFactory(); + + $capturedPayload = null; + $transporter->shouldReceive($transporterMethod) + ->once() + ->andReturnUsing(function (Payload $payload) use (&$capturedPayload, $response) { + $capturedPayload = $payload; + + return $response; + }); + + $client = new Client($transporter); + + $invoke($client); + + $request = $capturedPayload->toRequest( + BaseUri::from('api.anthropic.com/v1'), + Headers::withAuthorization(ApiKey::from('foo')), + QueryParams::create(), + ); + + expect($request->getHeaderLine('anthropic-beta'))->toBe('files-api-2025-04-14'); +})->with([ + 'upload' => [ + 'requestObject', + fn (Client $client) => $client->files()->upload(['file' => 'bytes']), + fn () => Response::from(fileResponse(), metaHeaders()), + ], + 'list' => [ + 'requestObject', + fn (Client $client) => $client->files()->list(), + fn () => Response::from(fileListResponse(), metaHeaders()), + ], + 'retrieveMetadata' => [ + 'requestObject', + fn (Client $client) => $client->files()->retrieveMetadata('file_011CNha8iCJcU1wXNR6q4V8w'), + fn () => Response::from(fileResponse(), metaHeaders()), + ], + 'download' => [ + 'requestContent', + fn (Client $client) => $client->files()->download('file_011CPMxVD3fHLUhvTqtsQA5w'), + fn () => 'raw-file-bytes', + ], + 'delete' => [ + 'requestObject', + fn (Client $client) => $client->files()->delete('file_011CNha8iCJcU1wXNR6q4V8w'), + fn () => Response::from(deletedFileResponse(), metaHeaders()), + ], +]); + +test('user-provided betas merge with the auto-injected files-api beta', function () { + $transporter = Mockery::mock(TransporterContract::class); + + $capturedPayload = null; + $transporter->shouldReceive('requestObject') + ->once() + ->andReturnUsing(function (Payload $payload) use (&$capturedPayload) { + $capturedPayload = $payload; + + return Response::from(fileListResponse(), metaHeaders()); + }); + + $client = new Client($transporter); + + $client->files()->list([ + 'limit' => 10, + 'betas' => ['extended-cache-ttl-2025-04-11'], + ]); + + $request = $capturedPayload->toRequest( + BaseUri::from('api.anthropic.com/v1'), + Headers::withAuthorization(ApiKey::from('foo')), + QueryParams::create(), + ); + + expect($request->getHeaderLine('anthropic-beta')) + ->toBe('extended-cache-ttl-2025-04-11,files-api-2025-04-14'); +}); diff --git a/tests/Responses/Files/DeletedFileResponse.php b/tests/Responses/Files/DeletedFileResponse.php new file mode 100644 index 0000000..c5651ec --- /dev/null +++ b/tests/Responses/Files/DeletedFileResponse.php @@ -0,0 +1,45 @@ +toBeInstanceOf(DeletedFileResponse::class) + ->id->toBe('file_011CNha8iCJcU1wXNR6q4V8w') + ->type->toBe('file_deleted') + ->meta()->toBeInstanceOf(MetaInformation::class); +}); + +test('from defaults type when missing', function () { + $result = DeletedFileResponse::from([ + 'id' => 'file_011CNha8iCJcU1wXNR6q4V8w', + ], meta()); + + expect($result->type)->toBe('file_deleted'); +}); + +test('as array accessible', function () { + $result = DeletedFileResponse::from(deletedFileResponse(), meta()); + + expect(isset($result['id']))->toBeTrue(); + expect($result['id'])->toBe('file_011CNha8iCJcU1wXNR6q4V8w'); +}); + +test('to array', function () { + $result = DeletedFileResponse::from(deletedFileResponse(), meta()); + + expect($result->toArray()) + ->toBeArray() + ->toBe(deletedFileResponse()); +}); + +test('fake', function () { + $response = DeletedFileResponse::fake(); + + expect($response) + ->id->toBe('file_011CNha8iCJcU1wXNR6q4V8w') + ->type->toBe('file_deleted'); +}); diff --git a/tests/Responses/Files/FileListResponse.php b/tests/Responses/Files/FileListResponse.php new file mode 100644 index 0000000..a3ad2d1 --- /dev/null +++ b/tests/Responses/Files/FileListResponse.php @@ -0,0 +1,69 @@ +toBeInstanceOf(FileListResponse::class) + ->data->toBeArray()->toHaveCount(1) + ->data->each->toBeInstanceOf(FileResponse::class) + ->firstId->toBe('file_011CNha8iCJcU1wXNR6q4V8w') + ->lastId->toBe('file_011CNha8iCJcU1wXNR6q4V8w') + ->hasMore->toBeFalse() + ->meta()->toBeInstanceOf(MetaInformation::class); +}); + +test('from empty list', function () { + $result = FileListResponse::from([ + 'data' => [], + 'first_id' => null, + 'last_id' => null, + 'has_more' => false, + ], meta()); + + expect($result) + ->data->toBe([]) + ->firstId->toBeNull() + ->lastId->toBeNull() + ->hasMore->toBeFalse(); +}); + +test('from without pagination metadata', function () { + $result = FileListResponse::from([ + 'data' => [fileResponse()], + ], meta()); + + expect($result) + ->firstId->toBeNull() + ->lastId->toBeNull() + ->hasMore->toBeFalse(); +}); + +test('as array accessible', function () { + $result = FileListResponse::from(fileListResponse(), meta()); + + expect(isset($result['data']))->toBeTrue(); + expect($result['first_id'])->toBe('file_011CNha8iCJcU1wXNR6q4V8w'); +}); + +test('to array', function () { + $result = FileListResponse::from(fileListResponse(), meta()); + + expect($result->toArray()) + ->toBeArray() + ->toBe(fileListResponse()); +}); + +test('fake', function () { + $response = FileListResponse::fake(); + + expect($response) + ->toBeInstanceOf(FileListResponse::class) + ->data->toHaveCount(1) + ->data->each->toBeInstanceOf(FileResponse::class) + ->hasMore->toBeFalse(); +}); diff --git a/tests/Responses/Files/FileResponse.php b/tests/Responses/Files/FileResponse.php new file mode 100644 index 0000000..2efa5eb --- /dev/null +++ b/tests/Responses/Files/FileResponse.php @@ -0,0 +1,98 @@ +toBeInstanceOf(FileResponse::class) + ->id->toBe('file_011CNha8iCJcU1wXNR6q4V8w') + ->type->toBe('file') + ->filename->toBe('document.pdf') + ->mimeType->toBe('application/pdf') + ->sizeBytes->toBe(1024000) + ->createdAt->toBe('2025-01-01T00:00:00Z') + ->downloadable->toBeFalse() + ->scope->toBeNull() + ->meta()->toBeInstanceOf(MetaInformation::class); +}); + +test('from with scope', function () { + $result = FileResponse::from(fileScopedResponse(), meta()); + + expect($result->scope) + ->toBeInstanceOf(FileResponseScope::class) + ->id->toBe('session_01AbCdEfGhIjKlMnOpQrStUv') + ->type->toBe('session'); +}); + +test('from without downloadable', function () { + $attributes = fileResponse(); + unset($attributes['downloadable']); + + $result = FileResponse::from($attributes, meta()); + + expect($result->downloadable)->toBeNull(); +}); + +test('as array accessible', function () { + $result = FileResponse::from(fileResponse(), meta()); + + expect(isset($result['id']))->toBeTrue(); + expect($result['id'])->toBe('file_011CNha8iCJcU1wXNR6q4V8w'); +}); + +test('to array', function () { + $result = FileResponse::from(fileResponse(), meta()); + + expect($result->toArray()) + ->toBeArray() + ->toBe(fileResponse()); +}); + +test('to array with scope', function () { + $result = FileResponse::from(fileScopedResponse(), meta()); + + expect($result->toArray()) + ->toBeArray() + ->toBe(fileScopedResponse()); +}); + +test('to array omits missing optional fields', function () { + $attributes = fileResponse(); + unset($attributes['downloadable']); + + $result = FileResponse::from($attributes, meta()); + + expect($result->toArray()) + ->toBeArray() + ->toBe($attributes) + ->not->toHaveKey('downloadable') + ->not->toHaveKey('scope'); +}); + +test('fake', function () { + $response = FileResponse::fake(); + + expect($response) + ->id->toBe('file_011CNha8iCJcU1wXNR6q4V8w') + ->type->toBe('file') + ->filename->toBe('document.pdf') + ->mimeType->toBe('application/pdf'); +}); + +test('fake with override', function () { + $response = FileResponse::fake([ + 'filename' => 'dataset.csv', + 'mime_type' => 'text/csv', + 'size_bytes' => 42, + ]); + + expect($response) + ->filename->toBe('dataset.csv') + ->mimeType->toBe('text/csv') + ->sizeBytes->toBe(42); +}); diff --git a/tests/Responses/Files/FileResponseScope.php b/tests/Responses/Files/FileResponseScope.php new file mode 100644 index 0000000..d82d54d --- /dev/null +++ b/tests/Responses/Files/FileResponseScope.php @@ -0,0 +1,28 @@ + 'session_01AbCdEfGhIjKlMnOpQrStUv', + 'type' => 'session', + ]); + + expect($result) + ->toBeInstanceOf(FileResponseScope::class) + ->id->toBe('session_01AbCdEfGhIjKlMnOpQrStUv') + ->type->toBe('session'); +}); + +test('to array', function () { + $result = FileResponseScope::from([ + 'id' => 'session_01AbCdEfGhIjKlMnOpQrStUv', + 'type' => 'session', + ]); + + expect($result->toArray()) + ->toBe([ + 'id' => 'session_01AbCdEfGhIjKlMnOpQrStUv', + 'type' => 'session', + ]); +}); diff --git a/tests/Testing/Resources/FilesTestResource.php b/tests/Testing/Resources/FilesTestResource.php new file mode 100644 index 0000000..22fd1db --- /dev/null +++ b/tests/Testing/Resources/FilesTestResource.php @@ -0,0 +1,79 @@ +files()->upload(['file' => $handle]); + + $fake->assertSent(Files::class, function ($method, $parameters) { + return $method === 'upload' && + is_array($parameters) && + array_key_exists('file', $parameters); + }); +}); + +it('records a files list request', function () { + $fake = new ClientFake([ + FileListResponse::fake(), + ]); + + $fake->files()->list(['limit' => 10]); + + $fake->assertSent(Files::class, function ($method, $parameters) { + return $method === 'list' && + $parameters === ['limit' => 10]; + }); +}); + +it('records a files retrieve metadata request', function () { + $fake = new ClientFake([ + FileResponse::fake(), + ]); + + $fake->files()->retrieveMetadata('file_011CNha8iCJcU1wXNR6q4V8w'); + + $fake->assertSent(Files::class, function ($method, $id) { + return $method === 'retrieveMetadata' && + $id === 'file_011CNha8iCJcU1wXNR6q4V8w'; + }); +}); + +it('records a files download request', function () { + $fake = new ClientFake([ + 'raw-file-bytes', + ]); + + $result = $fake->files()->download('file_011CPMxVD3fHLUhvTqtsQA5w'); + + expect($result)->toBe('raw-file-bytes'); + + $fake->assertSent(Files::class, function ($method, $id) { + return $method === 'download' && + $id === 'file_011CPMxVD3fHLUhvTqtsQA5w'; + }); +}); + +it('records a files delete request', function () { + $fake = new ClientFake([ + DeletedFileResponse::fake(), + ]); + + $fake->files()->delete('file_011CNha8iCJcU1wXNR6q4V8w'); + + $fake->assertSent(Files::class, function ($method, $id) { + return $method === 'delete' && + $id === 'file_011CNha8iCJcU1wXNR6q4V8w'; + }); +});