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
37 changes: 36 additions & 1 deletion src/Files/File.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,22 @@

namespace Laravel\Ai\Files;

use Closure;
use InvalidArgumentException;
use Laravel\Ai\Contracts\Files\HasName;
use Laravel\Ai\Contracts\HasProviderOptions;
use Laravel\Ai\Enums\Lab;
use Laravel\SerializableClosure\SerializableClosure;

abstract class File implements HasName
abstract class File implements HasName, HasProviderOptions
{
public ?string $name = null;

public ?string $mime = null;

/** @var array<string, mixed>|SerializableClosure */
protected array|SerializableClosure $providerOptions = [];

/**
* Reconstruct a file instance from its array representation.
*/
Expand Down Expand Up @@ -74,6 +81,34 @@ public function as(?string $name): static
return $this;
}

/**
* Specify provider-specific options for the file upload.
*
* @param array<string, mixed>|Closure(Lab|string): array<string, mixed> $options
*/
public function withProviderOptions(array|Closure $options): static
{
$this->providerOptions = $options instanceof Closure
? new SerializableClosure($options)
: $options;

return $this;
}

/**
* Get the provider-specific options for the file upload.
*
* @return array<string, mixed>
*/
public function providerOptions(Lab|string $provider): array
{
if ($this->providerOptions instanceof SerializableClosure) {
return ($this->providerOptions)($provider) ?: [];
}

return $this->providerOptions;
}

/**
* Set the file's MIME type.
*/
Expand Down
5 changes: 4 additions & 1 deletion src/Gateway/Anthropic/AnthropicFileGateway.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
use Laravel\Ai\Contracts\Files\StorableFile;
use Laravel\Ai\Contracts\Gateway\FileGateway;
use Laravel\Ai\Contracts\Providers\FileProvider;
use Laravel\Ai\Enums\Lab;
use Laravel\Ai\Gateway\Concerns\HandlesFailoverErrors;
use Laravel\Ai\Gateway\Concerns\PreparesStorableFiles;
use Laravel\Ai\Providers\Provider;
Expand Down Expand Up @@ -44,11 +45,13 @@ public function putFile(
): StoredFileResponse {
[$content, $mime, $name] = $this->prepareStorableFile($file);

$providerOptions = $this->resolveProviderOptions($file, Lab::Anthropic);

$response = $this->withErrorHandling(
$provider->name(),
fn () => $this->client($provider)
->attach('file', $content, $name, ['Content-Type' => $mime])
->post('files'),
->post('files', $providerOptions),
);

return new StoredFileResponse($response->json('id'));
Expand Down
9 changes: 9 additions & 0 deletions src/Gateway/AzureOpenAi/AzureOpenAiFileGateway.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

namespace Laravel\Ai\Gateway\AzureOpenAi;

use Laravel\Ai\Enums\Lab;
use Laravel\Ai\Gateway\OpenAi\OpenAiFileGateway;

class AzureOpenAiFileGateway extends OpenAiFileGateway
Expand All @@ -15,4 +16,12 @@ protected function defaultPurpose(): string
{
return 'assistants';
}

/**
* Get the provider key used to resolve file upload options.
*/
protected function providerOptionsKey(): Lab
{
return Lab::Azure;
}
}
12 changes: 12 additions & 0 deletions src/Gateway/Concerns/PreparesStorableFiles.php
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@
namespace Laravel\Ai\Gateway\Concerns;

use Laravel\Ai\Contracts\Files\StorableFile;
use Laravel\Ai\Contracts\HasProviderOptions;
use Laravel\Ai\Enums\Lab;

trait PreparesStorableFiles
{
Expand All @@ -19,4 +21,14 @@ protected function prepareStorableFile(StorableFile $file): array
$file->name() ?? 'file',
];
}

/**
* Resolve the provider-specific upload options for the given file.
*
* @return array<string, mixed>
*/
protected function resolveProviderOptions(StorableFile $file, Lab|string $provider): array
{
return $file instanceof HasProviderOptions ? $file->providerOptions($provider) : [];
}
}
7 changes: 5 additions & 2 deletions src/Gateway/Gemini/GeminiFileGateway.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
use Laravel\Ai\Contracts\Files\StorableFile;
use Laravel\Ai\Contracts\Gateway\FileGateway;
use Laravel\Ai\Contracts\Providers\FileProvider;
use Laravel\Ai\Enums\Lab;
use Laravel\Ai\Gateway\Concerns\HandlesFailoverErrors;
use Laravel\Ai\Gateway\Concerns\PreparesStorableFiles;
use Laravel\Ai\Providers\Provider;
Expand Down Expand Up @@ -45,13 +46,15 @@ public function putFile(

$uploadUrl = str_replace('/v1beta', '/upload/v1beta', $this->baseUrl($provider));

$providerOptions = $this->resolveProviderOptions($file, Lab::Gemini);

$response = $this->withErrorHandling($provider->name(), fn () => Http::withHeaders(array_filter([
'x-goog-api-key' => $provider->providerCredentials()['key'],
]))->attach(
'file', $content, $name, ['Content-Type' => $mime]
)->post("{$uploadUrl}/files", [
)->post("{$uploadUrl}/files", array_merge([
'file' => ['display_name' => $name],
])->throw());
], $providerOptions))->throw());

return new StoredFileResponse($response->json('file.name'));
}
Expand Down
18 changes: 15 additions & 3 deletions src/Gateway/OpenAi/OpenAiFileGateway.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
use Laravel\Ai\Contracts\Files\StorableFile;
use Laravel\Ai\Contracts\Gateway\FileGateway;
use Laravel\Ai\Contracts\Providers\FileProvider;
use Laravel\Ai\Enums\Lab;
use Laravel\Ai\Gateway\Concerns\HandlesFailoverErrors;
use Laravel\Ai\Gateway\Concerns\PreparesStorableFiles;
use Laravel\Ai\Responses\FileResponse;
Expand Down Expand Up @@ -41,13 +42,16 @@ public function putFile(
): StoredFileResponse {
[$content, $mime, $name] = $this->prepareStorableFile($file);

$providerOptions = $this->resolveProviderOptions($file, $this->providerOptionsKey());

$response = $this->withErrorHandling(
$provider->name(),
fn () => $this->client($provider)
->attach('file', $content, $name, ['Content-Type' => $mime])
->post('files', [
'purpose' => $this->defaultPurpose(),
])
->post('files', array_merge(
['purpose' => $this->defaultPurpose()],
$providerOptions,
))
);

return new StoredFileResponse($response->json('id'));
Expand All @@ -72,4 +76,12 @@ protected function defaultPurpose(): string
{
return 'user_data';
}

/**
* Get the provider key used to resolve file upload options.
*/
protected function providerOptionsKey(): Lab
{
return Lab::OpenAI;
}
}
31 changes: 31 additions & 0 deletions tests/Feature/FileProviderOptionsTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
<?php

use Laravel\Ai\Enums\Lab;
use Laravel\Ai\Files\Document;

test('array provider options resolve for the given provider', function () {
$document = Document::fromString('Hello')->withProviderOptions(['purpose' => 'fine-tune']);

expect($document->providerOptions(Lab::OpenAI))->toBe(['purpose' => 'fine-tune']);
});

test('closure provider options resolve per provider', function () {
$document = Document::fromString('Hello')->withProviderOptions(fn (Lab $provider) => match ($provider) {
Lab::OpenAI => ['purpose' => 'assistants'],
default => [],
});

expect($document->providerOptions(Lab::OpenAI))->toBe(['purpose' => 'assistants'])
->and($document->providerOptions(Lab::Anthropic))->toBe([]);
});

test('closure provider options survive php serialization', function () {
$document = Document::fromString('Hello')->withProviderOptions(fn (Lab $provider) => match ($provider) {
Lab::OpenAI => ['purpose' => 'assistants'],
default => [],
});

$restored = unserialize(serialize($document));

expect($restored->providerOptions(Lab::OpenAI))->toBe(['purpose' => 'assistants']);
});
58 changes: 58 additions & 0 deletions tests/Feature/Providers/Anthropic/FileGatewayTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
<?php

use Illuminate\Http\Client\Request;
use Illuminate\Support\Facades\Http;
use Laravel\Ai\Files;
use Laravel\Ai\Files\Document;

beforeEach(function () {
config(['ai.providers.anthropic' => [
...config('ai.providers.anthropic'),
'key' => 'test-key',
]]);
});

test('get file sends correct request', function () {
Http::fake([
'api.anthropic.com/*' => Http::response(['id' => 'file-abc123', 'mime_type' => 'text/plain']),
]);

$response = Files::get('file-abc123', provider: 'anthropic');

expect($response->id)->toBe('file-abc123');

Http::assertSent(fn (Request $request) => $request->method() === 'GET'
&& $request->url() === 'https://api.anthropic.com/v1/files/file-abc123'
&& $request->hasHeader('x-api-key', 'test-key'));
});

test('put file sends multipart upload', function () {
Http::fake([
'api.anthropic.com/*' => Http::response(['id' => 'file-uploaded123']),
]);

$response = Document::fromString('Hello, World!', 'text/plain')->as('hello.txt')->put(
provider: 'anthropic',
);

expect($response->id)->toBe('file-uploaded123');

$request = sentRequest();

expect($request->method())->toBe('POST')
->and($request->url())->toBe('https://api.anthropic.com/v1/files')
->and($request->header('Content-Type')[0] ?? '')->toContain('multipart/form-data')
->and($request->hasHeader('x-api-key', 'test-key'))->toBeTrue();
});

test('delete file sends correct request', function () {
Http::fake([
'api.anthropic.com/*' => Http::response(['id' => 'file-abc123']),
]);

Files::delete('file-abc123', provider: 'anthropic');

Http::assertSent(fn (Request $request) => $request->method() === 'DELETE'
&& $request->url() === 'https://api.anthropic.com/v1/files/file-abc123'
&& $request->hasHeader('x-api-key', 'test-key'));
});
17 changes: 17 additions & 0 deletions tests/Feature/Providers/AzureOpenAi/FileGatewayTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

use Illuminate\Http\Client\Request;
use Illuminate\Support\Facades\Http;
use Laravel\Ai\Enums\Lab;
use Laravel\Ai\Files\Document;

beforeEach(function () {
Expand Down Expand Up @@ -29,3 +30,19 @@
&& collect($request->data())->contains(fn ($field) => ($field['name'] ?? null) === 'purpose' && ($field['contents'] ?? null) === 'assistants')
&& $request->hasHeader('api-key', 'test-key'));
});

test('provider options are resolved with the azure key, not openai', function () {
Http::fake([
'test-resource.openai.azure.com/*' => Http::response(['id' => 'file-uploaded123']),
]);

Document::fromString('Hello, World!', 'text/plain')->as('hello.txt')
->withProviderOptions(fn (Lab $provider) => match ($provider) {
Lab::Azure => ['purpose' => 'batch'],
Lab::OpenAI => ['purpose' => 'vision'],
default => [],
})
->put(provider: 'azure');

expect(multipartField(sentRequest(), 'purpose'))->toBe('batch');
});
27 changes: 27 additions & 0 deletions tests/Feature/Providers/Gemini/FileGatewayTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,33 @@
});
});

test('put file merges flat provider options into the upload body', function () {
Http::fake([
'generativelanguage.googleapis.com/*' => Http::response(['file' => ['name' => 'files/uploaded123']]),
]);

Document::fromString('Hello, World!', 'text/plain')->as('hello.txt')
->withProviderOptions(['mime_type' => 'image/png'])
->put(provider: 'gemini');

$request = sentRequest();

expect(multipartField($request, 'mime_type'))->toBe('image/png')
->and(multipartArrayField($request, 'file'))->toBe(['display_name' => 'hello.txt']);
});

test('put file provider options shallow-merge replaces the file metadata key', function () {
Http::fake([
'generativelanguage.googleapis.com/*' => Http::response(['file' => ['name' => 'files/uploaded123']]),
]);

Document::fromString('Hello, World!', 'text/plain')->as('hello.txt')
->withProviderOptions(['file' => ['display_name' => 'override.txt']])
->put(provider: 'gemini');

expect(multipartArrayField(sentRequest(), 'file'))->toBe(['display_name' => 'override.txt']);
});

test('delete file sends correct request', function () {
Http::fake([
'generativelanguage.googleapis.com/*' => Http::response([], 200),
Expand Down
Loading
Loading