Skip to content
Merged
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
9 changes: 7 additions & 2 deletions UPGRADE.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,14 +56,19 @@ Platform
```

Accordingly, `Bridge\MiniMax\MiniMaxResultConverter` no longer takes an HTTP client, API key,
endpoint or clock, only an optional `MiniMaxJobClient` that creates the job handles; polling moved to the new `Bridge\MiniMax\MiniMaxJobClient`. Code building the
endpoint or clock - polling moved to the new `Bridge\MiniMax\MiniMaxJobClient`. Code building the
bridge through `Bridge\MiniMax\Factory` is unaffected.

```diff
-$converter = new MiniMaxResultConverter($httpClient, $apiKey, $endpoint, $clock);
+$converter = new MiniMaxResultConverter($jobClient);
+$converter = new MiniMaxResultConverter();
```

Its only argument left is the name of the provider it belongs to, which it stamps onto the
handles of the jobs it starts so they can be resolved later. It defaults to `minimax` and only
needs to be passed when the provider was registered under a different name - which
`Factory::createProvider()` does on its own.

Store
-----

Expand Down
28 changes: 20 additions & 8 deletions docs/components/platform.rst
Original file line number Diff line number Diff line change
Expand Up @@ -1127,10 +1127,11 @@ The handle holds no connection and no client, only what is needed to ask the pro
again, so it can be stored and picked up somewhere else entirely::

// in the process that started the job
$repository->save($handle->getId(), $handle->toString());
$jobId = $handle->getId();
$repository->save($jobId, $handle->toString());

// in a worker, possibly much later
$handle = JobHandle::fromString($repository->load($id));
$handle = JobHandle::fromString($repository->load($jobId));

if ($jobClient->getStatus($handle)->is(JobStateCase::SUCCEEDED)) {
$result = $jobClient->getResult($handle);
Expand Down Expand Up @@ -1175,27 +1176,35 @@ be given ten minutes in a worker and five seconds inside a web request. Say so p
$result = $runner->wait($jobClient, $handle, maxDuration: 5);

A budget passed to the runner's constructor applies to every job it waits for and sits between the
two: it overrules what a job asks for, and a single call overrules it in turn.
two: it overrules what a job asks for, and a single call overrules it in turn. Whichever wins, it is
spent as wall clock rather than as a number of polls - asking the provider takes time too, so five
seconds mean five seconds and not five requests that may each take one.

Before polling, the runner asks the client whether the handle is one it can resolve
(:method:`Symfony\\AI\\Platform\\Job\\JobClientInterface::supports`) - what that means is the
bridge's own judgement. A handle the client turns down raises an ``InvalidArgumentException`` rather
than a request that fails halfway.

In a Symfony application a runner using the application clock is available as
``ai.platform.job_runner`` and autowired through :class:`Symfony\\AI\\Platform\\Job\\JobRunner`. It
carries no budget of its own, so the same shared service serves a job finishing in seconds and one
running for minutes. Each job-capable platform also registers its client as
``ai.platform.job_client.<name>``, autowired by argument name::
``ai.platform.job_client.<name>``, autowired by the platform name as argument name - so the argument
of a MiniMax job client has to be called ``$minimax``::

public function __construct(
private JobRunner $jobRunner,
private JobClientInterface $minimaxJobClient,
private JobClientInterface $minimax,
) {
}

public function __invoke(JobHandle $handle): void
{
// trust the job
$this->jobRunner->wait($this->minimaxJobClient, $handle);
$this->jobRunner->wait($this->minimax, $handle);

// or bound it to what a request can afford
$this->jobRunner->wait($this->minimaxJobClient, $handle, maxDuration: 5);
$this->jobRunner->wait($this->minimax, $handle, maxDuration: 5);
}

An application holding handles of several providers picks the client by the name the handle carries,
Expand All @@ -1210,7 +1219,10 @@ from a locator over the ``ai.platform.job_client`` tag::

public function __invoke(JobHandle $handle): void
{
$this->jobRunner->wait($this->jobClients->get($handle->getProvider()), $handle);
// A handle only names a provider when the bridge that created it stated one.
$provider = $handle->getProvider() ?? throw new \InvalidArgumentException('The job handle does not name a provider.');

$this->jobRunner->wait($this->jobClients->get($provider), $handle);
}

The runner throws a :class:`Symfony\\AI\\Platform\\Exception\\JobFailedException` when the provider
Expand Down
2 changes: 2 additions & 0 deletions examples/minimax/.gitignore
Original file line number Diff line number Diff line change
@@ -1,2 +1,4 @@
minimax-speech.mp3
minimax-video-job.json
minimax-video.mp4
text-to-image.jpg
4 changes: 2 additions & 2 deletions examples/minimax/text-to-speech-async.php
Original file line number Diff line number Diff line change
Expand Up @@ -15,10 +15,10 @@

require_once dirname(__DIR__).'/bootstrap.php';

$provider = Factory::createProvider(env('MINI_MAX_API_KEY'), http_client());
$platform = Factory::createPlatform(env('MINI_MAX_API_KEY'), http_client());

// The async endpoint enqueues a task, so the invocation hands back a job handle instead of audio.
$handle = $provider->invoke('speech-2.6-hd', new Text('The real danger is not that computers start thinking like people, but that people start thinking like computers.'), [
$handle = $platform->invoke('speech-2.6-hd', new Text('The real danger is not that computers start thinking like people, but that people start thinking like computers.'), [
'async' => true,
'voice_setting' => [
'voice_id' => 'English_expressive_narrator',
Expand Down
4 changes: 2 additions & 2 deletions examples/minimax/text-to-video.php
Original file line number Diff line number Diff line change
Expand Up @@ -15,11 +15,11 @@

require_once dirname(__DIR__).'/bootstrap.php';

$provider = Factory::createProvider(env('MINI_MAX_API_KEY'), http_client());
$platform = Factory::createPlatform(env('MINI_MAX_API_KEY'), http_client());

// Video generation is asynchronous: MiniMax accepts the request and answers with a task, so the
// invocation returns a handle instead of a video.
$handle = $provider->invoke('MiniMax-Hailuo-02', new Text('A cat playing the piano on a stage, cinematic lighting'), [
$handle = $platform->invoke('MiniMax-Hailuo-02', new Text('A cat playing the piano on a stage, cinematic lighting'), [
'duration' => 6,
'resolution' => '768P',
])->asJob();
Expand Down
4 changes: 2 additions & 2 deletions examples/minimax/video-job-resume.php
Original file line number Diff line number Diff line change
Expand Up @@ -29,8 +29,8 @@
$storage = __DIR__.'/minimax-video-job.json';

if (!is_file($storage)) {
$provider = Factory::createProvider(env('MINI_MAX_API_KEY'), http_client());
$handle = $provider->invoke('MiniMax-Hailuo-02', new Text('A cat playing the piano on a stage, cinematic lighting'), [
$platform = Factory::createPlatform(env('MINI_MAX_API_KEY'), http_client());
$handle = $platform->invoke('MiniMax-Hailuo-02', new Text('A cat playing the piano on a stage, cinematic lighting'), [
'duration' => 6,
'resolution' => '768P',
])->asJob();
Expand Down
3 changes: 2 additions & 1 deletion src/ai-bundle/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
CHANGELOG
==
=========

0.14
----

Expand Down
1 change: 0 additions & 1 deletion src/ai-bundle/src/AiBundle.php
Original file line number Diff line number Diff line change
Expand Up @@ -1088,7 +1088,6 @@ private function processPlatformConfig(string $type, array $platform, ContainerB
$platform['api_key'],
new Reference($platform['http_client'], ContainerInterface::NULL_ON_INVALID_REFERENCE),
$platform['endpoint'],
'minimax',
])
->addTag('ai.platform.job_client', ['key' => 'minimax']));
$container->registerAliasForArgument($jobClientId, JobClientInterface::class, 'minimax');
Expand Down
3 changes: 2 additions & 1 deletion src/platform/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
CHANGELOG
==
=========

0.14
----

Expand Down
2 changes: 1 addition & 1 deletion src/platform/src/Bridge/MiniMax/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ CHANGELOG
----

* Add model information to token usage extraction
* [BC BREAK] Stop polling asynchronous tasks inside `MiniMaxResultConverter`. Video generation and asynchronous speech synthesis now return a `Result\JobResult` carrying a serializable job handle, resolved through the new `MiniMaxJobClient`, built by `Factory::createJobClient()` and creating the handles — see the platform `UPGRADE` notes. `MiniMaxResultConverter` no longer takes an HTTP client, API key, endpoint or clock, only an optional `MiniMaxJobClient`
* [BC BREAK] Stop polling asynchronous tasks inside `MiniMaxResultConverter`. Video generation and asynchronous speech synthesis now return a `Result\JobResult` carrying a serializable job handle, resolved through the new `MiniMaxJobClient`, built by `Factory::createJobClient()` — see the platform `UPGRADE` notes. `MiniMaxResultConverter` no longer takes an HTTP client, API key, endpoint or clock, only an optional provider name, which it stamps onto the handles it creates

0.11
----
Expand Down
16 changes: 7 additions & 9 deletions src/platform/src/Bridge/MiniMax/Factory.php
Original file line number Diff line number Diff line change
Expand Up @@ -27,25 +27,26 @@
*/
final class Factory
{
private const DEFAULT_ENDPOINT = 'https://api.minimax.io/v1';

/**
* @param non-empty-string $name
*/
public static function createProvider(
#[\SensitiveParameter] string $apiKey,
?HttpClientInterface $httpClient = null,
string $endpoint = 'https://api.minimax.io/v1',
string $endpoint = self::DEFAULT_ENDPOINT,
ModelCatalogInterface $modelCatalog = new ModelCatalog(),
?Contract $contract = null,
?EventDispatcherInterface $eventDispatcher = null,
string $name = 'minimax',
): ProviderInterface {
$httpClient = $httpClient instanceof EventSourceHttpClient ? $httpClient : new EventSourceHttpClient($httpClient);
$jobClient = self::createJobClient($apiKey, $httpClient, $endpoint, $name);

return new Provider(
$name,
[new MiniMaxClient($httpClient, $apiKey, $endpoint)],
[new MiniMaxResultConverter($jobClient)],
[new MiniMaxResultConverter($name)],
$modelCatalog,
$contract ?? MiniMaxContract::create(),
$eventDispatcher,
Expand All @@ -55,16 +56,13 @@ public static function createProvider(
/**
* The client resolving the jobs this bridge hands out - typically in a worker picking up a
* stored handle, without a provider or platform at hand.
*
* @param string $name the provider name stated on the handles this client creates
*/
public static function createJobClient(
#[\SensitiveParameter] string $apiKey,
?HttpClientInterface $httpClient = null,
string $endpoint = 'https://api.minimax.io/v1',
string $name = 'minimax',
string $endpoint = self::DEFAULT_ENDPOINT,
): MiniMaxJobClient {
return new MiniMaxJobClient($httpClient ?? new EventSourceHttpClient(), $apiKey, $endpoint, $name);
return new MiniMaxJobClient($httpClient ?? new EventSourceHttpClient(), $apiKey, $endpoint);
}

/**
Expand All @@ -73,7 +71,7 @@ public static function createJobClient(
public static function createPlatform(
#[\SensitiveParameter] string $apiKey,
?HttpClientInterface $httpClient = null,
string $endpoint = 'https://api.minimax.io/v1',
string $endpoint = self::DEFAULT_ENDPOINT,
ModelCatalogInterface $modelCatalog = new ModelCatalog(),
?Contract $contract = null,
?EventDispatcherInterface $eventDispatcher = null,
Expand Down
57 changes: 32 additions & 25 deletions src/platform/src/Bridge/MiniMax/MiniMaxJobClient.php
Original file line number Diff line number Diff line change
Expand Up @@ -28,8 +28,7 @@
* MiniMax answers such a request with a `task_id`, exposes the task under an endpoint-specific query
* path, and delivers the payload as a file that has to be looked up and downloaded separately. Both
* the query path and the expected MIME type are carried in the {@see JobHandle}, put there by
* {@see MiniMaxResultConverter} which knows the endpoint the task came from. It creates the handle
* through this client, which names the provider it serves.
* {@see MiniMaxResultConverter} which knows the endpoint the task came from.
*
* @author Johannes Wachter <johannes@sulu.io>
*/
Expand All @@ -55,45 +54,26 @@ public function __construct(
private readonly HttpClientInterface $httpClient,
#[\SensitiveParameter] private readonly string $apiKey,
private readonly string $endpoint = 'https://api.minimax.io/v1',
private readonly string $provider = 'minimax',
) {
}

/**
* @param array<string, mixed> $data
* @param int $maxDuration how long the task may reasonably take, in seconds
*/
public function createHandle(string $taskId, array $data, int $maxDuration): JobHandle
{
return new JobHandle($taskId, $data, $this->provider, $maxDuration);
}

public function supports(JobHandle $handle): bool
{
return \is_string($handle->get('query_path'));
}

public function getStatus(JobHandle $handle): JobStatus
{
$data = $this->query($handle);

$raw = (string) ($data['status'] ?? '');
$case = self::STATES[strtolower($raw)] ?? JobStateCase::UNKNOWN;

$error = $data['base_resp']['status_msg'] ?? null;

return new JobStatus($case, $raw, \is_string($error) && '' !== $error ? $error : null);
return $this->toStatus($this->query($handle));
}

public function getResult(JobHandle $handle): ResultInterface
{
$data = $this->query($handle);
$status = $this->toStatus($data);

$raw = (string) ($data['status'] ?? '');
$case = self::STATES[strtolower($raw)] ?? JobStateCase::UNKNOWN;

if (JobStateCase::SUCCEEDED !== $case) {
throw new JobFailedException(new JobStatus($case, $raw), \sprintf('The MiniMax task "%s" is not ready to be fetched, its status is "%s".', $handle->getId(), $raw));
if (!$status->is(JobStateCase::SUCCEEDED)) {
throw new JobFailedException($status, \sprintf('The MiniMax task "%s" is not ready to be fetched, its status is "%s".', $handle->getId(), $status->getRaw()));
}

// The file identifier can already be known from the submit response; the query response wins
Expand All @@ -116,6 +96,33 @@ public function getResult(JobHandle $handle): ResultInterface
return new BinaryResult($payload, \is_string($mimeType) ? $mimeType : null);
}

/**
* `status_msg` is a failure message only when `status_code` is not 0 - a healthy one says "success".
*
* @param array<string, mixed> $data
*/
private function toStatus(array $data): JobStatus
{
$raw = (string) ($data['status'] ?? '');
$case = self::STATES[strtolower($raw)] ?? JobStateCase::UNKNOWN;

$statusCode = $data['base_resp']['status_code'] ?? 0;

if (0 === $statusCode) {
return new JobStatus($case, $raw);
}

$message = $data['base_resp']['status_msg'] ?? null;

// No state at all next to an error code is a task MiniMax will not talk about.
if ('' === $raw) {
$case = JobStateCase::FAILED;
$raw = 'status code '.(\is_scalar($statusCode) ? (string) $statusCode : 'unknown');
}

return new JobStatus($case, $raw, \is_string($message) && '' !== $message ? $message : null);
}

/**
* @return array<string, mixed>
*/
Expand Down
22 changes: 9 additions & 13 deletions src/platform/src/Bridge/MiniMax/MiniMaxResultConverter.php
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
use Symfony\AI\Platform\Exception\IncompleteStreamException;
use Symfony\AI\Platform\Exception\RuntimeException;
use Symfony\AI\Platform\FinishReason\FinishReasonAwareTrait;
use Symfony\AI\Platform\Job\JobHandle;
use Symfony\AI\Platform\Model;
use Symfony\AI\Platform\Result\BinaryResult;
use Symfony\AI\Platform\Result\ChoiceResult;
Expand All @@ -28,7 +29,6 @@
use Symfony\AI\Platform\Result\TextResult;
use Symfony\AI\Platform\ResultConverterInterface;
use Symfony\AI\Platform\TokenUsage\TokenUsageExtractorInterface;
use Symfony\Component\HttpClient\EventSourceHttpClient;

/**
* @author Guillaume Loulier <personal@guillaumeloulier.fr>
Expand All @@ -47,16 +47,12 @@ final class MiniMaxResultConverter implements ResultConverterInterface

private const VIDEO_MAX_DURATION = 600;

private readonly MiniMaxJobClient $jobClient;

/**
* @param MiniMaxJobClient|null $jobClient creates the handles of the jobs this converter starts, so
* they name the provider the client serves
* @param string $provider the name stamped onto the handles of the jobs this converter starts
*/
public function __construct(?MiniMaxJobClient $jobClient = null)
{
// Only used to create handles, never to send a request.
$this->jobClient = $jobClient ?? new MiniMaxJobClient(new EventSourceHttpClient(), '');
public function __construct(
private readonly string $provider = 'minimax',
) {
}

public function supports(Model $model): bool
Expand Down Expand Up @@ -206,8 +202,8 @@ private function decodeHexAudio(array $data): string
/**
* MiniMax answered with a task identifier instead of a payload, so the invocation produces a
* reference to that task rather than a result. Resolving it - polling, and downloading the file
* it produces - is the job of {@see MiniMaxJobClient}, which therefore creates the handle; the
* handle carries what that client needs to know about the endpoint the task came from.
* it produces - is the job of {@see MiniMaxJobClient}; the handle carries what that client needs
* to know about the endpoint the task came from.
*
* @param array<string, mixed> $data
* @param int $maxDuration how long this endpoint may reasonably take, in seconds
Expand All @@ -218,11 +214,11 @@ private function startJob(array $data, string $queryPath, string $mimeType, int
{
$taskId = $data['task_id'] ?? throw new RuntimeException('The MiniMax response does not contain a task identifier.');

return new JobResult($this->jobClient->createHandle((string) $taskId, [
return new JobResult(new JobHandle((string) $taskId, [
'query_path' => $queryPath,
'mime_type' => $mimeType,
'archive_member' => $archiveMember,
'file_id' => $data['file_id'] ?? null,
], $maxDuration));
], $this->provider, $maxDuration));
}
}
5 changes: 0 additions & 5 deletions src/platform/src/Bridge/MiniMax/Tests/FactoryTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -32,9 +32,4 @@ public function testTheJobHandleCarriesTheNameTheProviderWasCreatedWith()
$this->assertSame('789', $handle->getId());
$this->assertSame('minimax-eu', $handle->getProvider());
}

public function testTheJobClientCreatesHandlesForTheGivenName()
{
$this->assertSame('minimax-eu', Factory::createJobClient('key', name: 'minimax-eu')->createHandle('789', [], 600)->getProvider());
}
}
Loading
Loading