diff --git a/.gitattributes b/.gitattributes
index 6d52733b0..aa4cb883c 100644
--- a/.gitattributes
+++ b/.gitattributes
@@ -10,3 +10,5 @@
/Taskfile.yml export-ignore
/testbench.yaml export-ignore
/whisky.json export-ignore
+/dev export-ignore
+/composer-require-checker.json export-ignore
diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md
index 03050ea67..617d78e78 100644
--- a/.github/pull_request_template.md
+++ b/.github/pull_request_template.md
@@ -1,4 +1,4 @@
-
+
## Description
## Breaking Changes
diff --git a/.github/workflows/formatting.yml b/.github/workflows/formatting.yml
index a16143847..3a62eaf87 100644
--- a/.github/workflows/formatting.yml
+++ b/.github/workflows/formatting.yml
@@ -7,6 +7,9 @@ on:
- "*.x"
pull_request:
+permissions:
+ contents: read
+
env:
phpv: 8.2
diff --git a/.github/workflows/phpstan.yml b/.github/workflows/phpstan.yml
index feb87f5d8..9e9708a7b 100644
--- a/.github/workflows/phpstan.yml
+++ b/.github/workflows/phpstan.yml
@@ -7,6 +7,9 @@ on:
- "*.x"
pull_request:
+permissions:
+ contents: read
+
env:
phpv: 8.2
diff --git a/.github/workflows/require-checker.yml b/.github/workflows/require-checker.yml
new file mode 100644
index 000000000..72527a382
--- /dev/null
+++ b/.github/workflows/require-checker.yml
@@ -0,0 +1,35 @@
+name: Require Checker
+
+on:
+ push:
+ branches:
+ - main
+ - "*.x"
+ pull_request:
+
+permissions:
+ contents: read
+
+env:
+ phpv: 8.2
+
+jobs:
+ require-checker:
+ name: composer-require-checker
+ runs-on: ubuntu-latest
+ timeout-minutes: 5
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Setup PHP
+ uses: shivammathur/setup-php@v2
+ with:
+ php-version: ${{ env.phpv }}
+ coverage: none
+ tools: composer-require-checker
+
+ - name: Install composer dependencies
+ uses: ramsey/composer-install@v3
+
+ - name: Check declared dependencies match used symbols
+ run: composer-require-checker check --config-file=composer-require-checker.json composer.json
diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml
index 291247344..bd87a8548 100644
--- a/.github/workflows/tests.yml
+++ b/.github/workflows/tests.yml
@@ -7,6 +7,9 @@ on:
- "*.x"
pull_request:
+permissions:
+ contents: read
+
jobs:
test:
runs-on: ${{ matrix.os }}
@@ -16,12 +19,9 @@ jobs:
matrix:
os: [ubuntu-latest]
php: [8.2, 8.3, 8.4]
- laravel: [11.*, 12.*, 13.*]
+ laravel: [12.*, 13.*]
stability: [prefer-lowest, prefer-stable]
include:
- - laravel: 11.*
- testbench: 9.*
- carbon: ^2.63
- laravel: 12.*
testbench: 10.*
carbon: ^3.8.4
diff --git a/.gitignore b/.gitignore
index c7dcf43d9..b0cb41814 100644
--- a/.gitignore
+++ b/.gitignore
@@ -12,5 +12,6 @@ phpunit.xml
ray.php
CLAUDE.md
.phpunit.result.cache
+AGENTS.md
.ddev/
.claude/
diff --git a/README.md b/README.md
index 0c62ea9ba..c5d3ff36b 100644
--- a/README.md
+++ b/README.md
@@ -3,19 +3,30 @@
-
-
+
+
-
-
+
+
-
-
+
+
# Prism
+> [!IMPORTANT]
+> **This is the actively maintained [Particle Academy](https://github.com/Particle-Academy) fork of [`prism-php/prism`](https://github.com/prism-php/prism).**
+> Upstream has been unmaintained since March 2026; this fork continues development,
+> absorbing the community's outstanding fixes and features. The package is published
+> as **`particle-academy/prism`** (the `Prism\Prism` namespace is unchanged, so it is
+> a drop-in replacement):
+>
+> ```
+> composer require particle-academy/prism
+> ```
+
Prism is a powerful Laravel package for integrating Large Language Models (LLMs) into your applications. It provides a fluent interface for generating text, handling multi-step conversations, and utilizing tools with various AI providers. This way, you can focus on developing outstanding AI applications for your users without getting lost in the technical intricacies.
## Official Documentation
@@ -28,7 +39,7 @@ While we aren't affiliated with Laravel, we follow the Laravel [Code of Conduct]
## Authors
-This library is created by [TJ Miller](https://tjmiller.me) with contributions from the [Open Source Community](https://github.com/prism-php/prism/graphs/contributors).
+This library is created by [TJ Miller](https://tjmiller.me) with contributions from the [Open Source Community](https://github.com/Particle-Academy/prism/graphs/contributors).
## License
diff --git a/composer-require-checker.json b/composer-require-checker.json
new file mode 100644
index 000000000..4996da3d7
--- /dev/null
+++ b/composer-require-checker.json
@@ -0,0 +1,10 @@
+{
+ "symbol-whitelist": [
+ "Laravel\\Mcp\\Request",
+ "Laravel\\Mcp\\Response",
+ "Laravel\\Mcp\\ResponseFactory",
+ "Laravel\\Mcp\\Server\\Tool",
+ "Laravel\\Mcp\\Support\\ValidationMessages",
+ "PHPUnit\\Framework\\Assert"
+ ]
+}
diff --git a/composer.json b/composer.json
index 100a9929f..a3d2baebf 100644
--- a/composer.json
+++ b/composer.json
@@ -1,6 +1,6 @@
{
- "name": "prism-php/prism",
- "description": "A powerful Laravel package for integrating Large Language Models (LLMs) into your applications.",
+ "name": "particle-academy/prism",
+ "description": "A powerful Laravel package for integrating Large Language Models (LLMs) into your applications. Actively maintained fork of prism-php/prism.",
"type": "library",
"license": "MIT",
"autoload": {
@@ -20,7 +20,14 @@
"require": {
"php": "^8.2",
"ext-fileinfo": "*",
- "laravel/framework": "^11.0|^12.0|^13.0"
+ "ext-mbstring": "*",
+ "ext-openssl": "*",
+ "laravel/framework": "^12.61.1|^13.12.0",
+ "psr/http-message": "^1.1|^2.0",
+ "symfony/http-foundation": "^7.2|^8.0"
+ },
+ "suggest": {
+ "laravel/mcp": "Required to expose Prism tools through a Laravel MCP server (LaravelMcpTool)."
},
"config": {
"allow-plugins": {
@@ -40,7 +47,7 @@
"phpstan/phpstan-deprecation-rules": "^2.0",
"rector/rector": "2.3.3",
"projektgopher/whisky": "^0.7.0",
- "orchestra/testbench": "^9|^10|^11",
+ "orchestra/testbench": "^10|^11",
"mockery/mockery": "^1.6",
"symplify/rule-doc-generator-contracts": "^11.2",
"phpstan/phpdoc-parser": "^2.0",
@@ -49,6 +56,7 @@
},
"autoload-dev": {
"psr-4": {
+ "Prism\\Dev\\": "dev/",
"Tests\\": "tests/",
"Workbench\\App\\": "workbench/app/",
"Workbench\\Database\\Factories\\": "workbench/database/factories/",
diff --git a/config/prism.php b/config/prism.php
index 99f3ea584..445450ffe 100644
--- a/config/prism.php
+++ b/config/prism.php
@@ -13,6 +13,7 @@
'api_key' => env('OPENAI_API_KEY', ''),
'organization' => env('OPENAI_ORGANIZATION', null),
'project' => env('OPENAI_PROJECT', null),
+ 'api_format' => env('OPENAI_API_FORMAT', 'responses'),
],
'anthropic' => [
'api_key' => env('ANTHROPIC_API_KEY', ''),
@@ -61,10 +62,44 @@
'x_title' => env('OPENROUTER_SITE_X_TITLE', null),
],
],
+ 'replicate' => [
+ 'api_key' => env('REPLICATE_API_KEY', ''),
+ 'url' => env('REPLICATE_URL', 'https://api.replicate.com/v1'),
+ 'webhook_url' => env('REPLICATE_WEBHOOK_URL', null),
+ 'use_sync_mode' => env('REPLICATE_USE_SYNC_MODE', true), // Use Prefer: wait header
+ 'polling_interval' => env('REPLICATE_POLLING_INTERVAL', 1000),
+ 'max_wait_time' => env('REPLICATE_MAX_WAIT_TIME', 60),
+ // Hosts generated images may be auto-downloaded from (https only).
+ 'download_hosts' => ['replicate.delivery'],
+ ],
+ 'qwen' => [
+ 'api_key' => env('QWEN_API_KEY', ''),
+ 'url' => env('QWEN_URL', 'https://dashscope-intl.aliyuncs.com/api/v1'),
+ ],
+ 'azure' => [
+ 'url' => env('AZURE_AI_URL', ''),
+ 'api_key' => env('AZURE_AI_API_KEY', ''),
+ 'api_version' => env('AZURE_AI_API_VERSION', '2024-10-21'),
+ 'deployment_name' => env('AZURE_AI_DEPLOYMENT', ''),
+ ],
+ 'requesty' => [
+ 'api_key' => env('REQUESTY_API_KEY', ''),
+ 'url' => env('REQUESTY_URL', 'https://router.requesty.ai/v1'),
+ 'site' => [
+ 'http_referer' => env('REQUESTY_SITE_HTTP_REFERER', null),
+ 'x_title' => env('REQUESTY_SITE_X_TITLE', null),
+ ],
+ ],
'perplexity' => [
'api_key' => env('PERPLEXITY_API_KEY', ''),
'url' => env('PERPLEXITY_URL', 'https://api.perplexity.ai'),
],
+ 'vertex' => [
+ 'project_id' => env('VERTEX_PROJECT_ID', ''),
+ 'region' => env('VERTEX_REGION', 'us-central1'),
+ 'access_token' => env('VERTEX_ACCESS_TOKEN', null),
+ 'credentials_path' => env('VERTEX_CREDENTIALS_PATH', null),
+ ],
'z' => [
'url' => env('Z_URL', 'https://api.z.ai/api/paas/v4'),
'api_key' => env('Z_API_KEY', ''),
diff --git a/src/Rectors/ReorderMethodsRector.php b/dev/Rectors/ReorderMethodsRector.php
similarity index 98%
rename from src/Rectors/ReorderMethodsRector.php
rename to dev/Rectors/ReorderMethodsRector.php
index 717060029..70afe0a5e 100644
--- a/src/Rectors/ReorderMethodsRector.php
+++ b/dev/Rectors/ReorderMethodsRector.php
@@ -2,7 +2,7 @@
declare(strict_types=1);
-namespace Prism\Prism\Rectors;
+namespace Prism\Dev\Rectors;
use PhpParser\Node;
use PhpParser\Node\Stmt;
diff --git a/docs/.vitepress/config.mts b/docs/.vitepress/config.mts
index 464ee2f0a..092ed0a4a 100644
--- a/docs/.vitepress/config.mts
+++ b/docs/.vitepress/config.mts
@@ -113,6 +113,10 @@ export default defineConfig({
text: "Tool & Function Calling",
link: "/core-concepts/tools-function-calling",
},
+ {
+ text: "Human-in-the-Loop",
+ link: "/core-concepts/human-in-the-loop",
+ },
{
text: "Structured Output",
link: "/core-concepts/structured-output",
@@ -133,6 +137,14 @@ export default defineConfig({
text: "Audio",
link: "/core-concepts/audio",
},
+ {
+ text: "Batch Processing",
+ link: "/core-concepts/batch",
+ },
+ {
+ text: "Files",
+ link: "/core-concepts/files",
+ },
{
text: "Schemas",
link: "/core-concepts/schemas",
@@ -211,6 +223,14 @@ export default defineConfig({
text: "OpenRouter",
link: "/providers/openrouter",
},
+ {
+ text: "Replicate",
+ link: "/providers/replicate",
+ text: "Qwen",
+ link: "/providers/qwen",
+ text: "Requesty",
+ link: "/providers/requesty",
+ },
{
text: "Voyage AI",
link: "/providers/voyageai",
diff --git a/docs/components/ProviderSupport.vue b/docs/components/ProviderSupport.vue
index a4a98dfc2..96c17f76f 100644
--- a/docs/components/ProviderSupport.vue
+++ b/docs/components/ProviderSupport.vue
@@ -328,6 +328,19 @@ export default {
documents: Supported,
moderation: Unsupported,
},
+ {
+ name: "Requesty",
+ text: Supported,
+ streaming: Supported,
+ structured: Supported,
+ embeddings: Unsupported,
+ image: Supported,
+ "speech-to-text": Unsupported,
+ "text-to-speech": Unsupported,
+ tools: Supported,
+ documents: Supported,
+ moderation: Unsupported,
+ },
{
name: "OpenAI",
text: Supported,
@@ -341,6 +354,31 @@ export default {
documents: Supported,
moderation: Supported,
},
+ {
+ name: "Qwen",
+ text: Supported,
+ streaming: Supported,
+ structured: Supported,
+ embeddings: Supported,
+ image: Supported,
+ "speech-to-text": Unsupported,
+ "text-to-speech": Unsupported,
+ tools: Supported,
+ documents: Unsupported,
+ moderation: Unsupported,
+ },
+ {
+ name: "Replicate",
+ text: Supported,
+ streaming: Supported,
+ structured: Supported,
+ embeddings: Supported,
+ image: Supported,
+ "speech-to-text": Supported,
+ "text-to-speech": Supported,
+ tools: Unsupported,
+ documents: Unsupported,
+ },
{
name: "VoyageAI",
text: Unsupported,
diff --git a/docs/core-concepts/batch.md b/docs/core-concepts/batch.md
new file mode 100644
index 000000000..7c576639f
--- /dev/null
+++ b/docs/core-concepts/batch.md
@@ -0,0 +1,268 @@
+# Batch Processing
+
+The Batch API lets you submit large numbers of requests for asynchronous processing. Instead of sending each prompt one at a time and waiting for a response, you queue them all at once and collect the results later. This makes batch processing significantly cheaper and better suited for offline workloads like dataset generation, bulk classification, or nightly report jobs.
+
+> [!IMPORTANT]
+> For deeper background, see the official provider documentation:
+> - [Anthropic](https://platform.claude.com/docs/en/build-with-claude/batch-processing)
+> - [OpenAI](https://developers.openai.com/api/docs/guides/batch/)
+
+## How It Works
+
+1. **Create** a batch (submit your requests)
+2. **Poll** the batch status until it reaches `Completed`
+3. **Retrieve** the results
+
+```php
+use Prism\Prism\Facades\Prism;
+use Prism\Prism\Enums\Provider;
+use Prism\Prism\Batch\BatchStatus;
+
+// 1. Create
+$job = Prism::batch()
+ ->using(Provider::Anthropic)
+ ->create(items: $items);
+
+// 2. Poll
+while ($job->status !== BatchStatus::Completed) {
+ sleep(30);
+ $job = Prism::batch()
+ ->using(Provider::Anthropic)
+ ->retrieve($job->id);
+}
+
+// 3. Get results
+$results = Prism::batch()
+ ->using(Provider::Anthropic)
+ ->getResults($job->id);
+
+foreach ($results as $result) {
+ echo "{$result->customId}: {$result->text}\n";
+}
+```
+
+## Creating a Batch
+
+### Anthropic
+
+Anthropic takes a list of `BatchRequestItem` objects directly. Each item wraps a normal `TextRequest` and a unique `customId` you'll use to match results back to the original input.
+
+```php
+use Prism\Prism\Facades\Prism;
+use Prism\Prism\Enums\Provider;
+use Prism\Prism\Batch\BatchRequestItem;
+use Prism\Prism\Text\Request as TextRequest;
+use Prism\Prism\ValueObjects\Messages\UserMessage;
+
+$items = collect($yourData)->map(fn ($row) => new BatchRequestItem(
+ customId: "row-{$row->id}",
+ request: new TextRequest(
+ model: 'claude-sonnet-4-20250514',
+ messages: [new UserMessage("Summarise: {$row->content}")],
+ ),
+))->all();
+
+$job = Prism::batch()
+ ->using(Provider::Anthropic)
+ ->create(items: $items);
+
+echo $job->id; // "msgbatch_01..."
+echo $job->status; // BatchStatus::Validating
+```
+
+### OpenAI
+
+OpenAI accepts the same `items` array as Anthropic. Prism automatically builds the JSONL payload and uploads it via the [Files API](/core-concepts/files) before creating the batch — you don't need to handle the file upload yourself.
+
+```php
+use Prism\Prism\Facades\Prism;
+use Prism\Prism\Enums\Provider;
+use Prism\Prism\Batch\BatchRequestItem;
+use Prism\Prism\Text\Request as TextRequest;
+use Prism\Prism\ValueObjects\Messages\UserMessage;
+
+$items = collect($yourData)->map(fn ($row) => new BatchRequestItem(
+ customId: "row-{$row->id}",
+ request: new TextRequest(
+ model: 'gpt-4o',
+ messages: [new UserMessage("Summarise: {$row->content}")],
+ ),
+))->all();
+
+$job = Prism::batch()
+ ->using(Provider::OpenAI)
+ ->create(items: $items);
+
+echo $job->id;
+```
+
+#### Bringing your own file
+
+If you've already uploaded a JSONL file via the [Files API](/core-concepts/files), pass its ID directly via `inputFileId` instead:
+
+```php
+$file = Prism::files()
+ ->using(Provider::OpenAI)
+ ->withProviderOptions(['purpose' => 'batch'])
+ ->upload(content: $jsonlContent, filename: 'batch-input.jsonl');
+
+$job = Prism::batch()
+ ->using(Provider::OpenAI)
+ ->create(inputFileId: $file->id);
+```
+
+> [!NOTE]
+> Providing both `items` and `inputFileId` at the same time throws a `PrismException`. Use one or the other.
+
+## Polling for Completion
+
+The batch doesn't complete immediately. Poll the status until it reaches a terminal state:
+
+```php
+use Prism\Prism\Batch\BatchStatus;
+
+do {
+ sleep(30);
+
+ $job = Prism::batch()
+ ->using(Provider::Anthropic)
+ ->retrieve($job->id);
+
+} while (! in_array($job->status, [
+ BatchStatus::Completed,
+ BatchStatus::Failed,
+ BatchStatus::Expired,
+ BatchStatus::Cancelled,
+]));
+```
+
+### BatchStatus values
+
+| Status | Meaning |
+|---|---|
+| `Validating` | Requests are being validated |
+| `InProgress` | Processing is underway |
+| `Finalizing` | Results are being prepared |
+| `Completed` | All requests have finished |
+| `Failed` | The batch failed to process |
+| `Cancelling` | Cancellation is in progress |
+| `Cancelled` | The batch was cancelled |
+| `Expired` | The batch expired before completing |
+
+### Request counts
+
+The `BatchJob` object includes a `requestCounts` property that breaks down progress:
+
+```php
+echo "Total: {$job->requestCounts->total}";
+echo "Processing: {$job->requestCounts->processing}";
+echo "Succeeded: {$job->requestCounts->succeeded}";
+echo "Failed: {$job->requestCounts->failed}";
+echo "Expired: {$job->requestCounts->expired}";
+```
+
+## Retrieving Results
+
+Once the batch is `Completed`, call `getResults()` to get all the result items back as an array:
+
+```php
+use Prism\Prism\Batch\BatchResultStatus;
+
+$results = Prism::batch()
+ ->using(Provider::Anthropic)
+ ->getResults($job->id);
+
+foreach ($results as $result) {
+ if ($result->status === BatchResultStatus::Succeeded) {
+ echo "{$result->customId}: {$result->text}\n";
+ echo "Tokens: {$result->usage->promptTokens} in / {$result->usage->completionTokens} out\n";
+ } else {
+ echo "{$result->customId} failed: {$result->errorMessage}\n";
+ }
+}
+```
+
+### BatchResultItem properties
+
+| Property | Type | Description |
+|---|---|---|
+| `customId` | `string` | The ID you assigned when creating the item |
+| `status` | `BatchResultStatus` | `Succeeded`, `Errored`, `Canceled`, or `Expired` |
+| `text` | `?string` | The generated text (null if not succeeded) |
+| `usage` | `?Usage` | Token counts |
+| `messageId` | `?string` | Provider message ID |
+| `model` | `?string` | Model used |
+| `errorType` | `?string` | Error code if failed |
+| `errorMessage` | `?string` | Human-readable error detail |
+
+## Listing Batches
+
+Browse previously created batches with optional pagination:
+
+```php
+$list = Prism::batch()
+ ->using(Provider::OpenAI)
+ ->list(limit: 20);
+
+foreach ($list->batches as $job) {
+ echo "{$job->id}: {$job->status->name}\n";
+}
+
+// Next page
+if ($list->hasMore) {
+ $nextPage = Prism::batch()
+ ->using(Provider::OpenAI)
+ ->list(limit: 20, afterId: $list->lastId);
+}
+```
+
+## Cancelling a Batch
+
+```php
+$job = Prism::batch()
+ ->using(Provider::Anthropic)
+ ->cancel($job->id);
+
+echo $job->status->name; // "Cancelling"
+```
+
+## Provider-Conditional Options
+
+Use `whenProvider()` when your batch logic needs to handle both providers from the same code path:
+
+```php
+$job = Prism::batch()
+ ->using($providerName)
+ ->whenProvider('openai', fn ($r) => $r->withProviderOptions(['completion_window' => '24h']))
+ ->create(items: $items, inputFileId: $inputFileId);
+```
+
+## Client Options
+
+```php
+$job = Prism::batch()
+ ->using(Provider::Anthropic)
+ ->withClientOptions(['timeout' => 60])
+ ->withClientRetry(3, 500)
+ ->create(items: $items);
+```
+
+## Error Handling
+
+```php
+use Prism\Prism\Exceptions\PrismException;
+use Throwable;
+
+try {
+ $job = Prism::batch()
+ ->using(Provider::Anthropic)
+ ->create(items: $items);
+} catch (PrismException $e) {
+ Log::error('Batch creation failed', ['error' => $e->getMessage()]);
+} catch (Throwable $e) {
+ Log::error('Unexpected error', ['error' => $e->getMessage()]);
+}
+```
+
+> [!TIP]
+> Individual result items can have their own failure status independent of the overall batch status. Always check `$result->status` on each item — a `Completed` batch can still contain `Errored` or `Expired` result items.
diff --git a/docs/core-concepts/files.md b/docs/core-concepts/files.md
new file mode 100644
index 000000000..b37639296
--- /dev/null
+++ b/docs/core-concepts/files.md
@@ -0,0 +1,200 @@
+# Files API
+
+The Files API lets you upload, list, inspect, download, and delete files stored on a provider's platform. It's most useful for supplying batch input to the Batch API, but any file you store with a provider can be referenced wherever that provider accepts a file ID.
+
+## Quick Start
+
+```php
+use Prism\Prism\Facades\Prism;
+use Prism\Prism\Enums\Provider;
+
+// Upload a file
+$file = Prism::files()
+ ->using(Provider::OpenAI)
+ ->upload(
+ content: file_get_contents('/path/to/data.jsonl'),
+ filename: 'data.jsonl',
+ );
+
+echo $file->id; // "file-abc123"
+```
+
+## Uploading Files
+
+Pass the raw file content and a filename. The optional `mimeType` argument lets you set the content type explicitly — useful when the filename alone isn't enough for the provider to infer the format.
+
+```php
+$file = Prism::files()
+ ->using(Provider::Anthropic)
+ ->upload(
+ content: file_get_contents('/path/to/document.pdf'),
+ filename: 'document.pdf',
+ mimeType: 'application/pdf',
+ );
+
+echo $file->id; // provider file ID
+echo $file->filename; // "document.pdf"
+echo $file->sizeBytes; // size in bytes
+echo $file->purpose; // provider-specific purpose string
+echo $file->createdAt; // creation timestamp
+```
+
+### Provider-specific options
+
+OpenAI requires a `purpose` field on upload. Accepted values are `assistants`, `batch`, `fine-tune`, `vision`, `user_data`, and `evals`. Pass it via `withProviderOptions()`:
+
+```php
+$file = Prism::files()
+ ->using(Provider::OpenAI)
+ ->withProviderOptions(['purpose' => 'batch'])
+ ->upload(
+ content: $jsonlContent,
+ filename: 'batch-input.jsonl',
+ );
+```
+
+## Listing Files
+
+```php
+$list = Prism::files()
+ ->using(Provider::OpenAI)
+ ->list();
+
+foreach ($list->data as $file) {
+ echo "{$file->id}: {$file->filename} ({$file->sizeBytes} bytes)\n";
+}
+```
+
+### Pagination
+
+`list()` supports cursor-based pagination via `limit`, `afterId`, and `beforeId`:
+
+```php
+$page1 = Prism::files()
+ ->using(Provider::Anthropic)
+ ->list(limit: 20);
+
+if ($page1->hasMore) {
+ // Forward pagination — fetch the page after the last item
+ $page2 = Prism::files()
+ ->using(Provider::Anthropic)
+ ->list(limit: 20, afterId: $page1->lastId);
+
+ // Backward pagination — fetch the page before the first item
+ $prevPage = Prism::files()
+ ->using(Provider::Anthropic)
+ ->list(limit: 20, beforeId: $page1->firstId);
+}
+```
+
+The `FileListResult` has four properties:
+
+| Property | Type | Description |
+|---|---|---|
+| `data` | `FileData[]` | Array of files in this page |
+| `hasMore` | `bool` | Whether there are more pages |
+| `firstId` | `?string` | ID of the first file in this page |
+| `lastId` | `?string` | ID of the last file in this page |
+
+### Provider support
+
+Not all parameters are supported by every provider:
+
+| Parameter | OpenAI | Anthropic |
+|---|---|---|
+| `limit` | ✓ | ✓ |
+| `afterId` | ✓ | ✓ |
+| `beforeId` | — | ✓ |
+| `purpose` (provider option) | ✓ | — |
+
+The `purpose` filter for OpenAI is passed via `withProviderOptions()`:
+
+```php
+$list = Prism::files()
+ ->using(Provider::OpenAI)
+ ->withProviderOptions(['purpose' => 'batch'])
+ ->list(limit: 50);
+```
+
+## Retrieving File Metadata
+
+```php
+$file = Prism::files()
+ ->using(Provider::OpenAI)
+ ->getMetadata(fileId: 'file-abc123');
+
+echo $file->filename;
+echo $file->sizeBytes;
+echo $file->createdAt;
+```
+
+## Downloading File Contents
+
+`download()` fetches the full file body and returns it as a string — already buffered, so you can pass it straight to storage or parse it directly.
+
+```php
+$contents = Prism::files()
+ ->using(Provider::OpenAI)
+ ->download(fileId: 'file-abc123');
+
+// Write to disk
+Storage::put('output.jsonl', $contents);
+
+// Or parse in-memory
+foreach (explode("\n", $contents) as $line) {
+ $row = json_decode($line, true);
+ // ...
+}
+```
+
+> [!NOTE]
+> Anthropic only allows downloading files that were generated by Claude (e.g. from code execution). Files you upload via the Files API cannot be downloaded back.
+
+## Deleting Files
+
+```php
+$result = Prism::files()
+ ->using(Provider::OpenAI)
+ ->delete(fileId: 'file-abc123');
+
+echo $result->id; // "file-abc123"
+echo $result->deleted; // true
+```
+
+## The FileData Object
+
+Every operation that returns a single file gives back a `FileData` value object:
+
+| Property | Type | Description |
+|---|---|---|
+| `id` | `string` | Provider file ID |
+| `filename` | `?string` | Original filename |
+| `mimeType` | `?string` | Content type |
+| `sizeBytes` | `?int` | File size in bytes |
+| `createdAt` | `?string` | Creation timestamp |
+| `purpose` | `?string` | Provider-specific purpose |
+| `raw` | `?array` | Full raw response from the provider |
+
+## Client Options
+
+All the same HTTP client helpers available on other Prism builders work here too:
+
+```php
+$file = Prism::files()
+ ->using(Provider::OpenAI)
+ ->withClientOptions(['timeout' => 120])
+ ->withClientRetry(3, 500)
+ ->upload(content: $largeFile, filename: 'big-upload.jsonl');
+```
+
+## Provider-Conditional Options
+
+Use `whenProvider()` to set options that only apply to a specific provider, which is handy when the same code path runs against multiple providers:
+
+```php
+$file = Prism::files()
+ ->using($providerName)
+ ->whenProvider('openai', fn ($r) => $r->withProviderOptions(['purpose' => 'batch']))
+ ->whenProvider('anthropic', fn ($r) => $r->withProviderOptions(['some_key' => 'value']))
+ ->upload(content: $content, filename: 'data.jsonl');
+```
\ No newline at end of file
diff --git a/docs/core-concepts/human-in-the-loop.md b/docs/core-concepts/human-in-the-loop.md
new file mode 100644
index 000000000..416d99d08
--- /dev/null
+++ b/docs/core-concepts/human-in-the-loop.md
@@ -0,0 +1,137 @@
+# Human-in-the-Loop
+
+Some tools are too consequential to run automatically — deleting files, sending
+money, emailing customers. Prism gives you two ways to keep a human (or your
+own application logic) between the model and the action:
+
+- **Client-executed tools** — the model can call the tool, but *your
+ application* executes it.
+- **Approval-required tools** — execution pauses until an explicit approval
+ decision is supplied. **Deny-by-default**: a tool call that never receives an
+ approval is refused, never executed.
+
+Both work across all providers for text and structured requests; streaming
+events are emitted for Anthropic and OpenAI.
+
+## Client-executed tools
+
+Mark a tool with `clientExecuted()` instead of giving it a handler:
+
+```php
+use Prism\Prism\Facades\Prism;
+use Prism\Prism\Facades\Tool;
+
+$searchTool = Tool::as('search_crm')
+ ->for('Search the CRM for a customer')
+ ->withStringParameter('query', 'The search query')
+ ->clientExecuted();
+
+$response = Prism::text()
+ ->using('anthropic', 'claude-sonnet-4-5')
+ ->withTools([$searchTool])
+ ->withMaxSteps(3)
+ ->withPrompt('Find the customer named Acme Corp')
+ ->asText();
+```
+
+When the model calls `search_crm`, the request loop **stops** and the response
+comes back with `FinishReason::ToolCalls`. Execute the tool yourself, then
+resume by appending your results to the conversation:
+
+```php
+use Prism\Prism\ValueObjects\Messages\ToolResultMessage;
+use Prism\Prism\ValueObjects\ToolResult;
+
+$pendingCall = $response->toolCalls[0];
+
+$results = new ToolResultMessage([
+ new ToolResult(
+ toolCallId: $pendingCall->id,
+ toolName: $pendingCall->name,
+ args: $pendingCall->arguments(),
+ result: MyCrm::search($pendingCall->arguments()['query']),
+ ),
+]);
+
+$final = Prism::text()
+ ->using('anthropic', 'claude-sonnet-4-5')
+ ->withTools([$searchTool])
+ ->withMessages([...$response->messages, $results])
+ ->asText();
+```
+
+## Approval-required tools
+
+Keep the handler, but gate it behind `requiresApproval()`:
+
+```php
+$deleteTool = Tool::as('delete_file')
+ ->for('Delete a file')
+ ->withStringParameter('path', 'File path to delete')
+ ->using(fn (string $path): string => Storage::delete($path) ? "Deleted {$path}" : "Failed")
+ ->requiresApproval();
+```
+
+Approval can also be conditional on the arguments:
+
+```php
+$transferTool = Tool::as('transfer')
+ ->for('Transfer money')
+ ->withNumberParameter('amount', 'Amount in dollars')
+ ->using(fn (int $amount): string => Bank::transfer($amount))
+ ->requiresApproval(fn (array $args): bool => ($args['amount'] ?? 0) > 100);
+```
+
+When approval is needed, the loop stops and the pending requests surface on the
+assistant message and the step:
+
+```php
+$step = $response->steps->last();
+
+foreach ($step->toolApprovalRequests as $request) {
+ // $request->approvalId — correlate the decision
+ // $request->toolCallId — which tool call it belongs to
+}
+```
+
+Resume with the decisions — approved calls execute, denied calls produce a
+denial result the model sees:
+
+```php
+use Prism\Prism\ValueObjects\Messages\ToolResultMessage;
+use Prism\Prism\ValueObjects\ToolApprovalResponse;
+
+$decisions = new ToolResultMessage([], [
+ new ToolApprovalResponse($request->approvalId, approved: true),
+ // or: new ToolApprovalResponse($id, approved: false, reason: 'Too risky'),
+]);
+
+$final = Prism::text()
+ ->using('anthropic', 'claude-sonnet-4-5')
+ ->withTools([$deleteTool])
+ ->withMessages([...$response->messages, $decisions])
+ ->asText();
+```
+
+::: warning
+Approval responses are trusted input from **your application** — Prism
+verifies correlation ids, not who clicked the button. Authenticate and
+authorize the approving user in your app before sending an approval.
+:::
+
+## Streaming
+
+For Anthropic and OpenAI streams, a pending approval emits a
+`ToolApprovalRequestEvent` (`tool_approval_request`) carrying the
+`approvalId`, the tool call, and its arguments — then the stream ends with
+`FinishReason::ToolCalls`. The event is broadcastable
+(`ToolApprovalRequestBroadcast`), so an approval prompt can be pushed to a UI
+over websockets in real time.
+
+```php
+foreach ($stream as $event) {
+ if ($event->type() === StreamEventType::ToolApprovalRequest) {
+ // Surface the approval prompt; resume with a ToolApprovalResponse
+ }
+}
+```
diff --git a/docs/core-concepts/image-generation.md b/docs/core-concepts/image-generation.md
index f39138455..54e906919 100644
--- a/docs/core-concepts/image-generation.md
+++ b/docs/core-concepts/image-generation.md
@@ -24,6 +24,7 @@ Currently, Prism supports image generation through:
- **OpenAI**: DALL-E 2, DALL-E 3, and GPT-Image-1 models
- **Gemini**: Gemini 2.0 Flash Preview Image Generation, Imagen 4, Imagen 3
+- **Qwen**: Qwen-Image (generation), Qwen-Image-Edit (editing & multi-image fusion)
Additional providers will be added in future releases as the ecosystem evolves.
@@ -250,6 +251,79 @@ $response = Prism::image()
->generate();
```
+### Qwen Options
+
+#### Image Generation
+
+Qwen provides image generation through `qwen-image-max` and `qwen-image-plus` models:
+
+```php
+$response = Prism::image()
+ ->using(Provider::Qwen, 'qwen-image-max')
+ ->withPrompt('A cute baby sea otter floating on its back')
+ ->withProviderOptions([
+ 'size' => '1328*1328', // Image resolution (width*height)
+ 'negative_prompt' => 'low quality, blurry', // Content to avoid
+ 'prompt_extend' => true, // Enable prompt rewriting
+ 'watermark' => false, // Disable watermark
+ 'seed' => 42, // Random seed for reproducibility
+ ])
+ ->generate();
+```
+
+> [!IMPORTANT]
+> Generated image URLs are valid for **24 hours** only. Download and save images promptly.
+
+#### Image Editing
+
+Qwen's `qwen-image-edit-max` and `qwen-image-edit-plus` models support single-image editing and multi-image fusion. Pass your images as the second parameter to `withPrompt()`:
+
+```php
+use Prism\Prism\ValueObjects\Media\Image;
+
+$response = Prism::image()
+ ->using(Provider::Qwen, 'qwen-image-edit-max')
+ ->withPrompt('Generate an image following this depth map: a red bicycle on a muddy path with dense forest', [
+ Image::fromUrl('https://example.com/depth-map.png'),
+ ])
+ ->withProviderOptions([
+ 'n' => 2, // Output 1-6 images (max/plus models)
+ 'size' => '1536*1024', // Custom output resolution
+ 'negative_prompt' => 'low quality', // Content to avoid
+ 'prompt_extend' => true, // Enable prompt rewriting
+ 'watermark' => false, // Disable watermark
+ ])
+ ->generate();
+```
+
+For multi-image fusion, pass multiple images — the model can combine elements from up to 3 input images:
+
+```php
+$response = Prism::image()
+ ->using(Provider::Qwen, 'qwen-image-edit-max')
+ ->withPrompt('The girl in image 1 wearing the black dress from image 2, sitting in the pose of image 3', [
+ Image::fromUrl('https://example.com/person.png'),
+ Image::fromUrl('https://example.com/dress.png'),
+ Image::fromUrl('https://example.com/pose.png'),
+ ])
+ ->withProviderOptions([
+ 'n' => 2,
+ 'size' => '1024*1536',
+ ])
+ ->generate();
+```
+
+You can also pass images from local files or base64 data:
+
+```php
+$response = Prism::image()
+ ->using(Provider::Qwen, 'qwen-image-edit-max')
+ ->withPrompt('Add a sunset sky to the background', [
+ Image::fromLocalPath('/path/to/photo.png'),
+ ])
+ ->generate();
+```
+
## Testing
Prism provides convenient fakes for testing image generation:
@@ -272,4 +346,4 @@ test('can generate images', function () {
});
```
-Need help with a specific provider or use case? Check the [openai documentation](/providers/openai) or [gemini documentation](/providers/gemini) for detailed configuration options and examples.
+Need help with a specific provider or use case? Check the [openai documentation](/providers/openai), [gemini documentation](/providers/gemini), or [qwen documentation](/providers/qwen) for detailed configuration options and examples.
diff --git a/docs/core-concepts/structured-output.md b/docs/core-concepts/structured-output.md
index 92d284ed8..a6a2ae004 100644
--- a/docs/core-concepts/structured-output.md
+++ b/docs/core-concepts/structured-output.md
@@ -72,6 +72,32 @@ $response = Prism::structured()
// ... rest of your configuration
```
+### Qwen: JSON Schema Mode
+Qwen supports two modes for structured output through DashScope's `response_format` parameter:
+
+- **JSON Object mode** (default): Ensures valid JSON output. Supported by most Qwen models.
+- **JSON Schema mode**: Strictly enforces the schema structure. Supported by `qwen3-max`, `qwen-plus`, `qwen-flash` series and later models.
+
+To use JSON Schema mode for strict schema validation:
+
+```php
+use Prism\Prism\Facades\Prism;
+use Prism\Prism\Enums\Provider;
+use Prism\Prism\Enums\StructuredMode;
+
+$response = Prism::structured()
+ ->using(Provider::Qwen, 'qwen-plus')
+ ->usingStructuredMode(StructuredMode::Structured)
+ ->withSchema($schema)
+ ->withPrompt('Extract user information')
+ ->asStructured();
+```
+
+> [!NOTE]
+> JSON Schema mode is only supported by newer models. If you use a model that does not support it, use the default JSON Object mode (or `StructuredMode::Auto`) instead. Check the [Qwen structured output documentation](https://www.alibabacloud.com/help/en/model-studio/qwen-structured-output) for the full list of supported models.
+
+### Anthropic: Tool Calling Mode
+Anthropic doesn't have native structured output, but Prism provides two approaches. For more reliable JSON parsing, especially with complex content or non-English text, use tool calling mode:
### Anthropic
Anthropic uses native structured outputs by default (Claude Sonnet 4.5+), providing guaranteed schema compliance through constrained decoding. For older models, you can use tool calling mode as a fallback:
diff --git a/docs/core-concepts/text-generation.md b/docs/core-concepts/text-generation.md
index 3a5ae695b..95f2cd2de 100644
--- a/docs/core-concepts/text-generation.md
+++ b/docs/core-concepts/text-generation.md
@@ -107,6 +107,19 @@ $response = Prism::text()
]
)
->asText();
+
+// Multi-image analysis with Qwen VL models
+$response = Prism::text()
+ ->using(Provider::Qwen, 'qwen-vl-max')
+ ->withPrompt(
+ 'What are these?',
+ [
+ Image::fromUrl('https://example.com/dog.jpeg'),
+ Image::fromUrl('https://example.com/tiger.png'),
+ Image::fromUrl('https://example.com/rabbit.png'),
+ ]
+ )
+ ->asText();
```
## Message Chains and Conversations
@@ -165,6 +178,15 @@ The value is passed through to the provider. The range depends on the provider a
> [!TIP]
> It is recommended to set either temperature or topP, but not both.
+`usingTopK`
+
+Top-K sampling.
+
+The value is passed through to the provider. Top-K sampling considers only the K most likely tokens. For example, a topK of 40 means only the top 40 tokens are considered for sampling. This parameter is supported by providers such as Gemini and Anthropic.
+
+> [!TIP]
+> It is recommended to set either temperature or topK, but not both.
+
`withClientOptions`
Under the hood we use Laravel's [HTTP client](https://laravel.com/docs/11.x/http-client#main-content). You can use this method to pass any of Guzzles [request options](https://docs.guzzlephp.org/en/stable/request-options.html) e.g. `->withClientOptions(['timeout' => 30])`.
@@ -219,6 +241,37 @@ foreach ($response->responseMessages as $message) {
}
```
+### Understanding Token Usage
+
+The `Usage` value object exposes these token counts:
+
+| Property | Meaning |
+|---|---|
+| `promptTokens` | Input tokens, **excluding** cached (prompt-cache) tokens |
+| `completionTokens` | Output tokens |
+| `cacheReadInputTokens` | Input tokens served from the provider's prompt cache |
+| `cacheWriteInputTokens` | Input tokens written to the prompt cache |
+| `thoughtTokens` | Reasoning / thinking tokens, where the provider reports them |
+| `cost` | Provider-reported cost, where available |
+
+Prism normalizes `promptTokens` to **exclude** cached input tokens across every
+provider that exposes them, so `promptTokens + cacheReadInputTokens` is the
+total input billed. This is consistent everywhere.
+
+> [!IMPORTANT]
+> **`completionTokens` and reasoning tokens are not yet normalized across providers.**
+> Most providers (OpenAI, Anthropic, and the OpenAI-compatible providers)
+> report `completionTokens` **inclusive** of `thoughtTokens` — the reasoning
+> tokens are part of the output count, and `thoughtTokens` breaks out how many
+> of them were reasoning. **Gemini and Vertex** report `completionTokens`
+> **exclusive** of reasoning (`thoughtTokens` is separate and additive).
+>
+> If you compute cost or output length from `completionTokens` for a
+> reasoning-enabled model, account for this difference — e.g. for a
+> provider-agnostic "total output tokens" figure use
+> `completionTokens + (thoughtTokens excluded ? thoughtTokens : 0)`, or read
+> the provider's `raw` usage block directly.
+
## Handling Completions with Callbacks
Need to perform actions after text generation completes? Pass a callback directly to `asText()` to handle the response without interrupting the return flow. This is perfect for persisting conversations, tracking analytics, or logging AI interactions.
diff --git a/docs/core-concepts/tools-function-calling.md b/docs/core-concepts/tools-function-calling.md
index 0fdf9dff1..e6d7c4445 100644
--- a/docs/core-concepts/tools-function-calling.md
+++ b/docs/core-concepts/tools-function-calling.md
@@ -253,8 +253,7 @@ class SearchTool extends Tool
$this
->as('search')
->for('useful when you need to search for current events')
- ->withStringParameter('query', 'Detailed search query. Best to search one topic at a time.')
- ->using($this);
+ ->withStringParameter('query', 'Detailed search query. Best to search one topic at a time.');
}
public function __invoke(string $query): string
diff --git a/docs/getting-started/installation.md b/docs/getting-started/installation.md
index 5f2be2925..6b53f6f8a 100644
--- a/docs/getting-started/installation.md
+++ b/docs/getting-started/installation.md
@@ -12,13 +12,13 @@ Before we dive in, make sure your project meets these requirements:
## Step 1: Composer Installation
::: tip
-Prism is actively evolving. To prevent unexpected issues from breaking changes, we strongly recommend pinning your installation to a specific version. Example: "prism-php/prism": "^0.3.0".
+Prism is actively evolving. To prevent unexpected issues from breaking changes, we strongly recommend pinning your installation to a specific version. Example: "particle-academy/prism": "^0.3.0".
:::
First, let's add Prism to your project using Composer. Open your terminal, navigate to your project directory, and run:
```bash
-composer require prism-php/prism
+composer require particle-academy/prism
```
This command will download Prism and its dependencies into your project.
diff --git a/docs/getting-started/introduction.md b/docs/getting-started/introduction.md
index e2d2e0b46..2e9b3f368 100644
--- a/docs/getting-started/introduction.md
+++ b/docs/getting-started/introduction.md
@@ -60,6 +60,19 @@ $response = Prism::text()
->withPrompt('Explain quantum computing to a 5-year-old.')
->asText();
+echo $response->text;
+```
+
+```php [Replicate]
+use Prism\Prism\Facades\Prism;
+use Prism\Prism\Enums\Provider;
+
+$response = Prism::text()
+ ->using(Provider::Replicate, 'meta/meta-llama-3.1-405b-instruct')
+ ->withSystemPrompt(view('prompts.system'))
+ ->withPrompt('Explain quantum computing to a 5-year-old.')
+ ->asText();
+
echo $response->text;
```
:::
@@ -92,6 +105,8 @@ We currently offer first-party support for these leading AI providers:
- [Mistral](/providers/mistral.md)
- [Ollama](/providers/ollama.md)
- [OpenAI](/providers/openai.md)
+- [Replicate](/providers/replicate.md)
+- [Qwen](/providers/qwen.md)
- [xAI](/providers/xai.md)
- [Perplexity](/providers/perplexity.md)
diff --git a/docs/index.md b/docs/index.md
index 5417da421..3a4b1819e 100644
--- a/docs/index.md
+++ b/docs/index.md
@@ -15,7 +15,7 @@ hero:
link: https://github.com/sponsors/sixlive
- theme: alt
text: Github
- link: https://github.com/prism-php/prism
+ link: https://github.com/Particle-Academy/prism
image:
src: /assets/prism-logo.webp
alt: Prism Logo
diff --git a/docs/package-lock.json b/docs/package-lock.json
index 451218922..5382079e8 100644
--- a/docs/package-lock.json
+++ b/docs/package-lock.json
@@ -4,378 +4,37 @@
"requires": true,
"packages": {
"": {
+ "name": "docs",
"devDependencies": {
- "vitepress": "^1.3.4",
- "vitepress-plugin-llms": "^1.2.0"
- }
- },
- "node_modules/@algolia/autocomplete-core": {
- "version": "1.9.3",
- "resolved": "https://registry.npmjs.org/@algolia/autocomplete-core/-/autocomplete-core-1.9.3.tgz",
- "integrity": "sha512-009HdfugtGCdC4JdXUbVJClA0q0zh24yyePn+KUGk3rP7j8FEe/m5Yo/z65gn6nP/cM39PxpzqKrL7A6fP6PPw==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@algolia/autocomplete-plugin-algolia-insights": "1.9.3",
- "@algolia/autocomplete-shared": "1.9.3"
- }
- },
- "node_modules/@algolia/autocomplete-plugin-algolia-insights": {
- "version": "1.9.3",
- "resolved": "https://registry.npmjs.org/@algolia/autocomplete-plugin-algolia-insights/-/autocomplete-plugin-algolia-insights-1.9.3.tgz",
- "integrity": "sha512-a/yTUkcO/Vyy+JffmAnTWbr4/90cLzw+CC3bRbhnULr/EM0fGNvM13oQQ14f2moLMcVDyAx/leczLlAOovhSZg==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@algolia/autocomplete-shared": "1.9.3"
- },
- "peerDependencies": {
- "search-insights": ">= 1 < 3"
- }
- },
- "node_modules/@algolia/autocomplete-preset-algolia": {
- "version": "1.9.3",
- "resolved": "https://registry.npmjs.org/@algolia/autocomplete-preset-algolia/-/autocomplete-preset-algolia-1.9.3.tgz",
- "integrity": "sha512-d4qlt6YmrLMYy95n5TB52wtNDr6EgAIPH81dvvvW8UmuWRgxEtY0NJiPwl/h95JtG2vmRM804M0DSwMCNZlzRA==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@algolia/autocomplete-shared": "1.9.3"
- },
- "peerDependencies": {
- "@algolia/client-search": ">= 4.9.1 < 6",
- "algoliasearch": ">= 4.9.1 < 6"
- }
- },
- "node_modules/@algolia/autocomplete-shared": {
- "version": "1.9.3",
- "resolved": "https://registry.npmjs.org/@algolia/autocomplete-shared/-/autocomplete-shared-1.9.3.tgz",
- "integrity": "sha512-Wnm9E4Ye6Rl6sTTqjoymD+l8DjSTHsHboVRYrKgEt8Q7UHm9nYbqhN/i0fhUYA3OAEH7WA8x3jfpnmJm3rKvaQ==",
- "dev": true,
- "license": "MIT",
- "peerDependencies": {
- "@algolia/client-search": ">= 4.9.1 < 6",
- "algoliasearch": ">= 4.9.1 < 6"
- }
- },
- "node_modules/@algolia/cache-browser-local-storage": {
- "version": "4.24.0",
- "resolved": "https://registry.npmjs.org/@algolia/cache-browser-local-storage/-/cache-browser-local-storage-4.24.0.tgz",
- "integrity": "sha512-t63W9BnoXVrGy9iYHBgObNXqYXM3tYXCjDSHeNwnsc324r4o5UiVKUiAB4THQ5z9U5hTj6qUvwg/Ez43ZD85ww==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@algolia/cache-common": "4.24.0"
- }
- },
- "node_modules/@algolia/cache-common": {
- "version": "4.24.0",
- "resolved": "https://registry.npmjs.org/@algolia/cache-common/-/cache-common-4.24.0.tgz",
- "integrity": "sha512-emi+v+DmVLpMGhp0V9q9h5CdkURsNmFC+cOS6uK9ndeJm9J4TiqSvPYVu+THUP8P/S08rxf5x2P+p3CfID0Y4g==",
- "dev": true,
- "license": "MIT"
- },
- "node_modules/@algolia/cache-in-memory": {
- "version": "4.24.0",
- "resolved": "https://registry.npmjs.org/@algolia/cache-in-memory/-/cache-in-memory-4.24.0.tgz",
- "integrity": "sha512-gDrt2so19jW26jY3/MkFg5mEypFIPbPoXsQGQWAi6TrCPsNOSEYepBMPlucqWigsmEy/prp5ug2jy/N3PVG/8w==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@algolia/cache-common": "4.24.0"
- }
- },
- "node_modules/@algolia/client-account": {
- "version": "4.24.0",
- "resolved": "https://registry.npmjs.org/@algolia/client-account/-/client-account-4.24.0.tgz",
- "integrity": "sha512-adcvyJ3KjPZFDybxlqnf+5KgxJtBjwTPTeyG2aOyoJvx0Y8dUQAEOEVOJ/GBxX0WWNbmaSrhDURMhc+QeevDsA==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@algolia/client-common": "4.24.0",
- "@algolia/client-search": "4.24.0",
- "@algolia/transporter": "4.24.0"
- }
- },
- "node_modules/@algolia/client-account/node_modules/@algolia/client-common": {
- "version": "4.24.0",
- "resolved": "https://registry.npmjs.org/@algolia/client-common/-/client-common-4.24.0.tgz",
- "integrity": "sha512-bc2ROsNL6w6rqpl5jj/UywlIYC21TwSSoFHKl01lYirGMW+9Eek6r02Tocg4gZ8HAw3iBvu6XQiM3BEbmEMoiA==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@algolia/requester-common": "4.24.0",
- "@algolia/transporter": "4.24.0"
- }
- },
- "node_modules/@algolia/client-account/node_modules/@algolia/client-search": {
- "version": "4.24.0",
- "resolved": "https://registry.npmjs.org/@algolia/client-search/-/client-search-4.24.0.tgz",
- "integrity": "sha512-uRW6EpNapmLAD0mW47OXqTP8eiIx5F6qN9/x/7HHO6owL3N1IXqydGwW5nhDFBrV+ldouro2W1VX3XlcUXEFCA==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@algolia/client-common": "4.24.0",
- "@algolia/requester-common": "4.24.0",
- "@algolia/transporter": "4.24.0"
- }
- },
- "node_modules/@algolia/client-analytics": {
- "version": "4.24.0",
- "resolved": "https://registry.npmjs.org/@algolia/client-analytics/-/client-analytics-4.24.0.tgz",
- "integrity": "sha512-y8jOZt1OjwWU4N2qr8G4AxXAzaa8DBvyHTWlHzX/7Me1LX8OayfgHexqrsL4vSBcoMmVw2XnVW9MhL+Y2ZDJXg==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@algolia/client-common": "4.24.0",
- "@algolia/client-search": "4.24.0",
- "@algolia/requester-common": "4.24.0",
- "@algolia/transporter": "4.24.0"
- }
- },
- "node_modules/@algolia/client-analytics/node_modules/@algolia/client-common": {
- "version": "4.24.0",
- "resolved": "https://registry.npmjs.org/@algolia/client-common/-/client-common-4.24.0.tgz",
- "integrity": "sha512-bc2ROsNL6w6rqpl5jj/UywlIYC21TwSSoFHKl01lYirGMW+9Eek6r02Tocg4gZ8HAw3iBvu6XQiM3BEbmEMoiA==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@algolia/requester-common": "4.24.0",
- "@algolia/transporter": "4.24.0"
- }
- },
- "node_modules/@algolia/client-analytics/node_modules/@algolia/client-search": {
- "version": "4.24.0",
- "resolved": "https://registry.npmjs.org/@algolia/client-search/-/client-search-4.24.0.tgz",
- "integrity": "sha512-uRW6EpNapmLAD0mW47OXqTP8eiIx5F6qN9/x/7HHO6owL3N1IXqydGwW5nhDFBrV+ldouro2W1VX3XlcUXEFCA==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@algolia/client-common": "4.24.0",
- "@algolia/requester-common": "4.24.0",
- "@algolia/transporter": "4.24.0"
- }
- },
- "node_modules/@algolia/client-common": {
- "version": "5.7.0",
- "resolved": "https://registry.npmjs.org/@algolia/client-common/-/client-common-5.7.0.tgz",
- "integrity": "sha512-hrYlN9yNQukmNj8bBlw9PCXi9jmRQqNUXaG6MXH1aDabjO6YD1WPVqTvaELbIBgTbDJzCn0R2owms0uaxQkjUg==",
- "dev": true,
- "license": "MIT",
- "peer": true,
- "engines": {
- "node": ">= 14.0.0"
- }
- },
- "node_modules/@algolia/client-personalization": {
- "version": "4.24.0",
- "resolved": "https://registry.npmjs.org/@algolia/client-personalization/-/client-personalization-4.24.0.tgz",
- "integrity": "sha512-l5FRFm/yngztweU0HdUzz1rC4yoWCFo3IF+dVIVTfEPg906eZg5BOd1k0K6rZx5JzyyoP4LdmOikfkfGsKVE9w==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@algolia/client-common": "4.24.0",
- "@algolia/requester-common": "4.24.0",
- "@algolia/transporter": "4.24.0"
- }
- },
- "node_modules/@algolia/client-personalization/node_modules/@algolia/client-common": {
- "version": "4.24.0",
- "resolved": "https://registry.npmjs.org/@algolia/client-common/-/client-common-4.24.0.tgz",
- "integrity": "sha512-bc2ROsNL6w6rqpl5jj/UywlIYC21TwSSoFHKl01lYirGMW+9Eek6r02Tocg4gZ8HAw3iBvu6XQiM3BEbmEMoiA==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@algolia/requester-common": "4.24.0",
- "@algolia/transporter": "4.24.0"
- }
- },
- "node_modules/@algolia/client-search": {
- "version": "5.7.0",
- "resolved": "https://registry.npmjs.org/@algolia/client-search/-/client-search-5.7.0.tgz",
- "integrity": "sha512-0Frfjt4oxvVP2qsTQAjwdaG5SvJ3TbHBkBrS6M7cG5RDrgHqOrhBnBGCFT+YO3CeNK54r+d57oB1VcD2F1lHuQ==",
- "dev": true,
- "license": "MIT",
- "peer": true,
- "dependencies": {
- "@algolia/client-common": "5.7.0",
- "@algolia/requester-browser-xhr": "5.7.0",
- "@algolia/requester-fetch": "5.7.0",
- "@algolia/requester-node-http": "5.7.0"
- },
- "engines": {
- "node": ">= 14.0.0"
- }
- },
- "node_modules/@algolia/logger-common": {
- "version": "4.24.0",
- "resolved": "https://registry.npmjs.org/@algolia/logger-common/-/logger-common-4.24.0.tgz",
- "integrity": "sha512-LLUNjkahj9KtKYrQhFKCzMx0BY3RnNP4FEtO+sBybCjJ73E8jNdaKJ/Dd8A/VA4imVHP5tADZ8pn5B8Ga/wTMA==",
- "dev": true,
- "license": "MIT"
- },
- "node_modules/@algolia/logger-console": {
- "version": "4.24.0",
- "resolved": "https://registry.npmjs.org/@algolia/logger-console/-/logger-console-4.24.0.tgz",
- "integrity": "sha512-X4C8IoHgHfiUROfoRCV+lzSy+LHMgkoEEU1BbKcsfnV0i0S20zyy0NLww9dwVHUWNfPPxdMU+/wKmLGYf96yTg==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@algolia/logger-common": "4.24.0"
- }
- },
- "node_modules/@algolia/recommend": {
- "version": "4.24.0",
- "resolved": "https://registry.npmjs.org/@algolia/recommend/-/recommend-4.24.0.tgz",
- "integrity": "sha512-P9kcgerfVBpfYHDfVZDvvdJv0lEoCvzNlOy2nykyt5bK8TyieYyiD0lguIJdRZZYGre03WIAFf14pgE+V+IBlw==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@algolia/cache-browser-local-storage": "4.24.0",
- "@algolia/cache-common": "4.24.0",
- "@algolia/cache-in-memory": "4.24.0",
- "@algolia/client-common": "4.24.0",
- "@algolia/client-search": "4.24.0",
- "@algolia/logger-common": "4.24.0",
- "@algolia/logger-console": "4.24.0",
- "@algolia/requester-browser-xhr": "4.24.0",
- "@algolia/requester-common": "4.24.0",
- "@algolia/requester-node-http": "4.24.0",
- "@algolia/transporter": "4.24.0"
- }
- },
- "node_modules/@algolia/recommend/node_modules/@algolia/client-common": {
- "version": "4.24.0",
- "resolved": "https://registry.npmjs.org/@algolia/client-common/-/client-common-4.24.0.tgz",
- "integrity": "sha512-bc2ROsNL6w6rqpl5jj/UywlIYC21TwSSoFHKl01lYirGMW+9Eek6r02Tocg4gZ8HAw3iBvu6XQiM3BEbmEMoiA==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@algolia/requester-common": "4.24.0",
- "@algolia/transporter": "4.24.0"
- }
- },
- "node_modules/@algolia/recommend/node_modules/@algolia/client-search": {
- "version": "4.24.0",
- "resolved": "https://registry.npmjs.org/@algolia/client-search/-/client-search-4.24.0.tgz",
- "integrity": "sha512-uRW6EpNapmLAD0mW47OXqTP8eiIx5F6qN9/x/7HHO6owL3N1IXqydGwW5nhDFBrV+ldouro2W1VX3XlcUXEFCA==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@algolia/client-common": "4.24.0",
- "@algolia/requester-common": "4.24.0",
- "@algolia/transporter": "4.24.0"
- }
- },
- "node_modules/@algolia/recommend/node_modules/@algolia/requester-browser-xhr": {
- "version": "4.24.0",
- "resolved": "https://registry.npmjs.org/@algolia/requester-browser-xhr/-/requester-browser-xhr-4.24.0.tgz",
- "integrity": "sha512-Z2NxZMb6+nVXSjF13YpjYTdvV3032YTBSGm2vnYvYPA6mMxzM3v5rsCiSspndn9rzIW4Qp1lPHBvuoKJV6jnAA==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@algolia/requester-common": "4.24.0"
- }
- },
- "node_modules/@algolia/recommend/node_modules/@algolia/requester-node-http": {
- "version": "4.24.0",
- "resolved": "https://registry.npmjs.org/@algolia/requester-node-http/-/requester-node-http-4.24.0.tgz",
- "integrity": "sha512-JF18yTjNOVYvU/L3UosRcvbPMGT9B+/GQWNWnenIImglzNVGpyzChkXLnrSf6uxwVNO6ESGu6oN8MqcGQcjQJw==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@algolia/requester-common": "4.24.0"
- }
- },
- "node_modules/@algolia/requester-browser-xhr": {
- "version": "5.7.0",
- "resolved": "https://registry.npmjs.org/@algolia/requester-browser-xhr/-/requester-browser-xhr-5.7.0.tgz",
- "integrity": "sha512-ohtIp+lyTGM3agrHyedC3w7ijfdUvSN6wmGuKqUezrNzd0nCkFoLW0OINlyv1ODrTEVnL8PAM/Zqubjafxd/Ww==",
- "dev": true,
- "license": "MIT",
- "peer": true,
- "dependencies": {
- "@algolia/client-common": "5.7.0"
- },
- "engines": {
- "node": ">= 14.0.0"
- }
- },
- "node_modules/@algolia/requester-common": {
- "version": "4.24.0",
- "resolved": "https://registry.npmjs.org/@algolia/requester-common/-/requester-common-4.24.0.tgz",
- "integrity": "sha512-k3CXJ2OVnvgE3HMwcojpvY6d9kgKMPRxs/kVohrwF5WMr2fnqojnycZkxPoEg+bXm8fi5BBfFmOqgYztRtHsQA==",
- "dev": true,
- "license": "MIT"
- },
- "node_modules/@algolia/requester-fetch": {
- "version": "5.7.0",
- "resolved": "https://registry.npmjs.org/@algolia/requester-fetch/-/requester-fetch-5.7.0.tgz",
- "integrity": "sha512-Eg8cBhNg2QNnDDldyK77aXvg3wIc5qnpCDCAJXQ2oaqZwwvvYaTgnP1ofznNG6+klri4Fk1YAaC9wyDBhByWIA==",
- "dev": true,
- "license": "MIT",
- "peer": true,
- "dependencies": {
- "@algolia/client-common": "5.7.0"
- },
- "engines": {
- "node": ">= 14.0.0"
- }
- },
- "node_modules/@algolia/requester-node-http": {
- "version": "5.7.0",
- "resolved": "https://registry.npmjs.org/@algolia/requester-node-http/-/requester-node-http-5.7.0.tgz",
- "integrity": "sha512-8BDssYEkcp1co06KtHO9b37H+5zVM/h+5kyesJb2C2EHFO3kgzLHWl/JyXOVtYlKQBkmdObYOI0s6JaXRy2yQA==",
- "dev": true,
- "license": "MIT",
- "peer": true,
- "dependencies": {
- "@algolia/client-common": "5.7.0"
- },
- "engines": {
- "node": ">= 14.0.0"
- }
- },
- "node_modules/@algolia/transporter": {
- "version": "4.24.0",
- "resolved": "https://registry.npmjs.org/@algolia/transporter/-/transporter-4.24.0.tgz",
- "integrity": "sha512-86nI7w6NzWxd1Zp9q3413dRshDqAzSbsQjhcDhPIatEFiZrL1/TjnHL8S7jVKFePlIMzDsZWXAXwXzcok9c5oA==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@algolia/cache-common": "4.24.0",
- "@algolia/logger-common": "4.24.0",
- "@algolia/requester-common": "4.24.0"
+ "vitepress": "^2.0.0-alpha.17",
+ "vitepress-plugin-llms": "^1.13.2"
}
},
"node_modules/@babel/helper-string-parser": {
- "version": "7.24.8",
- "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.24.8.tgz",
- "integrity": "sha512-pO9KhhRcuUyGnJWwyEgnRJTSIZHiT+vMD0kPeD+so0l7mxkMT19g3pjY9GTnHySck/hDzq+dtW/4VgnMkippsQ==",
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz",
+ "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==",
"dev": true,
- "license": "MIT",
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/helper-validator-identifier": {
- "version": "7.24.7",
- "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.24.7.tgz",
- "integrity": "sha512-rR+PBcQ1SMQDDyF6X0wxtG8QyLCgUB0eRAGguqRLfkCA87l7yAP7ehq8SNj96OOGTO8OBV70KhuFYcIkHXOg0w==",
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz",
+ "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==",
"dev": true,
- "license": "MIT",
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/parser": {
- "version": "7.25.6",
- "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.25.6.tgz",
- "integrity": "sha512-trGdfBdbD0l1ZPmcJ83eNxB9rbEax4ALFTF7fN386TMYbeCQbyme5cOEXQhbGXKebwGaB/J52w1mrklMcbgy6Q==",
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz",
+ "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==",
"dev": true,
- "license": "MIT",
"dependencies": {
- "@babel/types": "^7.25.6"
+ "@babel/types": "^7.29.7"
},
"bin": {
"parser": "bin/babel-parser.js"
@@ -385,758 +44,880 @@
}
},
"node_modules/@babel/types": {
- "version": "7.25.6",
- "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.25.6.tgz",
- "integrity": "sha512-/l42B1qxpG6RdfYf343Uw1vmDjeNhneUXtzhojE7pDgfpEypmRhI6j1kr17XCVv4Cgl9HdAiQY2x0GwKm7rWCw==",
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz",
+ "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==",
"dev": true,
- "license": "MIT",
"dependencies": {
- "@babel/helper-string-parser": "^7.24.8",
- "@babel/helper-validator-identifier": "^7.24.7",
- "to-fast-properties": "^2.0.0"
+ "@babel/helper-string-parser": "^7.29.7",
+ "@babel/helper-validator-identifier": "^7.29.7"
},
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@docsearch/css": {
- "version": "3.6.2",
- "resolved": "https://registry.npmjs.org/@docsearch/css/-/css-3.6.2.tgz",
- "integrity": "sha512-vKNZepO2j7MrYBTZIGXvlUOIR+v9KRf70FApRgovWrj3GTs1EITz/Xb0AOlm1xsQBp16clVZj1SY/qaOJbQtZw==",
- "dev": true,
- "license": "MIT"
+ "version": "4.6.3",
+ "resolved": "https://registry.npmjs.org/@docsearch/css/-/css-4.6.3.tgz",
+ "integrity": "sha512-nlOwcXcsNAptQl4vlL4MA78qNJKO0Qlds5GuBjCoePgkebTXLSf8Qt1oyZ3YBshYupKXG9VRGEsk1zr23d+bzQ==",
+ "dev": true
},
"node_modules/@docsearch/js": {
- "version": "3.6.2",
- "resolved": "https://registry.npmjs.org/@docsearch/js/-/js-3.6.2.tgz",
- "integrity": "sha512-pS4YZF+VzUogYrkblCucQ0Oy2m8Wggk8Kk7lECmZM60hTbaydSIhJTTiCrmoxtBqV8wxORnOqcqqOfbmkkQEcA==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@docsearch/react": "3.6.2",
- "preact": "^10.0.0"
- }
+ "version": "4.6.3",
+ "resolved": "https://registry.npmjs.org/@docsearch/js/-/js-4.6.3.tgz",
+ "integrity": "sha512-qUIX2b4Apew3tv4F0qhmgShsl/Lfw4m6mqv/5/5dWNxwTcDdLMp2s3YwZ+NMGh3IKCg0pBaXm7Q5VdyU5Rj+cQ==",
+ "dev": true
},
- "node_modules/@docsearch/react": {
- "version": "3.6.2",
- "resolved": "https://registry.npmjs.org/@docsearch/react/-/react-3.6.2.tgz",
- "integrity": "sha512-rtZce46OOkVflCQH71IdbXSFK+S8iJZlUF56XBW5rIgx/eG5qoomC7Ag3anZson1bBac/JFQn7XOBfved/IMRA==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@algolia/autocomplete-core": "1.9.3",
- "@algolia/autocomplete-preset-algolia": "1.9.3",
- "@docsearch/css": "3.6.2",
- "algoliasearch": "^4.19.1"
- },
- "peerDependencies": {
- "@types/react": ">= 16.8.0 < 19.0.0",
- "react": ">= 16.8.0 < 19.0.0",
- "react-dom": ">= 16.8.0 < 19.0.0",
- "search-insights": ">= 1 < 3"
- },
- "peerDependenciesMeta": {
- "@types/react": {
- "optional": true
- },
- "react": {
- "optional": true
- },
- "react-dom": {
- "optional": true
- },
- "search-insights": {
- "optional": true
- }
- }
+ "node_modules/@docsearch/sidepanel-js": {
+ "version": "4.6.3",
+ "resolved": "https://registry.npmjs.org/@docsearch/sidepanel-js/-/sidepanel-js-4.6.3.tgz",
+ "integrity": "sha512-grGSmvXzG0if+mrzdIKykvpIAuEQ9u0sEJ2eLRRCaQfJvsWqh2C2/aY04bIzWvDh7myi5rvl8D+tUNsVrjYQ3A==",
+ "dev": true
},
"node_modules/@esbuild/aix-ppc64": {
- "version": "0.21.5",
- "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz",
- "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==",
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz",
+ "integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==",
"cpu": [
"ppc64"
],
"dev": true,
- "license": "MIT",
"optional": true,
"os": [
"aix"
],
"engines": {
- "node": ">=12"
+ "node": ">=18"
}
},
"node_modules/@esbuild/android-arm": {
- "version": "0.21.5",
- "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz",
- "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==",
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz",
+ "integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==",
"cpu": [
"arm"
],
"dev": true,
- "license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
- "node": ">=12"
+ "node": ">=18"
}
},
"node_modules/@esbuild/android-arm64": {
- "version": "0.21.5",
- "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz",
- "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==",
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz",
+ "integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==",
"cpu": [
"arm64"
],
"dev": true,
- "license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
- "node": ">=12"
+ "node": ">=18"
}
},
"node_modules/@esbuild/android-x64": {
- "version": "0.21.5",
- "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz",
- "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==",
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz",
+ "integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==",
"cpu": [
"x64"
],
"dev": true,
- "license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
- "node": ">=12"
+ "node": ">=18"
}
},
"node_modules/@esbuild/darwin-arm64": {
- "version": "0.21.5",
- "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz",
- "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==",
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz",
+ "integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==",
"cpu": [
"arm64"
],
"dev": true,
- "license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
- "node": ">=12"
+ "node": ">=18"
}
},
"node_modules/@esbuild/darwin-x64": {
- "version": "0.21.5",
- "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz",
- "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==",
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz",
+ "integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==",
"cpu": [
"x64"
],
"dev": true,
- "license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
- "node": ">=12"
+ "node": ">=18"
}
},
"node_modules/@esbuild/freebsd-arm64": {
- "version": "0.21.5",
- "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz",
- "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==",
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz",
+ "integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==",
"cpu": [
"arm64"
],
"dev": true,
- "license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
- "node": ">=12"
+ "node": ">=18"
}
},
"node_modules/@esbuild/freebsd-x64": {
- "version": "0.21.5",
- "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz",
- "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==",
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz",
+ "integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==",
"cpu": [
"x64"
],
"dev": true,
- "license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
- "node": ">=12"
+ "node": ">=18"
}
},
"node_modules/@esbuild/linux-arm": {
- "version": "0.21.5",
- "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz",
- "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==",
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz",
+ "integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==",
"cpu": [
"arm"
],
"dev": true,
- "license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
- "node": ">=12"
+ "node": ">=18"
}
},
"node_modules/@esbuild/linux-arm64": {
- "version": "0.21.5",
- "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz",
- "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==",
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz",
+ "integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==",
"cpu": [
"arm64"
],
"dev": true,
- "license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
- "node": ">=12"
+ "node": ">=18"
}
},
"node_modules/@esbuild/linux-ia32": {
- "version": "0.21.5",
- "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz",
- "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==",
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz",
+ "integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==",
"cpu": [
"ia32"
],
"dev": true,
- "license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
- "node": ">=12"
+ "node": ">=18"
}
},
"node_modules/@esbuild/linux-loong64": {
- "version": "0.21.5",
- "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz",
- "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==",
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz",
+ "integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==",
"cpu": [
"loong64"
],
"dev": true,
- "license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
- "node": ">=12"
+ "node": ">=18"
}
},
"node_modules/@esbuild/linux-mips64el": {
- "version": "0.21.5",
- "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz",
- "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==",
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz",
+ "integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==",
"cpu": [
"mips64el"
],
"dev": true,
- "license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
- "node": ">=12"
+ "node": ">=18"
}
},
"node_modules/@esbuild/linux-ppc64": {
- "version": "0.21.5",
- "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz",
- "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==",
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz",
+ "integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==",
"cpu": [
"ppc64"
],
"dev": true,
- "license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
- "node": ">=12"
+ "node": ">=18"
}
},
"node_modules/@esbuild/linux-riscv64": {
- "version": "0.21.5",
- "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz",
- "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==",
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz",
+ "integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==",
"cpu": [
"riscv64"
],
"dev": true,
- "license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
- "node": ">=12"
+ "node": ">=18"
}
},
"node_modules/@esbuild/linux-s390x": {
- "version": "0.21.5",
- "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz",
- "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==",
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz",
+ "integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==",
"cpu": [
"s390x"
],
"dev": true,
- "license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
- "node": ">=12"
+ "node": ">=18"
}
},
"node_modules/@esbuild/linux-x64": {
- "version": "0.21.5",
- "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz",
- "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==",
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz",
+ "integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==",
"cpu": [
"x64"
],
"dev": true,
- "license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
- "node": ">=12"
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/netbsd-arm64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz",
+ "integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "netbsd"
+ ],
+ "engines": {
+ "node": ">=18"
}
},
"node_modules/@esbuild/netbsd-x64": {
- "version": "0.21.5",
- "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz",
- "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==",
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz",
+ "integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==",
"cpu": [
"x64"
],
"dev": true,
- "license": "MIT",
"optional": true,
"os": [
"netbsd"
],
"engines": {
- "node": ">=12"
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/openbsd-arm64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz",
+ "integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "openbsd"
+ ],
+ "engines": {
+ "node": ">=18"
}
},
"node_modules/@esbuild/openbsd-x64": {
- "version": "0.21.5",
- "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz",
- "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==",
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz",
+ "integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==",
"cpu": [
"x64"
],
"dev": true,
- "license": "MIT",
"optional": true,
"os": [
"openbsd"
],
"engines": {
- "node": ">=12"
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/openharmony-arm64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz",
+ "integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "openharmony"
+ ],
+ "engines": {
+ "node": ">=18"
}
},
"node_modules/@esbuild/sunos-x64": {
- "version": "0.21.5",
- "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz",
- "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==",
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz",
+ "integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==",
"cpu": [
"x64"
],
"dev": true,
- "license": "MIT",
"optional": true,
"os": [
"sunos"
],
"engines": {
- "node": ">=12"
+ "node": ">=18"
}
},
"node_modules/@esbuild/win32-arm64": {
- "version": "0.21.5",
- "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz",
- "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==",
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz",
+ "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==",
"cpu": [
"arm64"
],
"dev": true,
- "license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
- "node": ">=12"
+ "node": ">=18"
}
},
"node_modules/@esbuild/win32-ia32": {
- "version": "0.21.5",
- "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz",
- "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==",
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz",
+ "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==",
"cpu": [
"ia32"
],
"dev": true,
- "license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
- "node": ">=12"
+ "node": ">=18"
}
},
"node_modules/@esbuild/win32-x64": {
- "version": "0.21.5",
- "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz",
- "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==",
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz",
+ "integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==",
"cpu": [
"x64"
],
"dev": true,
- "license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
- "node": ">=12"
+ "node": ">=18"
}
},
- "node_modules/@jridgewell/sourcemap-codec": {
- "version": "1.5.0",
- "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.0.tgz",
- "integrity": "sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ==",
+ "node_modules/@iconify-json/simple-icons": {
+ "version": "1.2.89",
+ "resolved": "https://registry.npmjs.org/@iconify-json/simple-icons/-/simple-icons-1.2.89.tgz",
+ "integrity": "sha512-hRaCY5s2G5oWAIhc4LCGYn6g6RrwLL4zhoLOT+KUO3joVCxVlZKA+839bv/47Nbe9/ZD4UA6dznZ4XPYcI53wA==",
"dev": true,
- "license": "MIT"
+ "dependencies": {
+ "@iconify/types": "*"
+ }
+ },
+ "node_modules/@iconify/types": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/@iconify/types/-/types-2.0.0.tgz",
+ "integrity": "sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg==",
+ "dev": true
+ },
+ "node_modules/@jridgewell/sourcemap-codec": {
+ "version": "1.5.5",
+ "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
+ "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
+ "dev": true
+ },
+ "node_modules/@rolldown/pluginutils": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz",
+ "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==",
+ "dev": true
},
"node_modules/@rollup/rollup-android-arm-eabi": {
- "version": "4.23.0",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.23.0.tgz",
- "integrity": "sha512-8OR+Ok3SGEMsAZispLx8jruuXw0HVF16k+ub2eNXKHDmdxL4cf9NlNpAzhlOhNyXzKDEJuFeq0nZm+XlNb1IFw==",
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.2.tgz",
+ "integrity": "sha512-6o7ZLZK+BeenkZCFNDXqpbjw9bD6nuWonvS/lwQJp7NoVVxm6p3qE7qQ5jGuBjiFsgvqjD8mZAU5oWxTmbOeOg==",
"cpu": [
"arm"
],
"dev": true,
- "license": "MIT",
"optional": true,
"os": [
"android"
]
},
"node_modules/@rollup/rollup-android-arm64": {
- "version": "4.23.0",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.23.0.tgz",
- "integrity": "sha512-rEFtX1nP8gqmLmPZsXRMoLVNB5JBwOzIAk/XAcEPuKrPa2nPJ+DuGGpfQUR0XjRm8KjHfTZLpWbKXkA5BoFL3w==",
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.62.2.tgz",
+ "integrity": "sha512-BaH7BllCACHoH1LguOU56UItGfUWjujlO65kS9LAodViaN4bwIKd7oeW/ZHJ/4ljr/7MIiENnNy3HJ0zXv8Zkw==",
"cpu": [
"arm64"
],
"dev": true,
- "license": "MIT",
"optional": true,
"os": [
"android"
]
},
"node_modules/@rollup/rollup-darwin-arm64": {
- "version": "4.23.0",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.23.0.tgz",
- "integrity": "sha512-ZbqlMkJRMMPeapfaU4drYHns7Q5MIxjM/QeOO62qQZGPh9XWziap+NF9fsqPHT0KzEL6HaPspC7sOwpgyA3J9g==",
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.62.2.tgz",
+ "integrity": "sha512-v39RCCvj4He82I9sFmk+M1VZ0PLM9sfsLVikjfx2hYBNALhrrOR2D3JjQA6AhlaSOgcR+RzrKY7e1+bT6SUO/A==",
"cpu": [
"arm64"
],
"dev": true,
- "license": "MIT",
"optional": true,
"os": [
"darwin"
]
},
"node_modules/@rollup/rollup-darwin-x64": {
- "version": "4.23.0",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.23.0.tgz",
- "integrity": "sha512-PfmgQp78xx5rBCgn2oYPQ1rQTtOaQCna0kRaBlc5w7RlA3TDGGo7m3XaptgitUZ54US9915i7KeVPHoy3/W8tA==",
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.62.2.tgz",
+ "integrity": "sha512-yl0y2vq3S3lHeuXhEdss6TWfKW8vkujImO12tn4ZkG/4oghr09LvdYm2RElVjokTQiUvDUGXLGsYeLqUMCKpGA==",
"cpu": [
"x64"
],
"dev": true,
- "license": "MIT",
"optional": true,
"os": [
"darwin"
]
},
+ "node_modules/@rollup/rollup-freebsd-arm64": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.62.2.tgz",
+ "integrity": "sha512-tT4pvt4qXD+vEoezupCWi+a1F0vvDiksiHc+PxRlYTOH1I6/X4id9jPxTP+Fg+545euaFT1jJVs4CEdHZAU1vw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "freebsd"
+ ]
+ },
+ "node_modules/@rollup/rollup-freebsd-x64": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.62.2.tgz",
+ "integrity": "sha512-6nU5F2wCW+qvCBhTn1pdIU3bzsIoF7EUwsCDRxilWGprQR6yd508YnH9+OKFCwpfS8pjZqDUmnCAr7exax0XCg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "freebsd"
+ ]
+ },
"node_modules/@rollup/rollup-linux-arm-gnueabihf": {
- "version": "4.23.0",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.23.0.tgz",
- "integrity": "sha512-WAeZfAAPus56eQgBioezXRRzArAjWJGjNo/M+BHZygUcs9EePIuGI1Wfc6U/Ki+tMW17FFGvhCfYnfcKPh18SA==",
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.62.2.tgz",
+ "integrity": "sha512-n1GJHPOvpIfhi3TmrCeh6S6URt9BFCt0KQE3qvexyGCTAKpR4Lg+eWvNZEqu7epxwus/8ElT3hacYEucm49SZg==",
"cpu": [
"arm"
],
"dev": true,
- "license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@rollup/rollup-linux-arm-musleabihf": {
- "version": "4.23.0",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.23.0.tgz",
- "integrity": "sha512-v7PGcp1O5XKZxKX8phTXtmJDVpE20Ub1eF6w9iMmI3qrrPak6yR9/5eeq7ziLMrMTjppkkskXyxnmm00HdtXjA==",
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.62.2.tgz",
+ "integrity": "sha512-JqgflS8wEB+UXV/vS1RpRbifGBeN4D5lz8D8oOFbFZw4vedvdOgCFAjfBmIMdW3yL10XpQQ0Ambepw6MXrhOnA==",
"cpu": [
"arm"
],
"dev": true,
- "license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@rollup/rollup-linux-arm64-gnu": {
- "version": "4.23.0",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.23.0.tgz",
- "integrity": "sha512-nAbWsDZ9UkU6xQiXEyXBNHAKbzSAi95H3gTStJq9UGiS1v+YVXwRHcQOQEF/3CHuhX5BVhShKoeOf6Q/1M+Zhg==",
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.62.2.tgz",
+ "integrity": "sha512-wnFJkogWvN4jm/hQRF2UBaeUmk20j5+DmHvoyWii2b8HJDyvz1MF2OU/6ynXt2KR63rbZLWkFpoytpdc/yBuSA==",
"cpu": [
"arm64"
],
"dev": true,
- "license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@rollup/rollup-linux-arm64-musl": {
- "version": "4.23.0",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.23.0.tgz",
- "integrity": "sha512-5QT/Di5FbGNPaVw8hHO1wETunwkPuZBIu6W+5GNArlKHD9fkMHy7vS8zGHJk38oObXfWdsuLMogD4sBySLJ54g==",
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.62.2.tgz",
+ "integrity": "sha512-HVu2bp0zhvJ8xHEV9+UUs7S90VadmBSY3LcIMvozbPo4AuMGDWlz3ymHLHZPX4hR67TKTt8Qp5PJ5RBg/i+RMQ==",
"cpu": [
"arm64"
],
"dev": true,
- "license": "MIT",
"optional": true,
"os": [
"linux"
]
},
- "node_modules/@rollup/rollup-linux-powerpc64le-gnu": {
- "version": "4.23.0",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-powerpc64le-gnu/-/rollup-linux-powerpc64le-gnu-4.23.0.tgz",
- "integrity": "sha512-Sefl6vPyn5axzCsO13r1sHLcmPuiSOrKIImnq34CBurntcJ+lkQgAaTt/9JkgGmaZJ+OkaHmAJl4Bfd0DmdtOQ==",
+ "node_modules/@rollup/rollup-linux-loong64-gnu": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.62.2.tgz",
+ "integrity": "sha512-mQqqAV8QaoSgr9I2fKDLY2BAVvmKjWoGiu/cSYQonsLvtqwEn1E4QYfnCOcp5zoEqNhsDYin1s6jx/VJmrxlZg==",
+ "cpu": [
+ "loong64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-loong64-musl": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.62.2.tgz",
+ "integrity": "sha512-IxKLoxCQ2IWi6bT2akyDUBGsOImDKB+sPp4EsTmwFQ/fMwpCKm8uLSSgP/Kx/QYUgKis6SEZ5/Nlhup0DIA0PQ==",
+ "cpu": [
+ "loong64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-ppc64-gnu": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.62.2.tgz",
+ "integrity": "sha512-Mk5ha2RQSgyFfmYYLkBpPnUk8D8FriBxesO1u9O75X0mHgXL1UQcH5Itl2lurWL2tj0RxV9b9tJgipac0hRY9A==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-ppc64-musl": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.62.2.tgz",
+ "integrity": "sha512-CjvEnqJL/0/TQ3TXX3OPIJ/kmBellrWd4heXUmHeJlTnmwjKpSJzoehLaL6Xk0ZnMHBu9dZuFADNOrtjF4v+2w==",
"cpu": [
"ppc64"
],
"dev": true,
- "license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@rollup/rollup-linux-riscv64-gnu": {
- "version": "4.23.0",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.23.0.tgz",
- "integrity": "sha512-o4QI2KU/QbP7ZExMse6ULotdV3oJUYMrdx3rBZCgUF3ur3gJPfe8Fuasn6tia16c5kZBBw0aTmaUygad6VB/hQ==",
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.62.2.tgz",
+ "integrity": "sha512-1SiZbzwdkaDURsew/tSOrooKiYy7EQGT6m8ufavAi9NEyQb/6VuIxFXAL1fqa4iZe3g4NbNk4P7J32z2tw5Mgg==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-riscv64-musl": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.62.2.tgz",
+ "integrity": "sha512-nQts12zJ3NQRoE6uYljOH89v7szzLDvG2JD/vsX+vGXU8w/At1GowTZ5/7qeFQ8m7L55rpR8Okugnuo5bgjy2Q==",
"cpu": [
"riscv64"
],
"dev": true,
- "license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@rollup/rollup-linux-s390x-gnu": {
- "version": "4.23.0",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.23.0.tgz",
- "integrity": "sha512-+bxqx+V/D4FGrpXzPGKp/SEZIZ8cIW3K7wOtcJAoCrmXvzRtmdUhYNbgd+RztLzfDEfA2WtKj5F4tcbNPuqgeg==",
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.62.2.tgz",
+ "integrity": "sha512-E9/ll019jhPIJgpzfZoIkBGhcz+kKNgVWYRY0zr9srBdPPFVpvOKW8VaJKUbeK+eZXyQF9ltME+Kk6affeaPgg==",
"cpu": [
"s390x"
],
"dev": true,
- "license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@rollup/rollup-linux-x64-gnu": {
- "version": "4.23.0",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.23.0.tgz",
- "integrity": "sha512-I/eXsdVoCKtSgK9OwyQKPAfricWKUMNCwJKtatRYMmDo5N859tbO3UsBw5kT3dU1n6ZcM1JDzPRSGhAUkxfLxw==",
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.2.tgz",
+ "integrity": "sha512-5BqxR/pshjey51iliyzTD5Xi3EN0aLmQ2lZ3lvefVV9c82BvrLo2/6OT55iifpWBufs6kdwWbuOKS841DrmK9A==",
"cpu": [
"x64"
],
"dev": true,
- "license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@rollup/rollup-linux-x64-musl": {
- "version": "4.23.0",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.23.0.tgz",
- "integrity": "sha512-4ZoDZy5ShLbbe1KPSafbFh1vbl0asTVfkABC7eWqIs01+66ncM82YJxV2VtV3YVJTqq2P8HMx3DCoRSWB/N3rw==",
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.62.2.tgz",
+ "integrity": "sha512-uNN83XxQrRAh/w0/pmAfibcwyb6YWt4gP+dpnQKPVJshAloQ785ii8CT8ZCIxkGg9opVsvAlGhFitSm6D1Jjpg==",
"cpu": [
"x64"
],
"dev": true,
- "license": "MIT",
"optional": true,
"os": [
"linux"
]
},
+ "node_modules/@rollup/rollup-openbsd-x64": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.62.2.tgz",
+ "integrity": "sha512-srjEIxSH3LRnJN6THczDHWQplqEMFiAJrTab0msUryh9kwNpkICf3Ea6q6MN/2cZwRFUNx5w+h6Hpi4QuHS6Zg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "openbsd"
+ ]
+ },
+ "node_modules/@rollup/rollup-openharmony-arm64": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.62.2.tgz",
+ "integrity": "sha512-8hOJnxgbyObnCm5AlRA3A931xX19xq80RjVTKgJOvEKWqJruP/Uf12IbAOaDjjEXYRewwHLfmF0YRIdK3OwKWA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "openharmony"
+ ]
+ },
"node_modules/@rollup/rollup-win32-arm64-msvc": {
- "version": "4.23.0",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.23.0.tgz",
- "integrity": "sha512-+5Ky8dhft4STaOEbZu3/NU4QIyYssKO+r1cD3FzuusA0vO5gso15on7qGzKdNXnc1gOrsgCqZjRw1w+zL4y4hQ==",
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.62.2.tgz",
+ "integrity": "sha512-mmF4AY1i0hG/bLWUctUq59gtmgaSIRa3cu/A3JFRp/sCNEme2bgDEiDS22P9FbnJB8NJNF4jPJiSP5RHQpUTDg==",
"cpu": [
"arm64"
],
"dev": true,
- "license": "MIT",
"optional": true,
"os": [
"win32"
]
},
"node_modules/@rollup/rollup-win32-ia32-msvc": {
- "version": "4.23.0",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.23.0.tgz",
- "integrity": "sha512-0SPJk4cPZQhq9qA1UhIRumSE3+JJIBBjtlGl5PNC///BoaByckNZd53rOYD0glpTkYFBQSt7AkMeLVPfx65+BQ==",
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.62.2.tgz",
+ "integrity": "sha512-DZgkknc6jhHrk46V25vbAM0zZkyP0nSDkJB8/dRkLTxv470dOmWDqGoEJl/9A0dFfS7yE3REOwNDxpHwSLSt0Q==",
"cpu": [
"ia32"
],
"dev": true,
- "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-x64-gnu": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.62.2.tgz",
+ "integrity": "sha512-T6xr6ucWSFto+VGajA8YH26LdpHRuP4YLHEKAtCWvJDOlnmWcDZVCI2Jmjr+IFHDlt2zRaTAKE4tfjTaWLgJBg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
"optional": true,
"os": [
"win32"
]
},
"node_modules/@rollup/rollup-win32-x64-msvc": {
- "version": "4.23.0",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.23.0.tgz",
- "integrity": "sha512-lqCK5GQC8fNo0+JvTSxcG7YB1UKYp8yrNLhsArlvPWN+16ovSZgoehlVHg6X0sSWPUkpjRBR5TuR12ZugowZ4g==",
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.2.tgz",
+ "integrity": "sha512-BfzEnDJOt9T8M989/lA37EcJgat01wLRnoi5dQf3QzOH7jzpqTAzdDbVfRljVr5r+jzKqpbHeyOfAaXxAd0PAA==",
"cpu": [
"x64"
],
"dev": true,
- "license": "MIT",
"optional": true,
"os": [
"win32"
]
},
"node_modules/@shikijs/core": {
- "version": "1.21.0",
- "resolved": "https://registry.npmjs.org/@shikijs/core/-/core-1.21.0.tgz",
- "integrity": "sha512-zAPMJdiGuqXpZQ+pWNezQAk5xhzRXBNiECFPcJLtUdsFM3f//G95Z15EHTnHchYycU8kIIysqGgxp8OVSj1SPQ==",
+ "version": "3.23.0",
+ "resolved": "https://registry.npmjs.org/@shikijs/core/-/core-3.23.0.tgz",
+ "integrity": "sha512-NSWQz0riNb67xthdm5br6lAkvpDJRTgB36fxlo37ZzM2yq0PQFFzbd8psqC2XMPgCzo1fW6cVi18+ArJ44wqgA==",
"dev": true,
- "license": "MIT",
"dependencies": {
- "@shikijs/engine-javascript": "1.21.0",
- "@shikijs/engine-oniguruma": "1.21.0",
- "@shikijs/types": "1.21.0",
- "@shikijs/vscode-textmate": "^9.2.2",
+ "@shikijs/types": "3.23.0",
+ "@shikijs/vscode-textmate": "^10.0.2",
"@types/hast": "^3.0.4",
- "hast-util-to-html": "^9.0.3"
+ "hast-util-to-html": "^9.0.5"
}
},
"node_modules/@shikijs/engine-javascript": {
- "version": "1.21.0",
- "resolved": "https://registry.npmjs.org/@shikijs/engine-javascript/-/engine-javascript-1.21.0.tgz",
- "integrity": "sha512-jxQHNtVP17edFW4/0vICqAVLDAxmyV31MQJL4U/Kg+heQALeKYVOWo0sMmEZ18FqBt+9UCdyqGKYE7bLRtk9mg==",
+ "version": "3.23.0",
+ "resolved": "https://registry.npmjs.org/@shikijs/engine-javascript/-/engine-javascript-3.23.0.tgz",
+ "integrity": "sha512-aHt9eiGFobmWR5uqJUViySI1bHMqrAgamWE1TYSUoftkAeCCAiGawPMwM+VCadylQtF4V3VNOZ5LmfItH5f3yA==",
"dev": true,
- "license": "MIT",
"dependencies": {
- "@shikijs/types": "1.21.0",
- "@shikijs/vscode-textmate": "^9.2.2",
- "oniguruma-to-js": "0.4.3"
+ "@shikijs/types": "3.23.0",
+ "@shikijs/vscode-textmate": "^10.0.2",
+ "oniguruma-to-es": "^4.3.4"
}
},
"node_modules/@shikijs/engine-oniguruma": {
- "version": "1.21.0",
- "resolved": "https://registry.npmjs.org/@shikijs/engine-oniguruma/-/engine-oniguruma-1.21.0.tgz",
- "integrity": "sha512-AIZ76XocENCrtYzVU7S4GY/HL+tgHGbVU+qhiDyNw1qgCA5OSi4B4+HY4BtAoJSMGuD/L5hfTzoRVbzEm2WTvg==",
+ "version": "3.23.0",
+ "resolved": "https://registry.npmjs.org/@shikijs/engine-oniguruma/-/engine-oniguruma-3.23.0.tgz",
+ "integrity": "sha512-1nWINwKXxKKLqPibT5f4pAFLej9oZzQTsby8942OTlsJzOBZ0MWKiwzMsd+jhzu8YPCHAswGnnN1YtQfirL35g==",
+ "dev": true,
+ "dependencies": {
+ "@shikijs/types": "3.23.0",
+ "@shikijs/vscode-textmate": "^10.0.2"
+ }
+ },
+ "node_modules/@shikijs/langs": {
+ "version": "3.23.0",
+ "resolved": "https://registry.npmjs.org/@shikijs/langs/-/langs-3.23.0.tgz",
+ "integrity": "sha512-2Ep4W3Re5aB1/62RSYQInK9mM3HsLeB91cHqznAJMuylqjzNVAVCMnNWRHFtcNHXsoNRayP9z1qj4Sq3nMqYXg==",
+ "dev": true,
+ "dependencies": {
+ "@shikijs/types": "3.23.0"
+ }
+ },
+ "node_modules/@shikijs/themes": {
+ "version": "3.23.0",
+ "resolved": "https://registry.npmjs.org/@shikijs/themes/-/themes-3.23.0.tgz",
+ "integrity": "sha512-5qySYa1ZgAT18HR/ypENL9cUSGOeI2x+4IvYJu4JgVJdizn6kG4ia5Q1jDEOi7gTbN4RbuYtmHh0W3eccOrjMA==",
"dev": true,
- "license": "MIT",
"dependencies": {
- "@shikijs/types": "1.21.0",
- "@shikijs/vscode-textmate": "^9.2.2"
+ "@shikijs/types": "3.23.0"
}
},
"node_modules/@shikijs/transformers": {
- "version": "1.21.0",
- "resolved": "https://registry.npmjs.org/@shikijs/transformers/-/transformers-1.21.0.tgz",
- "integrity": "sha512-aA+XGGSzipcvqdsOYL8l6Q2RYiMuJNdhdt9eZnkJmW+wjSOixN/I7dBq3fISwvEMDlawrtuXM3eybLCEC+Fjlg==",
+ "version": "3.23.0",
+ "resolved": "https://registry.npmjs.org/@shikijs/transformers/-/transformers-3.23.0.tgz",
+ "integrity": "sha512-F9msZVxdF+krQNSdQ4V+Ja5QemeAoTQ2jxt7nJCwhDsdF1JWS3KxIQXA3lQbyKwS3J61oHRUSv4jYWv3CkaKTQ==",
"dev": true,
- "license": "MIT",
"dependencies": {
- "shiki": "1.21.0"
+ "@shikijs/core": "3.23.0",
+ "@shikijs/types": "3.23.0"
}
},
"node_modules/@shikijs/types": {
- "version": "1.21.0",
- "resolved": "https://registry.npmjs.org/@shikijs/types/-/types-1.21.0.tgz",
- "integrity": "sha512-tzndANDhi5DUndBtpojEq/42+dpUF2wS7wdCDQaFtIXm3Rd1QkrcVgSSRLOvEwexekihOXfbYJINW37g96tJRw==",
+ "version": "3.23.0",
+ "resolved": "https://registry.npmjs.org/@shikijs/types/-/types-3.23.0.tgz",
+ "integrity": "sha512-3JZ5HXOZfYjsYSk0yPwBrkupyYSLpAE26Qc0HLghhZNGTZg/SKxXIIgoxOpmmeQP0RRSDJTk1/vPfw9tbw+jSQ==",
"dev": true,
- "license": "MIT",
"dependencies": {
- "@shikijs/vscode-textmate": "^9.2.2",
+ "@shikijs/vscode-textmate": "^10.0.2",
"@types/hast": "^3.0.4"
}
},
"node_modules/@shikijs/vscode-textmate": {
- "version": "9.2.2",
- "resolved": "https://registry.npmjs.org/@shikijs/vscode-textmate/-/vscode-textmate-9.2.2.tgz",
- "integrity": "sha512-TMp15K+GGYrWlZM8+Lnj9EaHEFmOen0WJBrfa17hF7taDOYthuPPV0GWzfd/9iMij0akS/8Yw2ikquH7uVi/fg==",
- "dev": true,
- "license": "MIT"
+ "version": "10.0.2",
+ "resolved": "https://registry.npmjs.org/@shikijs/vscode-textmate/-/vscode-textmate-10.0.2.tgz",
+ "integrity": "sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==",
+ "dev": true
},
"node_modules/@types/debug": {
"version": "4.1.12",
@@ -1149,18 +930,16 @@
}
},
"node_modules/@types/estree": {
- "version": "1.0.6",
- "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.6.tgz",
- "integrity": "sha512-AYnb1nQyY49te+VRAVgmzfcgjYS91mY5P0TKUDCLEM+gNnA+3T6rWITXRLYCpahpqSQbN5cE+gHpnPyXjHWxcw==",
- "dev": true,
- "license": "MIT"
+ "version": "1.0.9",
+ "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz",
+ "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==",
+ "dev": true
},
"node_modules/@types/hast": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.4.tgz",
"integrity": "sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==",
"dev": true,
- "license": "MIT",
"dependencies": {
"@types/unist": "*"
}
@@ -1215,231 +994,197 @@
"license": "MIT"
},
"node_modules/@types/web-bluetooth": {
- "version": "0.0.20",
- "resolved": "https://registry.npmjs.org/@types/web-bluetooth/-/web-bluetooth-0.0.20.tgz",
- "integrity": "sha512-g9gZnnXVq7gM7v3tJCWV/qw7w+KeOlSHAhgF9RytFyifW6AF61hdT2ucrYhPq9hLs5JIryeupHV3qGk95dH9ow==",
- "dev": true,
- "license": "MIT"
+ "version": "0.0.21",
+ "resolved": "https://registry.npmjs.org/@types/web-bluetooth/-/web-bluetooth-0.0.21.tgz",
+ "integrity": "sha512-oIQLCGWtcFZy2JW77j9k8nHzAOpqMHLQejDA48XXMWH6tjCQHz5RCFz1bzsmROyL6PUm+LLnUiI4BCn221inxA==",
+ "dev": true
},
"node_modules/@ungap/structured-clone": {
- "version": "1.2.0",
- "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.2.0.tgz",
- "integrity": "sha512-zuVdFrMJiuCDQUMCzQaD6KL28MjnqqN8XnAqiEq9PNm/hCPTSGfrXCOfwj1ow4LFb/tNymJPwsNbVePc1xFqrQ==",
- "dev": true,
- "license": "ISC"
+ "version": "1.3.2",
+ "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.2.tgz",
+ "integrity": "sha512-5jsZFwgR5rTdKwidH9Qmat75RKwqfpKlWWB1frDkljN127mwqBu8K0PYo7/hFpF03IEJpfVPpCQDY/eDx3iHvA==",
+ "dev": true
},
"node_modules/@vitejs/plugin-vue": {
- "version": "5.1.4",
- "resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-5.1.4.tgz",
- "integrity": "sha512-N2XSI2n3sQqp5w7Y/AN/L2XDjBIRGqXko+eDp42sydYSBeJuSm5a1sLf8zakmo8u7tA8NmBgoDLA1HeOESjp9A==",
+ "version": "6.0.7",
+ "resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-6.0.7.tgz",
+ "integrity": "sha512-km+p+XdSz9Sxm5rqUbqcSfZYaAniKxWBj1KURl+Jr7UaPvvX7BmaWMdP69I5rrFDeQGyxAG7NXdc57vz+snhWg==",
"dev": true,
- "license": "MIT",
+ "dependencies": {
+ "@rolldown/pluginutils": "^1.0.1"
+ },
"engines": {
- "node": "^18.0.0 || >=20.0.0"
+ "node": "^20.19.0 || >=22.12.0"
},
"peerDependencies": {
- "vite": "^5.0.0",
+ "vite": "^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0",
"vue": "^3.2.25"
}
},
"node_modules/@vue/compiler-core": {
- "version": "3.5.10",
- "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.10.tgz",
- "integrity": "sha512-iXWlk+Cg/ag7gLvY0SfVucU8Kh2CjysYZjhhP70w9qI4MvSox4frrP+vDGvtQuzIcgD8+sxM6lZvCtdxGunTAA==",
+ "version": "3.5.39",
+ "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.39.tgz",
+ "integrity": "sha512-16KBTEXAJCpDr0mwlw+AZyhu8iyC7R3S2vBwsI7QnWJU6X3WKc9VKeNEZpiMdZ569qWhz9574L3vV55qRL0Vtw==",
"dev": true,
- "license": "MIT",
"dependencies": {
- "@babel/parser": "^7.25.3",
- "@vue/shared": "3.5.10",
- "entities": "^4.5.0",
+ "@babel/parser": "^7.29.7",
+ "@vue/shared": "3.5.39",
+ "entities": "^7.0.1",
"estree-walker": "^2.0.2",
- "source-map-js": "^1.2.0"
+ "source-map-js": "^1.2.1"
+ }
+ },
+ "node_modules/@vue/compiler-core/node_modules/entities": {
+ "version": "7.0.1",
+ "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz",
+ "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==",
+ "dev": true,
+ "engines": {
+ "node": ">=0.12"
+ },
+ "funding": {
+ "url": "https://github.com/fb55/entities?sponsor=1"
}
},
"node_modules/@vue/compiler-dom": {
- "version": "3.5.10",
- "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.10.tgz",
- "integrity": "sha512-DyxHC6qPcktwYGKOIy3XqnHRrrXyWR2u91AjP+nLkADko380srsC2DC3s7Y1Rk6YfOlxOlvEQKa9XXmLI+W4ZA==",
+ "version": "3.5.39",
+ "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.39.tgz",
+ "integrity": "sha512-oQPigALqYbNxTNPvNgSOe+czwVExfbVF02lz8jP0S3AXJiu3jxYDygNUiqSep4ezzW8XgnubqH63My2A7JR/vg==",
"dev": true,
- "license": "MIT",
"dependencies": {
- "@vue/compiler-core": "3.5.10",
- "@vue/shared": "3.5.10"
+ "@vue/compiler-core": "3.5.39",
+ "@vue/shared": "3.5.39"
}
},
"node_modules/@vue/compiler-sfc": {
- "version": "3.5.10",
- "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.10.tgz",
- "integrity": "sha512-to8E1BgpakV7224ZCm8gz1ZRSyjNCAWEplwFMWKlzCdP9DkMKhRRwt0WkCjY7jkzi/Vz3xgbpeig5Pnbly4Tow==",
+ "version": "3.5.39",
+ "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.39.tgz",
+ "integrity": "sha512-d0ki86iOyN8LoZPBmk5SJWNwHP19CnDDCfuo//+2WJa2g5Ke0Jay983PIBIcSSzldC68I8DrD5GrHV3OSDfodg==",
"dev": true,
- "license": "MIT",
"dependencies": {
- "@babel/parser": "^7.25.3",
- "@vue/compiler-core": "3.5.10",
- "@vue/compiler-dom": "3.5.10",
- "@vue/compiler-ssr": "3.5.10",
- "@vue/shared": "3.5.10",
+ "@babel/parser": "^7.29.7",
+ "@vue/compiler-core": "3.5.39",
+ "@vue/compiler-dom": "3.5.39",
+ "@vue/compiler-ssr": "3.5.39",
+ "@vue/shared": "3.5.39",
"estree-walker": "^2.0.2",
- "magic-string": "^0.30.11",
- "postcss": "^8.4.47",
- "source-map-js": "^1.2.0"
+ "magic-string": "^0.30.21",
+ "postcss": "^8.5.15",
+ "source-map-js": "^1.2.1"
}
},
"node_modules/@vue/compiler-ssr": {
- "version": "3.5.10",
- "resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.10.tgz",
- "integrity": "sha512-hxP4Y3KImqdtyUKXDRSxKSRkSm1H9fCvhojEYrnaoWhE4w/y8vwWhnosJoPPe2AXm5sU7CSbYYAgkt2ZPhDz+A==",
+ "version": "3.5.39",
+ "resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.39.tgz",
+ "integrity": "sha512-Ce7/wvwMHai74bdszfXExdazFigYnlF9zgCmEQUcM1j0fOymlouZ7XilTYNo8oUjhlnjYOZbGrcYKuqjz89Ucw==",
"dev": true,
- "license": "MIT",
"dependencies": {
- "@vue/compiler-dom": "3.5.10",
- "@vue/shared": "3.5.10"
+ "@vue/compiler-dom": "3.5.39",
+ "@vue/shared": "3.5.39"
}
},
"node_modules/@vue/devtools-api": {
- "version": "7.4.6",
- "resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-7.4.6.tgz",
- "integrity": "sha512-XipBV5k0/IfTr0sNBDTg7OBUCp51cYMMXyPxLXJZ4K/wmUeMqt8cVdr2ZZGOFq+si/jTyCYnNxeKoyev5DOUUA==",
+ "version": "8.1.5",
+ "resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-8.1.5.tgz",
+ "integrity": "sha512-YJipMVAKe5wT5CWf5kTYCaNV7NMNjFVxJkIkJaJ4W/nCxEBzlZzrOsYKeCymdCrFZmBS/+wTWFoUs3Jf/Q6XSQ==",
"dev": true,
- "license": "MIT",
"dependencies": {
- "@vue/devtools-kit": "^7.4.6"
+ "@vue/devtools-kit": "^8.1.5"
}
},
"node_modules/@vue/devtools-kit": {
- "version": "7.4.6",
- "resolved": "https://registry.npmjs.org/@vue/devtools-kit/-/devtools-kit-7.4.6.tgz",
- "integrity": "sha512-NbYBwPWgEic1AOd9bWExz9weBzFdjiIfov0yRn4DrRfR+EQJCI9dn4I0XS7IxYGdkmUJi8mFW42LLk18WsGqew==",
+ "version": "8.1.5",
+ "resolved": "https://registry.npmjs.org/@vue/devtools-kit/-/devtools-kit-8.1.5.tgz",
+ "integrity": "sha512-FcSAxsi4eWuXLCB7Rv9lj0aIVHHPNVQ2BazGf4RJTc2JCqb4BQg0hk87ZFhminCfl+mD5OUI0rX2cgyu4kJOGA==",
"dev": true,
- "license": "MIT",
"dependencies": {
- "@vue/devtools-shared": "^7.4.6",
- "birpc": "^0.2.17",
+ "@vue/devtools-shared": "^8.1.5",
+ "birpc": "^2.6.1",
"hookable": "^5.5.3",
- "mitt": "^3.0.1",
- "perfect-debounce": "^1.0.0",
- "speakingurl": "^14.0.1",
- "superjson": "^2.2.1"
+ "perfect-debounce": "^2.0.0"
}
},
"node_modules/@vue/devtools-shared": {
- "version": "7.4.6",
- "resolved": "https://registry.npmjs.org/@vue/devtools-shared/-/devtools-shared-7.4.6.tgz",
- "integrity": "sha512-rPeSBzElnHYMB05Cc056BQiJpgocQjY8XVulgni+O9a9Gr9tNXgPteSzFFD+fT/iWMxNuUgGKs9CuW5DZewfIg==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "rfdc": "^1.4.1"
- }
+ "version": "8.1.5",
+ "resolved": "https://registry.npmjs.org/@vue/devtools-shared/-/devtools-shared-8.1.5.tgz",
+ "integrity": "sha512-mhT4zcPFhF+Xk1O4BfhhrbXzpmfqY03fS6xGpcllbQG7lDjhQf8pQHcTIhqQIYx1hfwtHmk/6jM96ele0UxPqQ==",
+ "dev": true
},
"node_modules/@vue/reactivity": {
- "version": "3.5.10",
- "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.10.tgz",
- "integrity": "sha512-kW08v06F6xPSHhid9DJ9YjOGmwNDOsJJQk0ax21wKaUYzzuJGEuoKNU2Ujux8FLMrP7CFJJKsHhXN9l2WOVi2g==",
+ "version": "3.5.39",
+ "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.39.tgz",
+ "integrity": "sha512-TpsuBJ9gGlZa5d23XcM2y8EXanz9dZeVDQBXRwzy46ItgvM+rWpzs+UVM0wcRLxGvcav0HE5jz2gNL53xlRAog==",
"dev": true,
- "license": "MIT",
"dependencies": {
- "@vue/shared": "3.5.10"
+ "@vue/shared": "3.5.39"
}
},
"node_modules/@vue/runtime-core": {
- "version": "3.5.10",
- "resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.10.tgz",
- "integrity": "sha512-9Q86I5Qq3swSkFfzrZ+iqEy7Vla325M7S7xc1NwKnRm/qoi1Dauz0rT6mTMmscqx4qz0EDJ1wjB+A36k7rl8mA==",
+ "version": "3.5.39",
+ "resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.39.tgz",
+ "integrity": "sha512-9GLtNyRvPAUMbX+7ono0RC2j0guo2LXVi8LvcmAooImACUKm0oFf0jjwbX8/H0AE/t1nxhAkn8RSl9PMCzzxZw==",
"dev": true,
- "license": "MIT",
"dependencies": {
- "@vue/reactivity": "3.5.10",
- "@vue/shared": "3.5.10"
+ "@vue/reactivity": "3.5.39",
+ "@vue/shared": "3.5.39"
}
},
"node_modules/@vue/runtime-dom": {
- "version": "3.5.10",
- "resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.10.tgz",
- "integrity": "sha512-t3x7ht5qF8ZRi1H4fZqFzyY2j+GTMTDxRheT+i8M9Ph0oepUxoadmbwlFwMoW7RYCpNQLpP2Yx3feKs+fyBdpA==",
+ "version": "3.5.39",
+ "resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.39.tgz",
+ "integrity": "sha512-7Y6aAGboKcXAZ3ECuUy7RrS5yy2r47dhTp2SKaJmYxjopImaVFaNa5Ne66NwGovsrxVAl5S5rwc7m22UG7Lmww==",
"dev": true,
- "license": "MIT",
"dependencies": {
- "@vue/reactivity": "3.5.10",
- "@vue/runtime-core": "3.5.10",
- "@vue/shared": "3.5.10",
- "csstype": "^3.1.3"
+ "@vue/reactivity": "3.5.39",
+ "@vue/runtime-core": "3.5.39",
+ "@vue/shared": "3.5.39",
+ "csstype": "^3.2.3"
}
},
"node_modules/@vue/server-renderer": {
- "version": "3.5.10",
- "resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.10.tgz",
- "integrity": "sha512-IVE97tt2kGKwHNq9yVO0xdh1IvYfZCShvDSy46JIh5OQxP1/EXSpoDqetVmyIzL7CYOWnnmMkVqd7YK2QSWkdw==",
+ "version": "3.5.39",
+ "resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.39.tgz",
+ "integrity": "sha512-yZSakiAGw85rZfG7UM8akMnIF+FmeiNk47uvHf2nVBBSe+dIKUhZuZq9+XgJhbV3nS5Z4ALH23/MpXofW+mbcw==",
"dev": true,
- "license": "MIT",
"dependencies": {
- "@vue/compiler-ssr": "3.5.10",
- "@vue/shared": "3.5.10"
+ "@vue/compiler-ssr": "3.5.39",
+ "@vue/shared": "3.5.39"
},
"peerDependencies": {
- "vue": "3.5.10"
+ "vue": "3.5.39"
}
},
"node_modules/@vue/shared": {
- "version": "3.5.10",
- "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.10.tgz",
- "integrity": "sha512-VkkBhU97Ki+XJ0xvl4C9YJsIZ2uIlQ7HqPpZOS3m9VCvmROPaChZU6DexdMJqvz9tbgG+4EtFVrSuailUq5KGQ==",
- "dev": true,
- "license": "MIT"
+ "version": "3.5.39",
+ "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.39.tgz",
+ "integrity": "sha512-l1rrBtBfTnmxvtsvdQDXltUUy8S1Y+ZaqdfUzmAnJkTd8Z8rv5v/ytW+TKiqEOWyHPoqtPlNFSs0lhRmYVSHVA==",
+ "dev": true
},
"node_modules/@vueuse/core": {
- "version": "11.1.0",
- "resolved": "https://registry.npmjs.org/@vueuse/core/-/core-11.1.0.tgz",
- "integrity": "sha512-P6dk79QYA6sKQnghrUz/1tHi0n9mrb/iO1WTMk/ElLmTyNqgDeSZ3wcDf6fRBGzRJbeG1dxzEOvLENMjr+E3fg==",
+ "version": "14.3.0",
+ "resolved": "https://registry.npmjs.org/@vueuse/core/-/core-14.3.0.tgz",
+ "integrity": "sha512-aHfz47g0ZhMtTVHmIzMVpJy8ePhhOy68GY5bv110+5DVtZ+W7BsOx+m61UNQqfrWyPztIHIanWa3E2tib3NFIw==",
"dev": true,
- "license": "MIT",
"dependencies": {
- "@types/web-bluetooth": "^0.0.20",
- "@vueuse/metadata": "11.1.0",
- "@vueuse/shared": "11.1.0",
- "vue-demi": ">=0.14.10"
- },
- "funding": {
- "url": "https://github.com/sponsors/antfu"
- }
- },
- "node_modules/@vueuse/core/node_modules/vue-demi": {
- "version": "0.14.10",
- "resolved": "https://registry.npmjs.org/vue-demi/-/vue-demi-0.14.10.tgz",
- "integrity": "sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==",
- "dev": true,
- "hasInstallScript": true,
- "license": "MIT",
- "bin": {
- "vue-demi-fix": "bin/vue-demi-fix.js",
- "vue-demi-switch": "bin/vue-demi-switch.js"
- },
- "engines": {
- "node": ">=12"
+ "@types/web-bluetooth": "^0.0.21",
+ "@vueuse/metadata": "14.3.0",
+ "@vueuse/shared": "14.3.0"
},
"funding": {
"url": "https://github.com/sponsors/antfu"
},
"peerDependencies": {
- "@vue/composition-api": "^1.0.0-rc.1",
- "vue": "^3.0.0-0 || ^2.6.0"
- },
- "peerDependenciesMeta": {
- "@vue/composition-api": {
- "optional": true
- }
+ "vue": "^3.5.0"
}
},
"node_modules/@vueuse/integrations": {
- "version": "11.1.0",
- "resolved": "https://registry.npmjs.org/@vueuse/integrations/-/integrations-11.1.0.tgz",
- "integrity": "sha512-O2ZgrAGPy0qAjpoI2YR3egNgyEqwG85fxfwmA9BshRIGjV4G6yu6CfOPpMHAOoCD+UfsIl7Vb1bXJ6ifrHYDDA==",
+ "version": "14.3.0",
+ "resolved": "https://registry.npmjs.org/@vueuse/integrations/-/integrations-14.3.0.tgz",
+ "integrity": "sha512-76I5FT2ESvCmCaSwapI+a/u/CFtNXmzl9f9lNp1hRtx8vKB8hfiokJr8IvQqcQG5ckGXElyXK516b54ozV3MvA==",
"dev": true,
- "license": "MIT",
"dependencies": {
- "@vueuse/core": "11.1.0",
- "@vueuse/shared": "11.1.0",
- "vue-demi": ">=0.14.10"
+ "@vueuse/core": "14.3.0",
+ "@vueuse/shared": "14.3.0"
},
"funding": {
"url": "https://github.com/sponsors/antfu"
@@ -1449,14 +1194,15 @@
"axios": "^1",
"change-case": "^5",
"drauu": "^0.4",
- "focus-trap": "^7",
+ "focus-trap": "^7 || ^8",
"fuse.js": "^7",
"idb-keyval": "^6",
"jwt-decode": "^4",
"nprogress": "^0.2",
"qrcode": "^1.5",
"sortablejs": "^1",
- "universal-cookie": "^7"
+ "universal-cookie": "^7 || ^8",
+ "vue": "^3.5.0"
},
"peerDependenciesMeta": {
"async-validator": {
@@ -1497,148 +1243,25 @@
}
}
},
- "node_modules/@vueuse/integrations/node_modules/vue-demi": {
- "version": "0.14.10",
- "resolved": "https://registry.npmjs.org/vue-demi/-/vue-demi-0.14.10.tgz",
- "integrity": "sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==",
- "dev": true,
- "hasInstallScript": true,
- "license": "MIT",
- "bin": {
- "vue-demi-fix": "bin/vue-demi-fix.js",
- "vue-demi-switch": "bin/vue-demi-switch.js"
- },
- "engines": {
- "node": ">=12"
- },
- "funding": {
- "url": "https://github.com/sponsors/antfu"
- },
- "peerDependencies": {
- "@vue/composition-api": "^1.0.0-rc.1",
- "vue": "^3.0.0-0 || ^2.6.0"
- },
- "peerDependenciesMeta": {
- "@vue/composition-api": {
- "optional": true
- }
- }
- },
"node_modules/@vueuse/metadata": {
- "version": "11.1.0",
- "resolved": "https://registry.npmjs.org/@vueuse/metadata/-/metadata-11.1.0.tgz",
- "integrity": "sha512-l9Q502TBTaPYGanl1G+hPgd3QX5s4CGnpXriVBR5fEZ/goI6fvDaVmIl3Td8oKFurOxTmbXvBPSsgrd6eu6HYg==",
+ "version": "14.3.0",
+ "resolved": "https://registry.npmjs.org/@vueuse/metadata/-/metadata-14.3.0.tgz",
+ "integrity": "sha512-BwxmbAzwAVF50+MW57GXOUEV61nFBGnlBvrTqj49PqWJu3uw7hdu72ztXeZ33RdZtDY6kO+bfCAE1PCn88Tktw==",
"dev": true,
- "license": "MIT",
"funding": {
"url": "https://github.com/sponsors/antfu"
}
},
"node_modules/@vueuse/shared": {
- "version": "11.1.0",
- "resolved": "https://registry.npmjs.org/@vueuse/shared/-/shared-11.1.0.tgz",
- "integrity": "sha512-YUtIpY122q7osj+zsNMFAfMTubGz0sn5QzE5gPzAIiCmtt2ha3uQUY1+JPyL4gRCTsLPX82Y9brNbo/aqlA91w==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "vue-demi": ">=0.14.10"
- },
- "funding": {
- "url": "https://github.com/sponsors/antfu"
- }
- },
- "node_modules/@vueuse/shared/node_modules/vue-demi": {
- "version": "0.14.10",
- "resolved": "https://registry.npmjs.org/vue-demi/-/vue-demi-0.14.10.tgz",
- "integrity": "sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==",
+ "version": "14.3.0",
+ "resolved": "https://registry.npmjs.org/@vueuse/shared/-/shared-14.3.0.tgz",
+ "integrity": "sha512-bZpge9eSXwa4ToSiqJ7j6KRwhAsneMFoSz3LMWKQDkqimm3D/tbFlrklrs/IOqC8tEcYmXQZJ6N0UrjhBirVCg==",
"dev": true,
- "hasInstallScript": true,
- "license": "MIT",
- "bin": {
- "vue-demi-fix": "bin/vue-demi-fix.js",
- "vue-demi-switch": "bin/vue-demi-switch.js"
- },
- "engines": {
- "node": ">=12"
- },
"funding": {
"url": "https://github.com/sponsors/antfu"
},
"peerDependencies": {
- "@vue/composition-api": "^1.0.0-rc.1",
- "vue": "^3.0.0-0 || ^2.6.0"
- },
- "peerDependenciesMeta": {
- "@vue/composition-api": {
- "optional": true
- }
- }
- },
- "node_modules/algoliasearch": {
- "version": "4.24.0",
- "resolved": "https://registry.npmjs.org/algoliasearch/-/algoliasearch-4.24.0.tgz",
- "integrity": "sha512-bf0QV/9jVejssFBmz2HQLxUadxk574t4iwjCKp5E7NBzwKkrDEhKPISIIjAU/p6K5qDx3qoeh4+26zWN1jmw3g==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@algolia/cache-browser-local-storage": "4.24.0",
- "@algolia/cache-common": "4.24.0",
- "@algolia/cache-in-memory": "4.24.0",
- "@algolia/client-account": "4.24.0",
- "@algolia/client-analytics": "4.24.0",
- "@algolia/client-common": "4.24.0",
- "@algolia/client-personalization": "4.24.0",
- "@algolia/client-search": "4.24.0",
- "@algolia/logger-common": "4.24.0",
- "@algolia/logger-console": "4.24.0",
- "@algolia/recommend": "4.24.0",
- "@algolia/requester-browser-xhr": "4.24.0",
- "@algolia/requester-common": "4.24.0",
- "@algolia/requester-node-http": "4.24.0",
- "@algolia/transporter": "4.24.0"
- }
- },
- "node_modules/algoliasearch/node_modules/@algolia/client-common": {
- "version": "4.24.0",
- "resolved": "https://registry.npmjs.org/@algolia/client-common/-/client-common-4.24.0.tgz",
- "integrity": "sha512-bc2ROsNL6w6rqpl5jj/UywlIYC21TwSSoFHKl01lYirGMW+9Eek6r02Tocg4gZ8HAw3iBvu6XQiM3BEbmEMoiA==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@algolia/requester-common": "4.24.0",
- "@algolia/transporter": "4.24.0"
- }
- },
- "node_modules/algoliasearch/node_modules/@algolia/client-search": {
- "version": "4.24.0",
- "resolved": "https://registry.npmjs.org/@algolia/client-search/-/client-search-4.24.0.tgz",
- "integrity": "sha512-uRW6EpNapmLAD0mW47OXqTP8eiIx5F6qN9/x/7HHO6owL3N1IXqydGwW5nhDFBrV+ldouro2W1VX3XlcUXEFCA==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@algolia/client-common": "4.24.0",
- "@algolia/requester-common": "4.24.0",
- "@algolia/transporter": "4.24.0"
- }
- },
- "node_modules/algoliasearch/node_modules/@algolia/requester-browser-xhr": {
- "version": "4.24.0",
- "resolved": "https://registry.npmjs.org/@algolia/requester-browser-xhr/-/requester-browser-xhr-4.24.0.tgz",
- "integrity": "sha512-Z2NxZMb6+nVXSjF13YpjYTdvV3032YTBSGm2vnYvYPA6mMxzM3v5rsCiSspndn9rzIW4Qp1lPHBvuoKJV6jnAA==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@algolia/requester-common": "4.24.0"
- }
- },
- "node_modules/algoliasearch/node_modules/@algolia/requester-node-http": {
- "version": "4.24.0",
- "resolved": "https://registry.npmjs.org/@algolia/requester-node-http/-/requester-node-http-4.24.0.tgz",
- "integrity": "sha512-JF18yTjNOVYvU/L3UosRcvbPMGT9B+/GQWNWnenIImglzNVGpyzChkXLnrSf6uxwVNO6ESGu6oN8MqcGQcjQJw==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@algolia/requester-common": "4.24.0"
+ "vue": "^3.5.0"
}
},
"node_modules/ansi-regex": {
@@ -1689,48 +1312,33 @@
}
},
"node_modules/balanced-match": {
- "version": "1.0.2",
- "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
- "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
+ "version": "4.0.4",
+ "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
+ "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==",
"dev": true,
- "license": "MIT"
+ "engines": {
+ "node": "18 || 20 || >=22"
+ }
},
"node_modules/birpc": {
- "version": "0.2.17",
- "resolved": "https://registry.npmjs.org/birpc/-/birpc-0.2.17.tgz",
- "integrity": "sha512-+hkTxhot+dWsLpp3gia5AkVHIsKlZybNT5gIYiDlNzJrmYPcTM9k5/w2uaj3IPpd7LlEYpmCj4Jj1nC41VhDFg==",
+ "version": "2.9.0",
+ "resolved": "https://registry.npmjs.org/birpc/-/birpc-2.9.0.tgz",
+ "integrity": "sha512-KrayHS5pBi69Xi9JmvoqrIgYGDkD6mcSe/i6YKi3w5kekCLzrX4+nawcXqrj2tIp50Kw/mT/s3p+GVK0A0sKxw==",
"dev": true,
- "license": "MIT",
"funding": {
"url": "https://github.com/sponsors/antfu"
}
},
"node_modules/brace-expansion": {
- "version": "2.0.1",
- "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz",
- "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==",
+ "version": "5.0.7",
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.7.tgz",
+ "integrity": "sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==",
"dev": true,
- "license": "MIT",
"dependencies": {
- "balanced-match": "^1.0.0"
- }
- },
- "node_modules/byte-size": {
- "version": "9.0.1",
- "resolved": "https://registry.npmjs.org/byte-size/-/byte-size-9.0.1.tgz",
- "integrity": "sha512-YLe9x3rabBrcI0cueCdLS2l5ONUKywcRpTs02B8KP9/Cimhj7o3ZccGrPnRvcbyHMbb7W79/3MUJl7iGgTXKEw==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=12.17"
- },
- "peerDependencies": {
- "@75lb/nature": "latest"
+ "balanced-match": "^4.0.2"
},
- "peerDependenciesMeta": {
- "@75lb/nature": {
- "optional": true
- }
+ "engines": {
+ "node": "18 || 20 || >=22"
}
},
"node_modules/ccount": {
@@ -1738,7 +1346,6 @@
"resolved": "https://registry.npmjs.org/ccount/-/ccount-2.0.1.tgz",
"integrity": "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==",
"dev": true,
- "license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/wooorm"
@@ -1760,7 +1367,6 @@
"resolved": "https://registry.npmjs.org/character-entities-html4/-/character-entities-html4-2.1.0.tgz",
"integrity": "sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==",
"dev": true,
- "license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/wooorm"
@@ -1771,7 +1377,6 @@
"resolved": "https://registry.npmjs.org/character-entities-legacy/-/character-entities-legacy-3.0.0.tgz",
"integrity": "sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==",
"dev": true,
- "license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/wooorm"
@@ -1817,34 +1422,16 @@
"resolved": "https://registry.npmjs.org/comma-separated-tokens/-/comma-separated-tokens-2.0.3.tgz",
"integrity": "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==",
"dev": true,
- "license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/wooorm"
}
},
- "node_modules/copy-anything": {
- "version": "3.0.5",
- "resolved": "https://registry.npmjs.org/copy-anything/-/copy-anything-3.0.5.tgz",
- "integrity": "sha512-yCEafptTtb4bk7GLEQoM8KVJpxAfdBJYaXyzQEgQQQgYrZiDp8SJmGKlYza6CYjEDNstAdNdKA3UuoULlEbS6w==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "is-what": "^4.1.8"
- },
- "engines": {
- "node": ">=12.13"
- },
- "funding": {
- "url": "https://github.com/sponsors/mesqueeb"
- }
- },
"node_modules/csstype": {
- "version": "3.1.3",
- "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz",
- "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==",
- "dev": true,
- "license": "MIT"
+ "version": "3.2.3",
+ "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
+ "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
+ "dev": true
},
"node_modules/debug": {
"version": "4.4.1",
@@ -1914,7 +1501,6 @@
"resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz",
"integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==",
"dev": true,
- "license": "BSD-2-Clause",
"engines": {
"node": ">=0.12"
},
@@ -1923,42 +1509,44 @@
}
},
"node_modules/esbuild": {
- "version": "0.21.5",
- "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz",
- "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==",
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz",
+ "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==",
"dev": true,
"hasInstallScript": true,
- "license": "MIT",
"bin": {
"esbuild": "bin/esbuild"
},
"engines": {
- "node": ">=12"
+ "node": ">=18"
},
"optionalDependencies": {
- "@esbuild/aix-ppc64": "0.21.5",
- "@esbuild/android-arm": "0.21.5",
- "@esbuild/android-arm64": "0.21.5",
- "@esbuild/android-x64": "0.21.5",
- "@esbuild/darwin-arm64": "0.21.5",
- "@esbuild/darwin-x64": "0.21.5",
- "@esbuild/freebsd-arm64": "0.21.5",
- "@esbuild/freebsd-x64": "0.21.5",
- "@esbuild/linux-arm": "0.21.5",
- "@esbuild/linux-arm64": "0.21.5",
- "@esbuild/linux-ia32": "0.21.5",
- "@esbuild/linux-loong64": "0.21.5",
- "@esbuild/linux-mips64el": "0.21.5",
- "@esbuild/linux-ppc64": "0.21.5",
- "@esbuild/linux-riscv64": "0.21.5",
- "@esbuild/linux-s390x": "0.21.5",
- "@esbuild/linux-x64": "0.21.5",
- "@esbuild/netbsd-x64": "0.21.5",
- "@esbuild/openbsd-x64": "0.21.5",
- "@esbuild/sunos-x64": "0.21.5",
- "@esbuild/win32-arm64": "0.21.5",
- "@esbuild/win32-ia32": "0.21.5",
- "@esbuild/win32-x64": "0.21.5"
+ "@esbuild/aix-ppc64": "0.28.1",
+ "@esbuild/android-arm": "0.28.1",
+ "@esbuild/android-arm64": "0.28.1",
+ "@esbuild/android-x64": "0.28.1",
+ "@esbuild/darwin-arm64": "0.28.1",
+ "@esbuild/darwin-x64": "0.28.1",
+ "@esbuild/freebsd-arm64": "0.28.1",
+ "@esbuild/freebsd-x64": "0.28.1",
+ "@esbuild/linux-arm": "0.28.1",
+ "@esbuild/linux-arm64": "0.28.1",
+ "@esbuild/linux-ia32": "0.28.1",
+ "@esbuild/linux-loong64": "0.28.1",
+ "@esbuild/linux-mips64el": "0.28.1",
+ "@esbuild/linux-ppc64": "0.28.1",
+ "@esbuild/linux-riscv64": "0.28.1",
+ "@esbuild/linux-s390x": "0.28.1",
+ "@esbuild/linux-x64": "0.28.1",
+ "@esbuild/netbsd-arm64": "0.28.1",
+ "@esbuild/netbsd-x64": "0.28.1",
+ "@esbuild/openbsd-arm64": "0.28.1",
+ "@esbuild/openbsd-x64": "0.28.1",
+ "@esbuild/openharmony-arm64": "0.28.1",
+ "@esbuild/sunos-x64": "0.28.1",
+ "@esbuild/win32-arm64": "0.28.1",
+ "@esbuild/win32-ia32": "0.28.1",
+ "@esbuild/win32-x64": "0.28.1"
}
},
"node_modules/escalade": {
@@ -2002,8 +1590,7 @@
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz",
"integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==",
- "dev": true,
- "license": "MIT"
+ "dev": true
},
"node_modules/extend": {
"version": "3.0.2",
@@ -2039,14 +1626,30 @@
"url": "https://github.com/sponsors/wooorm"
}
},
+ "node_modules/fdir": {
+ "version": "6.5.0",
+ "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
+ "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
+ "dev": true,
+ "engines": {
+ "node": ">=12.0.0"
+ },
+ "peerDependencies": {
+ "picomatch": "^3 || ^4"
+ },
+ "peerDependenciesMeta": {
+ "picomatch": {
+ "optional": true
+ }
+ }
+ },
"node_modules/focus-trap": {
- "version": "7.6.0",
- "resolved": "https://registry.npmjs.org/focus-trap/-/focus-trap-7.6.0.tgz",
- "integrity": "sha512-1td0l3pMkWJLFipobUcGaf+5DTY4PLDDrcqoSaKP8ediO/CoWCCYk/fT/Y2A4e6TNB+Sh6clRJCjOPPnKoNHnQ==",
+ "version": "8.2.2",
+ "resolved": "https://registry.npmjs.org/focus-trap/-/focus-trap-8.2.2.tgz",
+ "integrity": "sha512-qV0g8hRYBqgACcFOH3f9wXc4zPKhr/0z9RI2a6ZijZ72EeBi4g8oBy8zAWuUR1TsMpOzwpUMFvjdasrC41Joug==",
"dev": true,
- "license": "MIT",
"dependencies": {
- "tabbable": "^6.2.0"
+ "tabbable": "^6.5.0"
}
},
"node_modules/format": {
@@ -2064,7 +1667,6 @@
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
"dev": true,
"hasInstallScript": true,
- "license": "MIT",
"optional": true,
"os": [
"darwin"
@@ -2100,11 +1702,10 @@
}
},
"node_modules/hast-util-to-html": {
- "version": "9.0.3",
- "resolved": "https://registry.npmjs.org/hast-util-to-html/-/hast-util-to-html-9.0.3.tgz",
- "integrity": "sha512-M17uBDzMJ9RPCqLMO92gNNUDuBSq10a25SDBI08iCCxmorf4Yy6sYHK57n9WAbRAAaU+DuR4W6GN9K4DFZesYg==",
+ "version": "9.0.5",
+ "resolved": "https://registry.npmjs.org/hast-util-to-html/-/hast-util-to-html-9.0.5.tgz",
+ "integrity": "sha512-OguPdidb+fbHQSU4Q4ZiLKnzWo8Wwsf5bZfbvu7//a9oTYoqD/fWpe96NuHkoS9h0ccGOTe0C4NGXdtS0iObOw==",
"dev": true,
- "license": "MIT",
"dependencies": {
"@types/hast": "^3.0.0",
"@types/unist": "^3.0.0",
@@ -2113,7 +1714,7 @@
"hast-util-whitespace": "^3.0.0",
"html-void-elements": "^3.0.0",
"mdast-util-to-hast": "^13.0.0",
- "property-information": "^6.0.0",
+ "property-information": "^7.0.0",
"space-separated-tokens": "^2.0.0",
"stringify-entities": "^4.0.0",
"zwitch": "^2.0.4"
@@ -2128,7 +1729,6 @@
"resolved": "https://registry.npmjs.org/hast-util-whitespace/-/hast-util-whitespace-3.0.0.tgz",
"integrity": "sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==",
"dev": true,
- "license": "MIT",
"dependencies": {
"@types/hast": "^3.0.0"
},
@@ -2141,15 +1741,13 @@
"version": "5.5.3",
"resolved": "https://registry.npmjs.org/hookable/-/hookable-5.5.3.tgz",
"integrity": "sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ==",
- "dev": true,
- "license": "MIT"
+ "dev": true
},
"node_modules/html-void-elements": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/html-void-elements/-/html-void-elements-3.0.0.tgz",
"integrity": "sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==",
"dev": true,
- "license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/wooorm"
@@ -2188,25 +1786,11 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
- "node_modules/is-what": {
- "version": "4.1.16",
- "resolved": "https://registry.npmjs.org/is-what/-/is-what-4.1.16.tgz",
- "integrity": "sha512-ZhMwEosbFJkA0YhFnNDgTM4ZxDRsS6HqTo7qsZM08fehyRYIYa0yHu5R6mgo1n/8MgaPBXiPimPD77baVFYg+A==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=12.13"
- },
- "funding": {
- "url": "https://github.com/sponsors/mesqueeb"
- }
- },
"node_modules/js-yaml": {
- "version": "3.14.1",
- "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.1.tgz",
- "integrity": "sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g==",
+ "version": "3.15.0",
+ "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.15.0.tgz",
+ "integrity": "sha512-ttBQIIQPDeLjpPOohtUdXuXUVoA2uIB6fEH9HyJ7234s5mBJ5wTx20njxplLZQgLaOfpmPQA7X2t5AX6tIPbog==",
"dev": true,
- "license": "MIT",
"dependencies": {
"argparse": "^1.0.7",
"esprima": "^4.0.0"
@@ -2225,6 +1809,25 @@
"node": ">=0.10.0"
}
},
+ "node_modules/linkify-it": {
+ "version": "5.0.2",
+ "resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-5.0.2.tgz",
+ "integrity": "sha512-ONTm2jCMAVZjgQa/Fy1kScXsuOoF5NPTsoFBdE1KVIZ2vAh/r9+Bqo+0jINCBYnavTPQZz38QzFTme79ENoN3Q==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/puzrin"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/markdown-it"
+ }
+ ],
+ "dependencies": {
+ "uc.micro": "^2.0.0"
+ }
+ },
"node_modules/longest-streak": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/longest-streak/-/longest-streak-3.1.0.tgz",
@@ -2237,13 +1840,12 @@
}
},
"node_modules/magic-string": {
- "version": "0.30.11",
- "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.11.tgz",
- "integrity": "sha512-+Wri9p0QHMy+545hKww7YAu5NyzF8iomPL/RQazugQ9+Ez4Ic3mERMd8ZTX5rfK944j+560ZJi8iAwgak1Ac7A==",
+ "version": "0.30.21",
+ "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
+ "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==",
"dev": true,
- "license": "MIT",
"dependencies": {
- "@jridgewell/sourcemap-codec": "^1.5.0"
+ "@jridgewell/sourcemap-codec": "^1.5.5"
}
},
"node_modules/mark.js": {
@@ -2253,6 +1855,39 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/markdown-it": {
+ "version": "14.3.0",
+ "resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-14.3.0.tgz",
+ "integrity": "sha512-RCEsPjR+sr0x+AuYp601tKTkgFG4YEPLCzHST3cQ/fhlJkqAkz1L2/Qbp1j9qw5SBwQHFBoW8+hoN5xssOF0Tw==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/puzrin"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/markdown-it"
+ }
+ ],
+ "dependencies": {
+ "argparse": "^2.0.1",
+ "entities": "^4.5.0",
+ "linkify-it": "^5.0.2",
+ "mdurl": "^2.0.0",
+ "punycode.js": "^2.3.1",
+ "uc.micro": "^2.1.0"
+ },
+ "bin": {
+ "markdown-it": "bin/markdown-it.mjs"
+ }
+ },
+ "node_modules/markdown-it/node_modules/argparse": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
+ "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==",
+ "dev": true
+ },
"node_modules/markdown-title": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/markdown-title/-/markdown-title-1.0.2.tgz",
@@ -2264,11 +1899,10 @@
}
},
"node_modules/mdast-util-from-markdown": {
- "version": "2.0.2",
- "resolved": "https://registry.npmjs.org/mdast-util-from-markdown/-/mdast-util-from-markdown-2.0.2.tgz",
- "integrity": "sha512-uZhTV/8NBuw0WHkPTrCqDOl0zVe1BIng5ZtHoDk49ME1qqcjYmmLmOf0gELgcRMxN4w2iuIeVso5/6QymSrgmA==",
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/mdast-util-from-markdown/-/mdast-util-from-markdown-2.0.3.tgz",
+ "integrity": "sha512-W4mAWTvSlKvf8L6J+VN9yLSqQ9AOAAvHuoDAmPkz4dHf553m5gVj2ejadHJhoJmcmxEnOv6Pa8XJhpxE93kb8Q==",
"dev": true,
- "license": "MIT",
"dependencies": {
"@types/mdast": "^4.0.0",
"@types/unist": "^3.0.0",
@@ -2323,11 +1957,10 @@
}
},
"node_modules/mdast-util-to-hast": {
- "version": "13.2.0",
- "resolved": "https://registry.npmjs.org/mdast-util-to-hast/-/mdast-util-to-hast-13.2.0.tgz",
- "integrity": "sha512-QGYKEuUsYT9ykKBCMOEDLsU5JRObWQusAolFMeko/tYPufNkRffBAQjIE+99jbA87xv6FgmjLtwjh9wBWajwAA==",
+ "version": "13.2.1",
+ "resolved": "https://registry.npmjs.org/mdast-util-to-hast/-/mdast-util-to-hast-13.2.1.tgz",
+ "integrity": "sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA==",
"dev": true,
- "license": "MIT",
"dependencies": {
"@types/hast": "^3.0.0",
"@types/mdast": "^4.0.0",
@@ -2380,6 +2013,12 @@
"url": "https://opencollective.com/unified"
}
},
+ "node_modules/mdurl": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/mdurl/-/mdurl-2.0.0.tgz",
+ "integrity": "sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w==",
+ "dev": true
+ },
"node_modules/micromark": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/micromark/-/micromark-4.0.2.tgz",
@@ -2874,34 +2513,25 @@
}
},
"node_modules/minimatch": {
- "version": "10.0.1",
- "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.0.1.tgz",
- "integrity": "sha512-ethXTt3SGGR+95gudmqJ1eNhRO7eGEGIgYA9vnPatK4/etz2MEVDno5GMCibdMTuBMyElzIlgxMna3K94XDIDQ==",
+ "version": "10.2.5",
+ "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz",
+ "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==",
"dev": true,
- "license": "ISC",
"dependencies": {
- "brace-expansion": "^2.0.1"
+ "brace-expansion": "^5.0.5"
},
"engines": {
- "node": "20 || >=22"
+ "node": "18 || 20 || >=22"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/minisearch": {
- "version": "7.1.0",
- "resolved": "https://registry.npmjs.org/minisearch/-/minisearch-7.1.0.tgz",
- "integrity": "sha512-tv7c/uefWdEhcu6hvrfTihflgeEi2tN6VV7HJnCjK6VxM75QQJh4t9FwJCsA2EsRS8LCnu3W87CuGPWMocOLCA==",
- "dev": true,
- "license": "MIT"
- },
- "node_modules/mitt": {
- "version": "3.0.1",
- "resolved": "https://registry.npmjs.org/mitt/-/mitt-3.0.1.tgz",
- "integrity": "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==",
- "dev": true,
- "license": "MIT"
+ "version": "7.2.0",
+ "resolved": "https://registry.npmjs.org/minisearch/-/minisearch-7.2.0.tgz",
+ "integrity": "sha512-dqT2XBYUOZOiC5t2HRnwADjhNS2cecp9u+TJRiJ1Qp/f5qjkeT5APcGPjHw+bz89Ms8Jp+cG4AlE+QZ/QnDglg==",
+ "dev": true
},
"node_modules/ms": {
"version": "2.1.3",
@@ -2911,9 +2541,9 @@
"license": "MIT"
},
"node_modules/nanoid": {
- "version": "3.3.7",
- "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.7.tgz",
- "integrity": "sha512-eSRppjcPIatRIMC1U6UngP8XFcz8MQWGQdt1MTBQ7NaAmvXDfvNxbvWV3x2y6CdEUciCSsDHDQZbhYaB8QEo2g==",
+ "version": "3.3.15",
+ "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.15.tgz",
+ "integrity": "sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==",
"dev": true,
"funding": [
{
@@ -2921,7 +2551,6 @@
"url": "https://github.com/sponsors/ai"
}
],
- "license": "MIT",
"bin": {
"nanoid": "bin/nanoid.cjs"
},
@@ -2929,25 +2558,34 @@
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
}
},
- "node_modules/oniguruma-to-js": {
- "version": "0.4.3",
- "resolved": "https://registry.npmjs.org/oniguruma-to-js/-/oniguruma-to-js-0.4.3.tgz",
- "integrity": "sha512-X0jWUcAlxORhOqqBREgPMgnshB7ZGYszBNspP+tS9hPD3l13CdaXcHbgImoHUHlrvGx/7AvFEkTRhAGYh+jzjQ==",
+ "node_modules/oniguruma-parser": {
+ "version": "0.12.2",
+ "resolved": "https://registry.npmjs.org/oniguruma-parser/-/oniguruma-parser-0.12.2.tgz",
+ "integrity": "sha512-6HVa5oIrgMC6aA6WF6XyyqbhRPJrKR02L20+2+zpDtO5QAzGHAUGw5TKQvwi5vctNnRHkJYmjAhRVQF2EKdTQw==",
+ "dev": true
+ },
+ "node_modules/oniguruma-to-es": {
+ "version": "4.3.6",
+ "resolved": "https://registry.npmjs.org/oniguruma-to-es/-/oniguruma-to-es-4.3.6.tgz",
+ "integrity": "sha512-csuQ9x3Yr0cEIs/Zgx/OEt9iBw9vqIunAPQkx19R/fiMq2oGVTgcMqO/V3Ybqefr1TBvosI6jU539ksaBULJyA==",
"dev": true,
- "license": "MIT",
"dependencies": {
- "regex": "^4.3.2"
- },
- "funding": {
- "url": "https://github.com/sponsors/antfu"
+ "oniguruma-parser": "^0.12.2",
+ "regex": "^6.1.0",
+ "regex-recursion": "^6.0.2"
}
},
+ "node_modules/path-to-regexp": {
+ "version": "6.3.0",
+ "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-6.3.0.tgz",
+ "integrity": "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==",
+ "dev": true
+ },
"node_modules/perfect-debounce": {
- "version": "1.0.0",
- "resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-1.0.0.tgz",
- "integrity": "sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==",
- "dev": true,
- "license": "MIT"
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-2.1.0.tgz",
+ "integrity": "sha512-LjgdTytVFXeUgtHZr9WYViYSM/g8MkcTPYDlPa3cDqMirHjKiSZPYd6DoL7pK8AJQr+uWkQvCjHNdiMqsrJs+g==",
+ "dev": true
},
"node_modules/picocolors": {
"version": "1.1.1",
@@ -2956,10 +2594,22 @@
"dev": true,
"license": "ISC"
},
+ "node_modules/picomatch": {
+ "version": "4.0.5",
+ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz",
+ "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==",
+ "dev": true,
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/jonschlinkert"
+ }
+ },
"node_modules/postcss": {
- "version": "8.4.47",
- "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.47.tgz",
- "integrity": "sha512-56rxCq7G/XfB4EkXq9Egn5GCqugWvDFjafDOThIdMBsI15iqPqR5r15TfSr1YPYeEI19YeaXMCbY6u88Y76GLQ==",
+ "version": "8.5.16",
+ "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.16.tgz",
+ "integrity": "sha512-vuwillviilfKZsg0VGj5R/YwwcHx4SLsIOI/7K6mQkWx+l5cUHTjj5g0AasTBcyXsbfTgrwsUNmVUb5xVwyPwg==",
"dev": true,
"funding": [
{
@@ -2975,44 +2625,69 @@
"url": "https://github.com/sponsors/ai"
}
],
- "license": "MIT",
"dependencies": {
- "nanoid": "^3.3.7",
- "picocolors": "^1.1.0",
+ "nanoid": "^3.3.12",
+ "picocolors": "^1.1.1",
"source-map-js": "^1.2.1"
},
"engines": {
"node": "^10 || ^12 || >=14"
}
},
- "node_modules/preact": {
- "version": "10.24.1",
- "resolved": "https://registry.npmjs.org/preact/-/preact-10.24.1.tgz",
- "integrity": "sha512-PnBAwFI3Yjxxcxw75n6VId/5TFxNW/81zexzWD9jn1+eSrOP84NdsS38H5IkF/UH3frqRPT+MvuCoVHjTDTnDw==",
+ "node_modules/pretty-bytes": {
+ "version": "7.1.0",
+ "resolved": "https://registry.npmjs.org/pretty-bytes/-/pretty-bytes-7.1.0.tgz",
+ "integrity": "sha512-nODzvTiYVRGRqAOvE84Vk5JDPyyxsVk0/fbA/bq7RqlnhksGpset09XTxbpvLTIjoaF7K8Z8DG8yHtKGTPSYRw==",
"dev": true,
- "license": "MIT",
+ "engines": {
+ "node": ">=20"
+ },
"funding": {
- "type": "opencollective",
- "url": "https://opencollective.com/preact"
+ "url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/property-information": {
- "version": "6.5.0",
- "resolved": "https://registry.npmjs.org/property-information/-/property-information-6.5.0.tgz",
- "integrity": "sha512-PgTgs/BlvHxOu8QuEN7wi5A0OmXaBcHpmCSTehcs6Uuu9IkDIEo13Hy7n898RHfrQ49vKCoGeWZSaAK01nwVig==",
+ "version": "7.2.0",
+ "resolved": "https://registry.npmjs.org/property-information/-/property-information-7.2.0.tgz",
+ "integrity": "sha512-IAtzIB6sUiWaJYrX9smp3V46pBGbBeLFRGdh25kg1334VcBlD8HzhPeNIWQH9zhGmo2itIe25EHt9dQP7G5hmg==",
"dev": true,
- "license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/wooorm"
}
},
+ "node_modules/punycode.js": {
+ "version": "2.3.1",
+ "resolved": "https://registry.npmjs.org/punycode.js/-/punycode.js-2.3.1.tgz",
+ "integrity": "sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==",
+ "dev": true,
+ "engines": {
+ "node": ">=6"
+ }
+ },
"node_modules/regex": {
- "version": "4.3.2",
- "resolved": "https://registry.npmjs.org/regex/-/regex-4.3.2.tgz",
- "integrity": "sha512-kK/AA3A9K6q2js89+VMymcboLOlF5lZRCYJv3gzszXFHBr6kO6qLGzbm+UIugBEV8SMMKCTR59txoY6ctRHYVw==",
+ "version": "6.1.0",
+ "resolved": "https://registry.npmjs.org/regex/-/regex-6.1.0.tgz",
+ "integrity": "sha512-6VwtthbV4o/7+OaAF9I5L5V3llLEsoPyq9P1JVXkedTP33c7MfCG0/5NOPcSJn0TzXcG9YUrR0gQSWioew3LDg==",
"dev": true,
- "license": "MIT"
+ "dependencies": {
+ "regex-utilities": "^2.3.0"
+ }
+ },
+ "node_modules/regex-recursion": {
+ "version": "6.0.2",
+ "resolved": "https://registry.npmjs.org/regex-recursion/-/regex-recursion-6.0.2.tgz",
+ "integrity": "sha512-0YCaSCq2VRIebiaUviZNs0cBz1kg5kVS2UKUfNIx8YVs1cN3AV7NTctO5FOKBA+UT2BPJIWZauYHPqJODG50cg==",
+ "dev": true,
+ "dependencies": {
+ "regex-utilities": "^2.3.0"
+ }
+ },
+ "node_modules/regex-utilities": {
+ "version": "2.3.0",
+ "resolved": "https://registry.npmjs.org/regex-utilities/-/regex-utilities-2.3.0.tgz",
+ "integrity": "sha512-8VhliFJAWRaUiVvREIiW2NXXTmHs4vMNnSzuJVhscgmGav3g9VDxLrQndI3dZZVVdp0ZO/5v0xmX516/7M9cng==",
+ "dev": true
},
"node_modules/remark": {
"version": "15.0.1",
@@ -3091,21 +2766,13 @@
"node": ">=0.10.0"
}
},
- "node_modules/rfdc": {
- "version": "1.4.1",
- "resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz",
- "integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==",
- "dev": true,
- "license": "MIT"
- },
"node_modules/rollup": {
- "version": "4.23.0",
- "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.23.0.tgz",
- "integrity": "sha512-vXB4IT9/KLDrS2WRXmY22sVB2wTsTwkpxjB8Q3mnakTENcYw3FRmfdYDy/acNmls+lHmDazgrRjK/yQ6hQAtwA==",
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.2.tgz",
+ "integrity": "sha512-RFnrW4lhXA3s3eqHDZvN654g8OTjzRfqpIRJYczCGB6HzphckVAi/Qh4tbPUbRuDi7s1Llv8g/NspLkttY3gTA==",
"dev": true,
- "license": "MIT",
"dependencies": {
- "@types/estree": "1.0.6"
+ "@types/estree": "1.0.9"
},
"bin": {
"rollup": "dist/bin/rollup"
@@ -3115,33 +2782,34 @@
"npm": ">=8.0.0"
},
"optionalDependencies": {
- "@rollup/rollup-android-arm-eabi": "4.23.0",
- "@rollup/rollup-android-arm64": "4.23.0",
- "@rollup/rollup-darwin-arm64": "4.23.0",
- "@rollup/rollup-darwin-x64": "4.23.0",
- "@rollup/rollup-linux-arm-gnueabihf": "4.23.0",
- "@rollup/rollup-linux-arm-musleabihf": "4.23.0",
- "@rollup/rollup-linux-arm64-gnu": "4.23.0",
- "@rollup/rollup-linux-arm64-musl": "4.23.0",
- "@rollup/rollup-linux-powerpc64le-gnu": "4.23.0",
- "@rollup/rollup-linux-riscv64-gnu": "4.23.0",
- "@rollup/rollup-linux-s390x-gnu": "4.23.0",
- "@rollup/rollup-linux-x64-gnu": "4.23.0",
- "@rollup/rollup-linux-x64-musl": "4.23.0",
- "@rollup/rollup-win32-arm64-msvc": "4.23.0",
- "@rollup/rollup-win32-ia32-msvc": "4.23.0",
- "@rollup/rollup-win32-x64-msvc": "4.23.0",
+ "@rollup/rollup-android-arm-eabi": "4.62.2",
+ "@rollup/rollup-android-arm64": "4.62.2",
+ "@rollup/rollup-darwin-arm64": "4.62.2",
+ "@rollup/rollup-darwin-x64": "4.62.2",
+ "@rollup/rollup-freebsd-arm64": "4.62.2",
+ "@rollup/rollup-freebsd-x64": "4.62.2",
+ "@rollup/rollup-linux-arm-gnueabihf": "4.62.2",
+ "@rollup/rollup-linux-arm-musleabihf": "4.62.2",
+ "@rollup/rollup-linux-arm64-gnu": "4.62.2",
+ "@rollup/rollup-linux-arm64-musl": "4.62.2",
+ "@rollup/rollup-linux-loong64-gnu": "4.62.2",
+ "@rollup/rollup-linux-loong64-musl": "4.62.2",
+ "@rollup/rollup-linux-ppc64-gnu": "4.62.2",
+ "@rollup/rollup-linux-ppc64-musl": "4.62.2",
+ "@rollup/rollup-linux-riscv64-gnu": "4.62.2",
+ "@rollup/rollup-linux-riscv64-musl": "4.62.2",
+ "@rollup/rollup-linux-s390x-gnu": "4.62.2",
+ "@rollup/rollup-linux-x64-gnu": "4.62.2",
+ "@rollup/rollup-linux-x64-musl": "4.62.2",
+ "@rollup/rollup-openbsd-x64": "4.62.2",
+ "@rollup/rollup-openharmony-arm64": "4.62.2",
+ "@rollup/rollup-win32-arm64-msvc": "4.62.2",
+ "@rollup/rollup-win32-ia32-msvc": "4.62.2",
+ "@rollup/rollup-win32-x64-gnu": "4.62.2",
+ "@rollup/rollup-win32-x64-msvc": "4.62.2",
"fsevents": "~2.3.2"
}
},
- "node_modules/search-insights": {
- "version": "2.17.2",
- "resolved": "https://registry.npmjs.org/search-insights/-/search-insights-2.17.2.tgz",
- "integrity": "sha512-zFNpOpUO+tY2D85KrxJ+aqwnIfdEGi06UH2+xEb+Bp9Mwznmauqc9djbnBibJO5mpfUPPa8st6Sx65+vbeO45g==",
- "dev": true,
- "license": "MIT",
- "peer": true
- },
"node_modules/section-matter": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/section-matter/-/section-matter-1.0.0.tgz",
@@ -3157,17 +2825,18 @@
}
},
"node_modules/shiki": {
- "version": "1.21.0",
- "resolved": "https://registry.npmjs.org/shiki/-/shiki-1.21.0.tgz",
- "integrity": "sha512-apCH5BoWTrmHDPGgg3RF8+HAAbEL/CdbYr8rMw7eIrdhCkZHdVGat5mMNlRtd1erNG01VPMIKHNQ0Pj2HMAiog==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@shikijs/core": "1.21.0",
- "@shikijs/engine-javascript": "1.21.0",
- "@shikijs/engine-oniguruma": "1.21.0",
- "@shikijs/types": "1.21.0",
- "@shikijs/vscode-textmate": "^9.2.2",
+ "version": "3.23.0",
+ "resolved": "https://registry.npmjs.org/shiki/-/shiki-3.23.0.tgz",
+ "integrity": "sha512-55Dj73uq9ZXL5zyeRPzHQsK7Nbyt6Y10k5s7OjuFZGMhpp4r/rsLBH0o/0fstIzX1Lep9VxefWljK/SKCzygIA==",
+ "dev": true,
+ "dependencies": {
+ "@shikijs/core": "3.23.0",
+ "@shikijs/engine-javascript": "3.23.0",
+ "@shikijs/engine-oniguruma": "3.23.0",
+ "@shikijs/langs": "3.23.0",
+ "@shikijs/themes": "3.23.0",
+ "@shikijs/types": "3.23.0",
+ "@shikijs/vscode-textmate": "^10.0.2",
"@types/hast": "^3.0.4"
}
},
@@ -3176,7 +2845,6 @@
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
"integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
"dev": true,
- "license": "BSD-3-Clause",
"engines": {
"node": ">=0.10.0"
}
@@ -3186,22 +2854,11 @@
"resolved": "https://registry.npmjs.org/space-separated-tokens/-/space-separated-tokens-2.0.2.tgz",
"integrity": "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==",
"dev": true,
- "license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/wooorm"
}
},
- "node_modules/speakingurl": {
- "version": "14.0.1",
- "resolved": "https://registry.npmjs.org/speakingurl/-/speakingurl-14.0.1.tgz",
- "integrity": "sha512-1POYv7uv2gXoyGFpBCmpDVSNV74IfsWlDW216UPjbWufNf+bSU6GdbDsxdcxtfwb4xlI3yxzOTKClUosxARYrQ==",
- "dev": true,
- "license": "BSD-3-Clause",
- "engines": {
- "node": ">=0.10.0"
- }
- },
"node_modules/sprintf-js": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz",
@@ -3229,7 +2886,6 @@
"resolved": "https://registry.npmjs.org/stringify-entities/-/stringify-entities-4.0.4.tgz",
"integrity": "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==",
"dev": true,
- "license": "MIT",
"dependencies": {
"character-entities-html4": "^2.0.0",
"character-entities-legacy": "^3.0.0"
@@ -3262,49 +2918,39 @@
"node": ">=0.10.0"
}
},
- "node_modules/superjson": {
- "version": "2.2.1",
- "resolved": "https://registry.npmjs.org/superjson/-/superjson-2.2.1.tgz",
- "integrity": "sha512-8iGv75BYOa0xRJHK5vRLEjE2H/i4lulTjzpUXic3Eg8akftYjkmQDa8JARQ42rlczXyFR3IeRoeFCc7RxHsYZA==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "copy-anything": "^3.0.2"
- },
- "engines": {
- "node": ">=16"
- }
- },
"node_modules/tabbable": {
- "version": "6.2.0",
- "resolved": "https://registry.npmjs.org/tabbable/-/tabbable-6.2.0.tgz",
- "integrity": "sha512-Cat63mxsVJlzYvN51JmVXIgNoUokrIaT2zLclCXjRd8boZ0004U4KCs/sToJ75C6sdlByWxpYnb5Boif1VSFew==",
- "dev": true,
- "license": "MIT"
+ "version": "6.5.0",
+ "resolved": "https://registry.npmjs.org/tabbable/-/tabbable-6.5.0.tgz",
+ "integrity": "sha512-wieBHXygIm7OyQOu5hQlkk62/WyCFYGlWg7L6/ZCUZwx0o398Zkn4pVmMyfYhfMG8kGrj/Krt8eIk6UKC6VzwA==",
+ "dev": true
},
- "node_modules/to-fast-properties": {
- "version": "2.0.0",
- "resolved": "https://registry.npmjs.org/to-fast-properties/-/to-fast-properties-2.0.0.tgz",
- "integrity": "sha512-/OaKK0xYrs3DmxRYqL/yDc+FxFUVYhDlXMhRmv3z915w2HF1tnN1omB354j8VUGO/hbRzyD6Y3sA7v7GS/ceog==",
+ "node_modules/tinyglobby": {
+ "version": "0.2.17",
+ "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
+ "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==",
"dev": true,
- "license": "MIT",
+ "dependencies": {
+ "fdir": "^6.5.0",
+ "picomatch": "^4.0.4"
+ },
"engines": {
- "node": ">=4"
+ "node": ">=12.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/SuperchupuDev"
}
},
"node_modules/tokenx": {
- "version": "0.4.1",
- "resolved": "https://registry.npmjs.org/tokenx/-/tokenx-0.4.1.tgz",
- "integrity": "sha512-LCMniis0WsHel07xh3K9OIt5c9Xla1awtOoWBmUHZBQR7pvTvgGFuYpLiCZWohXPC1YuZORnN0+fCVYI/ie8Jg==",
- "dev": true,
- "license": "MIT"
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/tokenx/-/tokenx-1.3.0.tgz",
+ "integrity": "sha512-NLdXTEZkKiO0gZuLtMoZKjCXTREXeZZt8nnnNeyoXtNZAfG/GKGSbQtLU5STspc0rMSwcA+UJfWZkbNU01iKmQ==",
+ "dev": true
},
"node_modules/trim-lines": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/trim-lines/-/trim-lines-3.0.1.tgz",
"integrity": "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==",
"dev": true,
- "license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/wooorm"
@@ -3321,6 +2967,12 @@
"url": "https://github.com/sponsors/wooorm"
}
},
+ "node_modules/uc.micro": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/uc.micro/-/uc.micro-2.1.0.tgz",
+ "integrity": "sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==",
+ "dev": true
+ },
"node_modules/unified": {
"version": "11.0.5",
"resolved": "https://registry.npmjs.org/unified/-/unified-11.0.5.tgz",
@@ -3360,7 +3012,6 @@
"resolved": "https://registry.npmjs.org/unist-util-position/-/unist-util-position-5.0.0.tgz",
"integrity": "sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA==",
"dev": true,
- "license": "MIT",
"dependencies": {
"@types/unist": "^3.0.0"
},
@@ -3400,11 +3051,10 @@
}
},
"node_modules/unist-util-visit": {
- "version": "5.0.0",
- "resolved": "https://registry.npmjs.org/unist-util-visit/-/unist-util-visit-5.0.0.tgz",
- "integrity": "sha512-MR04uvD+07cwl/yhVuVWAtw+3GOR/knlL55Nd/wAdblk27GCVt3lqpTivy/tkJcZoNPzTwS1Y+KMojlLDhoTzg==",
+ "version": "5.1.0",
+ "resolved": "https://registry.npmjs.org/unist-util-visit/-/unist-util-visit-5.1.0.tgz",
+ "integrity": "sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg==",
"dev": true,
- "license": "MIT",
"dependencies": {
"@types/unist": "^3.0.0",
"unist-util-is": "^6.0.0",
@@ -3461,21 +3111,23 @@
}
},
"node_modules/vite": {
- "version": "5.4.8",
- "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.8.tgz",
- "integrity": "sha512-FqrItQ4DT1NC4zCUqMB4c4AZORMKIa0m8/URVCZ77OZ/QSNeJ54bU1vrFADbDsuwfIPcgknRkmqakQcgnL4GiQ==",
+ "version": "7.3.6",
+ "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.6.tgz",
+ "integrity": "sha512-4XP60spRGjSZFf1qYH+dJIkK2znL3zQfl9KkOV9MkkRR/3Dls0dxaBsQPTloEc5BLXWPL9vsOxopxyKoMmDueg==",
"dev": true,
- "license": "MIT",
"dependencies": {
- "esbuild": "^0.21.3",
- "postcss": "^8.4.43",
- "rollup": "^4.20.0"
+ "esbuild": "^0.27.0 || ^0.28.0",
+ "fdir": "^6.5.0",
+ "picomatch": "^4.0.3",
+ "postcss": "^8.5.6",
+ "rollup": "^4.43.0",
+ "tinyglobby": "^0.2.15"
},
"bin": {
"vite": "bin/vite.js"
},
"engines": {
- "node": "^18.0.0 || >=20.0.0"
+ "node": "^20.19.0 || >=22.12.0"
},
"funding": {
"url": "https://github.com/vitejs/vite?sponsor=1"
@@ -3484,19 +3136,25 @@
"fsevents": "~2.3.3"
},
"peerDependencies": {
- "@types/node": "^18.0.0 || >=20.0.0",
- "less": "*",
+ "@types/node": "^20.19.0 || >=22.12.0",
+ "jiti": ">=1.21.0",
+ "less": "^4.0.0",
"lightningcss": "^1.21.0",
- "sass": "*",
- "sass-embedded": "*",
- "stylus": "*",
- "sugarss": "*",
- "terser": "^5.4.0"
+ "sass": "^1.70.0",
+ "sass-embedded": "^1.70.0",
+ "stylus": ">=0.54.8",
+ "sugarss": "^5.0.0",
+ "terser": "^5.16.0",
+ "tsx": "^4.8.1",
+ "yaml": "^2.4.2"
},
"peerDependenciesMeta": {
"@types/node": {
"optional": true
},
+ "jiti": {
+ "optional": true
+ },
"less": {
"optional": true
},
@@ -3517,84 +3175,100 @@
},
"terser": {
"optional": true
+ },
+ "tsx": {
+ "optional": true
+ },
+ "yaml": {
+ "optional": true
}
}
},
"node_modules/vitepress": {
- "version": "1.3.4",
- "resolved": "https://registry.npmjs.org/vitepress/-/vitepress-1.3.4.tgz",
- "integrity": "sha512-I1/F6OW1xl3kW4PaIMC6snxjWgf3qfziq2aqsDoFc/Gt41WbcRv++z8zjw8qGRIJ+I4bUW7ZcKFDHHN/jkH9DQ==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@docsearch/css": "^3.6.1",
- "@docsearch/js": "^3.6.1",
- "@shikijs/core": "^1.13.0",
- "@shikijs/transformers": "^1.13.0",
+ "version": "2.0.0-alpha.17",
+ "resolved": "https://registry.npmjs.org/vitepress/-/vitepress-2.0.0-alpha.17.tgz",
+ "integrity": "sha512-Z3VPUpwk/bHYqt1uMVOOK1/4xFiWQov1GNc2FvMdz6kvje4JRXEOngVI9C+bi5jeedMSHiA4dwKkff1NCvbZ9Q==",
+ "dev": true,
+ "dependencies": {
+ "@docsearch/css": "^4.5.3",
+ "@docsearch/js": "^4.5.3",
+ "@docsearch/sidepanel-js": "^4.5.3",
+ "@iconify-json/simple-icons": "^1.2.69",
+ "@shikijs/core": "^3.22.0",
+ "@shikijs/transformers": "^3.22.0",
+ "@shikijs/types": "^3.22.0",
"@types/markdown-it": "^14.1.2",
- "@vitejs/plugin-vue": "^5.1.2",
- "@vue/devtools-api": "^7.3.8",
- "@vue/shared": "^3.4.38",
- "@vueuse/core": "^11.0.0",
- "@vueuse/integrations": "^11.0.0",
- "focus-trap": "^7.5.4",
+ "@vitejs/plugin-vue": "^6.0.4",
+ "@vue/devtools-api": "^8.0.5",
+ "@vue/shared": "^3.5.27",
+ "@vueuse/core": "^14.2.0",
+ "@vueuse/integrations": "^14.2.0",
+ "focus-trap": "^8.0.0",
"mark.js": "8.11.1",
- "minisearch": "^7.1.0",
- "shiki": "^1.13.0",
- "vite": "^5.4.1",
- "vue": "^3.4.38"
+ "minisearch": "^7.2.0",
+ "shiki": "^3.22.0",
+ "vite": "^7.3.1",
+ "vue": "^3.5.27"
},
"bin": {
"vitepress": "bin/vitepress.js"
},
"peerDependencies": {
"markdown-it-mathjax3": "^4",
+ "oxc-minify": "*",
"postcss": "^8"
},
"peerDependenciesMeta": {
"markdown-it-mathjax3": {
"optional": true
},
+ "oxc-minify": {
+ "optional": true
+ },
"postcss": {
"optional": true
}
}
},
"node_modules/vitepress-plugin-llms": {
- "version": "1.2.0",
- "resolved": "https://registry.npmjs.org/vitepress-plugin-llms/-/vitepress-plugin-llms-1.2.0.tgz",
- "integrity": "sha512-AEMwSl1EhXFm7bVRTycDFxL0DS7HuJL+xUmgmwJHd01iD0TPfbMzSeQymn0FDe9V+Y7xXIg5VF8MlSfGXML8cQ==",
+ "version": "1.13.2",
+ "resolved": "https://registry.npmjs.org/vitepress-plugin-llms/-/vitepress-plugin-llms-1.13.2.tgz",
+ "integrity": "sha512-2O4s0I5pjEZzgnoWgBPCZCyhah9FH5uQB6lGADazMoyF1URJshtG04ZnmX+cbmQmniN3T5JzdJO9B4q8JHDKOQ==",
"dev": true,
- "license": "MIT",
"dependencies": {
- "byte-size": "^9.0.1",
"gray-matter": "^4.0.3",
+ "markdown-it": "^14.1.0",
"markdown-title": "^1.0.2",
+ "mdast-util-from-markdown": "^2.0.3",
"millify": "^6.1.0",
- "minimatch": "^10.0.1",
+ "minimatch": "^10.2.5",
+ "path-to-regexp": "^6.3.0",
"picocolors": "^1.1.1",
+ "pretty-bytes": "^7.1.0",
"remark": "^15.0.1",
"remark-frontmatter": "^5.0.0",
- "tokenx": "^0.4.1",
+ "tokenx": "^1.3.0",
"unist-util-remove": "^4.0.0",
- "unist-util-visit": "^5.0.0"
+ "unist-util-visit": "^5.1.0"
+ },
+ "engines": {
+ "node": ">=18"
},
"funding": {
- "url": "https://github.com/okineadev/vitepress-plugin-llms?sponsor=1"
+ "url": "https://github.com/sponsors/okineadev"
}
},
"node_modules/vue": {
- "version": "3.5.10",
- "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.10.tgz",
- "integrity": "sha512-Vy2kmJwHPlouC/tSnIgXVg03SG+9wSqT1xu1Vehc+ChsXsRd7jLkKgMltVEFOzUdBr3uFwBCG+41LJtfAcBRng==",
+ "version": "3.5.39",
+ "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.39.tgz",
+ "integrity": "sha512-xmZCYabFGcirU8r0fTuvl/LICc1OU620rnqepaJDL/a141ZigkG7AyaxQLdqJ02ZRYzWe6YPaDHeQx7MfknQfA==",
"dev": true,
- "license": "MIT",
"dependencies": {
- "@vue/compiler-dom": "3.5.10",
- "@vue/compiler-sfc": "3.5.10",
- "@vue/runtime-dom": "3.5.10",
- "@vue/server-renderer": "3.5.10",
- "@vue/shared": "3.5.10"
+ "@vue/compiler-dom": "3.5.39",
+ "@vue/compiler-sfc": "3.5.39",
+ "@vue/runtime-dom": "3.5.39",
+ "@vue/server-renderer": "3.5.39",
+ "@vue/shared": "3.5.39"
},
"peerDependencies": {
"typescript": "*"
diff --git a/docs/package.json b/docs/package.json
index 73fee41b0..8677959b8 100644
--- a/docs/package.json
+++ b/docs/package.json
@@ -5,7 +5,7 @@
"docs:preview": "vitepress preview"
},
"devDependencies": {
- "vitepress": "^1.3.4",
- "vitepress-plugin-llms": "^1.2.0"
+ "vitepress": "^2.0.0-alpha.17",
+ "vitepress-plugin-llms": "^1.13.2"
}
}
diff --git a/docs/providers/anthropic.md b/docs/providers/anthropic.md
index 8592d760e..0395757b3 100644
--- a/docs/providers/anthropic.md
+++ b/docs/providers/anthropic.md
@@ -117,63 +117,100 @@ When multiple tool results are returned, Prism automatically applies caching to
Please ensure you read Anthropic's [prompt caching documentation](https://docs.anthropic.com/en/docs/build-with-claude/prompt-caching), which covers some important information on e.g. minimum cacheable tokens and message order consistency.
-## Extended thinking
+## Thinking
-Claude Sonnet 3.7 supports an optional extended thinking mode, where it will reason before returning its answer. Please ensure your consider [Anthropic's own extended thinking documentation](https://docs.anthropic.com/en/docs/build-with-claude/extended-thinking) before using extended thinking with caching and/or tools, as there are some important limitations and behaviours to be aware of.
+Anthropic models support extended thinking, where the model reasons before returning its answer. Claude 4.6+ models (Opus 4.6, Sonnet 4.6) use **adaptive thinking** (recommended), where Claude dynamically determines when and how much to think. Older models use manual thinking with a fixed token budget.
-### Enabling extended thinking and setting budget
-Prism supports thinking mode for text and structured with the same API:
+Please refer to Anthropic's [adaptive thinking](https://docs.anthropic.com/en/docs/build-with-claude/adaptive-thinking) and [extended thinking](https://docs.anthropic.com/en/docs/build-with-claude/extended-thinking) documentation for important limitations and behaviours when using thinking with caching and/or tools.
+
+### Adaptive thinking (recommended for Claude 4.6+)
+
+Adaptive thinking lets Claude decide when and how much to think based on the complexity of each request:
```php
-use Prism\Prism\Enums\Provider;
use Prism\Prism\Facades\Prism;
+Prism::text()
+ ->using('anthropic', 'claude-sonnet-4-6')
+ ->withPrompt('What is the meaning of life, the universe and everything in popular fiction?')
+ ->withProviderOptions(['thinking' => ['type' => 'adaptive']])
+ ->asText();
+```
+
+Use the `effort` parameter to guide how much thinking Claude does:
+
+```php
+Prism::text()
+ ->using('anthropic', 'claude-sonnet-4-6')
+ ->withPrompt('Analyze the trade-offs between microservices and monolithic architectures')
+ ->withProviderOptions([
+ 'thinking' => ['type' => 'adaptive'],
+ 'effort' => 'high',
+ ])
+ ->asText();
+```
+
+Available effort levels:
+
+| Level | Description |
+|----------|--------------------------------------------------------------------------|
+| `max` | Maximum capability with no constraints on thinking depth. Opus 4.6 only. |
+| `high` | Deep reasoning on complex tasks. This is the default. |
+| `medium` | Balanced approach with moderate token savings. |
+| `low` | Most efficient. Significant token savings with some capability reduction.|
+
+> [!NOTE]
+> Setting `effort` to `"high"` produces the same behavior as omitting the parameter entirely.
+
+> [!TIP]
+> The `effort` parameter can also be used without thinking enabled, in which case it controls overall token spend for text responses and tool calls.
+
+Works identically with `Prism::structured()`.
+
+### Manual thinking (legacy)
+
+> [!WARNING]
+> Manual thinking with `enabled` and `budgetTokens` is deprecated on Claude 4.6+ models. Use adaptive thinking instead. Manual thinking is still required for older models (Sonnet 4.5, Opus 4.5, Sonnet 3.7, etc.).
+
+```php
Prism::text()
->using('anthropic', 'claude-3-7-sonnet-latest')
->withPrompt('What is the meaning of life, the universe and everything in popular fiction?')
- // enable thinking
- ->withProviderOptions(['thinking' => ['enabled' => true]])
+ ->withProviderOptions(['thinking' => ['enabled' => true]])
->asText();
```
+
By default Prism will set the thinking budget to the value set in config, or where that isn't set, the minimum allowed (1024).
-You can overide the config (or its default) using `withProviderOptions`:
+You can override the config (or its default) using `withProviderOptions`:
```php
-use Prism\Prism\Enums\Provider;
-use Prism\Prism\Facades\Prism;
-
Prism::text()
->using('anthropic', 'claude-3-7-sonnet-latest')
->withPrompt('What is the meaning of life, the universe and everything in popular fiction?')
- // Enable thinking and set a budget
->withProviderOptions([
'thinking' => [
- 'enabled' => true,
- 'budgetTokens' => 2048
- ]
+ 'enabled' => true,
+ 'budgetTokens' => 2048,
+ ],
]);
```
-Note that thinking tokens count towards output tokens, so you will be billed for them and your token budget must be less than the max tokens you have set for the request.
-If you expect a long response, you should ensure there's enough tokens left for the response - i.e. does (maxTokens - thinkingBudget) leave a sufficient remainder.
+Note that thinking tokens count towards output tokens, so you will be billed for them and your token budget must be less than the max tokens you have set for the request. If you expect a long response, ensure there's enough tokens left for the response — i.e. does (maxTokens - thinkingBudget) leave a sufficient remainder.
### Inspecting the thinking block
-Anthropic returns the thinking block with its response.
+Anthropic returns the thinking block with its response. This works identically for both adaptive and manual thinking modes.
You can access it via the additionalContent property on either the Response or the relevant step.
On the Response (easiest if not using tools):
```php
-use Prism\Prism\Enums\Provider;
-use Prism\Prism\Facades\Prism;
-
-Prism::text()
- ->using('anthropic', 'claude-3-7-sonnet-latest')
+$response = Prism::text()
+ ->using('anthropic', 'claude-sonnet-4-6')
->withPrompt('What is the meaning of life, the universe and everything in popular fiction?')
- ->withProviderOptions(['thinking' => ['enabled' => true']])
+ ->withProviderOptions(['thinking' => ['type' => 'adaptive']])
->asText();
$response->additionalContent['thinking'];
@@ -185,19 +222,22 @@ On the Step (necessary if using tools, as Anthropic returns the thinking block o
$tools = [...];
$response = Prism::text()
- ->using('anthropic', 'claude-3-7-sonnet-latest')
+ ->using('anthropic', 'claude-sonnet-4-6')
->withTools($tools)
->withMaxSteps(3)
->withPrompt('What time is the tigers game today and should I wear a coat?')
- ->withProviderOptions(['thinking' => ['enabled' => true]])
+ ->withProviderOptions(['thinking' => ['type' => 'adaptive']])
->asText();
$response->steps->first()->additionalContent->thinking;
```
+> [!NOTE]
+> With adaptive thinking, Claude may skip thinking for simple queries, in which case no thinking block is returned.
+
### Extended output mode
-Claude Sonnet 3.7 also brings extended output mode which increase the output limit to 128k tokens.
+Claude Sonnet 3.7 also brings extended output mode which increase the output limit to 128k tokens.
This feature is currently in beta, so you will need to enable to by adding `output-128k-2025-02-19` to your Anthropic anthropic_beta config (see [Configuration](#configuration) above).
@@ -219,9 +259,9 @@ return Prism::text()
->asEventStreamResponse();
```
-### Streaming with Extended Thinking
+### Streaming with Thinking
-When using extended thinking, the reasoning process streams separately from the final answer:
+When using thinking (adaptive or manual), the reasoning process streams separately from the final answer:
```php
use Prism\Prism\Enums\StreamEventType;
@@ -235,6 +275,29 @@ foreach ($stream as $event) {
}
```
+### Fine-grained tool streaming
+
+Anthropic’s [fine-grained tool streaming](https://docs.anthropic.com/en/docs/agents-and-tools/tool-use/fine-grained-tool-streaming) lets tool-use input arrive incrementally (with lower latency for large arguments) instead of waiting for a complete JSON payload. Enable it on each tool that should use this behavior with the `eager_input_streaming` provider option:
+
+```php
+use Prism\Prism\Facades\Prism;
+use Prism\Prism\Tool;
+
+$weatherTool = Tool::as('get_weather')
+ ->for('Get current weather for a location')
+ ->withStringParameter('location', 'The city and state')
+ ->withProviderOptions(['eager_input_streaming' => true])
+ ->using(fn (string $location): string => "Weather in {$location}: 72°F, sunny");
+
+$stream = Prism::text()
+ ->using('anthropic', 'claude-sonnet-4-5-20250929')
+ ->withTools([$weatherTool])
+ ->withPrompt('What is the weather in San Francisco?')
+ ->asStream();
+```
+
+Use this together with a streaming entrypoint (`asStream()`, `asEventStreamResponse()`, etc.). While the model is still emitting a tool call, streamed input may be partial JSON; only treat tool arguments as final once the stream indicates the tool input is complete.
+
For complete streaming documentation including Vercel Data Protocol and WebSocket broadcasting, see [Streaming Output](/core-concepts/streaming-output).
## Documents
diff --git a/docs/providers/mistral.md b/docs/providers/mistral.md
index ab8785072..4dbe45eef 100644
--- a/docs/providers/mistral.md
+++ b/docs/providers/mistral.md
@@ -9,6 +9,31 @@
```
## Provider-specific options
+### Reasoning Effort
+
+Adjustable reasoning is currently supported by `mistral-small-latest` via the `reasoning_effort` parameter.
+
+```php
+use Prism\Prism\Facades\Prism;
+
+$response = Prism::text()
+ ->using('mistral', 'mistral-small-latest')
+ ->withPrompt('Solve this logic problem step by step.')
+ ->withProviderOptions([ // [!code focus]
+ 'reasoning_effort' => 'high', // [!code focus]
+ ]) // [!code focus]
+ ->asText();
+
+// Access the final answer
+echo $response->text;
+
+// Access the reasoning process
+echo $response->additionalContent['thinking'];
+```
+
+More details in the official Mistral documentation:
+https://docs.mistral.ai/capabilities/reasoning/adjustable
+
## Reasoning Models
### Using Reasoning Models
diff --git a/docs/providers/openrouter.md b/docs/providers/openrouter.md
index fc3bd98cf..5ab759060 100644
--- a/docs/providers/openrouter.md
+++ b/docs/providers/openrouter.md
@@ -228,32 +228,11 @@ foreach ($stream as $event) {
### Reasoning/Thinking Tokens
-Some models (like OpenAI's o1 series) support reasoning tokens that show the model's thought process:
-
-```php
-use Prism\Prism\Facades\Prism;
-use Prism\Prism\Enums\Provider;
-use Prism\Prism\Enums\StreamEventType;
-
-$stream = Prism::text()
- ->using(Provider::OpenRouter, 'openai/o1-preview')
- ->withPrompt('Solve this complex math problem: What is the derivative of x^3 + 2x^2 - 5x + 1?')
- ->asStream();
-
-foreach ($stream as $event) {
- if ($event->type() === StreamEventType::ThinkingDelta) {
- // This is the model's reasoning/thinking process
- echo "Thinking: " . $event->delta . "\n";
- } elseif ($event->type() === StreamEventType::TextDelta) {
- // This is the final answer
- echo $event->delta;
- }
-}
-```
+OpenRouter provides a unified `reasoning` API that normalizes reasoning tokens across providers (Anthropic, OpenAI, Gemini, DeepSeek, etc.). Models that support reasoning will return their thought process alongside the response.
#### Reasoning Effort
-Control how much reasoning the model performs before generating a response using the `reasoning` parameter. The way this is structured depends on the underlying model you are calling:
+Control how much reasoning the model performs using the `reasoning` provider option. OpenRouter normalizes the configuration across different underlying providers:
```php
$response = Prism::text()
@@ -261,9 +240,9 @@ $response = Prism::text()
->withPrompt('Write a PHP function to implement a binary search algorithm with proper error handling')
->withProviderOptions([
'reasoning' => [
- 'effort' => 'high', // Can be "high", "medium", or "low" (OpenAI-style)
- 'max_tokens' => 2000, // Specific token limit (Gemini / Anthropic-style)
-
+ 'effort' => 'high', // "xhigh", "high", "medium", "low", "minimal", or "none" (OpenAI-style)
+ 'max_tokens' => 2000, // Specific token limit (Anthropic / Gemini-style)
+
// Optional: Default is false. All models support this.
'exclude' => false, // Set to true to exclude reasoning tokens from response
// Or enable reasoning with the default parameters:
@@ -273,6 +252,59 @@ $response = Prism::text()
->asText();
```
+> [!NOTE]
+> The `effort` and `max_tokens` options are mutually exclusive. Use `effort` for OpenAI-style models and `max_tokens` for Anthropic/Gemini-style models. OpenRouter will translate between the two formats as needed.
+
+#### Inspecting Reasoning Content
+
+Reasoning content is available via the `additionalContent` property on the response or individual steps:
+
+```php
+$response = Prism::text()
+ ->using(Provider::OpenRouter, 'anthropic/claude-sonnet-4.5')
+ ->withPrompt('What is the derivative of x^3 + 2x^2 - 5x + 1?')
+ ->withProviderOptions([
+ 'reasoning' => ['max_tokens' => 4000]
+ ])
+ ->asText();
+
+// Plaintext reasoning (when available)
+$response->additionalContent['reasoning'];
+
+// Structured reasoning details (includes type, format, and provider-specific metadata)
+$response->additionalContent['reasoning_details'];
+
+// Reasoning token usage
+$response->usage->thoughtTokens;
+```
+
+> [!NOTE]
+> Some models (like OpenAI's o-series and GPT-5) return encrypted reasoning via `reasoning_details` rather than plaintext `reasoning`. Prism preserves these opaque blocks so they can be passed back for multi-turn reasoning continuity.
+
+#### Streaming with Reasoning
+
+When streaming, reasoning tokens arrive as `ThinkingDelta` events before the text content:
+
+```php
+use Prism\Prism\Enums\StreamEventType;
+
+$stream = Prism::text()
+ ->using(Provider::OpenRouter, 'anthropic/claude-sonnet-4.5')
+ ->withPrompt('Solve this complex math problem step by step.')
+ ->withProviderOptions([
+ 'reasoning' => ['max_tokens' => 4000]
+ ])
+ ->asStream();
+
+foreach ($stream as $event) {
+ if ($event->type() === StreamEventType::ThinkingDelta) {
+ echo "Thinking: " . $event->delta . "\n";
+ } elseif ($event->type() === StreamEventType::TextDelta) {
+ echo $event->delta;
+ }
+}
+```
+
### Provider Routing & Advanced Options
Use `withProviderOptions()` to forward OpenRouter-specific controls such as model preferences or sampling parameters. Prism automatically forwards the native request values for `temperature`, `top_p`, and `max_tokens`, so you can continue tuning them through the usual Prism API without duplicating them in `withProviderOptions()`. For transform pipelines, OpenRouter currently documents `"middle-out"` as the primary example—consult the parameter reference for additional context.
diff --git a/docs/providers/qwen.md b/docs/providers/qwen.md
new file mode 100644
index 000000000..b6cb91606
--- /dev/null
+++ b/docs/providers/qwen.md
@@ -0,0 +1,523 @@
+# Qwen
+
+Alibaba Cloud's Qwen models are available through the [DashScope (Model Studio)](https://www.alibabacloud.com/help/en/model-studio/) API. Prism uses the **DashScope native API** (not the OpenAI-compatible mode) for all Qwen interactions, providing the most complete and up-to-date feature support.
+
+> [!NOTE]
+> This provider uses DashScope's native API (`/api/v1`) endpoints. While DashScope also offers an OpenAI-compatible interface (`/compatible-mode/v1`), the native API provides better feature coverage, more active maintenance from Alibaba Cloud, and a consistent experience across all capabilities (text, embeddings, images).
+
+## Configuration
+
+```php
+'qwen' => [
+ 'api_key' => env('QWEN_API_KEY', ''),
+ 'url' => env('QWEN_URL', 'https://dashscope-intl.aliyuncs.com/api/v1'),
+]
+```
+
+### Deployment Modes & Regions
+
+DashScope offers multiple [deployment modes](https://www.alibabacloud.com/help/en/model-studio/regions/), each with its own **base URL**, **API key**, and **available models**. You must configure the correct combination for your region:
+
+| Deployment Mode | Region | `QWEN_URL` |
+|----------------|--------|------------|
+| **International** | Singapore | `https://dashscope-intl.aliyuncs.com/api/v1` (default) |
+| **China Mainland** | Beijing | `https://dashscope.aliyuncs.com/api/v1` |
+| **Global** | Virginia (US) | `https://dashscope-us.aliyuncs.com/api/v1` |
+| **United States** | Virginia (US) | `https://dashscope-us.aliyuncs.com/api/v1` |
+
+> [!IMPORTANT]
+> **API keys and base URLs are region-specific and cannot be mixed across regions.** A Beijing API key will not work with the Singapore endpoint, and vice versa. Make sure your `QWEN_API_KEY` and `QWEN_URL` belong to the same deployment mode.
+
+> [!WARNING]
+> **Model availability varies by deployment mode.** Not all models are available in every region. Some models may have a `-us` suffix in the US deployment mode. Always check the [official model list](https://www.alibabacloud.com/help/en/model-studio/models) to confirm model availability in your region before use.
+
+### Getting an API Key
+
+1. Sign up or log in to [Alibaba Cloud Model Studio](https://www.alibabacloud.com/help/en/model-studio/get-api-key)
+2. Select the region matching your deployment mode
+3. Create an API key in the **Key Management** page
+
+```env
+QWEN_API_KEY=sk-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
+# Uncomment and change if not using International (Singapore):
+# QWEN_URL=https://dashscope.aliyuncs.com/api/v1
+```
+
+## Text Generation
+
+### Basic Usage
+
+```php
+use Prism\Prism\Enums\Provider;
+use Prism\Prism\Facades\Prism;
+
+$response = Prism::text()
+ ->using(Provider::Qwen, 'qwen-plus')
+ ->withPrompt('Explain quantum computing in simple terms')
+ ->asText();
+
+echo $response->text;
+```
+
+### With System Prompt
+
+```php
+$response = Prism::text()
+ ->using(Provider::Qwen, 'qwen-plus')
+ ->withSystemPrompt('You are a helpful coding assistant.')
+ ->withPrompt('How do I implement a binary search in PHP?')
+ ->asText();
+```
+
+### Tool Calling
+
+Qwen supports function calling with tools, allowing the model to request external data during generation:
+
+```php
+use Prism\Prism\Facades\Tool;
+
+$tools = [
+ Tool::as('get_weather')
+ ->for('Get current weather conditions for a city')
+ ->withStringParameter('city', 'The city name')
+ ->using(fn (string $city): string => "72°F and sunny in {$city}"),
+
+ Tool::as('search')
+ ->for('Search for current events or data')
+ ->withStringParameter('query', 'The search query')
+ ->using(fn (string $query): string => "Results for: {$query}"),
+];
+
+$response = Prism::text()
+ ->using(Provider::Qwen, 'qwen-plus')
+ ->withTools($tools)
+ ->withMaxSteps(4)
+ ->withPrompt('What is the weather in Detroit and when is the Tigers game?')
+ ->asText();
+```
+
+## Multi-Modal (Vision) Text Generation
+
+Qwen offers vision-language (VL) models like `qwen-vl-max` and `qwen-vl-plus` that can understand and analyze images alongside text. Simply include images in your messages — Prism automatically routes to the correct DashScope multimodal endpoint:
+
+```php
+use Prism\Prism\ValueObjects\Media\Image;
+
+$response = Prism::text()
+ ->using(Provider::Qwen, 'qwen-vl-max')
+ ->withPrompt('What objects do you see in this image?', [
+ Image::fromUrl('https://example.com/photo.jpeg'),
+ ])
+ ->asText();
+
+echo $response->text;
+```
+
+### Multi-Image Analysis
+
+Qwen VL models support analyzing multiple images simultaneously:
+
+```php
+use Prism\Prism\ValueObjects\Messages\UserMessage;
+
+$response = Prism::text()
+ ->using(Provider::Qwen, 'qwen-vl-max')
+ ->withMessages([
+ new UserMessage('What are these animals?', [
+ Image::fromUrl('https://example.com/dog.jpeg'),
+ Image::fromUrl('https://example.com/tiger.png'),
+ Image::fromUrl('https://example.com/rabbit.png'),
+ ]),
+ ])
+ ->asText();
+```
+
+### Supported Input Formats
+
+Images can be provided via URL, local file, or base64:
+
+```php
+// From URL
+Image::fromUrl('https://example.com/photo.jpeg')
+
+// From local file
+Image::fromLocalPath('/path/to/image.png')
+
+// From base64
+Image::fromBase64($base64Data, 'image/png')
+```
+
+### Available VL Models
+
+| Model | Description |
+|-------|-------------|
+| `qwen-vl-max` | Most capable vision model |
+| `qwen-vl-plus` | Balanced vision model |
+
+> [!NOTE]
+> When images are present in messages, Prism automatically routes the request to DashScope's `multimodal-generation` endpoint instead of `text-generation`. This is transparent — you don't need to configure anything differently.
+
+> [!TIP]
+> VL model availability varies by region. Check the [official model list](https://www.alibabacloud.com/help/en/model-studio/models) for your deployment mode.
+
+## Structured Output
+
+Qwen supports structured output through DashScope's native `response_format` parameter, with two modes:
+
+- **JSON Object mode** (default): Ensures the output is valid JSON. Broadly supported by most Qwen models.
+- **JSON Schema mode**: Strictly enforces schema structure and types. Supported by `qwen3-max`, `qwen-plus`, `qwen-flash` series and later models.
+
+### JSON Object Mode (Default)
+
+By default, Prism uses JSON Object mode with a system message containing the schema definition to guide the model:
+
+```php
+use Prism\Prism\Schema\BooleanSchema;
+use Prism\Prism\Schema\ObjectSchema;
+use Prism\Prism\Schema\StringSchema;
+
+$schema = new ObjectSchema(
+ 'weather_report',
+ 'Weather report with recommendations',
+ [
+ new StringSchema('weather', 'The weather forecast'),
+ new StringSchema('game_time', 'The game time'),
+ new BooleanSchema('coat_required', 'Whether a coat is needed'),
+ ],
+ ['weather', 'game_time', 'coat_required']
+);
+
+$response = Prism::structured()
+ ->withSchema($schema)
+ ->using(Provider::Qwen, 'qwen-plus')
+ ->withSystemPrompt('The Tigers game is at 3pm in Detroit, temperature is 75°F')
+ ->withPrompt('What time is the game and should I wear a coat?')
+ ->asStructured();
+
+echo $response->structured['weather']; // "75°F"
+echo $response->structured['coat_required']; // false
+```
+
+### JSON Schema Mode (Strict)
+
+For models that support it, JSON Schema mode provides strict schema enforcement — the model is guaranteed to return output that conforms exactly to your schema:
+
+```php
+use Prism\Prism\Enums\StructuredMode;
+
+$response = Prism::structured()
+ ->withSchema($schema)
+ ->using(Provider::Qwen, 'qwen-plus')
+ ->usingStructuredMode(StructuredMode::Structured)
+ ->withPrompt('What time is the game and should I wear a coat?')
+ ->asStructured();
+```
+
+> [!NOTE]
+> JSON Schema mode is supported by: `qwen3-max` series, `qwen-plus` series (qwen-plus-2025-07-28 and later), and `qwen-flash` series (qwen-flash-2025-07-28 and later). Check the [official documentation](https://www.alibabacloud.com/help/en/model-studio/qwen-structured-output) for the full list. Thinking mode models do not support structured output.
+
+## Streaming
+
+Qwen supports streaming responses in real-time via DashScope's SSE (Server-Sent Events) protocol. Prism handles the DashScope-specific SSE format (with `X-DashScope-SSE` header and `incremental_output` mode) transparently:
+
+```php
+$stream = Prism::text()
+ ->using(Provider::Qwen, 'qwen-plus')
+ ->withPrompt('Write a short story about a robot')
+ ->asStream();
+
+foreach ($stream as $event) {
+ if ($event instanceof \Prism\Prism\Streaming\Events\TextDeltaEvent) {
+ echo $event->delta;
+ }
+}
+```
+
+### Server-Sent Events
+
+```php
+return Prism::text()
+ ->using(Provider::Qwen, 'qwen-plus')
+ ->withPrompt(request('message'))
+ ->asEventStreamResponse();
+```
+
+### Streaming with Tools
+
+Streaming works seamlessly with tool calling. The stream will emit tool call and tool result events during multi-step interactions:
+
+```php
+$response = Prism::text()
+ ->using(Provider::Qwen, 'qwen-plus')
+ ->withTools($tools)
+ ->withMaxSteps(4)
+ ->withPrompt('What is the weather in Detroit?')
+ ->asStream();
+
+foreach ($response as $event) {
+ match (true) {
+ $event instanceof \Prism\Prism\Streaming\Events\TextDeltaEvent => echo $event->delta,
+ $event instanceof \Prism\Prism\Streaming\Events\ToolCallEvent => echo "Calling: {$event->toolCall->name}\n",
+ $event instanceof \Prism\Prism\Streaming\Events\ToolResultEvent => echo "Result received\n",
+ default => null,
+ };
+}
+```
+
+### Streaming with Reasoning (Thinking Models)
+
+Qwen's thinking-capable models like `qwq-plus` stream their reasoning process separately from the final answer through the `reasoning_content` field:
+
+```php
+use Prism\Prism\Enums\StreamEventType;
+
+$stream = Prism::text()
+ ->using(Provider::Qwen, 'qwq-plus')
+ ->withPrompt('Solve this step by step: What is 15% of 240?')
+ ->asStream();
+
+foreach ($stream as $event) {
+ match ($event->type()) {
+ StreamEventType::ThinkingDelta => echo "[Thinking] " . $event->delta . "\n",
+ StreamEventType::TextDelta => echo $event->delta,
+ default => null,
+ };
+}
+```
+
+> [!NOTE]
+> Reasoning/thinking tokens are only available with thinking-capable models such as `qwq-plus`. Standard models like `qwen-plus` do not produce reasoning content.
+
+For complete streaming documentation, see [Streaming Output](/core-concepts/streaming-output).
+
+## Embeddings
+
+Qwen provides text embedding capabilities through models like `text-embedding-v4`:
+
+```php
+$response = Prism::embeddings()
+ ->using(Provider::Qwen, 'text-embedding-v4')
+ ->fromInput('Hello, how are you?')
+ ->asEmbeddings();
+
+$embedding = $response->embeddings[0]->embedding; // Array of floats
+```
+
+### Custom Dimensions
+
+You can control the output dimensionality of embeddings using the `dimensions` provider option:
+
+```php
+$response = Prism::embeddings()
+ ->using(Provider::Qwen, 'text-embedding-v4')
+ ->withProviderOptions([
+ 'dimensions' => 512,
+ ])
+ ->fromInput('Hello, how are you?')
+ ->asEmbeddings();
+```
+
+## Audio Processing (TTS / STT)
+
+> [!CAUTION]
+> Audio processing is **not supported** through the Qwen provider. DashScope provides TTS via [CosyVoice](https://www.alibabacloud.com/help/en/model-studio/cosyvoice-websocket-api) (WebSocket-only, Beijing region only) and STT via [Paraformer](https://www.alibabacloud.com/help/en/model-studio/paraformer-recorded-speech-recognition-restful-api) (asynchronous REST API requiring task submission and polling).
+>
+> These use entirely different protocols that are not compatible with Prism's synchronous HTTP interface. For audio processing, use the official [DashScope SDK](https://www.alibabacloud.com/help/en/model-studio/developer-reference/sdk-reference) instead.
+
+## Image Generation
+
+Qwen provides image generation through models like `qwen-image-max` and `qwen-image-plus`, using the DashScope native multimodal generation endpoint:
+
+> [!NOTE]
+> Image model availability varies by region. Check the [official model list](https://www.alibabacloud.com/help/en/model-studio/models) to confirm which models are available in your deployment mode.
+
+### Basic Usage
+
+```php
+$response = Prism::image()
+ ->using(Provider::Qwen, 'qwen-image-max')
+ ->withPrompt('A cute baby sea otter floating on its back')
+ ->generate();
+
+$imageUrl = $response->firstImage()->url;
+```
+
+> [!IMPORTANT]
+> Generated image URLs are valid for **24 hours** only. Download and save images promptly.
+
+### Generation Models
+
+| Model | Description |
+|-------|-------------|
+| `qwen-image-max` | Enhanced realism and naturalness, excellent text rendering |
+| `qwen-image-plus` | Diverse artistic styles, strong at complex text rendering |
+
+### With Provider Options
+
+```php
+$response = Prism::image()
+ ->using(Provider::Qwen, 'qwen-image-max')
+ ->withPrompt('A sunset over mountain peaks')
+ ->withProviderOptions([
+ 'size' => '1328*1328', // Image resolution (width*height)
+ 'negative_prompt' => 'low quality, blurry', // Content to avoid
+ 'prompt_extend' => true, // Enable prompt rewriting
+ 'watermark' => false, // Disable watermark
+ 'seed' => 42, // Random seed for reproducibility
+ ])
+ ->generate();
+```
+
+### Available Sizes (Generation)
+
+| Size | Aspect Ratio |
+|------|-------------|
+| `1664*928` (default) | 16:9 |
+| `1472*1104` | 4:3 |
+| `1328*1328` | 1:1 |
+| `1104*1472` | 3:4 |
+| `928*1664` | 9:16 |
+
+## Image Editing
+
+Qwen's image editing models support single-image editing and multi-image fusion — you can precisely modify text, add/remove/move objects, change poses, transfer styles, and enhance details. Pass input images as the second parameter to `withPrompt()`:
+
+> [!TIP]
+> For complete API documentation, see [Qwen-Image-Edit API Reference](https://www.alibabacloud.com/help/en/model-studio/qwen-image-edit-api).
+
+### Single Image Editing
+
+```php
+use Prism\Prism\ValueObjects\Media\Image;
+
+$response = Prism::image()
+ ->using(Provider::Qwen, 'qwen-image-edit-max')
+ ->withPrompt('Generate an image following this depth map: a red bicycle on a muddy path with dense forest', [
+ Image::fromUrl('https://example.com/depth-map.png'),
+ ])
+ ->withProviderOptions([
+ 'n' => 2, // Output 1-6 images (max/plus models)
+ 'size' => '1536*1024', // Custom output resolution
+ 'negative_prompt' => 'low quality', // Content to avoid
+ 'prompt_extend' => true, // Enable prompt rewriting
+ 'watermark' => false, // Disable watermark
+ ])
+ ->generate();
+```
+
+### Multi-Image Fusion
+
+The model can combine elements from up to 3 input images. Images are referenced by their order in the array (image 1, image 2, image 3):
+
+```php
+$response = Prism::image()
+ ->using(Provider::Qwen, 'qwen-image-edit-max')
+ ->withPrompt('The girl in image 1 wearing the black dress from image 2, sitting in the pose of image 3', [
+ Image::fromUrl('https://example.com/person.png'),
+ Image::fromUrl('https://example.com/dress.png'),
+ Image::fromUrl('https://example.com/pose.png'),
+ ])
+ ->withProviderOptions([
+ 'n' => 2,
+ 'size' => '1024*1536',
+ ])
+ ->generate();
+```
+
+### Editing from Local Files
+
+You can also pass images from local files or base64 data:
+
+```php
+$response = Prism::image()
+ ->using(Provider::Qwen, 'qwen-image-edit-max')
+ ->withPrompt('Add a sunset sky to the background', [
+ Image::fromLocalPath('/path/to/photo.png'),
+ ])
+ ->generate();
+```
+
+### Editing Models
+
+| Model | Description | Output Images |
+|-------|-------------|--------------|
+| `qwen-image-edit-max` | Single-image editing & multi-image fusion, custom resolution | 1-6 |
+| `qwen-image-edit-plus` | Single-image editing & multi-image fusion, custom resolution | 1-6 |
+| `qwen-image-edit` | Single-image editing & multi-image fusion, auto resolution | 1 |
+
+### Available Sizes (Editing)
+
+Width and height must each be between 512 and 2048 pixels. Common recommended resolutions:
+
+| Size | Aspect Ratio |
+|------|-------------|
+| `1024*1024`, `1536*1536` | 1:1 |
+| `768*1152`, `1024*1536` | 2:3 |
+| `1152*768`, `1536*1024` | 3:2 |
+| `960*1280`, `1080*1440` | 3:4 |
+| `1280*960`, `1440*1080` | 4:3 |
+| `720*1280`, `1080*1920` | 9:16 |
+| `1280*720`, `1920*1080` | 16:9 |
+
+> [!NOTE]
+> If `size` is not specified, the output image will have a similar aspect ratio to the input image (last image when multiple inputs), with total pixels close to 1024×1024.
+
+### Response Details
+
+The response includes additional content with image dimensions:
+
+```php
+$response->additionalContent['image_count']; // Number of images
+$response->additionalContent['width']; // Image width in pixels
+$response->additionalContent['height']; // Image height in pixels
+$response->meta->id; // DashScope request ID
+```
+
+For complete image generation documentation, see [Image Generation](/core-concepts/image-generation).
+
+## Limitations
+
+### Tool Choice
+
+Qwen's `tool_choice` parameter only supports `"auto"` and `"none"`. Forcing a specific tool by name is not supported and will throw an `InvalidArgumentException`:
+
+```php
+// This works
+$response = Prism::text()
+ ->using(Provider::Qwen, 'qwen-plus')
+ ->withTools($tools)
+ ->withToolChoice(\Prism\Prism\Enums\ToolChoice::Auto)
+ ->withPrompt('...')
+ ->asText();
+
+// This will throw an InvalidArgumentException
+$response = Prism::text()
+ ->using(Provider::Qwen, 'qwen-plus')
+ ->withTools($tools)
+ ->withToolChoice('specific_tool_name')
+ ->withPrompt('...')
+ ->asText();
+```
+
+### Images in Messages
+
+Qwen supports images in user messages via URL, local file, and base64 encoding. When images are present, Prism automatically routes the request to DashScope's multimodal endpoint using the native content format (`{"image": "url"}`, `{"text": "text"}`). See [Multi-Modal (Vision) Text Generation](#multi-modal-vision-text-generation) for details.
+
+### Moderation
+
+Content moderation is not currently supported as a standalone feature through the Qwen provider.
+
+## Error Handling
+
+Qwen has a few provider-specific error codes that Prism handles automatically:
+
+| Error | Description |
+|-------|-------------|
+| `Arrearage` | Your Alibaba Cloud account has an unpaid balance |
+| `DataInspectionFailed` | Content moderation rejected the request |
+| `429` | Rate limit exceeded (throws `PrismRateLimitedException`) |
+| `503` | Service overloaded (throws `PrismProviderOverloadedException`) |
+
+DashScope native API errors return a top-level `code` and `message` field in the response body (as opposed to the nested `error` object in OpenAI-compatible mode). Prism handles both formats transparently.
+
+All other errors are handled through the standard Prism error handling flow. For more details, see [Error Handling](/advanced/error-handling).
diff --git a/docs/providers/replicate.md b/docs/providers/replicate.md
new file mode 100644
index 000000000..ef57d403c
--- /dev/null
+++ b/docs/providers/replicate.md
@@ -0,0 +1,455 @@
+# Replicate
+
+Replicate is a cloud platform that makes it easy to run machine learning models at scale. Unlike traditional LLM APIs, Replicate uses an **asynchronous prediction-based architecture** where you submit a request and poll for results.
+
+## Configuration
+
+```php
+'replicate' => [
+ 'api_key' => env('REPLICATE_API_KEY', ''),
+ 'url' => env('REPLICATE_URL', 'https://api.replicate.com/v1'),
+ 'webhook_url' => env('REPLICATE_WEBHOOK_URL', null),
+ 'use_sync_mode' => env('REPLICATE_USE_SYNC_MODE', true), // Use Prefer: wait header
+ 'polling_interval' => env('REPLICATE_POLLING_INTERVAL', 1000), // milliseconds
+ 'max_wait_time' => env('REPLICATE_MAX_WAIT_TIME', 60), // seconds
+]
+```
+
+### Configuration Options
+
+- **`api_key`**: Your Replicate API token (get one at [replicate.com/account](https://replicate.com/account))
+- **`url`**: Base API URL (default: `https://api.replicate.com/v1`)
+- **`webhook_url`**: Optional webhook URL for async completion notifications
+- **`use_sync_mode`**: Enable sync mode with `Prefer: wait` header (default: `true`) - reduces latency
+- **`polling_interval`**: Time between prediction status checks in milliseconds (default: 1000ms) - used when sync mode times out
+- **`max_wait_time`**: Maximum time to wait for prediction completion in seconds (default: 60s)
+
+## How Replicate Works
+
+Replicate's API differs from most LLM providers with an asynchronous prediction-based architecture. Prism provides two modes:
+
+### Sync Mode (Default - Recommended)
+Uses the `Prefer: wait` header to make Replicate wait for the prediction to complete before responding:
+
+1. **Submit prediction with `Prefer: wait`** → Replicate waits up to 60 seconds for completion
+2. **Immediate response** → Get results directly if prediction completes within timeout
+3. **Automatic fallback** → Falls back to polling if prediction takes longer than timeout
+
+**Benefits**: Lower latency, fewer API calls, faster responses for quick predictions.
+
+### Async Mode (Polling)
+Traditional polling approach:
+
+1. **Submit a prediction** → Get a prediction ID
+2. **Poll for completion** → Check prediction status until `succeeded` or `failed`
+3. **Retrieve output** → Extract results from the completed prediction
+
+**When to use**: Disable sync mode (`use_sync_mode: false`) for very long-running predictions (>60s) to avoid timeouts.
+
+Prism handles all complexity automatically, providing a clean synchronous interface regardless of mode.
+
+## Supported Features
+
+### ✅ Text Generation
+
+Generate text using large language models like Meta Llama 3.1.
+
+```php
+use Prism\Prism\Facades\Prism;
+use Prism\Prism\Enums\Provider;
+
+$response = Prism::text()
+ ->using(Provider::Replicate, 'meta/meta-llama-3-8b-instruct')
+ ->withPrompt('Explain quantum computing in simple terms')
+ ->generate();
+
+echo $response->text;
+```
+
+**Popular text models:**
+- `meta/meta-llama-3.1-405b-instruct` - Meta's flagship LLM
+- `meta/meta-llama-3-70b-instruct` - Balanced performance/cost
+- `meta/meta-llama-3-8b-instruct` - Fast, efficient model
+
+### ✅ Structured Output
+
+Extract structured data using JSON mode.
+
+```php
+use Prism\Prism\Facades\Prism;
+use Prism\Prism\Enums\Provider;
+use Prism\Prism\Schema\ObjectSchema;
+use Prism\Prism\Schema\StringSchema;
+use Prism\Prism\Schema\NumberSchema;
+
+$schema = new ObjectSchema(
+ name: "book_review",
+ description: "A structure movie review",
+ properties: [
+ new StringSchema("title", "Book title"),
+ new StringSchema("author", "Author name"),
+ new NumberSchema("rating", "Rating from 1-5"),
+ new StringSchema("summary", "Brief review summary")
+ ],
+ requiredFields: ["title", "author", "rating", "summary"]
+);
+
+$response = Prism::structured()
+ ->using(Provider::Replicate, 'meta/meta-llama-3-8b-instruct')
+ ->withPrompt('Review "1984" by George Orwell')
+ ->withSchema($schema)
+ ->generate();
+
+echo $response->structured['title']; // "1984"
+echo $response->structured['rating']; // 5
+```
+
+**How it works:** Prism injects the JSON schema into the prompt and instructs the model to return valid JSON matching the schema.
+
+### ✅ Streaming
+
+Stream text generation token-by-token for real-time UX using Server-Sent Events (SSE).
+
+```php
+use Prism\Prism\Facades\Prism;
+
+$stream = Prism::text()
+ ->using('replicate', 'meta-llama-3-8b-instruct')
+ ->withPrompt('Write a short story about a robot')
+ ->stream();
+
+foreach ($stream as $chunk) {
+ echo $chunk->text; // Prints tokens as they arrive in real-time
+}
+```
+
+**How it works:**
+- Prism connects to Replicate's SSE streaming endpoint (`urls.stream`) for true real-time token delivery
+- Tokens arrive progressively as the model generates them (no waiting for completion)
+- Full event lifecycle support: StreamStart → TextStart → TextDelta(s) → TextComplete → StreamEnd
+- Automatic fallback to simulated streaming if SSE is unavailable
+
+### ✅ Image Generation
+
+Generate images using state-of-the-art diffusion models.
+
+```php
+use Prism\Prism\Facades\Prism;
+
+$response = Prism::image()
+ ->using('replicate', 'black-forest-labs/flux-schnell')
+ ->withPrompt('A cute baby sea otter floating on its back in calm blue water')
+ ->generate();
+
+$image = $response->firstImage();
+echo $image->url;
+```
+
+**Popular image models:**
+- `bytedance/seedream-4` - Fast, high-quality generation (1-4 steps)
+- `black-forest-labs/flux-dev` - Development model with more control
+- `stability-ai/sdxl` - Stable Diffusion XL
+
+**Provider-specific options:**
+
+```php
+$response = Prism::image()
+ ->using("replicate", "bytedance/seedream-4")
+ ->withPrompt("A beautiful sunset over mountains")
+ ->withProviderOptions([
+ "size" => "2K",
+ "width" => 2048,
+ "height" => 2048,
+ "aspect_ratio" => "4:3"
+ ])
+ ->generate();
+```
+
+### ✅ Text-to-Speech (TTS)
+
+Convert text to natural-sounding speech.
+
+```php
+use Prism\Prism\Facades\Prism;
+
+$response = Prism::audio()
+ ->using('replicate', 'jaaari/kokoro-82m:f559560eb822dc509045f3921a1921234918b91739db4bf3daab2169b71c7a13')
+ ->withInput('Hello! Welcome to Replicate text-to-speech.')
+ ->withVoice('af_bella')
+ ->asAudio();
+
+$audio = $response->audio;
+if ($audio->hasBase64()) {
+ file_put_contents("output.mp3", base64_decode($audio->base64));
+ echo "Audio saved as: output.mp3";
+}
+```
+
+**Available voices for Kokoro-82m:**
+- `af_bella`
+- `af_nicole`
+- `am_fenrir`
+- `am_puck`
+
+**Provider-specific options:**
+
+```php
+$response = Prism::audio()
+ ->using(
+ "replicate",
+ "jaaari/kokoro-82m:f559560eb822dc509045f3921a1921234918b91739db4bf3daab2169b71c7a13"
+ )
+ ->withInput("Hello! Welcome to Replicate text-to-speech.")
+ ->withVoice("af_jessica")
+ ->withProviderOptions([
+ "speed" => 2 // Speech speed multiplier
+ ])
+ ->asAudio();
+```
+
+### ✅ Speech-to-Text (STT)
+
+Transcribe audio files to text using Whisper.
+
+```php
+use Prism\Prism\Facades\Prism;
+use Prism\Prism\ValueObjects\Media\Audio;
+
+$audioFile = new Audio('path/to/audio.mp3');
+
+$response = Prism::audio()
+ ->using('replicate', 'vaibhavs10/incredibly-fast-whisper:3ab86df6c8f54c11309d4d1f930ac292bad43ace52d10c80d87eb258b3c9f79c')
+ ->withInput($audioFile)
+ ->asText();
+
+echo "Transcription: " . $response->text;
+```
+
+**Supported formats:** WAV, MP3, FLAC, OGG, M4A
+
+**Provider-specific options:**
+
+```php
+$response = Prism::audio()
+ ->using('replicate', 'vaibhavs10/incredibly-fast-whisper:3ab86df6c8f54c11309d4d1f930ac292bad43ace52d10c80d87eb258b3c9f79c')
+ ->withInput($audioFile)
+ ->withProviderOptions([
+ 'task' => 'translate', // or 'translate' (to English)
+ 'language' => 'english', // Optional: specify source language
+ 'timestamp' => 'chunk', // chunk, word, or false
+ 'batch_size' => 64, // Batch size for processing
+ ])
+ ->asText();
+```
+
+### ✅ Embeddings
+
+Generate vector embeddings for semantic search and similarity.
+
+```php
+use Prism\Prism\Facades\Prism;
+
+// Single input
+$response = Prism::embeddings()
+ ->using(
+ "replicate",
+ "mark3labs/embeddings-gte-base:d619cff29338b9a37c3d06605042e1ff0594a8c3eff0175fd6967f5643fc4d47"
+ )
+ ->fromInput("The quick brown fox jumps over the lazy dog")
+ ->asEmbeddings();
+
+$embeddings = $response->embeddings[0]->embedding;
+
+// Check token usage
+echo $response->usage->tokens;
+
+// Multiple inputs
+$response = Prism::embeddings()
+ ->using(
+ "replicate",
+ "mark3labs/embeddings-gte-base:d619cff29338b9a37c3d06605042e1ff0594a8c3eff0175fd6967f5643fc4d47"
+ )
+ ->fromArray([
+ 'Document 1 text',
+ 'Document 2 text',
+ 'Document 3 text',
+ ])
+ ->asEmbeddings();
+
+foreach ($response->embeddings as $embedding) {
+ // Process each 768-dimensional vector
+}
+```
+
+**Embeddings model:**
+- `mark3labs/embeddings-gte-base` - 768-dimensional embeddings
+
+## Model Versioning
+
+Replicate models are versioned using SHA-256 hashes. Prism automatically maps friendly model names to their latest stable versions:
+
+```php
+// These are equivalent:
+->using('replicate', 'meta/meta-llama-3.1-405b-instruct')
+->using('replicate', 'meta/meta-llama-3.1-405b-instruct:e7...') // Full version hash
+```
+
+**Best practice:** Use the short name (without version hash) to automatically get the latest stable version.
+
+**NOTE:** When you are not using an Official Maintend Replicate model you need to used the hash version.
+
+## Async Predictions & Polling
+
+Prism handles Replicate's async architecture transparently:
+
+```php
+// This looks synchronous but Prism polls internally
+$response = Prism::text()
+ ->using('replicate', 'meta/meta-llama-3.1-405b-instruct')
+ ->withPrompt('Generate text')
+ ->generate();
+
+// Prism automatically:
+// 1. Creates a prediction
+// 2. Polls every 1 second (configurable via polling_interval)
+// 3. Returns when prediction succeeds (or times out after max_wait_time)
+```
+
+### Custom Polling Configuration
+
+```php
+// Set custom polling per provider instance
+$prism = Prism::text()
+ ->using(
+ new \Prism\Prism\Providers\Replicate\Replicate(
+ apiKey: env('REPLICATE_API_KEY'),
+ url: 'https://api.replicate.com/v1',
+ pollingInterval: 500, // Poll every 500ms
+ maxWaitTime: 120 // Wait up to 2 minutes
+ ),
+ 'meta/meta-llama-3.1-405b-instruct'
+ );
+```
+
+## Error Handling
+
+Replicate-specific exceptions:
+
+```php
+use Prism\Prism\Exceptions\PrismRateLimitedException;
+use Prism\Prism\Exceptions\PrismProviderOverloadedException;
+use Prism\Prism\Exceptions\PrismRequestTooLargeException;
+
+try {
+ $response = Prism::text()
+ ->using('replicate', 'meta/meta-llama-3.1-405b-instruct')
+ ->withPrompt('Generate text')
+ ->generate();
+} catch (PrismRateLimitedException $e) {
+ // HTTP 429: Rate limit exceeded
+ // Wait and retry with exponential backoff
+} catch (PrismProviderOverloadedException $e) {
+ // HTTP 529: Replicate's infrastructure is overloaded
+ // Retry with longer delay
+} catch (PrismRequestTooLargeException $e) {
+ // HTTP 413: Request payload too large
+ // Reduce input size
+}
+```
+
+## Performance Optimization
+
+### Sync Mode vs Async Mode
+
+By default, Prism uses **sync mode** (`Prefer: wait` header) for optimal performance:
+
+```php
+// Sync mode (default) - Recommended for most use cases
+'use_sync_mode' => true, // Uses Prefer: wait header
+
+// Benefits:
+// ✅ Lower latency (no polling delay)
+// ✅ Fewer API calls (single request)
+// ✅ Faster for quick predictions (<60s)
+// ✅ Automatic fallback to polling if needed
+```
+
+Disable sync mode for very long predictions:
+
+```php
+// Async mode - For predictions that take >60 seconds
+'use_sync_mode' => false, // Traditional polling
+
+// When to use:
+// • Very large image generations
+// • Complex multi-step processes
+// • Known slow models
+```
+
+### Custom Sync Mode
+
+You can also configure sync mode per provider instance:
+
+```php
+use Prism\Prism\Providers\Replicate\Replicate;
+
+$prism = Prism::text()
+ ->using(
+ new Replicate(
+ apiKey: env('REPLICATE_API_KEY'),
+ url: 'https://api.replicate.com/v1',
+ useSyncMode: true, // Enable sync mode
+ maxWaitTime: 60 // Max 60s for Prefer: wait
+ ),
+ 'meta/meta-llama-3.1-405b-instruct'
+ )
+ ->withPrompt('Generate text')
+ ->generate();
+```
+
+## Advanced: Webhooks (Future)
+
+> **Note:** Webhook support is planned but not yet implemented.
+
+Replicate supports webhooks for async notifications when predictions complete:
+
+```php
+// Future API
+'replicate' => [
+ 'webhook_url' => 'https://your-app.com/webhooks/replicate',
+]
+
+// Prediction will POST to webhook_url when complete
+```
+
+## Cost Optimization Tips
+
+1. **Use smaller models when possible**: `meta-llama-3.1-8b-instruct` is much cheaper than `405b-instruct`
+2. **Optimize image generation**: FLUX Schnell (1-4 steps) is faster and cheaper than FLUX Dev
+3. **Batch embeddings**: Process multiple texts in one request
+4. **Monitor polling**: Reduce `polling_interval` for faster results but more API calls
+
+## Rate Limits
+
+Replicate's rate limits vary by account tier:
+- **Free tier**: Limited predictions per month
+- **Pro/Team**: Higher limits based on subscription
+
+Prism automatically handles 429 responses with `PrismRateLimitedException`.
+
+## Resources
+
+- [Replicate Documentation](https://replicate.com/docs)
+- [Replicate API Reference](https://replicate.com/docs/reference/http)
+- [Replicate Models](https://replicate.com/explore)
+- [Get API Token](https://replicate.com/account)
+
+## Testing
+
+Prism provides comprehensive test coverage for Replicate:
+
+```bash
+./vendor/bin/pest tests/Providers/Replicate/
+```
+
+**Test fixtures:** All tests use real API response fixtures for consistent, offline testing.
diff --git a/docs/providers/requesty.md b/docs/providers/requesty.md
new file mode 100644
index 000000000..aa9db0ba3
--- /dev/null
+++ b/docs/providers/requesty.md
@@ -0,0 +1,329 @@
+# Requesty
+
+Requesty provides access to multiple AI models through a single OpenAI-compatible API. This provider allows you to use various models from different providers through Requesty's routing system.
+
+## Configuration
+
+Add your Requesty configuration to `config/prism.php`:
+
+```php
+'providers' => [
+ 'requesty' => [
+ 'api_key' => env('REQUESTY_API_KEY'),
+ 'url' => env('REQUESTY_URL', 'https://router.requesty.ai/v1'),
+ 'site' => [
+ 'http_referer' => env('REQUESTY_SITE_HTTP_REFERER'),
+ 'x_title' => env('REQUESTY_SITE_X_TITLE'),
+ ],
+ ],
+],
+```
+
+## Environment Variables
+
+Set your Requesty API key and URL in your `.env` file:
+
+```env
+REQUESTY_API_KEY=your_api_key_here
+REQUESTY_URL=https://router.requesty.ai/v1
+REQUESTY_SITE_HTTP_REFERER=https://your-site.example
+REQUESTY_SITE_X_TITLE="Your Site Name"
+```
+
+You can create an API key at [https://app.requesty.ai/api-keys](https://app.requesty.ai/api-keys).
+
+## Usage
+
+### Text Generation
+
+```php
+use Prism\Prism\Facades\Prism;
+use Prism\Prism\Enums\Provider;
+
+$response = Prism::text()
+ ->using(Provider::Requesty, 'openai/gpt-4-turbo')
+ ->withPrompt('Tell me a story about AI.')
+ ->generate();
+
+echo $response->text;
+```
+
+### Structured Output
+
+> [!NOTE]
+> Requesty uses OpenAI-compatible structured outputs. For strict schema validation, the root schema should be an `ObjectSchema`.
+
+```php
+use Prism\Prism\Facades\Prism;
+use Prism\Prism\Enums\Provider;
+use Prism\Prism\Schema\ObjectSchema;
+use Prism\Prism\Schema\StringSchema;
+
+$schema = new ObjectSchema('person', 'Person information', [
+ new StringSchema('name', 'The person\'s name'),
+ new StringSchema('occupation', 'The person\'s occupation'),
+]);
+
+$response = Prism::structured()
+ ->using(Provider::Requesty, 'openai/gpt-4-turbo')
+ ->withPrompt('Generate a person profile for John Doe.')
+ ->withSchema($schema)
+ ->generate();
+
+echo $response->text;
+```
+
+### Tool Calling
+
+```php
+use Prism\Prism\Facades\Prism;
+use Prism\Prism\Enums\Provider;
+use Prism\Prism\Tool;
+
+$weatherTool = Tool::as('get_weather')
+ ->for('Get the current weather for a location')
+ ->withStringParameter('location', 'The location to get weather for')
+ ->using(function (string $location) {
+ return "The weather in {$location} is sunny and 72°F";
+ });
+
+$response = Prism::text()
+ ->using(Provider::Requesty, 'openai/gpt-4-turbo')
+ ->withPrompt('What is the weather like in New York?')
+ ->withTools([$weatherTool])
+ ->generate();
+
+echo $response->text;
+```
+
+### Multimodal Prompts
+
+Requesty keeps the OpenAI content-part schema, so you can mix text and images inside a single user turn.
+
+```php
+use Prism\Prism\Facades\Prism;
+use Prism\Prism\Enums\Provider;
+use Prism\Prism\ValueObjects\Media\Image;
+
+$response = Prism::text()
+ ->using(Provider::Requesty, 'openai/gpt-4o-mini')
+ ->withPrompt('Describe the key trends in this diagram.', [
+ Image::fromLocalPath('storage/charts/retention.png'),
+ ])
+ ->generate();
+
+echo $response->text;
+```
+
+> [!TIP]
+> `Image` value objects are serialized into the `image_url` entries that Requesty expects, so you can attach multiple images or pair them with plain text in the same message.
+
+### Documents
+
+Requesty supports sending documents (PDFs) to compatible models:
+
+```php
+use Prism\Prism\Facades\Prism;
+use Prism\Prism\Enums\Provider;
+use Prism\Prism\ValueObjects\Media\Document;
+
+$response = Prism::text()
+ ->using(Provider::Requesty, 'anthropic/claude-sonnet-4')
+ ->withPrompt('Summarize this document.', [
+ Document::fromUrl('https://example.com/report.pdf', 'report.pdf'),
+ ])
+ ->generate();
+
+echo $response->text;
+```
+
+> [!TIP]
+> `Document` value objects support URLs and base64-encoded content. File IDs and chunks are not supported via Requesty.
+
+### Videos
+
+Requesty supports sending video files to compatible models (like Gemini). Videos can be provided as URLs or base64-encoded content:
+
+```php
+use Prism\Prism\Facades\Prism;
+use Prism\Prism\Enums\Provider;
+use Prism\Prism\ValueObjects\Media\Video;
+
+$response = Prism::text()
+ ->using(Provider::Requesty, 'google/gemini-3-flash-preview')
+ ->withPrompt('Describe what happens in this video.', [
+ Video::fromLocalPath('/path/to/video.mp4'),
+ ])
+ ->generate();
+
+echo $response->text;
+```
+
+> [!NOTE]
+> Video support varies by model. Check your model's capabilities before relying on video input.
+
+### Streaming
+
+```php
+use Prism\Prism\Facades\Prism;
+use Prism\Prism\Enums\Provider;
+use Prism\Prism\Enums\StreamEventType;
+
+$stream = Prism::text()
+ ->using(Provider::Requesty, 'openai/gpt-4-turbo')
+ ->withPrompt('Tell me a long story about AI.')
+ ->asStream();
+
+foreach ($stream as $event) {
+ if ($event->type() === StreamEventType::TextDelta) {
+ echo $event->delta;
+ }
+}
+```
+
+> [!WARNING]
+> Mid-stream failures may propagate as normal SSE payloads with `error` details while the HTTP status remains 200. Make sure to inspect each chunk for an `error` field so you can surface failures to the caller and stop reading the stream.
+
+### Streaming with Tools
+
+```php
+use Prism\Prism\Facades\Prism;
+use Prism\Prism\Enums\Provider;
+use Prism\Prism\Tool;
+
+$weatherTool = Tool::as('get_weather')
+ ->for('Get the current weather for a location')
+ ->withStringParameter('location', 'The location to get weather for')
+ ->using(function (string $location) {
+ return "The weather in {$location} is sunny and 72°F";
+ });
+
+$stream = Prism::text()
+ ->using(Provider::Requesty, 'openai/gpt-4-turbo')
+ ->withPrompt('What is the weather like in multiple cities?')
+ ->withTools([$weatherTool])
+ ->asStream();
+
+foreach ($stream as $event) {
+ match ($event->type()) {
+ StreamEventType::TextDelta => echo $event->delta,
+ StreamEventType::ToolCall => echo "Tool called: {$event->toolName}\n",
+ StreamEventType::ToolResult => echo "Tool result: " . json_encode($event->result) . "\n",
+ default => null,
+ };
+}
+```
+
+### Reasoning/Thinking Tokens
+
+Some models (like OpenAI's o1 series) support reasoning tokens that show the model's thought process:
+
+```php
+use Prism\Prism\Facades\Prism;
+use Prism\Prism\Enums\Provider;
+use Prism\Prism\Enums\StreamEventType;
+
+$stream = Prism::text()
+ ->using(Provider::Requesty, 'openai/o1-preview')
+ ->withPrompt('Solve this complex math problem: What is the derivative of x^3 + 2x^2 - 5x + 1?')
+ ->asStream();
+
+foreach ($stream as $event) {
+ if ($event->type() === StreamEventType::ThinkingDelta) {
+ // This is the model's reasoning/thinking process
+ echo "Thinking: " . $event->delta . "\n";
+ } elseif ($event->type() === StreamEventType::TextDelta) {
+ // This is the final answer
+ echo $event->delta;
+ }
+}
+```
+
+#### Reasoning Effort
+
+Control how much reasoning the model performs before generating a response using the `reasoning` parameter. The way this is structured depends on the underlying model you are calling:
+
+```php
+$response = Prism::text()
+ ->using(Provider::Requesty, 'openai/gpt-5-mini')
+ ->withPrompt('Write a PHP function to implement a binary search algorithm with proper error handling')
+ ->withProviderOptions([
+ 'reasoning' => [
+ 'effort' => 'high', // Can be "high", "medium", or "low" (OpenAI-style)
+ 'max_tokens' => 2000, // Specific token limit (Gemini / Anthropic-style)
+
+ // Optional: Default is false. All models support this.
+ 'exclude' => false, // Set to true to exclude reasoning tokens from response
+ // Or enable reasoning with the default parameters:
+ 'enabled' => true // Default: inferred from `effort` or `max_tokens`
+ ]
+ ])
+ ->asText();
+```
+
+### Provider Routing & Advanced Options
+
+Use `withProviderOptions()` to forward Requesty-specific controls such as model preferences or sampling parameters. Prism automatically forwards the native request values for `temperature`, `top_p`, and `max_tokens`, so you can continue tuning them through the usual Prism API without duplicating them in `withProviderOptions()`.
+
+```php
+use Prism\Prism\Facades\Prism;
+use Prism\Prism\Enums\Provider;
+
+$response = Prism::text()
+ ->using(Provider::Requesty, 'openai/gpt-4o')
+ ->withPrompt('Draft a concise product changelog entry.')
+ ->withProviderOptions([
+ 'models' => [
+ 'anthropic/claude-sonnet-4.5',
+ 'openai/gpt-4o-mini',
+ ],
+ 'top_k' => 40,
+ ])
+ ->generate();
+
+echo $response->text;
+```
+
+> [!IMPORTANT]
+> The values you supply here are passed directly to Requesty. Consult the [Requesty documentation](https://requesty.ai) for the full list of supported keys.
+
+## Available Models
+
+Requesty supports many models from different providers using the `provider/model` naming convention. Some popular options include:
+
+- `openai/gpt-4o`
+- `anthropic/claude-sonnet-4.5`
+- `google/gemini-2.5-flash`
+- `deepseek/deepseek-chat`
+
+Visit [Requesty](https://requesty.ai) for a complete list of available models.
+
+## Features
+
+- ✅ Text Generation
+- ✅ Structured Output
+- ✅ Tool Calling
+- ✅ Multiple Model Support
+- ✅ Provider Routing
+- ✅ Streaming
+- ✅ Reasoning/Thinking Tokens (for compatible models)
+- ✅ Image Support
+- ✅ Video Support
+- ✅ Document Support
+- ❌ Embeddings (not yet implemented)
+- ❌ Image Generation (not yet implemented)
+
+## API Reference
+
+For detailed API documentation, visit [Requesty's documentation](https://requesty.ai). Manage your API keys at [https://app.requesty.ai/api-keys](https://app.requesty.ai/api-keys).
+
+## Error Handling
+
+The Requesty provider includes standard error handling for common issues:
+
+- Rate limiting
+- Request too large
+- Provider overload
+- Invalid API key
+
+Errors are automatically mapped to appropriate Prism exceptions for consistent error handling across all providers.
diff --git a/docs/providers/vertex.md b/docs/providers/vertex.md
new file mode 100644
index 000000000..6b754277a
--- /dev/null
+++ b/docs/providers/vertex.md
@@ -0,0 +1,290 @@
+# Vertex AI
+
+Google Vertex AI provides enterprise-grade access to Google's Gemini models with enhanced security, compliance, and integration with Google Cloud services.
+
+## Configuration
+
+```php
+'vertex' => [
+ 'project_id' => env('VERTEX_PROJECT_ID', ''),
+ 'region' => env('VERTEX_REGION', 'us-central1'),
+ 'access_token' => env('VERTEX_ACCESS_TOKEN', null),
+ 'credentials_path' => env('VERTEX_CREDENTIALS_PATH', null),
+],
+```
+
+### Authentication
+
+Vertex AI supports multiple authentication methods:
+
+#### 1. Access Token (Recommended for development)
+
+Provide an access token directly:
+
+```env
+VERTEX_ACCESS_TOKEN=your-access-token
+```
+
+You can obtain an access token using the Google Cloud CLI:
+
+```bash
+gcloud auth print-access-token
+```
+
+#### 2. Service Account Credentials (Recommended for production)
+
+Provide the path to your service account JSON key file:
+
+```env
+VERTEX_CREDENTIALS_PATH=/path/to/service-account.json
+```
+
+#### 3. Application Default Credentials
+
+If no credentials are provided, Prism will attempt to use Application Default Credentials (ADC). Set up ADC by running:
+
+```bash
+gcloud auth application-default login
+```
+
+Or by setting the `GOOGLE_APPLICATION_CREDENTIALS` environment variable:
+
+```bash
+export GOOGLE_APPLICATION_CREDENTIALS=/path/to/service-account.json
+```
+
+## Basic Usage
+
+### Text Generation
+
+```php
+use Prism\Prism\Facades\Prism;
+use Prism\Prism\Enums\Provider;
+
+$response = Prism::text()
+ ->using(Provider::Vertex, 'gemini-1.5-flash')
+ ->withPrompt('Explain quantum computing in simple terms.')
+ ->asText();
+
+echo $response->text;
+```
+
+### With System Prompt
+
+```php
+$response = Prism::text()
+ ->using(Provider::Vertex, 'gemini-1.5-flash')
+ ->withSystemPrompt('You are a helpful coding assistant.')
+ ->withPrompt('Write a Python function to calculate fibonacci numbers.')
+ ->asText();
+```
+
+## Structured Output
+
+Vertex AI supports structured output, allowing you to define schemas that constrain the model's responses to match your exact data structure requirements.
+
+```php
+use Prism\Prism\Facades\Prism;
+use Prism\Prism\Enums\Provider;
+use Prism\Prism\Schema\ObjectSchema;
+use Prism\Prism\Schema\StringSchema;
+use Prism\Prism\Schema\NumberSchema;
+
+$schema = new ObjectSchema(
+ name: 'user_profile',
+ description: 'A user profile object',
+ properties: [
+ new StringSchema('name', 'The user\'s full name'),
+ new NumberSchema('age', 'The user\'s age'),
+ new StringSchema('email', 'The user\'s email address'),
+ ],
+ requiredFields: ['name', 'age', 'email']
+);
+
+$response = Prism::structured()
+ ->using(Provider::Vertex, 'gemini-1.5-flash')
+ ->withSchema($schema)
+ ->withPrompt('Generate a profile for a fictional user named John Doe.')
+ ->generate();
+
+// Access structured data
+$profile = $response->structured;
+echo $profile['name']; // "John Doe"
+echo $profile['age']; // 30
+echo $profile['email']; // "john.doe@example.com"
+```
+
+## Tool Usage
+
+Vertex AI supports function calling (tools) to extend the model's capabilities:
+
+```php
+use Prism\Prism\Facades\Prism;
+use Prism\Prism\Enums\Provider;
+use Prism\Prism\Tool;
+
+$weatherTool = (new Tool)
+ ->as('get_weather')
+ ->for('Get the current weather for a location')
+ ->withStringParameter('location', 'The city and state, e.g. San Francisco, CA')
+ ->using(function (string $location): string {
+ // Your weather API call here
+ return "The weather in {$location} is 72°F and sunny.";
+ });
+
+$response = Prism::text()
+ ->using(Provider::Vertex, 'gemini-1.5-flash')
+ ->withTools([$weatherTool])
+ ->withMaxSteps(3)
+ ->withPrompt('What is the weather like in San Francisco?')
+ ->asText();
+
+echo $response->text;
+```
+
+## Embeddings
+
+Vertex AI supports text embeddings for semantic search, clustering, and other ML tasks:
+
+```php
+use Prism\Prism\Facades\Prism;
+use Prism\Prism\Enums\Provider;
+
+$response = Prism::embeddings()
+ ->using(Provider::Vertex, 'text-embedding-004')
+ ->fromInput('The quick brown fox jumps over the lazy dog.')
+ ->generate();
+
+// Access the embedding vector
+$embedding = $response->embeddings[0]->embedding;
+```
+
+## Streaming
+
+Vertex AI supports streaming responses for real-time output:
+
+```php
+use Prism\Prism\Facades\Prism;
+use Prism\Prism\Enums\Provider;
+
+$stream = Prism::text()
+ ->using(Provider::Vertex, 'gemini-1.5-flash')
+ ->withPrompt('Write a short story about a robot.')
+ ->asStream();
+
+foreach ($stream as $event) {
+ if ($event instanceof \Prism\Prism\Streaming\Events\TextDeltaEvent) {
+ echo $event->delta;
+ }
+}
+```
+
+## Image Understanding
+
+Vertex AI supports multimodal inputs including images:
+
+```php
+use Prism\Prism\Facades\Prism;
+use Prism\Prism\Enums\Provider;
+use Prism\Prism\ValueObjects\Messages\UserMessage;
+use Prism\Prism\ValueObjects\Media\Image;
+
+$response = Prism::text()
+ ->using(Provider::Vertex, 'gemini-1.5-flash')
+ ->withMessages([
+ new UserMessage(
+ 'What do you see in this image?',
+ additionalContent: [
+ Image::fromLocalPath('/path/to/image.png'),
+ ],
+ ),
+ ])
+ ->asText();
+
+echo $response->text;
+```
+
+## Thinking Mode
+
+For models that support it (like Gemini 2.5), you can configure thinking mode:
+
+```php
+$response = Prism::text()
+ ->using(Provider::Vertex, 'gemini-2.5-flash-preview')
+ ->withPrompt('Solve this complex math problem...')
+ ->withProviderOptions([
+ 'thinkingBudget' => 2048,
+ ])
+ ->asText();
+
+// Access thinking token usage
+echo $response->usage->thoughtTokens;
+```
+
+## Available Models
+
+Vertex AI provides access to Google's Gemini model family:
+
+| Model | Description |
+|-------|-------------|
+| `gemini-1.5-flash` | Fast and efficient for most tasks |
+| `gemini-1.5-pro` | Most capable model for complex tasks |
+| `gemini-2.0-flash` | Latest generation with improved capabilities |
+| `gemini-2.5-flash-preview` | Preview of next generation with thinking support |
+| `text-embedding-004` | Text embeddings model |
+
+## Regions
+
+Vertex AI is available in multiple regions. Configure your region in the `.env` file:
+
+```env
+VERTEX_REGION=us-central1
+```
+
+### Regional Endpoints
+
+Prism selects the API hostname based on the configured region:
+
+| Region value | Endpoint hostname | Use case |
+|--------------|-------------------|----------|
+| `global` | `aiplatform.googleapis.com` | Global endpoint (no regional prefix) |
+| `eu` | `aiplatform.eu.rep.googleapis.com` | Multi-region EU |
+| `us` | `aiplatform.us.rep.googleapis.com` | Multi-region US |
+| Any other value (e.g. `us-central1`) | `{region}-aiplatform.googleapis.com` | Single-region endpoint |
+
+### Common Single-Region Values
+
+- `us-central1` (default)
+- `us-east1`
+- `us-west1`
+- `europe-west1`
+- `europe-west4`
+- `asia-northeast1`
+- `asia-southeast1`
+
+Use `global`, `eu`, or `us` when you need Google Cloud's multi-region endpoints rather than a specific single region:
+
+```env
+# Multi-region EU
+VERTEX_REGION=eu
+
+# Multi-region US
+VERTEX_REGION=us
+
+# Global endpoint
+VERTEX_REGION=global
+```
+
+## Differences from Gemini API
+
+While Vertex AI uses the same underlying Gemini models, there are key differences:
+
+| Feature | Gemini API | Vertex AI |
+|---------|------------|-----------|
+| Authentication | API Key | OAuth 2.0 / Service Account |
+| Pricing | Pay-as-you-go | Google Cloud billing |
+| Data residency | Global | Regional control |
+| Enterprise features | Limited | Full (VPC, audit logs, etc.) |
+| SLA | None | Enterprise SLA available |
+
+Choose Vertex AI when you need enterprise-grade security, compliance, or integration with other Google Cloud services.
diff --git a/rector.php b/rector.php
index f8942e528..82a8f8649 100644
--- a/rector.php
+++ b/rector.php
@@ -2,7 +2,7 @@
declare(strict_types=1);
-use Prism\Prism\Rectors\ReorderMethodsRector;
+use Prism\Dev\Rectors\ReorderMethodsRector;
use Rector\Config\RectorConfig;
use Rector\Set\ValueObject\LevelSetList;
use Rector\Set\ValueObject\SetList;
diff --git a/resources/boost/skills/developing-with-prism/SKILL.md b/resources/boost/skills/developing-with-prism/SKILL.md
index 7bc3b79fa..d677cb80f 100644
--- a/resources/boost/skills/developing-with-prism/SKILL.md
+++ b/resources/boost/skills/developing-with-prism/SKILL.md
@@ -108,19 +108,19 @@ $response = Prism::text()
1. **Read a specific doc file directly:**
```
- read vendor/prism-php/prism/docs/core-concepts/text-generation.md
- read vendor/prism-php/prism/docs/providers/openai.md
+ read vendor/particle-academy/prism/docs/core-concepts/text-generation.md
+ read vendor/particle-academy/prism/docs/providers/openai.md
```
2. **Search for a topic across docs:**
```
- grep "streaming" vendor/prism-php/prism/docs/
- grep "withProviderOptions" vendor/prism-php/prism/docs/providers/
+ grep "streaming" vendor/particle-academy/prism/docs/
+ grep "withProviderOptions" vendor/particle-academy/prism/docs/providers/
```
3. **Find all doc files:**
```
- glob "vendor/prism-php/prism/docs/**/*.md"
+ glob "vendor/particle-academy/prism/docs/**/*.md"
```
### Documentation Paths
@@ -181,11 +181,11 @@ $response = Prism::text()
**NEVER use the old package:** `echolabsdev/prism` is deprecated.
-**ALWAYS use:** `prism-php/prism`
+**ALWAYS use:** `particle-academy/prism`
```bash
# Correct
-composer require prism-php/prism
+composer require particle-academy/prism
# Wrong - do not use
composer require echolabsdev/prism
diff --git a/src/Audio/PendingRequest.php b/src/Audio/PendingRequest.php
index 118de2460..2a520763a 100644
--- a/src/Audio/PendingRequest.php
+++ b/src/Audio/PendingRequest.php
@@ -19,11 +19,11 @@ class PendingRequest
use ConfiguresProviders;
use HasProviderOptions;
- protected string|Audio $input;
+ protected string|Audio|int $input;
protected string $voice;
- public function withInput(string|Audio $input): self
+ public function withInput(string|Audio|int $input): self
{
$this->input = $input;
@@ -59,6 +59,28 @@ public function asText(): TextResponse
}
}
+ public function asTextProviderId(): ProviderIdResponse
+ {
+ $request = $this->toSpeechToTextRequest();
+
+ try {
+ return $this->provider->speechToTextProviderId($request);
+ } catch (RequestException $e) {
+ $this->provider->handleRequestException($request->model(), $e);
+ }
+ }
+
+ public function asTextAsync(): TextResponse
+ {
+ $request = $this->toSpeechToTextAsyncRequest();
+
+ try {
+ return $this->provider->speechToTextAsync($request);
+ } catch (RequestException $e) {
+ $this->provider->handleRequestException($request->model(), $e);
+ }
+ }
+
public function toTextToSpeechRequest(): TextToSpeechRequest
{
if (! is_string($this->input)) {
@@ -91,4 +113,20 @@ public function toSpeechToTextRequest(): SpeechToTextRequest
providerOptions: $this->providerOptions,
);
}
+
+ protected function toSpeechToTextAsyncRequest(): SpeechToTextAsyncRequest
+ {
+ if (! is_string($this->input) && ! is_int($this->input)) {
+ throw new InvalidArgumentException('Async speech-to-text requires the input be the Provider ID as a string or integer');
+ }
+
+ return new SpeechToTextAsyncRequest(
+ model: $this->model,
+ providerKey: $this->providerKey(),
+ input: $this->input,
+ clientOptions: $this->clientOptions,
+ clientRetry: $this->clientRetry,
+ providerOptions: $this->providerOptions,
+ );
+ }
}
diff --git a/src/Audio/ProviderIdResponse.php b/src/Audio/ProviderIdResponse.php
new file mode 100644
index 000000000..d9aac7d8a
--- /dev/null
+++ b/src/Audio/ProviderIdResponse.php
@@ -0,0 +1,17 @@
+ */
+ public array $additionalContent = []
+ ) {}
+}
diff --git a/src/Audio/SpeechToTextAsyncRequest.php b/src/Audio/SpeechToTextAsyncRequest.php
new file mode 100644
index 000000000..bd35b2c04
--- /dev/null
+++ b/src/Audio/SpeechToTextAsyncRequest.php
@@ -0,0 +1,62 @@
+ $clientOptions
+ * @param array{0: array|int, 1?: Closure|int, 2?: ?callable, 3?: bool} $clientRetry
+ * @param array $providerOptions
+ */
+ public function __construct(
+ protected string $model,
+ protected string $providerKey,
+ protected string|int $input,
+ protected array $clientOptions,
+ protected array $clientRetry,
+ array $providerOptions = [],
+ ) {
+ $this->providerOptions = $providerOptions;
+ }
+
+ /**
+ * @return array{0: array|int, 1?: Closure|int, 2?: ?callable, 3?: bool}
+ */
+ public function clientRetry(): array
+ {
+ return $this->clientRetry;
+ }
+
+ /**
+ * @return array
+ */
+ public function clientOptions(): array
+ {
+ return $this->clientOptions;
+ }
+
+ public function input(): string|int
+ {
+ return $this->input;
+ }
+
+ public function model(): string
+ {
+ return $this->model;
+ }
+
+ public function provider(): string
+ {
+ return $this->providerKey;
+ }
+}
diff --git a/src/Batch/BatchJob.php b/src/Batch/BatchJob.php
new file mode 100644
index 000000000..aca9a1a50
--- /dev/null
+++ b/src/Batch/BatchJob.php
@@ -0,0 +1,25 @@
+ $errors
+ */
+ public function __construct(
+ public string $id,
+ public BatchStatus $status,
+ public BatchJobRequestCounts $requestCounts,
+ public ?string $createdAt = null,
+ public ?string $expiresAt = null,
+ public ?string $endedAt = null,
+ public ?string $resultsUrl = null,
+ public ?string $inputFileId = null,
+ public ?string $outputFileId = null,
+ public ?string $errorFileId = null,
+ public array $errors = [],
+ ) {}
+}
diff --git a/src/Batch/BatchJobRequestCounts.php b/src/Batch/BatchJobRequestCounts.php
new file mode 100644
index 000000000..9f3b86aa5
--- /dev/null
+++ b/src/Batch/BatchJobRequestCounts.php
@@ -0,0 +1,17 @@
+|int, 1?: Closure|int, 2?: ?callable, 3?: bool}
+ */
+ public function clientRetry(): array
+ {
+ return $this->clientRetry;
+ }
+
+ /**
+ * @return array
+ */
+ public function clientOptions(): array
+ {
+ return $this->clientOptions;
+ }
+}
diff --git a/src/Batch/BatchRequestItem.php b/src/Batch/BatchRequestItem.php
new file mode 100644
index 000000000..153b31ec8
--- /dev/null
+++ b/src/Batch/BatchRequestItem.php
@@ -0,0 +1,15 @@
+|int, 1?: Closure|int, 2?: ?callable, 3?: bool}
+ */
+ public function clientRetry(): array
+ {
+ return $this->clientRetry;
+ }
+
+ /**
+ * @return array
+ */
+ public function clientOptions(): array
+ {
+ return $this->clientOptions;
+ }
+}
diff --git a/src/Batch/GetBatchResultsRequest.php b/src/Batch/GetBatchResultsRequest.php
new file mode 100644
index 000000000..9455f3f4b
--- /dev/null
+++ b/src/Batch/GetBatchResultsRequest.php
@@ -0,0 +1,35 @@
+|int, 1?: Closure|int, 2?: ?callable, 3?: bool}
+ */
+ public function clientRetry(): array
+ {
+ return $this->clientRetry;
+ }
+
+ /**
+ * @return array
+ */
+ public function clientOptions(): array
+ {
+ return $this->clientOptions;
+ }
+}
diff --git a/src/Batch/ListBatchesRequest.php b/src/Batch/ListBatchesRequest.php
new file mode 100644
index 000000000..aef2b90e1
--- /dev/null
+++ b/src/Batch/ListBatchesRequest.php
@@ -0,0 +1,37 @@
+|int, 1?: Closure|int, 2?: ?callable, 3?: bool}
+ */
+ public function clientRetry(): array
+ {
+ return $this->clientRetry;
+ }
+
+ /**
+ * @return array
+ */
+ public function clientOptions(): array
+ {
+ return $this->clientOptions;
+ }
+}
diff --git a/src/Batch/PendingRequest.php b/src/Batch/PendingRequest.php
new file mode 100644
index 000000000..9290e7782
--- /dev/null
+++ b/src/Batch/PendingRequest.php
@@ -0,0 +1,128 @@
+toCreateRequest($items, $inputFileId);
+
+ try {
+ return $this->provider->batch($request);
+ } catch (RequestException $e) {
+ $this->provider->handleRequestException('', $e);
+ }
+ }
+
+ public function retrieve(string $batchId): BatchJob
+ {
+ $request = $this->toRetrieveRequest($batchId);
+
+ try {
+ return $this->provider->retrieveBatch($request);
+ } catch (RequestException $e) {
+ $this->provider->handleRequestException('', $e);
+ }
+ }
+
+ public function list(?int $limit = null, ?string $afterId = null, ?string $beforeId = null): BatchListResult
+ {
+ $request = $this->toListRequest($limit, $afterId, $beforeId);
+
+ try {
+ return $this->provider->listBatches($request);
+ } catch (RequestException $e) {
+ $this->provider->handleRequestException('', $e);
+ }
+ }
+
+ /**
+ * @return BatchResultItem[]
+ */
+ public function getResults(string $batchId): array
+ {
+ $request = $this->toGetResultsRequest($batchId);
+
+ try {
+ return $this->provider->getBatchResults($request);
+ } catch (RequestException $e) {
+ $this->provider->handleRequestException('', $e);
+ }
+ }
+
+ public function cancel(string $batchId): BatchJob
+ {
+ $request = $this->toCancelRequest($batchId);
+
+ try {
+ return $this->provider->cancelBatch($request);
+ } catch (RequestException $e) {
+ $this->provider->handleRequestException('', $e);
+ }
+ }
+
+ /**
+ * @param BatchRequestItem[]|null $items
+ */
+ public function toCreateRequest(?array $items = null, ?string $inputFileId = null): BatchRequest
+ {
+ return (new BatchRequest(
+ items: $items,
+ inputFileId: $inputFileId,
+ ))
+ ->withClientOptions($this->clientOptions)
+ ->withClientRetry(...$this->clientRetry)
+ ->withProviderOptions($this->providerOptions);
+ }
+
+ public function toRetrieveRequest(string $batchId): RetrieveBatchRequest
+ {
+ return (new RetrieveBatchRequest(batchId: $batchId))
+ ->withClientOptions($this->clientOptions)
+ ->withClientRetry(...$this->clientRetry)
+ ->withProviderOptions($this->providerOptions);
+ }
+
+ public function toListRequest(?int $limit = null, ?string $afterId = null, ?string $beforeId = null): ListBatchesRequest
+ {
+ return (new ListBatchesRequest(
+ limit: $limit,
+ afterId: $afterId,
+ beforeId: $beforeId,
+ ))
+ ->withClientOptions($this->clientOptions)
+ ->withClientRetry(...$this->clientRetry)
+ ->withProviderOptions($this->providerOptions);
+ }
+
+ public function toGetResultsRequest(string $batchId): GetBatchResultsRequest
+ {
+ return (new GetBatchResultsRequest(batchId: $batchId))
+ ->withClientOptions($this->clientOptions)
+ ->withClientRetry(...$this->clientRetry)
+ ->withProviderOptions($this->providerOptions);
+ }
+
+ public function toCancelRequest(string $batchId): CancelBatchRequest
+ {
+ return (new CancelBatchRequest(batchId: $batchId))
+ ->withClientOptions($this->clientOptions)
+ ->withClientRetry(...$this->clientRetry)
+ ->withProviderOptions($this->providerOptions);
+ }
+}
diff --git a/src/Batch/RetrieveBatchRequest.php b/src/Batch/RetrieveBatchRequest.php
new file mode 100644
index 000000000..9a70f977c
--- /dev/null
+++ b/src/Batch/RetrieveBatchRequest.php
@@ -0,0 +1,35 @@
+|int, 1?: Closure|int, 2?: ?callable, 3?: bool}
+ */
+ public function clientRetry(): array
+ {
+ return $this->clientRetry;
+ }
+
+ /**
+ * @return array
+ */
+ public function clientOptions(): array
+ {
+ return $this->clientOptions;
+ }
+}
diff --git a/src/Concerns/CallsTools.php b/src/Concerns/CallsTools.php
index eae5ac824..34295d2b5 100644
--- a/src/Concerns/CallsTools.php
+++ b/src/Concerns/CallsTools.php
@@ -8,11 +8,22 @@
use Illuminate\Support\Facades\Concurrency;
use Illuminate\Support\ItemNotFoundException;
use Illuminate\Support\MultipleItemsFoundException;
+use Prism\Prism\Enums\FinishReason;
use Prism\Prism\Exceptions\PrismException;
use Prism\Prism\Streaming\EventID;
use Prism\Prism\Streaming\Events\ArtifactEvent;
+use Prism\Prism\Streaming\Events\StepFinishEvent;
+use Prism\Prism\Streaming\Events\StreamEndEvent;
+use Prism\Prism\Streaming\Events\ToolApprovalRequestEvent;
use Prism\Prism\Streaming\Events\ToolResultEvent;
+use Prism\Prism\Streaming\StreamState;
+use Prism\Prism\Structured\Request as StructuredRequest;
+use Prism\Prism\Text\Request as TextRequest;
use Prism\Prism\Tool;
+use Prism\Prism\ValueObjects\Messages\AssistantMessage;
+use Prism\Prism\ValueObjects\Messages\ToolResultMessage;
+use Prism\Prism\ValueObjects\ToolApprovalRequest;
+use Prism\Prism\ValueObjects\ToolApprovalResponse;
use Prism\Prism\ValueObjects\ToolCall;
use Prism\Prism\ValueObjects\ToolError;
use Prism\Prism\ValueObjects\ToolOutput;
@@ -39,21 +50,81 @@ protected function callTools(array $tools, array $toolCalls): array
return $toolResults;
}
+ /**
+ * Execute tools honoring client-executed and approval-required markers.
+ *
+ * Marked tool calls are NOT executed: $hasPendingToolCalls is set so the
+ * handler stops its loop, and approval requests are collected into
+ * $approvalRequests for correlation on resume.
+ *
+ * @param Tool[] $tools
+ * @param ToolCall[] $toolCalls
+ * @param ToolApprovalRequest[] $approvalRequests
+ * @return ToolResult[]
+ */
+ protected function callToolsWithPending(array $tools, array $toolCalls, bool &$hasPendingToolCalls, array &$approvalRequests): array
+ {
+ $toolResults = [];
+
+ foreach ($this->callToolsAndYieldEventsWithPending($tools, $toolCalls, EventID::generate(), $toolResults, $hasPendingToolCalls) as $event) {
+ if ($event instanceof ToolApprovalRequestEvent) {
+ $approvalRequests[] = new ToolApprovalRequest(
+ approvalId: $event->approvalId,
+ toolCallId: $event->toolCall->id,
+ );
+ }
+ }
+
+ return $toolResults;
+ }
+
/**
* Generate tool execution events and collect results (for streaming handlers).
*
* @param Tool[] $tools
* @param ToolCall[] $toolCalls
* @param ToolResult[] $toolResults Results are collected into this array by reference
- * @return Generator
+ * @return Generator
*/
protected function callToolsAndYieldEvents(array $tools, array $toolCalls, string $messageId, array &$toolResults): Generator
{
- $groupedToolCalls = $this->groupToolCallsByConcurrency($tools, $toolCalls);
+ $hasPendingToolCalls = false;
+
+ yield from $this->yieldToolEvents($tools, $toolCalls, $messageId, $toolResults, $hasPendingToolCalls, filterPending: false);
+ }
+
+ /**
+ * Streaming variant honoring client-executed and approval-required markers.
+ *
+ * @param Tool[] $tools
+ * @param ToolCall[] $toolCalls
+ * @param ToolResult[] $toolResults Results are collected into this array by reference
+ * @return Generator
+ */
+ protected function callToolsAndYieldEventsWithPending(array $tools, array $toolCalls, string $messageId, array &$toolResults, bool &$hasPendingToolCalls): Generator
+ {
+ yield from $this->yieldToolEvents($tools, $toolCalls, $messageId, $toolResults, $hasPendingToolCalls, filterPending: true);
+ }
+
+ /**
+ * @param Tool[] $tools
+ * @param ToolCall[] $toolCalls
+ * @param ToolResult[] $toolResults
+ * @return Generator
+ */
+ protected function yieldToolEvents(array $tools, array $toolCalls, string $messageId, array &$toolResults, bool &$hasPendingToolCalls, bool $filterPending): Generator
+ {
+ $approvalRequiredToolCalls = [];
+
+ $executableToolCalls = $filterPending
+ ? $this->filterServerExecutedToolCalls($tools, $toolCalls, $hasPendingToolCalls, $approvalRequiredToolCalls)
+ : $toolCalls;
+
+ $groupedToolCalls = $this->groupToolCallsByConcurrency($tools, $executableToolCalls);
$executionResults = $this->executeToolsWithConcurrency($tools, $groupedToolCalls, $messageId);
- foreach (array_keys($toolCalls) as $index) {
+ foreach (collect($executionResults)->keys()->sort() as $index) {
$result = $executionResults[$index];
$toolResults[] = $result['toolResult'];
@@ -62,6 +133,223 @@ protected function callToolsAndYieldEvents(array $tools, array $toolCalls, strin
yield $event;
}
}
+
+ foreach ($approvalRequiredToolCalls as $toolCall) {
+ yield new ToolApprovalRequestEvent(
+ id: EventID::generate(),
+ timestamp: time(),
+ toolCall: $toolCall,
+ messageId: $messageId,
+ approvalId: EventID::generate('apr'),
+ );
+ }
+ }
+
+ /**
+ * Split out client-executed and approval-required tool calls, setting the
+ * pending flag when any are found.
+ *
+ * @param Tool[] $tools
+ * @param ToolCall[] $toolCalls
+ * @param ToolCall[] $approvalRequiredToolCalls Collected by reference
+ * @return array Server-executed tool calls with original indices preserved
+ */
+ protected function filterServerExecutedToolCalls(array $tools, array $toolCalls, bool &$hasPendingToolCalls, array &$approvalRequiredToolCalls): array
+ {
+ $serverToolCalls = [];
+
+ foreach ($toolCalls as $index => $toolCall) {
+ try {
+ $tool = $this->resolveTool($toolCall->name, $tools);
+
+ if ($tool->isClientExecuted()) {
+ $hasPendingToolCalls = true;
+
+ continue;
+ }
+
+ if ($tool->needsApproval($toolCall->arguments())) {
+ $hasPendingToolCalls = true;
+ $approvalRequiredToolCalls[] = $toolCall;
+
+ continue;
+ }
+
+ $serverToolCalls[$index] = $toolCall;
+ } catch (PrismException) {
+ // Unknown tool — keep it so error handling fires in executeToolCall.
+ $serverToolCalls[$index] = $toolCall;
+ }
+ }
+
+ return $serverToolCalls;
+ }
+
+ /**
+ * Yield stream completion events when marked tool calls are pending: the
+ * step and stream end with FinishReason::ToolCalls so the consumer can
+ * collect the pending calls and resume.
+ *
+ * @return Generator
+ */
+ protected function yieldToolCallsFinishEvents(StreamState $state): Generator
+ {
+ yield new StepFinishEvent(
+ id: EventID::generate(),
+ timestamp: time(),
+ usage: $state->usage(),
+ );
+
+ yield new StreamEndEvent(
+ id: EventID::generate(),
+ timestamp: time(),
+ finishReason: FinishReason::ToolCalls,
+ usage: $state->usage(),
+ citations: $state->citations() !== [] ? $state->citations() : null,
+ );
+ }
+
+ /**
+ * Resolve pending tool approvals from a previous request (non-streaming).
+ *
+ * Scans the request messages for a ToolResultMessage carrying approval
+ * responses after the last tool-calling AssistantMessage. Approved tools
+ * are executed; denied or unanswered ones produce denial results
+ * (deny-by-default). The tool message is replaced with one containing the
+ * merged results so the conversation is complete before the next send.
+ */
+ protected function resolveToolApprovals(StructuredRequest|TextRequest $request): void
+ {
+ foreach ($this->resolveToolApprovalsAndYieldEvents($request, EventID::generate()) as $event) {
+ // Events are discarded for non-streaming handlers
+ }
+ }
+
+ /**
+ * @return Generator
+ */
+ protected function resolveToolApprovalsAndYieldEvents(StructuredRequest|TextRequest $request, string $messageId): Generator
+ {
+ $messages = $request->messages();
+
+ $assistantMessage = null;
+ $assistantMessageIndex = null;
+
+ for ($i = count($messages) - 1; $i >= 0; $i--) {
+ if ($messages[$i] instanceof AssistantMessage && $messages[$i]->toolCalls !== []) {
+ $assistantMessage = $messages[$i];
+ $assistantMessageIndex = $i;
+
+ break;
+ }
+ }
+
+ if (! $assistantMessage instanceof AssistantMessage || $assistantMessageIndex === null) {
+ return;
+ }
+
+ $toolsByName = collect($request->tools())->keyBy(fn (Tool $tool): string => $tool->name());
+
+ $isAnyToolApprovalConfigured = collect($assistantMessage->toolCalls)->contains(
+ fn (ToolCall $toolCall): bool => $toolsByName->get($toolCall->name)?->hasApprovalConfigured() === true,
+ );
+
+ if (! $isAnyToolApprovalConfigured) {
+ return;
+ }
+
+ $toolMessage = null;
+ $toolMessageIndex = null;
+ $messageCount = count($messages);
+
+ for ($i = $assistantMessageIndex + 1; $i < $messageCount; $i++) {
+ if ($messages[$i] instanceof ToolResultMessage) {
+ $toolMessage = $messages[$i];
+ $toolMessageIndex = $i;
+
+ break;
+ }
+ }
+
+ if (! $toolMessage instanceof ToolResultMessage) {
+ $toolMessage = new ToolResultMessage;
+ $toolMessageIndex = null;
+ }
+
+ $toolCallIdToApprovalId = [];
+ foreach ($assistantMessage->toolApprovalRequests as $approvalRequest) {
+ $toolCallIdToApprovalId[$approvalRequest->toolCallId] = $approvalRequest->approvalId;
+ }
+
+ $approvalResolvedToolResults = [];
+
+ foreach ($assistantMessage->toolCalls as $toolCall) {
+ $approvalId = $toolCallIdToApprovalId[$toolCall->id] ?? null;
+ $approval = $approvalId !== null ? $toolMessage->findByApprovalId($approvalId) : null;
+
+ if (! $approval instanceof ToolApprovalResponse) {
+ if (collect($toolMessage->toolResults)->contains(fn (ToolResult $toolResult): bool => $toolResult->toolCallId === $toolCall->id)) {
+ continue; // already executed
+ }
+
+ if ($toolsByName->get($toolCall->name)?->hasApprovalConfigured() !== true) {
+ continue;
+ }
+
+ // Deny by default when no approval response was provided.
+ $approval = new ToolApprovalResponse($approvalId ?? EventID::generate('apr'), false, 'No approval response provided');
+ }
+
+ if ($approval->approved) {
+ $result = $this->executeToolCall($request->tools(), $toolCall, $messageId);
+
+ $approvalResolvedToolResults[] = $result['toolResult'];
+
+ foreach ($result['events'] as $event) {
+ yield $event;
+ }
+
+ continue;
+ }
+
+ $reason = $approval->reason ?? 'User denied tool execution';
+
+ $toolResult = new ToolResult(
+ toolCallId: $toolCall->id,
+ toolName: $toolCall->name,
+ args: $toolCall->arguments(),
+ result: $reason,
+ toolCallResultId: $toolCall->resultId,
+ );
+
+ $approvalResolvedToolResults[] = $toolResult;
+
+ yield new ToolResultEvent(
+ id: EventID::generate(),
+ timestamp: time(),
+ toolResult: $toolResult,
+ messageId: $messageId,
+ success: false,
+ error: $reason,
+ );
+ }
+
+ if ($approvalResolvedToolResults === []) {
+ return;
+ }
+
+ if ($toolMessageIndex !== null) {
+ $request->setMessages(array_values(array_filter(
+ $messages,
+ fn (int $index): bool => $index !== $toolMessageIndex,
+ ARRAY_FILTER_USE_KEY,
+ )));
+ }
+
+ $request->addMessage(new ToolResultMessage(
+ array_merge($toolMessage->toolResults, $approvalResolvedToolResults),
+ $toolMessage->toolApprovalResponses,
+ ));
}
/**
@@ -126,12 +414,12 @@ protected function executeToolsWithConcurrency(array $tools, array $groupedToolC
* @param Tool[] $tools
* @return array{toolResult: ToolResult, events: array}
*/
- protected function executeToolCall(array $tools, ToolCall $toolCall, string $messageId): array
+ protected static function executeToolCall(array $tools, ToolCall $toolCall, string $messageId): array
{
$events = [];
try {
- $tool = $this->resolveTool($toolCall->name, $tools);
+ $tool = self::resolveTool($toolCall->name, $tools);
$output = call_user_func_array(
$tool->handle(...),
$toolCall->arguments()
@@ -198,10 +486,18 @@ protected function executeToolCall(array $tools, ToolCall $toolCall, string $mes
'events' => $events,
];
} catch (PrismException $e) {
+ try {
+ $args = $toolCall->arguments();
+ } catch (PrismException) {
+ // Malformed arguments are themselves the failure being reported —
+ // surface the raw string so the model can see what it sent.
+ $args = ['raw' => is_string($toolCall->arguments) ? $toolCall->arguments : ''];
+ }
+
$toolResult = new ToolResult(
toolCallId: $toolCall->id,
toolName: $toolCall->name,
- args: $toolCall->arguments(),
+ args: $args,
result: $e->getMessage(),
toolCallResultId: $toolCall->resultId,
);
@@ -224,8 +520,10 @@ protected function executeToolCall(array $tools, ToolCall $toolCall, string $mes
/**
* @param Tool[] $tools
+ *
+ * @throws PrismException
*/
- protected function resolveTool(string $name, array $tools): Tool
+ protected static function resolveTool(string $name, array $tools): Tool
{
try {
return collect($tools)
diff --git a/src/Concerns/ConfiguresModels.php b/src/Concerns/ConfiguresModels.php
index 924ee3d44..f76209b8b 100644
--- a/src/Concerns/ConfiguresModels.php
+++ b/src/Concerns/ConfiguresModels.php
@@ -12,6 +12,8 @@ trait ConfiguresModels
protected int|float|null $topP = null;
+ protected ?int $topK = null;
+
public function withMaxTokens(?int $maxTokens): self
{
$this->maxTokens = $maxTokens;
@@ -32,4 +34,11 @@ public function usingTopP(int|float $topP): self
return $this;
}
+
+ public function usingTopK(int $topK): self
+ {
+ $this->topK = $topK;
+
+ return $this;
+ }
}
diff --git a/src/Concerns/HasProviderOptions.php b/src/Concerns/HasProviderOptions.php
index 4b6cdce32..4eb8058e6 100644
--- a/src/Concerns/HasProviderOptions.php
+++ b/src/Concerns/HasProviderOptions.php
@@ -17,12 +17,12 @@ public function withProviderOptions(array $options = []): self
return $this;
}
- public function providerOptions(?string $valuePath = null): mixed
+ public function providerOptions(?string $valuePath = null, mixed $default = null): mixed
{
if ($valuePath === null) {
return $this->providerOptions;
}
- return data_get($this->providerOptions, $valuePath);
+ return data_get($this->providerOptions, $valuePath, $default);
}
}
diff --git a/src/Concerns/HasReasoning.php b/src/Concerns/HasReasoning.php
new file mode 100644
index 000000000..c22269973
--- /dev/null
+++ b/src/Concerns/HasReasoning.php
@@ -0,0 +1,37 @@
+reasoningEnabled = $enabled;
+
+ return $this;
+ }
+
+ public function reasoningEnabled(): ?bool
+ {
+ return $this->reasoningEnabled;
+ }
+}
diff --git a/src/Contracts/ProviderMediaMapper.php b/src/Contracts/ProviderMediaMapper.php
index 0b318f3ef..0f2c60ef4 100644
--- a/src/Contracts/ProviderMediaMapper.php
+++ b/src/Contracts/ProviderMediaMapper.php
@@ -26,7 +26,7 @@ protected function runValidation(): void
$calledClass = static::class;
- throw new PrismException("The $providerName provider does not support the mediums available in the provided `$calledClass`. Pleae consult the Prism documentation for more information on which mediums the $providerName provider supports.");
+ throw new PrismException("The $providerName provider does not support the mediums available in the provided `$calledClass`. Please consult the Prism documentation for more information on which mediums the $providerName provider supports.");
}
}
}
diff --git a/src/Enums/FinishReason.php b/src/Enums/FinishReason.php
index f6fffa579..04817c5de 100644
--- a/src/Enums/FinishReason.php
+++ b/src/Enums/FinishReason.php
@@ -10,6 +10,8 @@ enum FinishReason: string
case Length = 'length';
case ContentFilter = 'content-filter';
case ToolCalls = 'tool-calls';
+ case Pause = 'pause';
+ case Refusal = 'refusal';
case Error = 'error';
case Other = 'other';
case Unknown = 'unknown';
diff --git a/src/Enums/Provider.php b/src/Enums/Provider.php
index 0b0fecb71..98707b819 100644
--- a/src/Enums/Provider.php
+++ b/src/Enums/Provider.php
@@ -11,12 +11,17 @@ enum Provider: string
case Ollama = 'ollama';
case OpenAI = 'openai';
case OpenRouter = 'openrouter';
+ case Requesty = 'requesty';
case Mistral = 'mistral';
case Groq = 'groq';
case XAI = 'xai';
case Gemini = 'gemini';
case VoyageAI = 'voyageai';
case ElevenLabs = 'elevenlabs';
+ case Replicate = 'replicate';
+ case Qwen = 'qwen';
+ case Azure = 'azure';
case Perplexity = 'perplexity';
+ case Vertex = 'vertex';
case Z = 'z';
}
diff --git a/src/Enums/StreamEventType.php b/src/Enums/StreamEventType.php
index 187d80dbe..afbf75788 100644
--- a/src/Enums/StreamEventType.php
+++ b/src/Enums/StreamEventType.php
@@ -17,6 +17,7 @@ enum StreamEventType: string
case ToolCallDelta = 'tool_call_delta';
case ProviderToolEvent = 'provider_tool_event';
case ToolResult = 'tool_result';
+ case ToolApprovalRequest = 'tool_approval_request';
case Citation = 'citation';
case Artifact = 'artifact';
case Error = 'error';
diff --git a/src/Events/Broadcasting/ToolApprovalRequestBroadcast.php b/src/Events/Broadcasting/ToolApprovalRequestBroadcast.php
new file mode 100644
index 000000000..91f25ada4
--- /dev/null
+++ b/src/Events/Broadcasting/ToolApprovalRequestBroadcast.php
@@ -0,0 +1,7 @@
+value;
+
+ parent::__construct(
+ sprintf(
+ '%s request payload size exceeded the maximum of %s bytes.',
+ $provider,
+ number_format($maxPayloadBytes)
+ )
+ );
+ }
+
+ public static function make(string|Provider $provider, int $maxPayloadBytes): self
+ {
+ return new self($provider, $maxPayloadBytes);
+ }
+}
diff --git a/src/Exceptions/PrismBatchRequestLimitExceededException.php b/src/Exceptions/PrismBatchRequestLimitExceededException.php
new file mode 100644
index 000000000..6422608fd
--- /dev/null
+++ b/src/Exceptions/PrismBatchRequestLimitExceededException.php
@@ -0,0 +1,29 @@
+value;
+
+ parent::__construct(
+ sprintf(
+ '%s batch limit exceeded: %d requests submitted, maximum is %s.',
+ $provider,
+ $requestCount,
+ number_format($maxRequests)
+ )
+ );
+ }
+
+ public static function make(string|Provider $provider, int $requestCount, int $maxRequests): self
+ {
+ return new self($provider, $requestCount, $maxRequests);
+ }
+}
diff --git a/src/Exceptions/PrismException.php b/src/Exceptions/PrismException.php
index 9c9f852d8..b543ae53f 100644
--- a/src/Exceptions/PrismException.php
+++ b/src/Exceptions/PrismException.php
@@ -10,6 +10,10 @@
class PrismException extends Exception
{
+ public ?int $httpStatus = null;
+
+ public ?string $responseBody = null;
+
public static function promptOrMessages(): self
{
return new self('You can only use `prompt` or `messages`');
@@ -39,6 +43,14 @@ public static function toolCallFailed(ToolCall $toolCall, Throwable $previous):
);
}
+ public static function malformedToolCallArguments(string $toolName, Throwable $previous): self
+ {
+ return new self(
+ sprintf('Tool call arguments for tool %s are not valid JSON', $toolName),
+ previous: $previous
+ );
+ }
+
public static function invalidParameterInTool(string $toolName, Throwable $previous): self
{
return new self(
@@ -55,9 +67,16 @@ public static function invalidReturnTypeInTool(string $toolName, Throwable $prev
);
}
- public static function providerResponseError(string $message): self
- {
- return new self($message);
+ public static function providerResponseError(
+ string $message,
+ ?int $httpStatus = null,
+ ?string $responseBody = null,
+ ): self {
+ $e = new self($message);
+ $e->httpStatus = $httpStatus;
+ $e->responseBody = $responseBody;
+
+ return $e;
}
public static function providerRequestError(string $model, Throwable $previous): self
diff --git a/src/Facades/Prism.php b/src/Facades/Prism.php
index 55b84ba8e..ef606be1a 100644
--- a/src/Facades/Prism.php
+++ b/src/Facades/Prism.php
@@ -30,6 +30,8 @@
* @method static PendingImageRequest image()
* @method static PendingAudioRequest audio()
* @method static PendingModerationRequest moderation()
+ * @method static \Prism\Prism\Files\PendingRequest files()
+ * @method static \Prism\Prism\Batch\PendingRequest batch()
*
* @see \Prism\Prism\Prism
*/
diff --git a/src/Files/DeleteFileRequest.php b/src/Files/DeleteFileRequest.php
new file mode 100644
index 000000000..1667c56ce
--- /dev/null
+++ b/src/Files/DeleteFileRequest.php
@@ -0,0 +1,35 @@
+|int, 1?: Closure|int, 2?: ?callable, 3?: bool}
+ */
+ public function clientRetry(): array
+ {
+ return $this->clientRetry;
+ }
+
+ /**
+ * @return array
+ */
+ public function clientOptions(): array
+ {
+ return $this->clientOptions;
+ }
+}
diff --git a/src/Files/DeleteFileResult.php b/src/Files/DeleteFileResult.php
new file mode 100644
index 000000000..12394058d
--- /dev/null
+++ b/src/Files/DeleteFileResult.php
@@ -0,0 +1,13 @@
+|int, 1?: Closure|int, 2?: ?callable, 3?: bool}
+ */
+ public function clientRetry(): array
+ {
+ return $this->clientRetry;
+ }
+
+ /**
+ * @return array
+ */
+ public function clientOptions(): array
+ {
+ return $this->clientOptions;
+ }
+}
diff --git a/src/Files/FileData.php b/src/Files/FileData.php
new file mode 100644
index 000000000..768796e2e
--- /dev/null
+++ b/src/Files/FileData.php
@@ -0,0 +1,21 @@
+|null $raw
+ */
+ public function __construct(
+ public string $id,
+ public ?string $filename = null,
+ public ?string $mimeType = null,
+ public ?int $sizeBytes = null,
+ public ?string $createdAt = null,
+ public ?string $purpose = null,
+ public ?array $raw = null,
+ ) {}
+}
diff --git a/src/Files/FileListResult.php b/src/Files/FileListResult.php
new file mode 100644
index 000000000..009d94dfb
--- /dev/null
+++ b/src/Files/FileListResult.php
@@ -0,0 +1,18 @@
+|int, 1?: Closure|int, 2?: ?callable, 3?: bool}
+ */
+ public function clientRetry(): array
+ {
+ return $this->clientRetry;
+ }
+
+ /**
+ * @return array
+ */
+ public function clientOptions(): array
+ {
+ return $this->clientOptions;
+ }
+}
diff --git a/src/Files/ListFilesRequest.php b/src/Files/ListFilesRequest.php
new file mode 100644
index 000000000..3e70a2dff
--- /dev/null
+++ b/src/Files/ListFilesRequest.php
@@ -0,0 +1,37 @@
+|int, 1?: Closure|int, 2?: ?callable, 3?: bool}
+ */
+ public function clientRetry(): array
+ {
+ return $this->clientRetry;
+ }
+
+ /**
+ * @return array
+ */
+ public function clientOptions(): array
+ {
+ return $this->clientOptions;
+ }
+}
diff --git a/src/Files/PendingRequest.php b/src/Files/PendingRequest.php
new file mode 100644
index 000000000..6bfa2b1b9
--- /dev/null
+++ b/src/Files/PendingRequest.php
@@ -0,0 +1,120 @@
+toUploadRequest($content, $filename, $mimeType);
+
+ try {
+ return $this->provider->uploadFile($request);
+ } catch (RequestException $e) {
+ $this->provider->handleRequestException('', $e);
+ }
+ }
+
+ public function list(?int $limit = null, ?string $afterId = null, ?string $beforeId = null): FileListResult
+ {
+ $request = $this->toListRequest($limit, $afterId, $beforeId);
+
+ try {
+ return $this->provider->listFiles($request);
+ } catch (RequestException $e) {
+ $this->provider->handleRequestException('', $e);
+ }
+ }
+
+ public function getMetadata(string $fileId): FileData
+ {
+ $request = $this->toGetMetadataRequest($fileId);
+
+ try {
+ return $this->provider->getFileMetadata($request);
+ } catch (RequestException $e) {
+ $this->provider->handleRequestException('', $e);
+ }
+ }
+
+ public function delete(string $fileId): DeleteFileResult
+ {
+ $request = $this->toDeleteRequest($fileId);
+
+ try {
+ return $this->provider->deleteFile($request);
+ } catch (RequestException $e) {
+ $this->provider->handleRequestException('', $e);
+ }
+ }
+
+ public function download(string $fileId): string
+ {
+ $request = $this->toDownloadRequest($fileId);
+
+ try {
+ return $this->provider->downloadFile($request);
+ } catch (RequestException $e) {
+ $this->provider->handleRequestException('', $e);
+ }
+ }
+
+ public function toUploadRequest(string $content, string $filename, ?string $mimeType = null): UploadFileRequest
+ {
+ return (new UploadFileRequest(
+ filename: $filename,
+ content: $content,
+ mimeType: $mimeType,
+ ))
+ ->withClientOptions($this->clientOptions)
+ ->withClientRetry(...$this->clientRetry)
+ ->withProviderOptions($this->providerOptions);
+ }
+
+ public function toListRequest(?int $limit = null, ?string $afterId = null, ?string $beforeId = null): ListFilesRequest
+ {
+ return (new ListFilesRequest(
+ limit: $limit,
+ afterId: $afterId,
+ beforeId: $beforeId,
+ ))
+ ->withClientOptions($this->clientOptions)
+ ->withClientRetry(...$this->clientRetry)
+ ->withProviderOptions($this->providerOptions);
+ }
+
+ public function toGetMetadataRequest(string $fileId): GetFileMetadataRequest
+ {
+ return (new GetFileMetadataRequest(fileId: $fileId))
+ ->withClientOptions($this->clientOptions)
+ ->withClientRetry(...$this->clientRetry)
+ ->withProviderOptions($this->providerOptions);
+ }
+
+ public function toDeleteRequest(string $fileId): DeleteFileRequest
+ {
+ return (new DeleteFileRequest(fileId: $fileId))
+ ->withClientOptions($this->clientOptions)
+ ->withClientRetry(...$this->clientRetry)
+ ->withProviderOptions($this->providerOptions);
+ }
+
+ public function toDownloadRequest(string $fileId): DownloadFileRequest
+ {
+ return (new DownloadFileRequest(fileId: $fileId))
+ ->withClientOptions($this->clientOptions)
+ ->withClientRetry(...$this->clientRetry)
+ ->withProviderOptions($this->providerOptions);
+ }
+}
diff --git a/src/Files/UploadFileRequest.php b/src/Files/UploadFileRequest.php
new file mode 100644
index 000000000..5d718c59d
--- /dev/null
+++ b/src/Files/UploadFileRequest.php
@@ -0,0 +1,37 @@
+|int, 1?: Closure|int, 2?: ?callable, 3?: bool}
+ */
+ public function clientRetry(): array
+ {
+ return $this->clientRetry;
+ }
+
+ /**
+ * @return array
+ */
+ public function clientOptions(): array
+ {
+ return $this->clientOptions;
+ }
+}
diff --git a/src/Fim/PendingRequest.php b/src/Fim/PendingRequest.php
new file mode 100644
index 000000000..bc30efedc
--- /dev/null
+++ b/src/Fim/PendingRequest.php
@@ -0,0 +1,113 @@
+ */
+ protected array $stop = [];
+
+ public function withPrompt(string $prompt): self
+ {
+ $this->prompt = $prompt;
+
+ return $this;
+ }
+
+ public function withSuffix(?string $suffix): self
+ {
+ $this->suffix = $suffix;
+
+ return $this;
+ }
+
+ public function withMaxTokens(int $maxTokens): self
+ {
+ $this->maxTokens = $maxTokens;
+
+ return $this;
+ }
+
+ public function withTemperature(int|float $temperature): self
+ {
+ $this->temperature = $temperature;
+
+ return $this;
+ }
+
+ public function withTopP(int|float $topP): self
+ {
+ $this->topP = $topP;
+
+ return $this;
+ }
+
+ /**
+ * @param string|array $stop
+ */
+ public function withStop(string|array $stop): self
+ {
+ $this->stop = is_string($stop) ? [$stop] : $stop;
+
+ return $this;
+ }
+
+ public function asText(): Response
+ {
+ $request = $this->toRequest();
+
+ try {
+ return $this->provider->fim($request);
+ } catch (RequestException $e) {
+ $this->provider->handleRequestException($request->model(), $e);
+ }
+ }
+
+ /**
+ * @deprecated Use `asText` instead.
+ */
+ public function generate(): Response
+ {
+ return $this->asText();
+ }
+
+ public function toRequest(): Request
+ {
+ return new Request(
+ model: $this->model,
+ providerKey: $this->providerKey(),
+ prompt: $this->prompt,
+ suffix: $this->suffix,
+ maxTokens: $this->maxTokens,
+ temperature: $this->temperature,
+ topP: $this->topP,
+ stop: $this->stop,
+ clientOptions: $this->clientOptions,
+ clientRetry: $this->clientRetry,
+ providerOptions: $this->providerOptions,
+ );
+ }
+}
diff --git a/src/Fim/Request.php b/src/Fim/Request.php
new file mode 100644
index 000000000..4e45f7942
--- /dev/null
+++ b/src/Fim/Request.php
@@ -0,0 +1,95 @@
+ $stop
+ * @param array $clientOptions
+ * @param array $clientRetry
+ * @param array $providerOptions
+ */
+ public function __construct(
+ protected string $model,
+ protected string $providerKey,
+ protected string $prompt,
+ protected ?string $suffix,
+ protected ?int $maxTokens,
+ protected int|float|null $temperature,
+ protected int|float|null $topP,
+ protected array $stop,
+ protected array $clientOptions,
+ protected array $clientRetry,
+ array $providerOptions = [],
+ ) {
+ $this->providerOptions = $providerOptions;
+ }
+
+ public function model(): string
+ {
+ return $this->model;
+ }
+
+ public function provider(): string
+ {
+ return $this->providerKey;
+ }
+
+ public function prompt(): string
+ {
+ return $this->prompt;
+ }
+
+ public function suffix(): ?string
+ {
+ return $this->suffix;
+ }
+
+ public function maxTokens(): ?int
+ {
+ return $this->maxTokens;
+ }
+
+ public function temperature(): int|float|null
+ {
+ return $this->temperature;
+ }
+
+ public function topP(): int|float|null
+ {
+ return $this->topP;
+ }
+
+ /**
+ * @return array
+ */
+ public function stop(): array
+ {
+ return $this->stop;
+ }
+
+ /**
+ * @return array
+ */
+ public function clientOptions(): array
+ {
+ return $this->clientOptions;
+ }
+
+ /**
+ * @return array
+ */
+ public function clientRetry(): array
+ {
+ return $this->clientRetry;
+ }
+}
diff --git a/src/Fim/Response.php b/src/Fim/Response.php
new file mode 100644
index 000000000..82cd528d2
--- /dev/null
+++ b/src/Fim/Response.php
@@ -0,0 +1,37 @@
+
+ */
+readonly class Response implements Arrayable
+{
+ public function __construct(
+ public string $text,
+ public FinishReason $finishReason,
+ public Usage $usage,
+ public Meta $meta,
+ ) {}
+
+ /**
+ * @return array
+ */
+ #[\Override]
+ public function toArray(): array
+ {
+ return [
+ 'text' => $this->text,
+ 'finish_reason' => $this->finishReason->value,
+ 'usage' => $this->usage->toArray(),
+ 'meta' => $this->meta->toArray(),
+ ];
+ }
+}
diff --git a/src/Prism.php b/src/Prism.php
index 7ed3a4953..45f7b1007 100644
--- a/src/Prism.php
+++ b/src/Prism.php
@@ -6,7 +6,10 @@
use Illuminate\Support\Traits\Macroable;
use Prism\Prism\Audio\PendingRequest as PendingAudioRequest;
+use Prism\Prism\Batch\PendingRequest as PendingBatchRequest;
use Prism\Prism\Embeddings\PendingRequest as PendingEmbeddingRequest;
+use Prism\Prism\Files\PendingRequest as PendingFilesRequest;
+use Prism\Prism\Fim\PendingRequest as PendingFimRequest;
use Prism\Prism\Images\PendingRequest as PendingImageRequest;
use Prism\Prism\Moderation\PendingRequest as PendingModerationRequest;
use Prism\Prism\Structured\PendingRequest as PendingStructuredRequest;
@@ -45,4 +48,19 @@ public function moderation(): PendingModerationRequest
{
return new PendingModerationRequest;
}
+
+ public function files(): PendingFilesRequest
+ {
+ return new PendingFilesRequest;
+ }
+
+ public function batch(): PendingBatchRequest
+ {
+ return new PendingBatchRequest;
+ }
+
+ public function fim(): PendingFimRequest
+ {
+ return new PendingFimRequest;
+ }
}
diff --git a/src/PrismManager.php b/src/PrismManager.php
index 31c0f4f1d..b41c8ec62 100644
--- a/src/PrismManager.php
+++ b/src/PrismManager.php
@@ -9,6 +9,7 @@
use InvalidArgumentException;
use Prism\Prism\Enums\Provider as ProviderEnum;
use Prism\Prism\Providers\Anthropic\Anthropic;
+use Prism\Prism\Providers\Azure\Azure;
use Prism\Prism\Providers\DeepSeek\DeepSeek;
use Prism\Prism\Providers\ElevenLabs\ElevenLabs;
use Prism\Prism\Providers\Gemini\Gemini;
@@ -19,6 +20,10 @@
use Prism\Prism\Providers\OpenRouter\OpenRouter;
use Prism\Prism\Providers\Perplexity\Perplexity;
use Prism\Prism\Providers\Provider;
+use Prism\Prism\Providers\Qwen\Qwen;
+use Prism\Prism\Providers\Replicate\Replicate;
+use Prism\Prism\Providers\Requesty\Requesty;
+use Prism\Prism\Providers\Vertex\Vertex;
use Prism\Prism\Providers\VoyageAI\VoyageAI;
use Prism\Prism\Providers\XAI\XAI;
use Prism\Prism\Providers\Z\Z;
@@ -82,6 +87,7 @@ protected function createOpenaiProvider(array $config): OpenAI
url: $config['url'],
organization: $config['organization'] ?? null,
project: $config['project'] ?? null,
+ apiFormat: $config['api_format'] ?? 'responses',
);
}
@@ -215,6 +221,35 @@ protected function createOpenrouterProvider(array $config): OpenRouter
);
}
+ /**
+ * @param array $config
+ */
+ protected function createRequestyProvider(array $config): Requesty
+ {
+ $siteConfig = $config['site'] ?? null;
+ $site = is_array($siteConfig) ? $siteConfig : [];
+
+ return new Requesty(
+ apiKey: $config['api_key'] ?? '',
+ url: $config['url'] ?? 'https://router.requesty.ai/v1',
+ httpReferer: $site['http_referer'] ?? null,
+ xTitle: $site['x_title'] ?? null,
+ );
+ }
+
+ /**
+ * @param array $config
+ */
+ protected function createAzureProvider(array $config): Azure
+ {
+ return new Azure(
+ url: $config['url'] ?? '',
+ apiKey: $config['api_key'] ?? '',
+ apiVersion: $config['api_version'] ?? '2024-10-21',
+ deploymentName: $config['deployment_name'] ?? null,
+ );
+ }
+
/**
* @param array $config
*/
@@ -226,6 +261,45 @@ protected function createElevenlabsProvider(array $config): ElevenLabs
);
}
+ /**
+ * @param array $config
+ */
+ protected function createReplicateProvider(array $config): Replicate
+ {
+ return new Replicate(
+ apiKey: $config['api_key'] ?? '',
+ url: $config['url'] ?? 'https://api.replicate.com/v1',
+ webhookUrl: $config['webhook_url'] ?? null,
+ useSyncMode: $config['use_sync_mode'] ?? true,
+ pollingInterval: $config['polling_interval'] ?? 1000,
+ maxWaitTime: $config['max_wait_time'] ?? 60,
+ );
+ }
+
+ /**
+ * @param array $config
+ */
+ protected function createQwenProvider(array $config): Qwen
+ {
+ return new Qwen(
+ apiKey: $config['api_key'] ?? '',
+ url: $config['url'] ?? 'https://dashscope-intl.aliyuncs.com/api/v1',
+ );
+ }
+
+ /**
+ * @param array $config
+ */
+ protected function createVertexProvider(array $config): Vertex
+ {
+ return new Vertex(
+ projectId: $config['project_id'] ?? '',
+ region: $config['region'] ?? 'us-central1',
+ accessToken: $config['access_token'] ?? null,
+ credentialsPath: $config['credentials_path'] ?? null,
+ );
+ }
+
/**
* @param array $config
*/
diff --git a/src/Providers/Anthropic/Anthropic.php b/src/Providers/Anthropic/Anthropic.php
index c709757ac..2a5ad446a 100644
--- a/src/Providers/Anthropic/Anthropic.php
+++ b/src/Providers/Anthropic/Anthropic.php
@@ -7,13 +7,39 @@
use Generator;
use Illuminate\Http\Client\PendingRequest;
use Illuminate\Http\Client\RequestException;
+use Prism\Prism\Batch\BatchJob;
+use Prism\Prism\Batch\BatchListResult;
+use Prism\Prism\Batch\BatchRequest;
+use Prism\Prism\Batch\BatchResultItem;
+use Prism\Prism\Batch\CancelBatchRequest;
+use Prism\Prism\Batch\GetBatchResultsRequest;
+use Prism\Prism\Batch\ListBatchesRequest;
+use Prism\Prism\Batch\RetrieveBatchRequest;
use Prism\Prism\Concerns\InitializesClient;
use Prism\Prism\Enums\Provider as ProviderName;
use Prism\Prism\Exceptions\PrismException;
use Prism\Prism\Exceptions\PrismProviderOverloadedException;
use Prism\Prism\Exceptions\PrismRateLimitedException;
use Prism\Prism\Exceptions\PrismRequestTooLargeException;
+use Prism\Prism\Files\DeleteFileRequest;
+use Prism\Prism\Files\DeleteFileResult;
+use Prism\Prism\Files\DownloadFileRequest;
+use Prism\Prism\Files\FileData;
+use Prism\Prism\Files\FileListResult;
+use Prism\Prism\Files\GetFileMetadataRequest;
+use Prism\Prism\Files\ListFilesRequest;
+use Prism\Prism\Files\UploadFileRequest;
use Prism\Prism\Providers\Anthropic\Concerns\ProcessesRateLimits;
+use Prism\Prism\Providers\Anthropic\Handlers\Batch\Cancel;
+use Prism\Prism\Providers\Anthropic\Handlers\Batch\Create;
+use Prism\Prism\Providers\Anthropic\Handlers\Batch\ListBatches;
+use Prism\Prism\Providers\Anthropic\Handlers\Batch\Results;
+use Prism\Prism\Providers\Anthropic\Handlers\Batch\Retrieve;
+use Prism\Prism\Providers\Anthropic\Handlers\Files\Delete as FileDelete;
+use Prism\Prism\Providers\Anthropic\Handlers\Files\Download as FileDownload;
+use Prism\Prism\Providers\Anthropic\Handlers\Files\GetMetadata as FileGetMetadata;
+use Prism\Prism\Providers\Anthropic\Handlers\Files\ListFiles as FileListFiles;
+use Prism\Prism\Providers\Anthropic\Handlers\Files\Upload as FileUpload;
use Prism\Prism\Providers\Anthropic\Handlers\Stream;
use Prism\Prism\Providers\Anthropic\Handlers\Structured;
use Prism\Prism\Providers\Anthropic\Handlers\Text;
@@ -41,7 +67,8 @@ public function text(TextRequest $request): TextResponse
$handler = new Text(
$this->client(
$request->clientOptions(),
- $request->clientRetry()
+ $request->clientRetry(),
+ betaFeatures: $this->requestBetaFeatures($request)
),
$request
);
@@ -55,7 +82,8 @@ public function structured(StructuredRequest $request): StructuredResponse
$handler = new Structured(
$this->client(
$request->clientOptions(),
- $request->clientRetry()
+ $request->clientRetry(),
+ betaFeatures: $this->requestBetaFeatures($request)
),
$request
);
@@ -71,12 +99,76 @@ public function stream(TextRequest $request): Generator
{
$handler = new Stream($this->client(
$request->clientOptions(),
- $request->clientRetry()
+ $request->clientRetry(),
+ betaFeatures: $this->requestBetaFeatures($request)
));
return $handler->handle($request);
}
+ #[\Override]
+ public function batch(BatchRequest $request): BatchJob
+ {
+ return (new Create($this->client($request->clientOptions(), $request->clientRetry())))->handle($request);
+ }
+
+ #[\Override]
+ public function retrieveBatch(RetrieveBatchRequest $request): BatchJob
+ {
+ return (new Retrieve($this->client($request->clientOptions(), $request->clientRetry())))->handle($request->batchId);
+ }
+
+ #[\Override]
+ public function listBatches(ListBatchesRequest $request): BatchListResult
+ {
+ return (new ListBatches($this->client($request->clientOptions(), $request->clientRetry())))->handle($request);
+ }
+
+ /**
+ * @return BatchResultItem[]
+ */
+ #[\Override]
+ public function getBatchResults(GetBatchResultsRequest $request): array
+ {
+ return (new Results($this->client($request->clientOptions(), $request->clientRetry())))->handle($request->batchId);
+ }
+
+ #[\Override]
+ public function cancelBatch(CancelBatchRequest $request): BatchJob
+ {
+ return (new Cancel($this->client($request->clientOptions(), $request->clientRetry())))->handle($request->batchId);
+ }
+
+ #[\Override]
+ public function uploadFile(UploadFileRequest $request): FileData
+ {
+ return (new FileUpload($this->client($request->clientOptions(), $request->clientRetry())))->handle($request);
+ }
+
+ #[\Override]
+ public function listFiles(ListFilesRequest $request): FileListResult
+ {
+ return (new FileListFiles($this->client($request->clientOptions(), $request->clientRetry())))->handle($request);
+ }
+
+ #[\Override]
+ public function getFileMetadata(GetFileMetadataRequest $request): FileData
+ {
+ return (new FileGetMetadata($this->client($request->clientOptions(), $request->clientRetry())))->handle($request);
+ }
+
+ #[\Override]
+ public function deleteFile(DeleteFileRequest $request): DeleteFileResult
+ {
+ return (new FileDelete($this->client($request->clientOptions(), $request->clientRetry())))->handle($request);
+ }
+
+ #[\Override]
+ public function downloadFile(DownloadFileRequest $request): string
+ {
+ return (new FileDownload($this->client($request->clientOptions(), $request->clientRetry())))->handle($request);
+ }
+
public function handleRequestException(string $model, RequestException $e): never
{
match ($e->response->getStatusCode()) {
@@ -109,16 +201,35 @@ protected function handleResponseErrors(RequestException $e): never
* @param array $options
* @param array $retry
*/
- protected function client(array $options = [], array $retry = [], ?string $baseUrl = null): PendingRequest
+ protected function client(array $options = [], array $retry = [], ?string $baseUrl = null, ?string $betaFeatures = null): PendingRequest
{
return $this->baseClient()
->withHeaders(array_filter([
'x-api-key' => $this->apiKey,
'anthropic-version' => $this->apiVersion,
- 'anthropic-beta' => $this->betaFeatures,
+ 'anthropic-beta' => $betaFeatures ?? $this->betaFeatures,
]))
->withOptions($options)
->when($retry !== [], fn ($client) => $client->retry(...$retry))
->baseUrl($baseUrl ?? $this->url);
}
+
+ /**
+ * Merge config-level beta features with per-request ones supplied via
+ * withProviderOptions(['anthropic_beta' => 'skills-2025-10-02']) — a
+ * comma-separated string or an array of feature flags.
+ */
+ protected function requestBetaFeatures(TextRequest|StructuredRequest $request): ?string
+ {
+ $requested = $request->providerOptions('anthropic_beta');
+
+ $merged = collect([$this->betaFeatures])
+ ->merge(is_array($requested) ? $requested : [$requested])
+ ->flatMap(fn (mixed $value): array => is_string($value) ? array_map(trim(...), explode(',', $value)) : [])
+ ->filter()
+ ->unique()
+ ->implode(',');
+
+ return $merged === '' ? null : $merged;
+ }
}
diff --git a/src/Providers/Anthropic/Concerns/ExtractsThinking.php b/src/Providers/Anthropic/Concerns/ExtractsThinking.php
index 5f23fc393..53ef7f935 100644
--- a/src/Providers/Anthropic/Concerns/ExtractsThinking.php
+++ b/src/Providers/Anthropic/Concerns/ExtractsThinking.php
@@ -14,7 +14,8 @@ trait ExtractsThinking
*/
protected function extractThinking(array $data): array
{
- if ($this->request->providerOptions('thinking.enabled') !== true) {
+ if ($this->request->providerOptions('thinking.enabled') !== true
+ && $this->request->providerOptions('thinking.type') !== 'adaptive') {
return [];
}
diff --git a/src/Providers/Anthropic/Concerns/HandlesBatchResponse.php b/src/Providers/Anthropic/Concerns/HandlesBatchResponse.php
new file mode 100644
index 000000000..ae13274dd
--- /dev/null
+++ b/src/Providers/Anthropic/Concerns/HandlesBatchResponse.php
@@ -0,0 +1,69 @@
+|null $data
+ */
+ protected function handleResponseErrors(?array $data): void
+ {
+ if (data_get($data, 'type') === 'error') {
+ throw PrismException::providerResponseError(vsprintf(
+ 'Anthropic Error: [%s] %s',
+ [
+ data_get($data, 'error.type', 'unknown'),
+ data_get($data, 'error.message'),
+ ]
+ ));
+ }
+ }
+
+ /**
+ * @param array $data
+ */
+ protected static function mapBatchJob(array $data): BatchJob
+ {
+ $processingCounts = (int) data_get($data, 'request_counts.processing', 0);
+ $succeededCounts = (int) data_get($data, 'request_counts.succeeded', 0);
+ $failedCounts = (int) data_get($data, 'request_counts.errored', 0);
+ $canceledCounts = (int) data_get($data, 'request_counts.canceled', 0);
+ $expiredCounts = (int) data_get($data, 'request_counts.expired', 0);
+ $totalCounts = $processingCounts + $succeededCounts + $failedCounts + $canceledCounts + $expiredCounts;
+
+ return new BatchJob(
+ id: data_get($data, 'id'),
+ status: self::mapStatus(data_get($data, 'processing_status', '')),
+ requestCounts: new BatchJobRequestCounts(
+ processing: $processingCounts,
+ succeeded: $succeededCounts,
+ failed: $failedCounts,
+ canceled: $canceledCounts,
+ expired: $expiredCounts,
+ total: $totalCounts,
+ ),
+ createdAt: data_get($data, 'created_at'),
+ expiresAt: data_get($data, 'expires_at'),
+ endedAt: data_get($data, 'ended_at'),
+ resultsUrl: data_get($data, 'results_url'),
+ );
+ }
+
+ protected static function mapStatus(string $status): BatchStatus
+ {
+ return match ($status) {
+ 'in_progress' => BatchStatus::InProgress,
+ 'canceling' => BatchStatus::Cancelling,
+ 'ended' => BatchStatus::Completed,
+ default => throw new PrismException("Unknown Anthropic batch status: {$status}"),
+ };
+ }
+}
diff --git a/src/Providers/Anthropic/Concerns/HandlesFileResponse.php b/src/Providers/Anthropic/Concerns/HandlesFileResponse.php
new file mode 100644
index 000000000..f423841c5
--- /dev/null
+++ b/src/Providers/Anthropic/Concerns/HandlesFileResponse.php
@@ -0,0 +1,43 @@
+|null $data
+ */
+ protected function handleResponseErrors(?array $data): void
+ {
+ if (data_get($data, 'type') === 'error') {
+ throw PrismException::providerResponseError(vsprintf(
+ 'Anthropic Error: [%s] %s',
+ [
+ data_get($data, 'error.type', 'unknown'),
+ data_get($data, 'error.message'),
+ ]
+ ));
+ }
+ }
+
+ /**
+ * @param array $data
+ */
+ protected static function mapFileData(array $data): FileData
+ {
+ return new FileData(
+ id: data_get($data, 'id', ''),
+ filename: data_get($data, 'filename'),
+ mimeType: data_get($data, 'mime_type'),
+ sizeBytes: data_get($data, 'size_bytes'),
+ createdAt: data_get($data, 'created_at'),
+ purpose: null,
+ raw: $data,
+ );
+ }
+}
diff --git a/src/Providers/Anthropic/Concerns/MapsBatchResults.php b/src/Providers/Anthropic/Concerns/MapsBatchResults.php
new file mode 100644
index 000000000..59776979b
--- /dev/null
+++ b/src/Providers/Anthropic/Concerns/MapsBatchResults.php
@@ -0,0 +1,86 @@
+ $data
+ */
+ protected static function mapResultItem(array $data): BatchResultItem
+ {
+ $customId = data_get($data, 'custom_id', '');
+ $resultType = data_get($data, 'result.type', '');
+
+ return match ($resultType) {
+ 'succeeded' => new BatchResultItem(
+ customId: $customId,
+ status: BatchResultStatus::Succeeded,
+ text: self::extractText(data_get($data, 'result.message', [])),
+ usage: self::extractUsage(data_get($data, 'result.message.usage', [])),
+ messageId: data_get($data, 'result.message.id'),
+ model: data_get($data, 'result.message.model'),
+ ),
+ 'errored' => new BatchResultItem(
+ customId: $customId,
+ status: BatchResultStatus::Errored,
+ errorType: data_get($data, 'result.error.type'),
+ errorMessage: data_get($data, 'result.error.message'),
+ ),
+ 'canceled' => new BatchResultItem(
+ customId: $customId,
+ status: BatchResultStatus::Canceled,
+ ),
+ 'expired' => new BatchResultItem(
+ customId: $customId,
+ status: BatchResultStatus::Expired,
+ ),
+ default => new BatchResultItem(
+ customId: $customId,
+ status: BatchResultStatus::Errored,
+ errorType: 'unknown',
+ errorMessage: "Unknown result type: {$resultType}",
+ ),
+ };
+ }
+
+ /**
+ * @param array $message
+ */
+ protected static function extractText(array $message): string
+ {
+ $content = data_get($message, 'content', []);
+
+ $texts = [];
+ foreach ($content as $block) {
+ if (data_get($block, 'type') === 'text') {
+ $texts[] = data_get($block, 'text', '');
+ }
+ }
+
+ return implode('', $texts);
+ }
+
+ /**
+ * @param array $usageData
+ */
+ protected static function extractUsage(array $usageData): Usage
+ {
+ return new Usage(
+ promptTokens: (int) data_get($usageData, 'input_tokens', 0),
+ completionTokens: (int) data_get($usageData, 'output_tokens', 0),
+ cacheWriteInputTokens: data_get($usageData, 'cache_creation_input_tokens') !== null
+ ? (int) data_get($usageData, 'cache_creation_input_tokens')
+ : null,
+ cacheReadInputTokens: data_get($usageData, 'cache_read_input_tokens') !== null
+ ? (int) data_get($usageData, 'cache_read_input_tokens')
+ : null,
+ );
+ }
+}
diff --git a/src/Providers/Anthropic/Handlers/Batch/Cancel.php b/src/Providers/Anthropic/Handlers/Batch/Cancel.php
new file mode 100644
index 000000000..3af306a56
--- /dev/null
+++ b/src/Providers/Anthropic/Handlers/Batch/Cancel.php
@@ -0,0 +1,30 @@
+client->post("messages/batches/{$batchId}/cancel");
+ $data = $response->json();
+ $this->handleResponseErrors($data);
+
+ return self::mapBatchJob($data);
+ }
+}
diff --git a/src/Providers/Anthropic/Handlers/Batch/Create.php b/src/Providers/Anthropic/Handlers/Batch/Create.php
new file mode 100644
index 000000000..8c8b77fb0
--- /dev/null
+++ b/src/Providers/Anthropic/Handlers/Batch/Create.php
@@ -0,0 +1,87 @@
+items === null) {
+ throw new PrismException('Anthropic batch requires "items" to be provided.');
+ }
+
+ if (count($batchRequest->items) > self::MAX_REQUESTS) {
+ throw PrismBatchRequestLimitExceededException::make('Anthropic', count($batchRequest->items), self::MAX_REQUESTS);
+ }
+
+ $requests = array_map(static fn (BatchRequestItem $item): array => [
+ 'custom_id' => $item->customId,
+ 'params' => Text::buildHttpRequestPayload($item->request),
+ ], $batchRequest->items);
+
+ $payload = ['requests' => $requests];
+
+ $maxPayloadBytes = $this->maxPayloadBytes();
+ $payloadSize = strlen((string) json_encode($payload));
+ if ($payloadSize > $maxPayloadBytes) {
+ throw PrismBatchPayloadSizeExceededException::make('Anthropic', $maxPayloadBytes);
+ }
+
+ $response = $this->client->post('messages/batches', $payload);
+ $data = $response->json();
+ $this->handleResponseErrors($data);
+
+ return self::mapBatchJob($data);
+ }
+
+ /**
+ * Overridable via config so the guard can be tested without materializing
+ * multi-hundred-megabyte payloads.
+ */
+ protected function maxPayloadBytes(): int
+ {
+ return (int) config('prism.anthropic.batch.max_payload_bytes', self::MAX_PAYLOAD_BYTES);
+ }
+}
diff --git a/src/Providers/Anthropic/Handlers/Batch/ListBatches.php b/src/Providers/Anthropic/Handlers/Batch/ListBatches.php
new file mode 100644
index 000000000..71e7bdd43
--- /dev/null
+++ b/src/Providers/Anthropic/Handlers/Batch/ListBatches.php
@@ -0,0 +1,47 @@
+ $request->afterId,
+ 'before_id' => $request->beforeId,
+ 'limit' => $request->limit,
+ ]);
+
+ $response = $this->client->get('messages/batches', $query ?: null);
+ $data = $response->json();
+ $this->handleResponseErrors($data);
+
+ $jobs = array_map(
+ self::mapBatchJob(...),
+ data_get($data, 'data', [])
+ );
+
+ return new BatchListResult(
+ data: $jobs,
+ hasMore: (bool) data_get($data, 'has_more', false),
+ lastId: data_get($data, 'last_id'),
+ );
+ }
+}
diff --git a/src/Providers/Anthropic/Handlers/Batch/Results.php b/src/Providers/Anthropic/Handlers/Batch/Results.php
new file mode 100644
index 000000000..2a42f621d
--- /dev/null
+++ b/src/Providers/Anthropic/Handlers/Batch/Results.php
@@ -0,0 +1,78 @@
+client->withOptions(['stream' => true])->get("messages/batches/{$batchId}/results");
+
+ if ($response->failed()) {
+ throw PrismException::providerResponseError(vsprintf(
+ 'Anthropic Error: failed to retrieve batch results for batch "%s" (HTTP %s)',
+ [$batchId, $response->status()]
+ ));
+ }
+
+ $body = $response->getBody();
+
+ $buffer = '';
+ $items = [];
+
+ while (! $body->eof()) {
+ $buffer .= $body->read(self::STREAM_BUFFER_BYTES);
+
+ while (($newlinePos = strpos($buffer, "\n")) !== false) {
+ $line = substr($buffer, 0, $newlinePos);
+ $buffer = substr($buffer, $newlinePos + 1);
+
+ $line = trim($line);
+ if ($line === '') {
+ continue;
+ }
+
+ $decoded = json_decode($line, true);
+ if (! is_array($decoded)) {
+ continue;
+ }
+
+ $items[] = self::mapResultItem($decoded);
+ }
+ }
+
+ $buffer = trim($buffer);
+ if ($buffer !== '') {
+ $decoded = json_decode($buffer, true);
+ if (is_array($decoded)) {
+ $items[] = self::mapResultItem($decoded);
+ }
+ }
+
+ return $items;
+ }
+}
diff --git a/src/Providers/Anthropic/Handlers/Batch/Retrieve.php b/src/Providers/Anthropic/Handlers/Batch/Retrieve.php
new file mode 100644
index 000000000..d5269da64
--- /dev/null
+++ b/src/Providers/Anthropic/Handlers/Batch/Retrieve.php
@@ -0,0 +1,28 @@
+client->get("messages/batches/{$batchId}");
+ $data = $response->json();
+
+ $this->handleResponseErrors($data);
+
+ return self::mapBatchJob($data);
+ }
+}
diff --git a/src/Providers/Anthropic/Handlers/Files/Delete.php b/src/Providers/Anthropic/Handlers/Files/Delete.php
new file mode 100644
index 000000000..81ef2f3d0
--- /dev/null
+++ b/src/Providers/Anthropic/Handlers/Files/Delete.php
@@ -0,0 +1,35 @@
+client->delete("files/{$request->fileId}");
+
+ $data = $response->json();
+ $this->handleResponseErrors($data);
+
+ return new DeleteFileResult(
+ id: data_get($data, 'id', ''),
+ deleted: data_get($data, 'type') === 'file_deleted',
+ );
+ }
+}
diff --git a/src/Providers/Anthropic/Handlers/Files/Download.php b/src/Providers/Anthropic/Handlers/Files/Download.php
new file mode 100644
index 000000000..489c586f5
--- /dev/null
+++ b/src/Providers/Anthropic/Handlers/Files/Download.php
@@ -0,0 +1,25 @@
+client->get("files/{$request->fileId}/content");
+
+ return $response->body();
+ }
+}
diff --git a/src/Providers/Anthropic/Handlers/Files/GetMetadata.php b/src/Providers/Anthropic/Handlers/Files/GetMetadata.php
new file mode 100644
index 000000000..ed2de5a3b
--- /dev/null
+++ b/src/Providers/Anthropic/Handlers/Files/GetMetadata.php
@@ -0,0 +1,32 @@
+client->get("files/{$request->fileId}");
+
+ $data = $response->json();
+ $this->handleResponseErrors($data);
+
+ return self::mapFileData($data);
+ }
+}
diff --git a/src/Providers/Anthropic/Handlers/Files/ListFiles.php b/src/Providers/Anthropic/Handlers/Files/ListFiles.php
new file mode 100644
index 000000000..56c6d4070
--- /dev/null
+++ b/src/Providers/Anthropic/Handlers/Files/ListFiles.php
@@ -0,0 +1,49 @@
+ $request->limit,
+ 'after_id' => $request->afterId,
+ 'before_id' => $request->beforeId,
+ ]);
+
+ $response = $this->client->get('files', $query);
+
+ $data = $response->json();
+ $this->handleResponseErrors($data);
+
+ $files = array_map(
+ self::mapFileData(...),
+ data_get($data, 'data', [])
+ );
+
+ return new FileListResult(
+ data: $files,
+ hasMore: data_get($data, 'has_more', false),
+ firstId: data_get($data, 'first_id'),
+ lastId: data_get($data, 'last_id'),
+ );
+ }
+}
diff --git a/src/Providers/Anthropic/Handlers/Files/Upload.php b/src/Providers/Anthropic/Handlers/Files/Upload.php
new file mode 100644
index 000000000..b47ab6f42
--- /dev/null
+++ b/src/Providers/Anthropic/Handlers/Files/Upload.php
@@ -0,0 +1,35 @@
+client
+ ->asMultipart()
+ ->attach('file', $request->content, $request->filename)
+ ->post('files');
+
+ $data = $response->json();
+ $this->handleResponseErrors($data);
+
+ return self::mapFileData($data);
+ }
+}
diff --git a/src/Providers/Anthropic/Handlers/Stream.php b/src/Providers/Anthropic/Handlers/Stream.php
index 20652744c..21c6584f1 100644
--- a/src/Providers/Anthropic/Handlers/Stream.php
+++ b/src/Providers/Anthropic/Handlers/Stream.php
@@ -56,6 +56,8 @@ public function __construct(protected PendingRequest $client)
*/
public function handle(Request $request): Generator
{
+ yield from $this->resolveToolApprovalsAndYieldEvents($request, EventID::generate());
+
$this->state->reset();
$response = $this->sendRequest($request);
@@ -499,7 +501,17 @@ protected function handleToolCalls(Request $request, int $depth): Generator
// Execute tools and emit results
$toolResults = [];
- yield from $this->callToolsAndYieldEvents($request->tools(), $toolCalls, $this->state->messageId(), $toolResults);
+ $hasPendingToolCalls = false;
+ yield from $this->callToolsAndYieldEventsWithPending($request->tools(), $toolCalls, $this->state->messageId(), $toolResults, $hasPendingToolCalls);
+
+ if ($hasPendingToolCalls) {
+ // Client-executed or approval-required tool calls: end the stream
+ // with FinishReason::ToolCalls so the consumer resolves and resumes.
+ $this->state->markStepFinished();
+ yield from $this->yieldToolCallsFinishEvents($this->state);
+
+ return;
+ }
// Add messages to request for next turn
if ($toolResults !== []) {
@@ -513,10 +525,13 @@ protected function handleToolCalls(Request $request, int $depth): Generator
$request->addMessage(new AssistantMessage(
content: $this->state->currentText(),
toolCalls: $toolCalls,
- additionalContent: in_array($this->state->currentThinking(), ['', '0'], true) ? [] : [
- 'thinking' => $this->state->currentThinking(),
- 'thinking_signature' => $this->state->currentThinkingSignature(),
- ]
+ additionalContent: Arr::whereNotNull([
+ 'thinking' => $this->state->currentThinking() ?: null,
+ 'thinking_signature' => $this->state->currentThinkingSignature() ?: null,
+ 'citations' => $this->state->citations() ?: null,
+ 'provider_tool_calls' => array_values($this->state->providerToolCalls()) ?: null,
+ 'provider_tool_results' => array_values($this->state->providerToolResults()) ?: null,
+ ]),
));
$request->addMessage(new ToolResultMessage($toolResults));
diff --git a/src/Providers/Anthropic/Handlers/Structured.php b/src/Providers/Anthropic/Handlers/Structured.php
index 244f1747b..2a77d814d 100644
--- a/src/Providers/Anthropic/Handlers/Structured.php
+++ b/src/Providers/Anthropic/Handlers/Structured.php
@@ -11,7 +11,6 @@
use Prism\Prism\Concerns\CallsTools;
use Prism\Prism\Contracts\PrismRequest;
use Prism\Prism\Enums\FinishReason;
-use Prism\Prism\Exceptions\PrismException;
use Prism\Prism\Providers\Anthropic\Concerns\ExtractsCitations;
use Prism\Prism\Providers\Anthropic\Concerns\ExtractsProviderToolCalls;
use Prism\Prism\Providers\Anthropic\Concerns\ExtractsText;
@@ -33,6 +32,7 @@
use Prism\Prism\ValueObjects\Messages\ToolResultMessage;
use Prism\Prism\ValueObjects\Meta;
use Prism\Prism\ValueObjects\ProviderTool;
+use Prism\Prism\ValueObjects\ToolApprovalRequest;
use Prism\Prism\ValueObjects\ToolCall;
use Prism\Prism\ValueObjects\ToolResult;
use Prism\Prism\ValueObjects\Usage;
@@ -53,6 +53,8 @@ public function __construct(protected PendingRequest $client, protected Structur
public function handle(): Response
{
+ $this->resolveToolApprovals($this->request);
+
$this->strategy->appendMessages();
$this->sendRequest();
@@ -71,8 +73,7 @@ public function handle(): Response
return match ($tempResponse->finishReason) {
FinishReason::ToolCalls => $this->handleToolCalls($toolCalls, $tempResponse),
- FinishReason::Stop, FinishReason::Length => $this->handleStop($tempResponse),
- default => throw new PrismException('Anthropic: unknown finish reason'),
+ default => $this->handleStop($tempResponse),
};
}
@@ -93,14 +94,7 @@ public static function buildHttpRequestPayload(PrismRequest $request): array
'model' => $request->model(),
'messages' => MessageMap::map($request->messages(), $request->providerOptions()),
'system' => MessageMap::mapSystemMessages($request->systemPrompts()) ?: null,
- 'thinking' => $request->providerOptions('thinking.enabled') === true
- ? [
- 'type' => 'enabled',
- 'budget_tokens' => is_int($request->providerOptions('thinking.budgetTokens'))
- ? $request->providerOptions('thinking.budgetTokens')
- : config('prism.anthropic.default_thinking_budget', 1024),
- ]
- : null,
+ 'thinking' => static::resolveThinking($request),
'max_tokens' => $request->maxTokens() ?? 64000,
'temperature' => $request->temperature(),
'top_p' => $request->topP(),
@@ -108,6 +102,9 @@ public static function buildHttpRequestPayload(PrismRequest $request): array
'tool_choice' => ToolChoiceMap::map($request->toolChoice()),
'mcp_servers' => $request->providerOptions('mcp_servers'),
'cache_control' => $request->providerOptions('cache_control'),
+ 'output_config' => $request->providerOptions('effort') !== null
+ ? ['effort' => $request->providerOptions('effort')]
+ : null,
]);
return $structuredStrategy->mutatePayload($basePayload);
@@ -143,6 +140,32 @@ protected static function buildTools(StructuredRequest $request): array
return array_merge($providerTools, $tools);
}
+ /**
+ * @param StructuredRequest $request
+ * @return array|null
+ */
+ protected static function resolveThinking(PrismRequest $request): ?array
+ {
+ if ($request->reasoningEnabled() === false) {
+ return null;
+ }
+
+ if ($request->providerOptions('thinking.type') === 'adaptive') {
+ return ['type' => 'adaptive'];
+ }
+
+ if ($request->providerOptions('thinking.enabled') === true) {
+ return [
+ 'type' => 'enabled',
+ 'budget_tokens' => is_int($request->providerOptions('thinking.budgetTokens'))
+ ? $request->providerOptions('thinking.budgetTokens')
+ : config('prism.anthropic.default_thinking_budget', 1024),
+ ];
+ }
+
+ return null;
+ }
+
/**
* @param ToolCall[] $toolCalls
*/
@@ -168,8 +191,12 @@ protected function handleToolCalls(array $toolCalls, Response $tempResponse): Re
protected function executeCustomToolsAndFinalize(array $toolCalls, Response $tempResponse): Response
{
$customToolCalls = $this->filterCustomToolCalls($toolCalls);
- $toolResults = $this->callTools($this->request->tools(), $customToolCalls);
- $this->addStep($toolCalls, $tempResponse, $toolResults);
+ $hasPendingToolCalls = false;
+ $approvalRequests = [];
+ $toolResults = $this->callToolsWithPending($this->request->tools(), $customToolCalls, $hasPendingToolCalls, $approvalRequests);
+
+ $this->attachApprovalRequests($approvalRequests, $tempResponse, $toolCalls);
+ $this->addStep($toolCalls, $tempResponse, $toolResults, $approvalRequests);
return $this->responseBuilder->toResponse();
}
@@ -180,7 +207,11 @@ protected function executeCustomToolsAndFinalize(array $toolCalls, Response $tem
protected function executeCustomToolsAndContinue(array $toolCalls, Response $tempResponse): Response
{
$customToolCalls = $this->filterCustomToolCalls($toolCalls);
- $toolResults = $this->callTools($this->request->tools(), $customToolCalls);
+ $hasPendingToolCalls = false;
+ $approvalRequests = [];
+ $toolResults = $this->callToolsWithPending($this->request->tools(), $customToolCalls, $hasPendingToolCalls, $approvalRequests);
+
+ $this->attachApprovalRequests($approvalRequests, $tempResponse, $toolCalls);
$message = new ToolResultMessage($toolResults);
if ($toolResultCacheType = $this->request->providerOptions('tool_result_cache_type')) {
@@ -189,15 +220,40 @@ protected function executeCustomToolsAndContinue(array $toolCalls, Response $tem
$this->request->addMessage($message);
$this->request->resetToolChoice();
- $this->addStep($toolCalls, $tempResponse, $toolResults);
+ $this->addStep($toolCalls, $tempResponse, $toolResults, $approvalRequests);
- if ($this->canContinue()) {
+ if (! $hasPendingToolCalls && $this->canContinue()) {
return $this->handle();
}
return $this->responseBuilder->toResponse();
}
+ /**
+ * Replace the already-appended assistant message with one carrying the
+ * approval requests, so the resume pass can correlate them.
+ *
+ * @param ToolApprovalRequest[] $approvalRequests
+ * @param ToolCall[] $toolCalls
+ */
+ protected function attachApprovalRequests(array $approvalRequests, Response $tempResponse, array $toolCalls): void
+ {
+ if ($approvalRequests === []) {
+ return;
+ }
+
+ $messages = $this->request->messages();
+ array_pop($messages);
+ $this->request->setMessages($messages);
+
+ $this->request->addMessage(new AssistantMessage(
+ content: $tempResponse->text,
+ toolCalls: $toolCalls,
+ additionalContent: $tempResponse->additionalContent,
+ toolApprovalRequests: $approvalRequests,
+ ));
+ }
+
/**
* @param ToolCall[] $toolCalls
*/
@@ -263,8 +319,9 @@ protected function hasStructuredToolCall(array $toolCalls): bool
/**
* @param ToolCall[] $toolCalls
* @param ToolResult[] $toolResults
+ * @param ToolApprovalRequest[] $toolApprovalRequests
*/
- protected function addStep(array $toolCalls, Response $tempResponse, array $toolResults = []): void
+ protected function addStep(array $toolCalls, Response $tempResponse, array $toolResults = [], array $toolApprovalRequests = []): void
{
$data = $this->httpResponse->json();
$isStructuredStep = $this->determineIfStructuredStep($toolCalls, $toolResults);
@@ -282,6 +339,7 @@ protected function addStep(array $toolCalls, Response $tempResponse, array $tool
providerToolCalls: $this->extractProviderToolCalls($data),
toolResults: $toolResults,
raw: $data,
+ toolApprovalRequests: $toolApprovalRequests,
));
}
diff --git a/src/Providers/Anthropic/Handlers/StructuredStrategies/NativeOutputFormatStructuredStrategy.php b/src/Providers/Anthropic/Handlers/StructuredStrategies/NativeOutputFormatStructuredStrategy.php
index 31f5d3a58..b84b04611 100644
--- a/src/Providers/Anthropic/Handlers/StructuredStrategies/NativeOutputFormatStructuredStrategy.php
+++ b/src/Providers/Anthropic/Handlers/StructuredStrategies/NativeOutputFormatStructuredStrategy.php
@@ -20,12 +20,12 @@ public function mutatePayload(array $payload): array
{
$schemaArray = $this->request->schema()->toArray();
- $payload['output_config'] = [
+ $payload['output_config'] = array_merge($payload['output_config'] ?? [], [
'format' => [
'type' => 'json_schema',
'schema' => $schemaArray,
],
- ];
+ ]);
return $payload;
}
diff --git a/src/Providers/Anthropic/Handlers/StructuredStrategies/ToolStructuredStrategy.php b/src/Providers/Anthropic/Handlers/StructuredStrategies/ToolStructuredStrategy.php
index ab1bb3097..b9b2ee410 100644
--- a/src/Providers/Anthropic/Handlers/StructuredStrategies/ToolStructuredStrategy.php
+++ b/src/Providers/Anthropic/Handlers/StructuredStrategies/ToolStructuredStrategy.php
@@ -95,7 +95,8 @@ public function mutateResponse(HttpResponse $httpResponse, PrismResponse $prismR
protected function resolveToolChoice(): string|array|null
{
// Thinking mode doesn't support tool_choice (Anthropic restriction)
- if ($this->request->providerOptions('thinking.enabled') === true) {
+ if ($this->request->providerOptions('thinking.enabled') === true
+ || $this->request->providerOptions('thinking.type') === 'adaptive') {
return null;
}
diff --git a/src/Providers/Anthropic/Handlers/Text.php b/src/Providers/Anthropic/Handlers/Text.php
index ee6b2e37a..ff6dcca6a 100644
--- a/src/Providers/Anthropic/Handlers/Text.php
+++ b/src/Providers/Anthropic/Handlers/Text.php
@@ -11,7 +11,6 @@
use Prism\Prism\Concerns\CallsTools;
use Prism\Prism\Contracts\PrismRequest;
use Prism\Prism\Enums\FinishReason;
-use Prism\Prism\Exceptions\PrismException;
use Prism\Prism\Providers\Anthropic\Concerns\ExtractsCitations;
use Prism\Prism\Providers\Anthropic\Concerns\ExtractsProviderToolCalls;
use Prism\Prism\Providers\Anthropic\Concerns\ExtractsText;
@@ -30,6 +29,7 @@
use Prism\Prism\ValueObjects\Messages\ToolResultMessage;
use Prism\Prism\ValueObjects\Meta;
use Prism\Prism\ValueObjects\ProviderTool;
+use Prism\Prism\ValueObjects\ToolApprovalRequest;
use Prism\Prism\ValueObjects\ToolCall;
use Prism\Prism\ValueObjects\ToolResult;
use Prism\Prism\ValueObjects\Usage;
@@ -49,14 +49,18 @@ public function __construct(protected PendingRequest $client, protected TextRequ
public function handle(): Response
{
+ $this->resolveToolApprovals($this->request);
+
$this->sendRequest();
$this->prepareTempResponse();
return match ($this->tempResponse->finishReason) {
FinishReason::ToolCalls => $this->handleToolCalls(),
- FinishReason::Stop, FinishReason::Length => $this->handleStop(),
- default => throw new PrismException('Anthropic: unknown finish reason'),
+ FinishReason::Pause => $this->handlePause(),
+ // Refusal and unknown reasons resolve gracefully (prism-php/prism#996);
+ // the response carries the mapped finish reason for the caller.
+ default => $this->handleStop(),
};
}
@@ -75,14 +79,7 @@ public static function buildHttpRequestPayload(PrismRequest $request): array
'model' => $request->model(),
'system' => MessageMap::mapSystemMessages($request->systemPrompts()) ?: null,
'messages' => MessageMap::map($request->messages(), $request->providerOptions()),
- 'thinking' => $request->providerOptions('thinking.enabled') === true
- ? [
- 'type' => 'enabled',
- 'budget_tokens' => is_int($request->providerOptions('thinking.budgetTokens'))
- ? $request->providerOptions('thinking.budgetTokens')
- : config('prism.anthropic.default_thinking_budget', 1024),
- ]
- : null,
+ 'thinking' => static::resolveThinking($request),
'max_tokens' => $request->maxTokens() ?? 64000,
'temperature' => $request->temperature(),
'top_p' => $request->topP(),
@@ -90,19 +87,48 @@ public static function buildHttpRequestPayload(PrismRequest $request): array
'tool_choice' => ToolChoiceMap::map($request->toolChoice()),
'mcp_servers' => $request->providerOptions('mcp_servers'),
'cache_control' => $request->providerOptions('cache_control'),
+ 'output_config' => $request->providerOptions('effort') !== null
+ ? ['effort' => $request->providerOptions('effort')]
+ : null,
]);
}
+ /**
+ * Anthropic returns stop_reason="pause_turn" when a long-running server-side
+ * tool (e.g. web_search, web_fetch) needs the client to continue the turn.
+ * Per Anthropic's docs, the client should append the assistant message to
+ * the conversation and re-send the request unchanged so the model can resume.
+ */
+ protected function handlePause(): Response
+ {
+ $this->addStep();
+
+ $this->request->addMessage(new AssistantMessage(
+ $this->tempResponse->text,
+ $this->tempResponse->toolCalls,
+ $this->tempResponse->additionalContent,
+ ));
+
+ if ($this->responseBuilder->steps->count() < $this->request->maxSteps()) {
+ return $this->handle();
+ }
+
+ return $this->responseBuilder->toResponse();
+ }
+
protected function handleToolCalls(): Response
{
- $toolResults = $this->callTools($this->request->tools(), $this->tempResponse->toolCalls);
+ $hasPendingToolCalls = false;
+ $approvalRequests = [];
+ $toolResults = $this->callToolsWithPending($this->request->tools(), $this->tempResponse->toolCalls, $hasPendingToolCalls, $approvalRequests);
- $this->addStep($toolResults);
+ $this->addStep($toolResults, $approvalRequests);
$this->request->addMessage(new AssistantMessage(
$this->tempResponse->text,
$this->tempResponse->toolCalls,
$this->tempResponse->additionalContent,
+ $approvalRequests,
));
$toolResultMessage = new ToolResultMessage($toolResults);
@@ -110,7 +136,7 @@ protected function handleToolCalls(): Response
$this->request->addMessage($toolResultMessage);
$this->request->resetToolChoice();
- if ($this->responseBuilder->steps->count() < $this->request->maxSteps()) {
+ if (! $hasPendingToolCalls && $this->responseBuilder->steps->count() < $this->request->maxSteps()) {
return $this->handle();
}
@@ -126,8 +152,9 @@ protected function handleStop(): Response
/**
* @param ToolResult[] $toolResults
+ * @param ToolApprovalRequest[] $toolApprovalRequests
*/
- protected function addStep(array $toolResults = []): void
+ protected function addStep(array $toolResults = [], array $toolApprovalRequests = []): void
{
$data = $this->httpResponse->json();
@@ -143,6 +170,7 @@ protected function addStep(array $toolResults = []): void
systemPrompts: $this->request->systemPrompts(),
additionalContent: $this->tempResponse->additionalContent,
raw: $data,
+ toolApprovalRequests: $toolApprovalRequests,
));
}
@@ -171,10 +199,52 @@ protected function prepareTempResponse(): void
additionalContent: Arr::whereNotNull([
'citations' => $this->extractCitations($data),
...$this->extractThinking($data),
+ ...$this->extractProviderToolContent($data),
])
);
}
+ /**
+ * Extract server tool use and result content blocks from the response.
+ *
+ * These must be preserved in additionalContent so that MessageMap can
+ * replay them alongside citations during multi-step tool loops.
+ *
+ * @param array $data
+ * @return array>>
+ */
+ protected function extractProviderToolContent(array $data): array
+ {
+ $providerToolCalls = [];
+ $providerToolResults = [];
+
+ foreach (data_get($data, 'content', []) as $content) {
+ $type = data_get($content, 'type');
+
+ if ($type === 'server_tool_use') {
+ $providerToolCalls[] = [
+ 'type' => $type,
+ 'id' => data_get($content, 'id'),
+ 'name' => data_get($content, 'name'),
+ 'input' => json_encode(data_get($content, 'input', [])),
+ ];
+ }
+
+ if (str_ends_with((string) $type, '_tool_result')) {
+ $providerToolResults[] = [
+ 'type' => $type,
+ 'tool_use_id' => data_get($content, 'tool_use_id'),
+ 'content' => data_get($content, 'content'),
+ ];
+ }
+ }
+
+ return Arr::whereNotNull([
+ 'provider_tool_calls' => $providerToolCalls !== [] ? $providerToolCalls : null,
+ 'provider_tool_results' => $providerToolResults !== [] ? $providerToolResults : null,
+ ]);
+ }
+
/**
* @return array
*/
@@ -198,6 +268,32 @@ protected static function buildTools(TextRequest $request): array
return array_merge($providerTools, $tools);
}
+ /**
+ * @param TextRequest $request
+ * @return array|null
+ */
+ protected static function resolveThinking(PrismRequest $request): ?array
+ {
+ if ($request->reasoningEnabled() === false) {
+ return null;
+ }
+
+ if ($request->providerOptions('thinking.type') === 'adaptive') {
+ return ['type' => 'adaptive'];
+ }
+
+ if ($request->providerOptions('thinking.enabled') === true) {
+ return [
+ 'type' => 'enabled',
+ 'budget_tokens' => is_int($request->providerOptions('thinking.budgetTokens'))
+ ? $request->providerOptions('thinking.budgetTokens')
+ : config('prism.anthropic.default_thinking_budget', 1024),
+ ];
+ }
+
+ return null;
+ }
+
/**
* @param array $data
* @return ToolCall[]
diff --git a/src/Providers/Anthropic/Maps/FinishReasonMap.php b/src/Providers/Anthropic/Maps/FinishReasonMap.php
index af580c73b..ba322300d 100644
--- a/src/Providers/Anthropic/Maps/FinishReasonMap.php
+++ b/src/Providers/Anthropic/Maps/FinishReasonMap.php
@@ -14,6 +14,8 @@ public static function map(string $reason): FinishReason
'end_turn', 'stop_sequence' => FinishReason::Stop,
'tool_use' => FinishReason::ToolCalls,
'max_tokens' => FinishReason::Length,
+ 'pause_turn' => FinishReason::Pause,
+ 'refusal' => FinishReason::Refusal,
default => FinishReason::Unknown,
};
}
diff --git a/src/Providers/Anthropic/Maps/ToolMap.php b/src/Providers/Anthropic/Maps/ToolMap.php
index 9588f6316..b6a817436 100644
--- a/src/Providers/Anthropic/Maps/ToolMap.php
+++ b/src/Providers/Anthropic/Maps/ToolMap.php
@@ -30,6 +30,7 @@ public static function map(array $tools): array
],
'cache_control' => self::normalizeCacheControl($tool),
'strict' => (bool) $tool->providerOptions('strict'),
+ 'eager_input_streaming' => (bool) $tool->providerOptions('eager_input_streaming'),
]);
}, $tools);
}
diff --git a/src/Providers/Azure/Azure.php b/src/Providers/Azure/Azure.php
new file mode 100644
index 000000000..4e5b275dc
--- /dev/null
+++ b/src/Providers/Azure/Azure.php
@@ -0,0 +1,219 @@
+buildUrl($request->model());
+
+ $handler = new Text(
+ $this->client(
+ $request->clientOptions(),
+ $request->clientRetry(),
+ $builtUrl
+ ),
+ $this
+ );
+
+ return $handler->handle($request);
+ }
+
+ #[\Override]
+ public function structured(StructuredRequest $request): StructuredResponse
+ {
+ $builtUrl = $this->buildUrl($request->model());
+
+ $handler = new Structured(
+ $this->client(
+ $request->clientOptions(),
+ $request->clientRetry(),
+ $builtUrl
+ ),
+ $this
+ );
+
+ return $handler->handle($request);
+ }
+
+ #[\Override]
+ public function embeddings(EmbeddingsRequest $request): EmbeddingsResponse
+ {
+ $builtUrl = $this->buildUrl($request->model());
+
+ $handler = new Embeddings(
+ $this->client(
+ $request->clientOptions(),
+ $request->clientRetry(),
+ $builtUrl
+ ),
+ $this
+ );
+
+ return $handler->handle($request);
+ }
+
+ #[\Override]
+ public function images(ImagesRequest $request): ImagesResponse
+ {
+ $builtUrl = $this->buildUrl($request->model());
+
+ $handler = new Images(
+ $this->client(
+ $request->clientOptions(),
+ $request->clientRetry(),
+ $builtUrl
+ ),
+ $this
+ );
+
+ return $handler->handle($request);
+ }
+
+ #[\Override]
+ public function stream(TextRequest $request): Generator
+ {
+ $builtUrl = $this->buildUrl($request->model());
+
+ $handler = new Stream(
+ $this->client(
+ $request->clientOptions(),
+ $request->clientRetry(),
+ $builtUrl
+ ),
+ $this
+ );
+
+ return $handler->handle($request);
+ }
+
+ public function handleRequestException(string $model, RequestException $e): never
+ {
+ $statusCode = $e->response->getStatusCode();
+ $responseData = $e->response->json();
+ $errorMessage = data_get($responseData, 'error.message', 'Unknown error');
+
+ match ($statusCode) {
+ 429 => throw PrismRateLimitedException::make(
+ rateLimits: [],
+ retryAfter: (int) $e->response->header('retry-after')
+ ),
+ 413 => throw PrismRequestTooLargeException::make(ProviderName::Azure),
+ 400, 404 => throw PrismException::providerResponseError(
+ sprintf('Azure Error: %s', $errorMessage)
+ ),
+ default => throw PrismException::providerRequestError($model, $e),
+ };
+ }
+
+ public function usesV1ForModel(string $model): bool
+ {
+ if (str_contains($this->url, '/openai/v1')) {
+ return true;
+ }
+
+ if (mb_strtolower(trim($this->apiVersion)) === 'v1') {
+ return true;
+ }
+
+ return str_contains(mb_strtolower($model), 'gpt-5');
+ }
+
+ public function resolveModelIdentifier(string $model): string
+ {
+ return $this->deploymentName ?: $model;
+ }
+
+ /**
+ * Build the URL for the Azure deployment.
+ * Supports both Azure OpenAI and Azure AI Model Inference endpoints.
+ */
+ protected function buildUrl(string $model): string
+ {
+ // If URL already contains the full path, use it directly
+ if (str_contains($this->url, '/chat/completions') || str_contains($this->url, '/embeddings') || str_contains($this->url, '/images/generations')) {
+ return $this->url;
+ }
+
+ if ($this->usesV1ForModel($model)) {
+ if (str_contains($this->url, '/openai/v1')) {
+ return rtrim($this->url, '/');
+ }
+
+ return rtrim($this->url, '/').'/openai/v1';
+ }
+
+ // Use deployment name from config, or model name as fallback
+ $deployment = $this->deploymentName ?: $model;
+
+ // If URL already contains 'deployments', append the deployment
+ if (str_contains($this->url, '/openai/deployments')) {
+ return rtrim($this->url, '/')."/{$deployment}";
+ }
+
+ // Build standard Azure OpenAI URL pattern
+ return rtrim($this->url, '/')."/openai/deployments/{$deployment}";
+ }
+
+ /**
+ * @param array $options
+ * @param array $retry
+ */
+ protected function client(array $options = [], array $retry = [], ?string $baseUrl = null): PendingRequest
+ {
+ $resolvedBaseUrl = $baseUrl ?? $this->url;
+ $usesV1 = str_contains($resolvedBaseUrl, '/openai/v1');
+
+ $client = $this->baseClient()
+ ->withHeaders([
+ 'api-key' => $this->apiKey,
+ ])
+ ->withOptions($options)
+ ->when($retry !== [], fn ($client) => $client->retry(...$retry))
+ ->baseUrl($resolvedBaseUrl);
+
+ if (! $usesV1) {
+ return $client->withQueryParameters([
+ 'api-version' => $this->apiVersion,
+ ]);
+ }
+
+ return $client;
+ }
+}
diff --git a/src/Providers/Azure/Concerns/MapsFinishReason.php b/src/Providers/Azure/Concerns/MapsFinishReason.php
new file mode 100644
index 000000000..1a774baaa
--- /dev/null
+++ b/src/Providers/Azure/Concerns/MapsFinishReason.php
@@ -0,0 +1,19 @@
+ $data
+ */
+ protected function mapFinishReason(array $data): FinishReason
+ {
+ return FinishReasonMap::map(data_get($data, 'choices.0.finish_reason', ''));
+ }
+}
diff --git a/src/Providers/Azure/Concerns/ValidatesResponses.php b/src/Providers/Azure/Concerns/ValidatesResponses.php
new file mode 100644
index 000000000..56e95ee47
--- /dev/null
+++ b/src/Providers/Azure/Concerns/ValidatesResponses.php
@@ -0,0 +1,26 @@
+ $data
+ */
+ protected function validateResponse(array $data): void
+ {
+ if ($data === []) {
+ throw PrismException::providerResponseError('Azure Error: Empty response');
+ }
+
+ if (data_get($data, 'error')) {
+ throw PrismException::providerResponseError(
+ sprintf('Azure Error: %s', data_get($data, 'error.message', 'Unknown error'))
+ );
+ }
+ }
+}
diff --git a/src/Providers/Azure/Handlers/Embeddings.php b/src/Providers/Azure/Handlers/Embeddings.php
new file mode 100644
index 000000000..4d73e78f4
--- /dev/null
+++ b/src/Providers/Azure/Handlers/Embeddings.php
@@ -0,0 +1,79 @@
+sendRequest($request);
+
+ $this->validateResponse($response);
+
+ $data = $response->json();
+
+ return new EmbeddingsResponse(
+ embeddings: array_map(fn (array $item): Embedding => Embedding::fromArray($item['embedding']), data_get($data, 'data', [])),
+ usage: new EmbeddingsUsage(data_get($data, 'usage.total_tokens')),
+ meta: new Meta(
+ id: '',
+ model: data_get($data, 'model', ''),
+ ),
+ );
+ }
+
+ protected function sendRequest(Request $request): Response
+ {
+ try {
+ /** @var Response $response */
+ $response = $this->client
+ ->throw()
+ ->post(
+ 'embeddings',
+ Arr::whereNotNull([
+ 'model' => $this->provider->usesV1ForModel($request->model())
+ ? $this->provider->resolveModelIdentifier($request->model())
+ : null,
+ 'input' => $request->inputs(),
+ ...($request->providerOptions() ?? []),
+ ])
+ );
+
+ return $response;
+ } catch (RequestException $e) {
+ $this->provider->handleRequestException($request->model(), $e);
+ }
+ }
+
+ protected function validateResponse(Response $response): void
+ {
+ if ($response->json() === null) {
+ throw PrismException::providerResponseError('Azure Error: Empty embeddings response');
+ }
+
+ if ($response->json('error')) {
+ throw PrismException::providerResponseError(
+ sprintf('Azure Error: %s', data_get($response->json(), 'error.message', 'Unknown error'))
+ );
+ }
+ }
+}
diff --git a/src/Providers/Azure/Handlers/Images.php b/src/Providers/Azure/Handlers/Images.php
new file mode 100644
index 000000000..463149819
--- /dev/null
+++ b/src/Providers/Azure/Handlers/Images.php
@@ -0,0 +1,80 @@
+provider);
+
+ /** @var \Illuminate\Http\Client\Response $response */
+ $response = $this->client->post('images/generations', $payload);
+
+ $data = $response->json();
+
+ if (! $data || data_get($data, 'error')) {
+ throw PrismException::providerResponseError(vsprintf(
+ 'Azure Image Error: [%s] %s',
+ [
+ data_get($data, 'error.code', 'unknown'),
+ data_get($data, 'error.message', 'unknown'),
+ ]
+ ));
+ }
+
+ $images = $this->extractImages($data);
+
+ $responseBuilder = new ResponseBuilder(
+ usage: new Usage(
+ promptTokens: data_get($data, 'usage.prompt_tokens', 0),
+ completionTokens: data_get($data, 'usage.completion_tokens', 0),
+ ),
+ meta: new Meta(
+ id: data_get($data, 'id', 'img_'.bin2hex(random_bytes(8))),
+ model: data_get($data, 'model', $request->model()),
+ rateLimits: [],
+ ),
+ images: $images,
+ );
+
+ return $responseBuilder->toResponse();
+ }
+
+ /**
+ * @param array $data
+ * @return GeneratedImage[]
+ */
+ protected function extractImages(array $data): array
+ {
+ $images = [];
+
+ foreach (data_get($data, 'data', []) as $imageData) {
+ $images[] = new GeneratedImage(
+ url: data_get($imageData, 'url'),
+ base64: data_get($imageData, 'b64_json'),
+ revisedPrompt: data_get($imageData, 'revised_prompt'),
+ );
+ }
+
+ return $images;
+ }
+}
diff --git a/src/Providers/Azure/Handlers/Stream.php b/src/Providers/Azure/Handlers/Stream.php
new file mode 100644
index 000000000..19fca206a
--- /dev/null
+++ b/src/Providers/Azure/Handlers/Stream.php
@@ -0,0 +1,872 @@
+state = new StreamState;
+ }
+
+ /**
+ * @return Generator
+ */
+ public function handle(Request $request): Generator
+ {
+ if ($this->provider->usesV1ForModel($request->model())) {
+ yield from $this->processV1NativeStream($request);
+
+ return;
+ }
+
+ $response = $this->sendRequest($request);
+
+ yield from $this->processStream($response, $request);
+ }
+
+ /**
+ * @return Generator
+ */
+ protected function processV1NativeStream(Request $request, int $depth = 0): Generator
+ {
+ if ($depth >= $request->maxSteps()) {
+ throw new PrismException('Maximum tool call chain depth exceeded');
+ }
+
+ if ($depth === 0) {
+ $this->state->reset();
+ }
+
+ $context = stream_context_create([
+ 'http' => [
+ 'method' => 'POST',
+ 'header' => implode("\r\n", [
+ 'Content-Type: application/json',
+ 'api-key: '.$this->provider->apiKey,
+ 'Accept: text/event-stream',
+ ]),
+ 'content' => json_encode($this->buildRequestPayload($request, true), JSON_UNESCAPED_SLASHES),
+ 'ignore_errors' => true,
+ 'timeout' => 300,
+ ],
+ ]);
+
+ $url = $this->v1ChatCompletionsUrl();
+ $stream = fopen($url, 'r', false, $context);
+
+ if (! is_resource($stream)) {
+ yield new ErrorEvent(
+ id: EventID::generate(),
+ timestamp: time(),
+ errorType: 'azure_v1_stream_open_failed',
+ message: 'Failed to open Azure v1 stream connection.',
+ recoverable: true
+ );
+
+ yield from $this->emitFallbackNonStreamResponse($request);
+
+ return;
+ }
+
+ stream_set_blocking($stream, true);
+
+ $text = '';
+ $toolCalls = [];
+ $dataChunkCount = 0;
+
+ if ($this->state->shouldEmitStreamStart()) {
+ $this->state->withMessageId(EventID::generate())->markStreamStarted();
+
+ yield new StreamStartEvent(
+ id: EventID::generate(),
+ timestamp: time(),
+ model: $request->model(),
+ provider: 'azure'
+ );
+ }
+
+ while (! feof($stream)) {
+ $line = fgets($stream);
+ if ($line === false) {
+ usleep(15_000);
+
+ continue;
+ }
+
+ $line = trim($line);
+ if ($line === '') {
+ continue;
+ }
+ if (! str_starts_with($line, 'data:')) {
+ continue;
+ }
+
+ $payload = ltrim(substr($line, strlen('data:')));
+ if ($payload === '') {
+ continue;
+ }
+ if (Str::contains($payload, '[DONE]')) {
+ continue;
+ }
+
+ $data = json_decode($payload, true);
+ if (! is_array($data)) {
+ continue;
+ }
+
+ $dataChunkCount++;
+
+ if ($this->hasError($data)) {
+ yield from $this->handleErrors($data, $request);
+
+ continue;
+ }
+
+ if ($this->hasToolCalls($data)) {
+ $toolCalls = $this->extractToolCalls($data, $toolCalls);
+
+ $rawFinishReason = data_get($data, 'choices.0.finish_reason');
+ if ($rawFinishReason === 'tool_calls') {
+ if ($this->state->hasTextStarted() && $text !== '') {
+ yield new TextCompleteEvent(
+ id: EventID::generate(),
+ timestamp: time(),
+ messageId: $this->state->messageId()
+ );
+ }
+
+ fclose($stream);
+ yield from $this->handleToolCalls($request, $text, $toolCalls, $depth);
+
+ return;
+ }
+
+ continue;
+ }
+
+ $content = $this->extractContentDelta($data);
+ if ($content !== '' && $content !== '0') {
+ if ($this->state->shouldEmitTextStart()) {
+ $this->state->markTextStarted();
+
+ yield new TextStartEvent(
+ id: EventID::generate(),
+ timestamp: time(),
+ messageId: $this->state->messageId()
+ );
+ }
+
+ $text .= $content;
+
+ yield new TextDeltaEvent(
+ id: EventID::generate(),
+ timestamp: time(),
+ delta: $content,
+ messageId: $this->state->messageId()
+ );
+
+ continue;
+ }
+
+ $rawFinishReason = data_get($data, 'choices.0.finish_reason');
+ if ($rawFinishReason !== null) {
+ $finishReason = $this->mapFinishReason($data);
+ $this->state->withFinishReason($finishReason);
+
+ $usage = $this->extractUsage($data);
+ if ($usage instanceof Usage) {
+ $this->state->addUsage($usage);
+ }
+ }
+ }
+
+ fclose($stream);
+
+ if ($toolCalls !== []) {
+ yield from $this->handleToolCalls($request, $text, $toolCalls, $depth);
+
+ return;
+ }
+
+ if ($dataChunkCount === 0) {
+ yield from $this->emitFallbackNonStreamResponse($request);
+
+ return;
+ }
+
+ yield new StreamEndEvent(
+ id: EventID::generate(),
+ timestamp: time(),
+ finishReason: $this->state->finishReason() ?? FinishReason::Stop,
+ usage: $this->state->usage()
+ );
+ }
+
+ /**
+ * @return Generator
+ */
+ protected function processStream(Response $response, Request $request, int $depth = 0, int $streamAttempt = 0): Generator
+ {
+ if ($depth >= $request->maxSteps()) {
+ throw new PrismException('Maximum tool call chain depth exceeded');
+ }
+
+ if ($depth === 0) {
+ $this->state->reset();
+ }
+
+ $text = '';
+ $toolCalls = [];
+ $dataChunkCount = 0;
+
+ $body = $response->getBody();
+
+ if ($body->isSeekable()) {
+ $body->rewind();
+ }
+
+ if ($this->state->shouldEmitStreamStart()) {
+ $this->state->withMessageId(EventID::generate())->markStreamStarted();
+
+ yield new StreamStartEvent(
+ id: EventID::generate(),
+ timestamp: time(),
+ model: $request->model(),
+ provider: 'azure'
+ );
+ }
+
+ while (true) {
+ $data = $this->parseNextDataLine($body);
+
+ if ($data === null) {
+ if ($body->eof()) {
+ break;
+ }
+
+ continue;
+ }
+
+ $dataChunkCount++;
+
+ if ($this->hasError($data)) {
+ yield from $this->handleErrors($data, $request);
+
+ continue;
+ }
+
+ if ($this->hasToolCalls($data)) {
+ $toolCalls = $this->extractToolCalls($data, $toolCalls);
+
+ $rawFinishReason = data_get($data, 'choices.0.finish_reason');
+ if ($rawFinishReason === 'tool_calls') {
+ if ($this->state->hasTextStarted() && $text !== '') {
+ yield new TextCompleteEvent(
+ id: EventID::generate(),
+ timestamp: time(),
+ messageId: $this->state->messageId()
+ );
+ }
+
+ yield from $this->handleToolCalls($request, $text, $toolCalls, $depth);
+
+ return;
+ }
+
+ continue;
+ }
+
+ $content = $this->extractContentDelta($data);
+ if ($content !== '' && $content !== '0') {
+ if ($this->state->shouldEmitTextStart()) {
+ $this->state->markTextStarted();
+
+ yield new TextStartEvent(
+ id: EventID::generate(),
+ timestamp: time(),
+ messageId: $this->state->messageId()
+ );
+ }
+
+ $text .= $content;
+
+ yield new TextDeltaEvent(
+ id: EventID::generate(),
+ timestamp: time(),
+ delta: $content,
+ messageId: $this->state->messageId()
+ );
+
+ continue;
+ }
+
+ $rawFinishReason = data_get($data, 'choices.0.finish_reason');
+ if ($rawFinishReason !== null) {
+ $finishReason = $this->mapFinishReason($data);
+
+ if ($finishReason === FinishReason::Length && $text === '') {
+ yield new ErrorEvent(
+ id: EventID::generate(),
+ timestamp: time(),
+ errorType: 'length_exhausted',
+ message: 'Azure Error: No output text was produced before the token limit was reached. Lower reasoning effort or increase max completion tokens.',
+ recoverable: true
+ );
+ }
+
+ if ($this->state->hasTextStarted() && $text !== '') {
+ yield new TextCompleteEvent(
+ id: EventID::generate(),
+ timestamp: time(),
+ messageId: $this->state->messageId()
+ );
+ }
+
+ $this->state->withFinishReason($finishReason);
+
+ $usage = $this->extractUsage($data);
+ if ($usage instanceof Usage) {
+ $this->state->addUsage($usage);
+ }
+ }
+ }
+
+ if ($toolCalls !== []) {
+ yield from $this->handleToolCalls($request, $text, $toolCalls, $depth);
+
+ return;
+ }
+
+ if ($dataChunkCount === 0 && $streamAttempt < 1 && ! $this->provider->usesV1ForModel($request->model())) {
+ $retryResponse = $this->sendRequest($request);
+ yield from $this->processStream($retryResponse, $request, $depth, $streamAttempt + 1);
+
+ return;
+ }
+
+ if ($dataChunkCount === 0) {
+ yield from $this->emitFallbackNonStreamResponse($request);
+
+ return;
+ }
+
+ yield new StreamEndEvent(
+ id: EventID::generate(),
+ timestamp: time(),
+ finishReason: $this->state->finishReason() ?? FinishReason::Stop,
+ usage: $this->state->usage()
+ );
+ }
+
+ /**
+ * @return array|null
+ *
+ * @throws PrismStreamDecodeException
+ */
+ protected function parseNextDataLine(StreamInterface $stream): ?array
+ {
+ $line = $this->readLine($stream);
+
+ $line = trim($line);
+
+ if ($line === '' || ! str_starts_with($line, 'data:')) {
+ return null;
+ }
+
+ $line = ltrim(substr($line, strlen('data:')));
+
+ if (Str::contains($line, '[DONE]')) {
+ return null;
+ }
+
+ try {
+ return json_decode($line, true, flags: JSON_THROW_ON_ERROR);
+ } catch (Throwable $e) {
+ throw new PrismStreamDecodeException('Azure', $e);
+ }
+ }
+
+ /**
+ * @param array $data
+ */
+ protected function hasToolCalls(array $data): bool
+ {
+ return ! empty(data_get($data, 'choices.0.delta.tool_calls', []));
+ }
+
+ /**
+ * @param array $data
+ * @param array> $toolCalls
+ * @return array>
+ */
+ protected function extractToolCalls(array $data, array $toolCalls): array
+ {
+ $deltaToolCalls = data_get($data, 'choices.0.delta.tool_calls', []);
+
+ foreach ($deltaToolCalls as $deltaToolCall) {
+ $index = data_get($deltaToolCall, 'index', 0);
+
+ if (! isset($toolCalls[$index])) {
+ $toolCalls[$index] = [
+ 'id' => '',
+ 'name' => '',
+ 'arguments' => '',
+ ];
+ }
+
+ if ($id = data_get($deltaToolCall, 'id')) {
+ $toolCalls[$index]['id'] = $id;
+ }
+
+ if ($name = data_get($deltaToolCall, 'function.name')) {
+ $toolCalls[$index]['name'] = $name;
+ }
+
+ if ($arguments = data_get($deltaToolCall, 'function.arguments')) {
+ $toolCalls[$index]['arguments'] .= $arguments;
+ }
+ }
+
+ return $toolCalls;
+ }
+
+ /**
+ * @param array $data
+ */
+ protected function extractContentDelta(array $data): string
+ {
+ return data_get($data, 'choices.0.delta.content') ?? '';
+ }
+
+ /**
+ * @param array $data
+ */
+ protected function extractUsage(array $data): ?Usage
+ {
+ $usage = data_get($data, 'usage');
+
+ if (! $usage) {
+ return null;
+ }
+
+ return new Usage(
+ promptTokens: max(0, (int) data_get($usage, 'prompt_tokens', 0) - (int) data_get($usage, 'prompt_tokens_details.cached_tokens', 0)),
+ completionTokens: (int) data_get($usage, 'completion_tokens', 0),
+ cacheReadInputTokens: (int) data_get($usage, 'prompt_tokens_details.cached_tokens', 0) ?: null,
+ );
+ }
+
+ /**
+ * @param array $data
+ */
+ protected function hasError(array $data): bool
+ {
+ return data_get($data, 'error') !== null;
+ }
+
+ /**
+ * @param array $data
+ * @return Generator
+ */
+ protected function handleErrors(array $data, Request $request): Generator
+ {
+ $error = data_get($data, 'error', []);
+ $type = data_get($error, 'type', data_get($error, 'code', 'unknown_error'));
+ $message = data_get($error, 'message', 'No error message provided');
+
+ if ($type === 'rate_limit_exceeded' || $type === '429') {
+ throw new PrismRateLimitedException([]);
+ }
+
+ yield new ErrorEvent(
+ id: EventID::generate(),
+ timestamp: time(),
+ errorType: $type,
+ message: $message,
+ recoverable: false
+ );
+ }
+
+ /**
+ * @param array> $toolCalls
+ * @return Generator
+ */
+ protected function handleToolCalls(Request $request, string $text, array $toolCalls, int $depth): Generator
+ {
+ $mappedToolCalls = $this->mapToolCalls($toolCalls);
+
+ foreach ($mappedToolCalls as $toolCall) {
+ yield new ToolCallEvent(
+ id: EventID::generate(),
+ timestamp: time(),
+ toolCall: $toolCall,
+ messageId: $this->state->messageId()
+ );
+ }
+
+ $toolResults = $this->callTools($request->tools(), $mappedToolCalls);
+
+ foreach ($toolResults as $result) {
+ yield new ToolResultEvent(
+ id: EventID::generate(),
+ timestamp: time(),
+ toolResult: $result,
+ messageId: $this->state->messageId()
+ );
+
+ foreach ($result->artifacts as $artifact) {
+ yield new ArtifactEvent(
+ id: EventID::generate(),
+ timestamp: time(),
+ artifact: $artifact,
+ toolCallId: $result->toolCallId,
+ toolName: $result->toolName,
+ messageId: $this->state->messageId(),
+ );
+ }
+ }
+
+ $request->addMessage(new AssistantMessage($text, $mappedToolCalls));
+ $request->addMessage(new ToolResultMessage($toolResults));
+
+ $this->state->resetTextState();
+ $this->state->withMessageId(EventID::generate());
+
+ if ($this->provider->usesV1ForModel($request->model())) {
+ yield from $this->processV1NativeStream($request, $depth + 1);
+
+ return;
+ }
+
+ $nextResponse = $this->sendRequest($request);
+ yield from $this->processStream($nextResponse, $request, $depth + 1, 0);
+ }
+
+ /**
+ * @param array> $toolCalls
+ * @return array
+ */
+ protected function mapToolCalls(array $toolCalls): array
+ {
+ return array_map(fn (array $toolCall): ToolCall => new ToolCall(
+ id: data_get($toolCall, 'id'),
+ name: data_get($toolCall, 'name'),
+ arguments: data_get($toolCall, 'arguments'),
+ ), $toolCalls);
+ }
+
+ /**
+ * @throws ConnectionException
+ */
+ protected function sendRequest(Request $request): Response
+ {
+ /** @var Response $response */
+ $response = $this->client
+ ->withOptions([
+ 'stream' => true,
+ ])
+ ->post(
+ 'chat/completions',
+ $this->buildRequestPayload($request, true)
+ );
+
+ if ($response->failed()) {
+ $exception = $response->toException();
+
+ if ($exception instanceof RequestException) {
+ $this->provider->handleRequestException($request->model(), $exception);
+ }
+ }
+
+ return $response;
+ }
+
+ protected function sendFallbackRequest(Request $request): Response
+ {
+ /** @var Response $response */
+ $response = $this->client
+ ->withOptions([
+ 'stream' => false,
+ ])
+ ->post(
+ 'chat/completions',
+ $this->buildRequestPayload($request, false)
+ );
+
+ if ($response->failed()) {
+ $exception = $response->toException();
+
+ if ($exception instanceof RequestException) {
+ $this->provider->handleRequestException($request->model(), $exception);
+ }
+ }
+
+ return $response;
+ }
+
+ /**
+ * @return array
+ */
+ protected function buildRequestPayload(Request $request, bool $stream): array
+ {
+ $tokenParameter = $this->tokenParameter($request->model());
+
+ return array_merge([
+ 'stream' => $stream ? true : null,
+ 'stream_options' => $stream ? ['include_usage' => true] : null,
+ 'model' => $this->provider->usesV1ForModel($request->model())
+ ? $this->provider->resolveModelIdentifier($request->model())
+ : null,
+ 'messages' => (new MessageMap($request->messages(), $request->systemPrompts()))(),
+ $tokenParameter => $request->maxTokens(),
+ ], Arr::whereNotNull([
+ 'temperature' => $request->temperature(),
+ 'top_p' => $request->topP(),
+ 'tools' => ToolMap::map($request->tools()) ?: null,
+ 'tool_choice' => ToolChoiceMap::map($request->toolChoice()),
+ 'reasoning_effort' => $request->providerOptions('reasoning_effort')
+ ?? $request->providerOptions('reasoning.effort'),
+ 'verbosity' => $request->providerOptions('verbosity')
+ ?? $request->providerOptions('text_verbosity'),
+ ]));
+ }
+
+ /**
+ * @return Generator
+ */
+ protected function emitFallbackNonStreamResponse(Request $request): Generator
+ {
+ $response = $this->sendFallbackRequest($request);
+ $payload = $response->json();
+
+ if (! is_array($payload)) {
+ yield new ErrorEvent(
+ id: EventID::generate(),
+ timestamp: time(),
+ errorType: 'empty_fallback_response',
+ message: 'Azure returned an invalid fallback response payload.',
+ recoverable: false
+ );
+
+ yield new StreamEndEvent(
+ id: EventID::generate(),
+ timestamp: time(),
+ finishReason: FinishReason::Stop,
+ usage: $this->state->usage()
+ );
+
+ return;
+ }
+
+ if ($this->state->shouldEmitStreamStart()) {
+ $this->state->withMessageId(EventID::generate())->markStreamStarted();
+
+ yield new StreamStartEvent(
+ id: EventID::generate(),
+ timestamp: time(),
+ model: $request->model(),
+ provider: 'azure'
+ );
+ }
+
+ $content = $this->extractFallbackContent($payload);
+ if ($content !== '') {
+ if ($this->state->shouldEmitTextStart()) {
+ $this->state->markTextStarted();
+
+ yield new TextStartEvent(
+ id: EventID::generate(),
+ timestamp: time(),
+ messageId: $this->state->messageId()
+ );
+ }
+
+ $chunks = $this->chunkFallbackText($content);
+ foreach ($chunks as $chunk) {
+ yield new TextDeltaEvent(
+ id: EventID::generate(),
+ timestamp: time(),
+ delta: $chunk,
+ messageId: $this->state->messageId()
+ );
+
+ // Emit chunks progressively so clients render streaming text.
+ usleep(12_000);
+ }
+
+ yield new TextCompleteEvent(
+ id: EventID::generate(),
+ timestamp: time(),
+ messageId: $this->state->messageId()
+ );
+ }
+
+ $finishReason = $this->mapFinishReason($payload);
+ $this->state->withFinishReason($finishReason);
+
+ $usage = $this->extractUsage($payload);
+ if ($usage instanceof Usage) {
+ $this->state->addUsage($usage);
+ }
+
+ yield new StreamEndEvent(
+ id: EventID::generate(),
+ timestamp: time(),
+ finishReason: $this->state->finishReason() ?? FinishReason::Stop,
+ usage: $this->state->usage()
+ );
+ }
+
+ /**
+ * @param array $payload
+ */
+ protected function extractFallbackContent(array $payload): string
+ {
+ $content = data_get($payload, 'choices.0.message.content');
+
+ if (is_string($content)) {
+ return $content;
+ }
+
+ if (! is_array($content)) {
+ return '';
+ }
+
+ $parts = [];
+ foreach ($content as $item) {
+ if (! is_array($item)) {
+ continue;
+ }
+
+ $text = data_get($item, 'text');
+ if (is_string($text) && $text !== '') {
+ $parts[] = $text;
+ }
+ }
+
+ return implode('', $parts);
+ }
+
+ /**
+ * @return array
+ */
+ protected function chunkFallbackText(string $text): array
+ {
+ $normalized = str_replace(["\r\n", "\r"], "\n", $text);
+ $length = mb_strlen($normalized);
+ $chunkSize = 80;
+ $chunks = [];
+
+ for ($offset = 0; $offset < $length; $offset += $chunkSize) {
+ $chunks[] = mb_substr($normalized, $offset, $chunkSize);
+ }
+
+ return $chunks === [] ? [$normalized] : $chunks;
+ }
+
+ protected function v1ChatCompletionsUrl(): string
+ {
+ $base = rtrim($this->provider->url, '/');
+
+ if (str_contains($base, '/chat/completions')) {
+ return $base;
+ }
+
+ if (str_contains($base, '/openai/v1')) {
+ return $base.'/chat/completions';
+ }
+
+ return $base.'/openai/v1/chat/completions';
+ }
+
+ protected function readLine(StreamInterface $stream): string
+ {
+ $buffer = '';
+ $idleReads = 0;
+ $maxIdleReads = 150; // up to ~1.5 seconds waiting for the next chunk
+
+ while (true) {
+ $byte = $stream->read(1);
+
+ if ($byte === '') {
+ if ($stream->eof()) {
+ if ($buffer !== '') {
+ break;
+ }
+
+ if ($idleReads >= $maxIdleReads) {
+ break;
+ }
+ }
+
+ $idleReads++;
+ usleep(10_000);
+
+ continue;
+ }
+
+ $idleReads = 0;
+ $buffer .= $byte;
+
+ if ($byte === "\n") {
+ break;
+ }
+ }
+
+ return $buffer;
+ }
+
+ protected function tokenParameter(string $model): string
+ {
+ return str_contains(mb_strtolower($model), 'gpt-5')
+ ? 'max_completion_tokens'
+ : 'max_tokens';
+ }
+}
diff --git a/src/Providers/Azure/Handlers/Structured.php b/src/Providers/Azure/Handlers/Structured.php
new file mode 100644
index 000000000..fc5ed27d3
--- /dev/null
+++ b/src/Providers/Azure/Handlers/Structured.php
@@ -0,0 +1,143 @@
+responseBuilder = new ResponseBuilder;
+ }
+
+ public function handle(Request $request): StructuredResponse
+ {
+ $request = $this->appendMessageForJsonMode($request);
+
+ $data = $this->sendRequest($request);
+
+ $this->validateResponse($data);
+
+ return $this->createResponse($request, $data);
+ }
+
+ /**
+ * @return array
+ */
+ protected function sendRequest(Request $request): array
+ {
+ $tokenParameter = $this->tokenParameter($request->model());
+
+ try {
+ /** @var Response $response */
+ $response = $this->client
+ ->throw()
+ ->post(
+ 'chat/completions',
+ array_merge([
+ 'model' => $this->provider->usesV1ForModel($request->model())
+ ? $this->provider->resolveModelIdentifier($request->model())
+ : null,
+ 'messages' => (new MessageMap($request->messages(), $request->systemPrompts()))(),
+ $tokenParameter => $request->maxTokens(),
+ ], Arr::whereNotNull([
+ 'temperature' => $request->temperature(),
+ 'top_p' => $request->topP(),
+ 'response_format' => ['type' => 'json_object'],
+ 'reasoning_effort' => $request->providerOptions('reasoning_effort')
+ ?? $request->providerOptions('reasoning.effort'),
+ 'verbosity' => $request->providerOptions('verbosity')
+ ?? $request->providerOptions('text_verbosity'),
+ ]))
+ );
+
+ return $response->json();
+ } catch (RequestException $e) {
+ $this->provider->handleRequestException($request->model(), $e);
+ }
+ }
+
+ /**
+ * @param array $data
+ */
+ protected function validateResponse(array $data): void
+ {
+ if ($data === []) {
+ throw PrismException::providerResponseError('Azure Error: Empty response');
+ }
+ }
+
+ /**
+ * @param array $data
+ */
+ protected function createResponse(Request $request, array $data): StructuredResponse
+ {
+ $text = data_get($data, 'choices.0.message.content') ?? '';
+
+ $responseMessage = new AssistantMessage($text);
+ $request->addMessage($responseMessage);
+
+ $step = new Step(
+ text: $text,
+ finishReason: FinishReasonMap::map(data_get($data, 'choices.0.finish_reason', '')),
+ usage: new Usage(
+ promptTokens: max(0, (int) data_get($data, 'usage.prompt_tokens', 0) - (int) data_get($data, 'usage.prompt_tokens_details.cached_tokens', 0)),
+ completionTokens: (int) data_get($data, 'usage.completion_tokens', 0),
+ cacheReadInputTokens: (int) data_get($data, 'usage.prompt_tokens_details.cached_tokens', 0) ?: null,
+ ),
+ meta: new Meta(
+ id: data_get($data, 'id'),
+ model: data_get($data, 'model'),
+ ),
+ messages: $request->messages(),
+ systemPrompts: $request->systemPrompts(),
+ additionalContent: [],
+ );
+
+ $this->responseBuilder->addStep($step);
+
+ return $this->responseBuilder->toResponse();
+ }
+
+ protected function appendMessageForJsonMode(Request $request): Request
+ {
+ return $request->addMessage(new SystemMessage(sprintf(
+ "You MUST respond EXCLUSIVELY with a JSON object that strictly adheres to the following schema. \n Do NOT explain or add other content. Validate your response against this schema \n %s",
+ json_encode($request->schema()->toArray(), JSON_PRETTY_PRINT)
+ )));
+ }
+
+ protected function tokenParameter(string $model): string
+ {
+ return str_contains(mb_strtolower($model), 'gpt-5')
+ ? 'max_completion_tokens'
+ : 'max_tokens';
+ }
+}
diff --git a/src/Providers/Azure/Handlers/Text.php b/src/Providers/Azure/Handlers/Text.php
new file mode 100644
index 000000000..38eb661d7
--- /dev/null
+++ b/src/Providers/Azure/Handlers/Text.php
@@ -0,0 +1,191 @@
+responseBuilder = new ResponseBuilder;
+ }
+
+ public function handle(Request $request): TextResponse
+ {
+ $this->resolveToolApprovals($request);
+
+ $data = $this->sendRequest($request);
+
+ $this->validateResponse($data);
+
+ $responseMessage = new AssistantMessage(
+ data_get($data, 'choices.0.message.content') ?? '',
+ ToolCallMap::map(data_get($data, 'choices.0.message.tool_calls', [])),
+ []
+ );
+
+ $request = $request->addMessage($responseMessage);
+
+ return match ($this->mapFinishReason($data)) {
+ FinishReason::ToolCalls => $this->handleToolCalls($data, $request),
+ FinishReason::Stop => $this->handleStop($data, $request),
+ FinishReason::Length => throw new PrismException('Azure: max tokens exceeded'),
+ FinishReason::ContentFilter => throw new PrismException('Azure: content filter triggered'),
+ default => throw new PrismException('Azure: unknown finish reason'),
+ };
+ }
+
+ /**
+ * @param array $data
+ */
+ protected function handleToolCalls(array $data, Request $request): TextResponse
+ {
+ $toolCalls = ToolCallMap::map(data_get($data, 'choices.0.message.tool_calls', []));
+
+ $hasPendingToolCalls = false;
+ $approvalRequests = [];
+ $toolResults = $this->callToolsWithPending($request->tools(), $toolCalls, $hasPendingToolCalls, $approvalRequests);
+
+ if ($approvalRequests !== []) {
+ // Replace the assistant message appended in handle() with one
+ // carrying the approval requests so the resume pass correlates them.
+ $messages = $request->messages();
+ array_pop($messages);
+ $request->setMessages($messages);
+ $request->addMessage(new AssistantMessage(
+ data_get($data, 'choices.0.message.content') ?? '',
+ $toolCalls,
+ toolApprovalRequests: $approvalRequests,
+ ));
+ }
+
+ $request = $request->addMessage(new ToolResultMessage($toolResults));
+
+ $this->addStep($data, $request, $toolResults);
+
+ if (! $hasPendingToolCalls && $this->shouldContinue($request)) {
+ return $this->handle($request);
+ }
+
+ return $this->responseBuilder->toResponse();
+ }
+
+ /**
+ * @param array $data
+ */
+ protected function handleStop(array $data, Request $request): TextResponse
+ {
+ $this->addStep($data, $request);
+
+ return $this->responseBuilder->toResponse();
+ }
+
+ protected function shouldContinue(Request $request): bool
+ {
+ return $this->responseBuilder->steps->count() < $request->maxSteps();
+ }
+
+ /**
+ * @return array
+ */
+ protected function sendRequest(Request $request): array
+ {
+ $tokenParameter = $this->tokenParameter($request->model());
+
+ try {
+ /** @var Response $response */
+ $response = $this->client
+ ->throw()
+ ->post(
+ 'chat/completions',
+ array_merge([
+ 'model' => $this->provider->usesV1ForModel($request->model())
+ ? $this->provider->resolveModelIdentifier($request->model())
+ : null,
+ 'messages' => (new MessageMap($request->messages(), $request->systemPrompts()))(),
+ $tokenParameter => $request->maxTokens(),
+ ], Arr::whereNotNull([
+ 'temperature' => $request->temperature(),
+ 'top_p' => $request->topP(),
+ 'tools' => ToolMap::map($request->tools()) ?: null,
+ 'tool_choice' => ToolChoiceMap::map($request->toolChoice()),
+ 'reasoning_effort' => $request->providerOptions('reasoning_effort')
+ ?? $request->providerOptions('reasoning.effort'),
+ 'verbosity' => $request->providerOptions('verbosity')
+ ?? $request->providerOptions('text_verbosity'),
+ ]))
+ );
+
+ return $response->json();
+ } catch (RequestException $e) {
+ $this->provider->handleRequestException($request->model(), $e);
+ }
+ }
+
+ /**
+ * @param array $data
+ * @param array $toolResults
+ */
+ protected function addStep(array $data, Request $request, array $toolResults = []): void
+ {
+ $this->responseBuilder->addStep(new Step(
+ text: data_get($data, 'choices.0.message.content') ?? '',
+ finishReason: $this->mapFinishReason($data),
+ toolCalls: ToolCallMap::map(data_get($data, 'choices.0.message.tool_calls', [])),
+ toolResults: $toolResults,
+ providerToolCalls: [],
+ usage: new Usage(
+ promptTokens: max(0, (int) data_get($data, 'usage.prompt_tokens', 0) - (int) data_get($data, 'usage.prompt_tokens_details.cached_tokens', 0)),
+ completionTokens: (int) data_get($data, 'usage.completion_tokens', 0),
+ cacheReadInputTokens: (int) data_get($data, 'usage.prompt_tokens_details.cached_tokens', 0) ?: null,
+ ),
+ meta: new Meta(
+ id: data_get($data, 'id'),
+ model: data_get($data, 'model'),
+ ),
+ messages: $request->messages(),
+ systemPrompts: $request->systemPrompts(),
+ additionalContent: [],
+ ));
+ }
+
+ protected function tokenParameter(string $model): string
+ {
+ return str_contains(mb_strtolower($model), 'gpt-5')
+ ? 'max_completion_tokens'
+ : 'max_tokens';
+ }
+}
diff --git a/src/Providers/Azure/Maps/FinishReasonMap.php b/src/Providers/Azure/Maps/FinishReasonMap.php
new file mode 100644
index 000000000..af08d6547
--- /dev/null
+++ b/src/Providers/Azure/Maps/FinishReasonMap.php
@@ -0,0 +1,21 @@
+ FinishReason::Stop,
+ 'tool_calls' => FinishReason::ToolCalls,
+ 'length' => FinishReason::Length,
+ 'content_filter' => FinishReason::ContentFilter,
+ default => FinishReason::Unknown,
+ };
+ }
+}
diff --git a/src/Providers/Azure/Maps/ImageMapper.php b/src/Providers/Azure/Maps/ImageMapper.php
new file mode 100644
index 000000000..0543c1fac
--- /dev/null
+++ b/src/Providers/Azure/Maps/ImageMapper.php
@@ -0,0 +1,44 @@
+
+ */
+ public function toPayload(): array
+ {
+ return [
+ 'type' => 'image_url',
+ 'image_url' => [
+ 'url' => $this->media->isUrl()
+ ? $this->media->url()
+ : sprintf('data:%s;base64,%s', $this->media->mimeType(), $this->media->base64()),
+ ],
+ ];
+ }
+
+ protected function provider(): string|Provider
+ {
+ return Provider::Azure;
+ }
+
+ protected function validateMedia(): bool
+ {
+ if ($this->media->isUrl()) {
+ return true;
+ }
+
+ return $this->media->hasRawContent();
+ }
+}
diff --git a/src/Providers/Azure/Maps/ImageRequestMap.php b/src/Providers/Azure/Maps/ImageRequestMap.php
new file mode 100644
index 000000000..4ced15206
--- /dev/null
+++ b/src/Providers/Azure/Maps/ImageRequestMap.php
@@ -0,0 +1,43 @@
+
+ */
+ public static function map(Request $request, Azure $provider): array
+ {
+ $baseData = Arr::whereNotNull([
+ 'prompt' => $request->prompt(),
+ ]);
+
+ // Include model in payload for v1 endpoints
+ if ($provider->usesV1ForModel($request->model())) {
+ $baseData['model'] = $request->model();
+ }
+
+ $providerOptions = $request->providerOptions();
+
+ $supportedOptions = Arr::whereNotNull([
+ 'n' => $providerOptions['n'] ?? null,
+ 'size' => $providerOptions['size'] ?? null,
+ 'response_format' => $providerOptions['response_format'] ?? null,
+ 'quality' => $providerOptions['quality'] ?? null,
+ 'style' => $providerOptions['style'] ?? null,
+ 'output_format' => $providerOptions['output_format'] ?? null,
+ ]);
+
+ // Include any additional provider options not explicitly handled
+ $additionalOptions = array_diff_key($providerOptions, $supportedOptions);
+
+ return array_merge($baseData, $supportedOptions, $additionalOptions);
+ }
+}
diff --git a/src/Providers/Azure/Maps/MessageMap.php b/src/Providers/Azure/Maps/MessageMap.php
new file mode 100644
index 000000000..a10e1a099
--- /dev/null
+++ b/src/Providers/Azure/Maps/MessageMap.php
@@ -0,0 +1,108 @@
+ */
+ protected array $mappedMessages = [];
+
+ /**
+ * @param array $messages
+ * @param SystemMessage[] $systemPrompts
+ */
+ public function __construct(
+ protected array $messages,
+ protected array $systemPrompts
+ ) {
+ $this->messages = array_merge(
+ $this->systemPrompts,
+ $this->messages
+ );
+ }
+
+ /**
+ * @return array
+ */
+ public function __invoke(): array
+ {
+ array_map(
+ $this->mapMessage(...),
+ $this->messages
+ );
+
+ return $this->mappedMessages;
+ }
+
+ protected function mapMessage(Message $message): void
+ {
+ match ($message::class) {
+ UserMessage::class => $this->mapUserMessage($message),
+ AssistantMessage::class => $this->mapAssistantMessage($message),
+ ToolResultMessage::class => $this->mapToolResultMessage($message),
+ SystemMessage::class => $this->mapSystemMessage($message),
+ default => throw new Exception('Could not map message type '.$message::class),
+ };
+ }
+
+ protected function mapSystemMessage(SystemMessage $message): void
+ {
+ $this->mappedMessages[] = [
+ 'role' => 'system',
+ 'content' => $message->content,
+ ];
+ }
+
+ protected function mapToolResultMessage(ToolResultMessage $message): void
+ {
+ foreach ($message->toolResults as $toolResult) {
+ $this->mappedMessages[] = [
+ 'role' => 'tool',
+ 'tool_call_id' => $toolResult->toolCallId,
+ 'content' => $toolResult->result,
+ ];
+ }
+ }
+
+ protected function mapUserMessage(UserMessage $message): void
+ {
+ $imageParts = array_map(fn (Image $image): array => (new ImageMapper($image))->toPayload(), $message->images());
+
+ $this->mappedMessages[] = [
+ 'role' => 'user',
+ 'content' => [
+ ['type' => 'text', 'text' => $message->text()],
+ ...$imageParts,
+ ],
+ ];
+ }
+
+ protected function mapAssistantMessage(AssistantMessage $message): void
+ {
+ $toolCalls = array_map(fn (ToolCall $toolCall): array => [
+ 'id' => $toolCall->id,
+ 'type' => 'function',
+ 'function' => [
+ 'name' => $toolCall->name,
+ 'arguments' => json_encode($toolCall->arguments()),
+ ],
+ ], $message->toolCalls);
+
+ $this->mappedMessages[] = array_filter([
+ 'role' => 'assistant',
+ 'content' => $message->content,
+ 'tool_calls' => $toolCalls,
+ ]);
+ }
+}
diff --git a/src/Providers/Azure/Maps/ToolCallMap.php b/src/Providers/Azure/Maps/ToolCallMap.php
new file mode 100644
index 000000000..d15a02ecf
--- /dev/null
+++ b/src/Providers/Azure/Maps/ToolCallMap.php
@@ -0,0 +1,23 @@
+> $toolCalls
+ * @return array
+ */
+ public static function map(array $toolCalls): array
+ {
+ return array_map(fn (array $toolCall): ToolCall => new ToolCall(
+ id: data_get($toolCall, 'id'),
+ name: data_get($toolCall, 'function.name'),
+ arguments: data_get($toolCall, 'function.arguments'),
+ ), $toolCalls);
+ }
+}
diff --git a/src/Providers/Azure/Maps/ToolChoiceMap.php b/src/Providers/Azure/Maps/ToolChoiceMap.php
new file mode 100644
index 000000000..24d2e492f
--- /dev/null
+++ b/src/Providers/Azure/Maps/ToolChoiceMap.php
@@ -0,0 +1,32 @@
+|string|null
+ */
+ public static function map(string|ToolChoice|null $toolChoice): string|array|null
+ {
+ if (is_string($toolChoice)) {
+ return [
+ 'type' => 'function',
+ 'function' => [
+ 'name' => $toolChoice,
+ ],
+ ];
+ }
+
+ return match ($toolChoice) {
+ ToolChoice::Auto => 'auto',
+ ToolChoice::Any => 'required',
+ ToolChoice::None => 'none',
+ null => $toolChoice,
+ };
+ }
+}
diff --git a/src/Providers/Azure/Maps/ToolMap.php b/src/Providers/Azure/Maps/ToolMap.php
new file mode 100644
index 000000000..921c788e8
--- /dev/null
+++ b/src/Providers/Azure/Maps/ToolMap.php
@@ -0,0 +1,33 @@
+
+ */
+ public static function map(array $tools): array
+ {
+ return array_map(fn (Tool $tool): array => array_filter([
+ 'type' => 'function',
+ 'function' => [
+ 'name' => $tool->name(),
+ 'description' => $tool->description(),
+ ...$tool->hasParameters() ? [
+ 'parameters' => [
+ 'type' => 'object',
+ 'properties' => $tool->parametersAsArray(),
+ 'required' => $tool->requiredParameters(),
+ ],
+ ] : [],
+ ],
+ 'strict' => $tool->providerOptions('strict'),
+ ]), $tools);
+ }
+}
diff --git a/src/Providers/DeepSeek/Handlers/Stream.php b/src/Providers/DeepSeek/Handlers/Stream.php
index 8ae26e57b..bc1cdc016 100644
--- a/src/Providers/DeepSeek/Handlers/Stream.php
+++ b/src/Providers/DeepSeek/Handlers/Stream.php
@@ -57,6 +57,8 @@ public function __construct(protected PendingRequest $client)
*/
public function handle(Request $request): Generator
{
+ yield from $this->resolveToolApprovalsAndYieldEvents($request, EventID::generate());
+
$response = $this->sendRequest($request);
yield from $this->processStream($response, $request);
@@ -357,9 +359,15 @@ protected function extractUsage(array $data): ?Usage
return null;
}
+ $totalPrompt = (int) data_get($usage, 'prompt_tokens', 0);
+ $cacheHit = (int) data_get($usage, 'prompt_cache_hit_tokens', 0);
+ $reasoning = (int) data_get($usage, 'completion_tokens_details.reasoning_tokens', 0);
+
return new Usage(
- promptTokens: (int) data_get($usage, 'prompt_tokens', 0),
- completionTokens: (int) data_get($usage, 'completion_tokens', 0)
+ promptTokens: max(0, $totalPrompt - $cacheHit),
+ completionTokens: (int) data_get($usage, 'completion_tokens', 0),
+ cacheReadInputTokens: $cacheHit > 0 ? $cacheHit : null,
+ thoughtTokens: $reasoning > 0 ? $reasoning : null,
);
}
@@ -381,7 +389,17 @@ protected function handleToolCalls(Request $request, string $text, array $toolCa
}
$toolResults = [];
- yield from $this->callToolsAndYieldEvents($request->tools(), $mappedToolCalls, $this->state->messageId(), $toolResults);
+ $hasPendingToolCalls = false;
+ yield from $this->callToolsAndYieldEventsWithPending($request->tools(), $mappedToolCalls, $this->state->messageId(), $toolResults, $hasPendingToolCalls);
+
+ if ($hasPendingToolCalls) {
+ // Client-executed or approval-required tool calls: end the stream
+ // with FinishReason::ToolCalls so the consumer resolves and resumes.
+ $this->state->markStepFinished();
+ yield from $this->yieldToolCallsFinishEvents($this->state);
+
+ return;
+ }
$this->state->markStepFinished();
yield new StepFinishEvent(
@@ -424,7 +442,7 @@ protected function mapToolCalls(array $toolCalls): array
protected function sendRequest(Request $request): Response
{
/** @var Response $response */
- $response = $this->client->post(
+ $response = $this->client->withOptions(['stream' => true])->post(
'chat/completions',
array_merge([
'stream' => true,
diff --git a/src/Providers/DeepSeek/Handlers/Text.php b/src/Providers/DeepSeek/Handlers/Text.php
index c8a15d253..0a78b9194 100644
--- a/src/Providers/DeepSeek/Handlers/Text.php
+++ b/src/Providers/DeepSeek/Handlers/Text.php
@@ -9,7 +9,6 @@
use Illuminate\Support\Arr;
use Prism\Prism\Concerns\CallsTools;
use Prism\Prism\Enums\FinishReason;
-use Prism\Prism\Exceptions\PrismException;
use Prism\Prism\Providers\DeepSeek\Concerns\MapsFinishReason;
use Prism\Prism\Providers\DeepSeek\Concerns\ValidatesResponses;
use Prism\Prism\Providers\DeepSeek\Maps\MessageMap;
@@ -41,14 +40,15 @@ public function __construct(protected PendingRequest $client)
public function handle(Request $request): TextResponse
{
+ $this->resolveToolApprovals($request);
+
$data = $this->sendRequest($request);
$this->validateResponse($data);
return match ($this->mapFinishReason($data)) {
FinishReason::ToolCalls => $this->handleToolCalls($data, $request),
- FinishReason::Stop => $this->handleStop($data, $request),
- default => throw new PrismException('DeepSeek: unknown finish reason'),
+ default => $this->handleStop($data, $request),
};
}
@@ -59,19 +59,25 @@ protected function handleToolCalls(array $data, Request $request): TextResponse
{
$toolCalls = ToolCallMap::map(data_get($data, 'choices.0.message.tool_calls', []));
- $toolResults = $this->callTools($request->tools(), $toolCalls);
+ $hasPendingToolCalls = false;
+ $approvalRequests = [];
+ $toolResults = $this->callToolsWithPending($request->tools(), $toolCalls, $hasPendingToolCalls, $approvalRequests);
$this->addStep($data, $request, $toolResults);
+ $reasoningContent = data_get($data, 'choices.0.message.reasoning_content');
+ $additionalContent = $reasoningContent ? ['reasoning_content' => $reasoningContent] : [];
+
$request = $request->addMessage(new AssistantMessage(
data_get($data, 'choices.0.message.content') ?? '',
$toolCalls,
- []
+ $additionalContent,
+ toolApprovalRequests: $approvalRequests,
));
$request = $request->addMessage(new ToolResultMessage($toolResults));
$request->resetToolChoice();
- if ($this->shouldContinue($request)) {
+ if (! $hasPendingToolCalls && $this->shouldContinue($request)) {
return $this->handle($request);
}
@@ -122,6 +128,11 @@ protected function sendRequest(Request $request): array
*/
protected function addStep(array $data, Request $request, array $toolResults = []): void
{
+ $totalPrompt = (int) (data_get($data, 'usage.prompt_tokens') ?? 0);
+ $cacheHit = (int) (data_get($data, 'usage.prompt_cache_hit_tokens') ?? 0);
+ $reasoning = (int) (data_get($data, 'usage.completion_tokens_details.reasoning_tokens') ?? 0);
+ $reasoningContent = data_get($data, 'choices.0.message.reasoning_content');
+
$this->responseBuilder->addStep(new Step(
text: data_get($data, 'choices.0.message.content') ?? '',
finishReason: $this->mapFinishReason($data),
@@ -129,8 +140,10 @@ protected function addStep(array $data, Request $request, array $toolResults = [
toolResults: $toolResults,
providerToolCalls: [],
usage: new Usage(
- data_get($data, 'usage.prompt_tokens'),
- data_get($data, 'usage.completion_tokens'),
+ promptTokens: max(0, $totalPrompt - $cacheHit),
+ completionTokens: (int) (data_get($data, 'usage.completion_tokens') ?? 0),
+ cacheReadInputTokens: $cacheHit > 0 ? $cacheHit : null,
+ thoughtTokens: $reasoning > 0 ? $reasoning : null,
),
meta: new Meta(
id: data_get($data, 'id'),
@@ -138,7 +151,7 @@ protected function addStep(array $data, Request $request, array $toolResults = [
),
messages: $request->messages(),
systemPrompts: $request->systemPrompts(),
- additionalContent: [],
+ additionalContent: $reasoningContent ? ['reasoning_content' => $reasoningContent] : [],
raw: $data,
));
}
diff --git a/src/Providers/DeepSeek/Maps/MessageMap.php b/src/Providers/DeepSeek/Maps/MessageMap.php
index c75352761..f414e498a 100644
--- a/src/Providers/DeepSeek/Maps/MessageMap.php
+++ b/src/Providers/DeepSeek/Maps/MessageMap.php
@@ -99,10 +99,10 @@ protected function mapAssistantMessage(AssistantMessage $message): void
],
], $message->toolCalls);
- $this->mappedMessages[] = array_filter([
+ $this->mappedMessages[] = array_filter(array_merge([
'role' => 'assistant',
'content' => $message->content,
'tool_calls' => $toolCalls,
- ]);
+ ], $message->additionalContent));
}
}
diff --git a/src/Providers/Gemini/Concerns/ProcessesRateLimits.php b/src/Providers/Gemini/Concerns/ProcessesRateLimits.php
new file mode 100644
index 000000000..0e06269f1
--- /dev/null
+++ b/src/Providers/Gemini/Concerns/ProcessesRateLimits.php
@@ -0,0 +1,79 @@
+ $responseData
+ * @return ProviderRateLimit[]
+ */
+ protected function processRateLimits(array $responseData): array
+ {
+ $quotaViolations = data_get($this->responseDetail($responseData, 'QuotaFailure'), 'violations', []);
+
+ if (! is_array($quotaViolations)) {
+ return [];
+ }
+
+ $resetsAt = $this->buildResetsAtFromResponse($responseData);
+
+ return collect($quotaViolations)
+ ->filter(fn (mixed $violation): bool => is_array($violation))
+ ->map(fn (array $violation): ProviderRateLimit => new ProviderRateLimit(
+ name: (string) data_get($violation, 'quotaId', data_get($violation, 'quotaMetric', 'quota')),
+ limit: $this->toNullableInt(data_get($violation, 'quotaValue')),
+ remaining: null,
+ resetsAt: $resetsAt
+ ))
+ ->all();
+ }
+
+ /**
+ * @param array $responseData
+ */
+ protected function extractRetryAfterSeconds(array $responseData): ?int
+ {
+ $retryDelay = data_get($this->responseDetail($responseData, 'RetryInfo'), 'retryDelay');
+
+ if (is_string($retryDelay) && preg_match('/^(?\d+)(?:\.\d+)?s$/', $retryDelay, $matches) === 1) {
+ return (int) $matches['seconds'];
+ }
+
+ return null;
+ }
+
+ /**
+ * @param array $responseData
+ */
+ private function buildResetsAtFromResponse(array $responseData): ?Carbon
+ {
+ $retryAfter = $this->extractRetryAfterSeconds($responseData);
+
+ return $retryAfter === null ? null : Carbon::now()->addSeconds($retryAfter);
+ }
+
+ private function toNullableInt(mixed $value): ?int
+ {
+ return is_int($value) || (is_string($value) && preg_match('/^\d+$/', $value) === 1)
+ ? (int) $value
+ : null;
+ }
+
+ /**
+ * @param array $responseData
+ */
+ private function responseDetail(array $responseData, string $type): mixed
+ {
+ $details = data_get($responseData, 'error.details', []);
+
+ return is_array($details)
+ ? collect($details)->first(fn (mixed $detail): bool => data_get($detail, '@type') === "type.googleapis.com/google.rpc.$type")
+ : null;
+ }
+}
diff --git a/src/Providers/Gemini/Gemini.php b/src/Providers/Gemini/Gemini.php
index f8e845aaa..5673bbcf7 100644
--- a/src/Providers/Gemini/Gemini.php
+++ b/src/Providers/Gemini/Gemini.php
@@ -18,6 +18,7 @@
use Prism\Prism\Exceptions\PrismRateLimitedException;
use Prism\Prism\Images\Request as ImagesRequest;
use Prism\Prism\Images\Response as ImagesResponse;
+use Prism\Prism\Providers\Gemini\Concerns\ProcessesRateLimits;
use Prism\Prism\Providers\Gemini\Handlers\Audio;
use Prism\Prism\Providers\Gemini\Handlers\Cache;
use Prism\Prism\Providers\Gemini\Handlers\Embeddings;
@@ -36,6 +37,7 @@
class Gemini extends Provider
{
use InitializesClient;
+ use ProcessesRateLimits;
public function __construct(
#[\SensitiveParameter] public readonly string $apiKey,
@@ -110,8 +112,13 @@ public function stream(TextRequest $request): Generator
public function handleRequestException(string $model, RequestException $e): never
{
+ $responseData = $e->response->json() ?? [];
+
match ($e->response->getStatusCode()) {
- 429 => throw PrismRateLimitedException::make([]),
+ 429 => throw PrismRateLimitedException::make(
+ rateLimits: $this->processRateLimits($responseData),
+ retryAfter: $this->extractRetryAfterSeconds($responseData)
+ ),
503 => throw PrismProviderOverloadedException::make(class_basename($this)),
default => $this->handleResponseErrors($e),
};
diff --git a/src/Providers/Gemini/Handlers/Stream.php b/src/Providers/Gemini/Handlers/Stream.php
index e7acfad33..d899458aa 100644
--- a/src/Providers/Gemini/Handlers/Stream.php
+++ b/src/Providers/Gemini/Handlers/Stream.php
@@ -14,7 +14,7 @@
use Prism\Prism\Exceptions\PrismStreamDecodeException;
use Prism\Prism\Providers\Gemini\Maps\FinishReasonMap;
use Prism\Prism\Providers\Gemini\Maps\MessageMap;
-use Prism\Prism\Providers\Gemini\Maps\ToolChoiceMap;
+use Prism\Prism\Providers\Gemini\Maps\ToolConfigMap;
use Prism\Prism\Providers\Gemini\Maps\ToolMap;
use Prism\Prism\Streaming\EventID;
use Prism\Prism\Streaming\Events\StepFinishEvent;
@@ -58,6 +58,8 @@ public function __construct(
*/
public function handle(Request $request): Generator
{
+ yield from $this->resolveToolApprovalsAndYieldEvents($request, EventID::generate());
+
$this->state->reset();
$this->currentThoughtSignature = null;
$response = $this->sendRequest($request);
@@ -241,7 +243,8 @@ protected function processStream(Response $response, Request $request, int $dept
$this->state->markStepFinished();
yield new StepFinishEvent(
id: EventID::generate(),
- timestamp: time()
+ timestamp: time(),
+ usage: $this->state->usage(),
);
yield $this->emitStreamEndEvent();
@@ -334,14 +337,25 @@ protected function handleToolCalls(
// Execute tools and emit results
$toolResults = [];
- yield from $this->callToolsAndYieldEvents($request->tools(), $mappedToolCalls, $this->state->messageId(), $toolResults);
+ $hasPendingToolCalls = false;
+ yield from $this->callToolsAndYieldEventsWithPending($request->tools(), $mappedToolCalls, $this->state->messageId(), $toolResults, $hasPendingToolCalls);
+
+ if ($hasPendingToolCalls) {
+ // Client-executed or approval-required tool calls: end the stream
+ // with FinishReason::ToolCalls so the consumer resolves and resumes.
+ $this->state->markStepFinished();
+ yield from $this->yieldToolCallsFinishEvents($this->state);
+
+ return;
+ }
if ($toolResults !== []) {
// Emit step finish after tool calls
$this->state->markStepFinished();
yield new StepFinishEvent(
id: EventID::generate(),
- timestamp: time()
+ timestamp: time(),
+ usage: $this->state->usage(),
);
$request->addMessage(new AssistantMessage($this->state->currentText(), $mappedToolCalls));
@@ -415,12 +429,8 @@ protected function hasToolCalls(array $data): bool
*/
protected function extractUsage(array $data, Request $request): Usage
{
- $providerOptions = $request->providerOptions();
-
return new Usage(
- promptTokens: isset($providerOptions['cachedContentName'])
- ? (data_get($data, 'usageMetadata.promptTokenCount', 0) - data_get($data, 'usageMetadata.cachedContentTokenCount', 0))
- : data_get($data, 'usageMetadata.promptTokenCount', 0),
+ promptTokens: max(0, (int) data_get($data, 'usageMetadata.promptTokenCount', 0) - (int) data_get($data, 'usageMetadata.cachedContentTokenCount', 0)),
completionTokens: data_get($data, 'usageMetadata.candidatesTokenCount', 0),
cacheReadInputTokens: data_get($data, 'usageMetadata.cachedContentTokenCount'),
thoughtTokens: data_get($data, 'usageMetadata.thoughtsTokenCount'),
@@ -447,13 +457,8 @@ protected function sendRequest(Request $request): Response
{
$providerOptions = $request->providerOptions();
- if ($request->tools() !== [] && $request->providerTools() !== []) {
- throw new PrismException('Use of provider tools with custom tools is not currently supported by Gemini.');
- }
-
- if ($request->tools() !== [] && ($providerOptions['searchGrounding'] ?? false)) {
- throw new PrismException('Use of search grounding with custom tools is not currently supported by Prism.');
- }
+ $hasSearchGrounding = (bool) ($providerOptions['searchGrounding'] ?? false);
+ $hasBothToolTypes = $request->tools() !== [] && ($request->providerTools() !== [] || $hasSearchGrounding);
$tools = [];
@@ -464,14 +469,16 @@ protected function sendRequest(Request $request): Response
],
$request->providerTools()
);
- } elseif ($providerOptions['searchGrounding'] ?? false) {
+ } elseif ($hasSearchGrounding) {
$tools = [
[
'google_search' => (object) [],
],
];
- } elseif ($request->tools() !== []) {
- $tools = ['function_declarations' => ToolMap::map($request->tools())];
+ }
+
+ if ($request->tools() !== []) {
+ $tools['function_declarations'] = ToolMap::map($request->tools());
}
$thinkingConfig = $providerOptions['thinkingConfig'] ?? null;
@@ -490,6 +497,12 @@ protected function sendRequest(Request $request): Response
];
}
+ if ($request->reasoningEnabled() === false && $thinkingConfig === null) {
+ $thinkingConfig = ['thinkingBudget' => 0];
+ }
+
+ $toolConfig = ToolConfigMap::map($request->toolChoice(), $hasBothToolTypes);
+
/** @var Response $response */
$response = $this->client
->withOptions(['stream' => true])
@@ -501,12 +514,14 @@ protected function sendRequest(Request $request): Response
'generationConfig' => Arr::whereNotNull([
'temperature' => $request->temperature(),
'topP' => $request->topP(),
+ 'topK' => $request->topK(),
'maxOutputTokens' => $request->maxTokens(),
'thinkingConfig' => $thinkingConfig,
]) ?: null,
'tools' => $tools !== [] ? $tools : null,
- 'tool_config' => $request->toolChoice() ? ToolChoiceMap::map($request->toolChoice()) : null,
+ 'tool_config' => $toolConfig,
'safetySettings' => $providerOptions['safetySettings'] ?? null,
+ 'service_tier' => $providerOptions['serviceTier'] ?? null,
])
);
diff --git a/src/Providers/Gemini/Handlers/Structured.php b/src/Providers/Gemini/Handlers/Structured.php
index 6d4870f59..da1633b14 100644
--- a/src/Providers/Gemini/Handlers/Structured.php
+++ b/src/Providers/Gemini/Handlers/Structured.php
@@ -18,7 +18,7 @@
use Prism\Prism\Providers\Gemini\Maps\MessageMap;
use Prism\Prism\Providers\Gemini\Maps\SchemaMap;
use Prism\Prism\Providers\Gemini\Maps\ToolCallMap;
-use Prism\Prism\Providers\Gemini\Maps\ToolChoiceMap;
+use Prism\Prism\Providers\Gemini\Maps\ToolConfigMap;
use Prism\Prism\Providers\Gemini\Maps\ToolMap;
use Prism\Prism\Structured\Request;
use Prism\Prism\Structured\Response as StructuredResponse;
@@ -47,6 +47,8 @@ public function __construct(protected PendingRequest $client)
public function handle(Request $request): StructuredResponse
{
+ $this->resolveToolApprovals($request);
+
$data = $this->sendRequest($request);
$this->validateResponse($data);
@@ -67,8 +69,7 @@ public function handle(Request $request): StructuredResponse
return match ($finishReason) {
FinishReason::ToolCalls => $this->handleToolCalls($data, $request),
- FinishReason::Stop, FinishReason::Length => $this->handleStop($data, $request, $finishReason),
- default => throw new PrismException('Gemini: unhandled finish reason'),
+ default => $this->handleStop($data, $request, $finishReason),
};
}
@@ -79,9 +80,7 @@ public function sendRequest(Request $request): array
{
$providerOptions = $request->providerOptions();
- if ($request->tools() !== [] && $request->providerTools() !== []) {
- throw new PrismException('Use of provider tools with custom tools is not currently supported by Gemini.');
- }
+ $hasBothToolTypes = $request->tools() !== [] && $request->providerTools() !== [];
$tools = [];
@@ -97,10 +96,8 @@ public function sendRequest(Request $request): array
}
if ($request->tools() !== []) {
- $tools = [
- [
- 'function_declarations' => ToolMap::map($request->tools()),
- ],
+ $tools[] = [
+ 'function_declarations' => ToolMap::map($request->tools()),
];
}
@@ -120,6 +117,12 @@ public function sendRequest(Request $request): array
]);
}
+ if ($request->reasoningEnabled() === false && $thinkingConfig === null) {
+ $thinkingConfig = ['thinkingBudget' => 0];
+ }
+
+ $toolConfig = ToolConfigMap::map($request->toolChoice(), $hasBothToolTypes);
+
/** @var Response $response */
$response = $this->client->post(
"{$request->model()}:generateContent",
@@ -128,15 +131,21 @@ public function sendRequest(Request $request): array
'cachedContent' => $providerOptions['cachedContentName'] ?? null,
'generationConfig' => Arr::whereNotNull([
'response_mime_type' => 'application/json',
- 'response_schema' => (new SchemaMap($request->schema()))->toArray(),
+ // response_json_schema takes standard JSON Schema, which the
+ // Prism schema objects already emit natively (type arrays for
+ // nullability, null in nullable enums) — no OpenAPI-style
+ // SchemaMap conversion, which would lose nullable semantics.
+ 'response_json_schema' => $request->schema()->toArray(),
'temperature' => $request->temperature(),
'topP' => $request->topP(),
+ 'topK' => $request->topK(),
'maxOutputTokens' => $request->maxTokens(),
'thinkingConfig' => $thinkingConfig,
]),
'tools' => $tools !== [] ? $tools : null,
- 'tool_config' => $request->toolChoice() ? ToolChoiceMap::map($request->toolChoice()) : null,
+ 'tool_config' => $toolConfig,
'safetySettings' => $providerOptions['safetySettings'] ?? null,
+ 'service_tier' => $providerOptions['serviceTier'] ?? null,
])
);
@@ -202,17 +211,28 @@ protected function handleStop(array $data, Request $request, FinishReason $finis
*/
protected function handleToolCalls(array $data, Request $request): StructuredResponse
{
- $toolResults = $this->callTools(
- $request->tools(),
- ToolCallMap::map(data_get($data, 'candidates.0.content.parts', []))
- );
+ $toolCalls = ToolCallMap::map(data_get($data, 'candidates.0.content.parts', []));
+
+ $hasPendingToolCalls = false;
+ $approvalRequests = [];
+ $toolResults = $this->callToolsWithPending($request->tools(), $toolCalls, $hasPendingToolCalls, $approvalRequests);
+
+ if ($approvalRequests !== []) {
+ // Record the tool calls + approval requests so the resume pass
+ // can correlate approval responses.
+ $request->addMessage(new AssistantMessage(
+ $this->extractTextContent($data),
+ $toolCalls,
+ toolApprovalRequests: $approvalRequests,
+ ));
+ }
$request->addMessage(new ToolResultMessage($toolResults));
$request->resetToolChoice();
$this->addStep($data, $request, FinishReason::ToolCalls, $toolResults);
- if ($this->shouldContinue($request)) {
+ if (! $hasPendingToolCalls && $this->shouldContinue($request)) {
return $this->handle($request);
}
@@ -234,7 +254,7 @@ protected function addStep(array $data, Request $request, FinishReason $finishRe
text: $textContent,
finishReason: $finishReason,
usage: new Usage(
- promptTokens: data_get($data, 'usageMetadata.promptTokenCount', 0),
+ promptTokens: max(0, (int) data_get($data, 'usageMetadata.promptTokenCount', 0) - (int) data_get($data, 'usageMetadata.cachedContentTokenCount', 0)),
completionTokens: data_get($data, 'usageMetadata.candidatesTokenCount', 0),
cacheReadInputTokens: data_get($data, 'usageMetadata.cachedContentTokenCount'),
thoughtTokens: data_get($data, 'usageMetadata.thoughtsTokenCount'),
diff --git a/src/Providers/Gemini/Handlers/Text.php b/src/Providers/Gemini/Handlers/Text.php
index fce051b67..c0dcde2f1 100644
--- a/src/Providers/Gemini/Handlers/Text.php
+++ b/src/Providers/Gemini/Handlers/Text.php
@@ -9,13 +9,12 @@
use Illuminate\Support\Arr;
use Prism\Prism\Concerns\CallsTools;
use Prism\Prism\Enums\FinishReason;
-use Prism\Prism\Exceptions\PrismException;
use Prism\Prism\Providers\Gemini\Concerns\ValidatesResponse;
use Prism\Prism\Providers\Gemini\Maps\CitationMapper;
use Prism\Prism\Providers\Gemini\Maps\FinishReasonMap;
use Prism\Prism\Providers\Gemini\Maps\MessageMap;
use Prism\Prism\Providers\Gemini\Maps\ToolCallMap;
-use Prism\Prism\Providers\Gemini\Maps\ToolChoiceMap;
+use Prism\Prism\Providers\Gemini\Maps\ToolConfigMap;
use Prism\Prism\Providers\Gemini\Maps\ToolMap;
use Prism\Prism\Text\Request;
use Prism\Prism\Text\Response as TextResponse;
@@ -43,6 +42,8 @@ public function __construct(
public function handle(Request $request): TextResponse
{
+ $this->resolveToolApprovals($request);
+
$response = $this->sendRequest($request);
$this->validateResponse($response);
@@ -58,8 +59,7 @@ public function handle(Request $request): TextResponse
return match ($finishReason) {
FinishReason::ToolCalls => $this->handleToolCalls($data, $request),
- FinishReason::Stop, FinishReason::Length => $this->handleStop($data, $request, $finishReason),
- default => throw new PrismException('Gemini: unhandled finish reason'),
+ default => $this->handleStop($data, $request, $finishReason),
};
}
@@ -83,16 +83,19 @@ protected function sendRequest(Request $request): ClientResponse
]);
}
+ if ($request->reasoningEnabled() === false && $thinkingConfig === null) {
+ $thinkingConfig = ['thinkingBudget' => 0];
+ }
+
$generationConfig = Arr::whereNotNull([
'temperature' => $request->temperature(),
'topP' => $request->topP(),
+ 'topK' => $request->topK(),
'maxOutputTokens' => $request->maxTokens(),
'thinkingConfig' => $thinkingConfig,
]);
- if ($request->tools() !== [] && $request->providerTools() != []) {
- throw new PrismException('Use of provider tools with custom tools is not currently supported by Gemini.');
- }
+ $hasBothToolTypes = $request->tools() !== [] && $request->providerTools() !== [];
$tools = [];
@@ -109,6 +112,8 @@ protected function sendRequest(Request $request): ClientResponse
$tools['function_declarations'] = ToolMap::map($request->tools());
}
+ $toolConfig = ToolConfigMap::map($request->toolChoice(), $hasBothToolTypes);
+
/** @var ClientResponse $response */
$response = $this->client->post(
"{$request->model()}:generateContent",
@@ -117,8 +122,9 @@ protected function sendRequest(Request $request): ClientResponse
'cachedContent' => $providerOptions['cachedContentName'] ?? null,
'generationConfig' => $generationConfig !== [] ? $generationConfig : null,
'tools' => $tools !== [] ? $tools : null,
- 'tool_config' => $request->toolChoice() ? ToolChoiceMap::map($request->toolChoice()) : null,
+ 'tool_config' => $toolConfig,
'safetySettings' => $providerOptions['safetySettings'] ?? null,
+ 'service_tier' => $providerOptions['serviceTier'] ?? null,
])
);
@@ -142,18 +148,21 @@ protected function handleToolCalls(array $data, Request $request): TextResponse
{
$toolCalls = ToolCallMap::map(data_get($data, 'candidates.0.content.parts', []));
- $toolResults = $this->callTools($request->tools(), $toolCalls);
+ $hasPendingToolCalls = false;
+ $approvalRequests = [];
+ $toolResults = $this->callToolsWithPending($request->tools(), $toolCalls, $hasPendingToolCalls, $approvalRequests);
$this->addStep($data, $request, FinishReason::ToolCalls, $toolResults);
$request->addMessage(new AssistantMessage(
$this->extractTextContent($data),
$toolCalls,
+ toolApprovalRequests: $approvalRequests,
));
$request->addMessage(new ToolResultMessage($toolResults));
$request->resetToolChoice();
- if ($this->shouldContinue($request)) {
+ if (! $hasPendingToolCalls && $this->shouldContinue($request)) {
return $this->handle($request);
}
@@ -171,8 +180,6 @@ protected function shouldContinue(Request $request): bool
*/
protected function addStep(array $data, Request $request, FinishReason $finishReason, array $toolResults = []): void
{
- $providerOptions = $request->providerOptions();
-
$thoughtSummaries = $this->extractThoughtSummaries($data);
$this->responseBuilder->addStep(new Step(
@@ -182,9 +189,7 @@ protected function addStep(array $data, Request $request, FinishReason $finishRe
toolResults: $toolResults,
providerToolCalls: [],
usage: new Usage(
- promptTokens: isset($providerOptions['cachedContentName'])
- ? (data_get($data, 'usageMetadata.promptTokenCount', 0) - data_get($data, 'usageMetadata.cachedContentTokenCount', 0))
- : data_get($data, 'usageMetadata.promptTokenCount', 0),
+ promptTokens: max(0, (int) data_get($data, 'usageMetadata.promptTokenCount', 0) - (int) data_get($data, 'usageMetadata.cachedContentTokenCount', 0)),
completionTokens: data_get($data, 'usageMetadata.candidatesTokenCount', 0),
cacheReadInputTokens: data_get($data, 'usageMetadata.cachedContentTokenCount'),
thoughtTokens: data_get($data, 'usageMetadata.thoughtsTokenCount'),
diff --git a/src/Providers/Gemini/Maps/SchemaMap.php b/src/Providers/Gemini/Maps/SchemaMap.php
index 7c9f80a14..c8ddfc7b8 100644
--- a/src/Providers/Gemini/Maps/SchemaMap.php
+++ b/src/Providers/Gemini/Maps/SchemaMap.php
@@ -27,6 +27,13 @@ public function toArray(): array
// Remove unsupported fields
unset($schemaArray['additionalProperties'], $schemaArray['name']);
+ // Gemini's OpenAPI-flavoured schema expresses nullability via the
+ // `nullable` flag; a JSON Schema style null entry in the enum list is
+ // not a valid Gemini enum value.
+ if (isset($schemaArray['enum']) && is_array($schemaArray['enum'])) {
+ $schemaArray['enum'] = array_values(array_filter($schemaArray['enum'], fn ($option): bool => $option !== null));
+ }
+
if ($this->schema instanceof RawSchema) {
return $schemaArray;
}
@@ -55,6 +62,9 @@ public function toArray(): array
array_filter([
...$schemaArray,
'type' => $this->mapType(),
+ 'properties' => isset($schemaArray['properties'])
+ ? $this->normalizeProperties($schemaArray['properties'])
+ : null,
]),
array_filter([
'items' => property_exists($this->schema, 'items') && $this->schema->items
@@ -75,6 +85,41 @@ public function toArray(): array
);
}
+ /**
+ * Normalize properties that use JSON Schema array-type notation for nullability
+ * (e.g. ["integer", "null"]) into Gemini's supported format ("nullable": true).
+ *
+ * This handles schemas passed as raw arrays (e.g. from Laravel's JsonSchema
+ * serializer) which bypass Prism's native schema objects and are never run
+ * through the per-property SchemaMap normalization.
+ *
+ * @param array $properties
+ * @return array
+ */
+ protected function normalizeProperties(array $properties): array
+ {
+ return array_map(function (array $property): array {
+ if (! is_array($property['type'] ?? null)) {
+ return $property;
+ }
+
+ $types = $property['type'];
+ $nullIndex = array_search('null', $types, true);
+
+ if ($nullIndex === false) {
+ return $property;
+ }
+
+ unset($types[$nullIndex]);
+ $remaining = array_values($types);
+
+ $property['type'] = count($remaining) === 1 ? $remaining[0] : $remaining;
+ $property['nullable'] = true;
+
+ return $property;
+ }, $properties);
+ }
+
protected function mapType(): string
{
if ($this->schema instanceof ArraySchema) {
diff --git a/src/Providers/Gemini/Maps/ToolConfigMap.php b/src/Providers/Gemini/Maps/ToolConfigMap.php
new file mode 100644
index 000000000..5519b10cc
--- /dev/null
+++ b/src/Providers/Gemini/Maps/ToolConfigMap.php
@@ -0,0 +1,27 @@
+|null
+ */
+ public static function map(string|ToolChoice|null $toolChoice, bool $includeServerSideToolInvocations = false): ?array
+ {
+ $config = ToolChoiceMap::map($toolChoice);
+
+ /** @var array|null $config */
+ $config = is_array($config) ? $config : null;
+
+ if ($includeServerSideToolInvocations) {
+ return array_merge($config ?? [], ['includeServerSideToolInvocations' => true]);
+ }
+
+ return $config;
+ }
+}
diff --git a/src/Providers/Groq/Handlers/Stream.php b/src/Providers/Groq/Handlers/Stream.php
index eaddf4323..e821d7a9e 100644
--- a/src/Providers/Groq/Handlers/Stream.php
+++ b/src/Providers/Groq/Handlers/Stream.php
@@ -56,6 +56,8 @@ public function __construct(protected PendingRequest $client)
*/
public function handle(Request $request): Generator
{
+ yield from $this->resolveToolApprovalsAndYieldEvents($request, EventID::generate());
+
$response = $this->sendRequest($request);
yield from $this->processStream($response, $request);
@@ -277,7 +279,17 @@ protected function handleToolCalls(
}
$toolResults = [];
- yield from $this->callToolsAndYieldEvents($request->tools(), $mappedToolCalls, $this->state->messageId(), $toolResults);
+ $hasPendingToolCalls = false;
+ yield from $this->callToolsAndYieldEventsWithPending($request->tools(), $mappedToolCalls, $this->state->messageId(), $toolResults, $hasPendingToolCalls);
+
+ if ($hasPendingToolCalls) {
+ // Client-executed or approval-required tool calls: end the stream
+ // with FinishReason::ToolCalls so the consumer resolves and resumes.
+ $this->state->markStepFinished();
+ yield from $this->yieldToolCallsFinishEvents($this->state);
+
+ return;
+ }
// Emit step finish after tool calls
$this->state->markStepFinished();
@@ -371,8 +383,9 @@ protected function extractUsage(array $data): ?Usage
}
return new Usage(
- promptTokens: (int) data_get($usage, 'prompt_tokens', 0),
- completionTokens: (int) data_get($usage, 'completion_tokens', 0)
+ promptTokens: max(0, (int) data_get($usage, 'prompt_tokens', 0) - (int) data_get($usage, 'prompt_tokens_details.cached_tokens', 0)),
+ completionTokens: (int) data_get($usage, 'completion_tokens', 0),
+ cacheReadInputTokens: (int) data_get($usage, 'prompt_tokens_details.cached_tokens', 0) ?: null,
);
}
diff --git a/src/Providers/Groq/Handlers/Structured.php b/src/Providers/Groq/Handlers/Structured.php
index 7d293c8d6..a92dcb36e 100644
--- a/src/Providers/Groq/Handlers/Structured.php
+++ b/src/Providers/Groq/Handlers/Structured.php
@@ -75,8 +75,9 @@ protected function createResponse(Request $request, array $data, ClientResponse
text: $text,
finishReason: FinishReasonMap::map(data_get($data, 'choices.0.finish_reason', '')),
usage: new Usage(
- data_get($data, 'usage.prompt_tokens'),
- data_get($data, 'usage.completion_tokens'),
+ promptTokens: max(0, (int) data_get($data, 'usage.prompt_tokens', 0) - (int) data_get($data, 'usage.prompt_tokens_details.cached_tokens', 0)),
+ completionTokens: (int) data_get($data, 'usage.completion_tokens', 0),
+ cacheReadInputTokens: (int) data_get($data, 'usage.prompt_tokens_details.cached_tokens', 0) ?: null,
),
meta: new Meta(
id: data_get($data, 'id'),
diff --git a/src/Providers/Groq/Handlers/Text.php b/src/Providers/Groq/Handlers/Text.php
index a88c3007e..a45bff794 100644
--- a/src/Providers/Groq/Handlers/Text.php
+++ b/src/Providers/Groq/Handlers/Text.php
@@ -9,7 +9,6 @@
use Illuminate\Support\Arr;
use Prism\Prism\Concerns\CallsTools;
use Prism\Prism\Enums\FinishReason;
-use Prism\Prism\Exceptions\PrismException;
use Prism\Prism\Providers\Groq\Concerns\ProcessRateLimits;
use Prism\Prism\Providers\Groq\Concerns\ValidateResponse;
use Prism\Prism\Providers\Groq\Maps\FinishReasonMap;
@@ -40,6 +39,8 @@ public function __construct(protected PendingRequest $client)
public function handle(Request $request): TextResponse
{
+ $this->resolveToolApprovals($request);
+
$response = $this->sendRequest($request);
$this->validateResponse($response);
@@ -50,8 +51,7 @@ public function handle(Request $request): TextResponse
return match ($finishReason) {
FinishReason::ToolCalls => $this->handleToolCalls($data, $request, $response),
- FinishReason::Stop, FinishReason::Length => $this->handleStop($data, $request, $response, $finishReason),
- default => throw new PrismException('Groq: unhandled finish reason'),
+ default => $this->handleStop($data, $request, $response, $finishReason),
};
}
@@ -81,18 +81,21 @@ protected function handleToolCalls(array $data, Request $request, ClientResponse
{
$toolCalls = $this->mapToolCalls(data_get($data, 'choices.0.message.tool_calls', []) ?? []);
- $toolResults = $this->callTools($request->tools(), $toolCalls);
+ $hasPendingToolCalls = false;
+ $approvalRequests = [];
+ $toolResults = $this->callToolsWithPending($request->tools(), $toolCalls, $hasPendingToolCalls, $approvalRequests);
$this->addStep($data, $request, $clientResponse, FinishReason::ToolCalls, $toolResults);
$request->addMessage(new AssistantMessage(
data_get($data, 'choices.0.message.content') ?? '',
$toolCalls,
+ toolApprovalRequests: $approvalRequests,
));
$request->addMessage(new ToolResultMessage($toolResults));
$request->resetToolChoice();
- if ($this->shouldContinue($request)) {
+ if (! $hasPendingToolCalls && $this->shouldContinue($request)) {
return $this->handle($request);
}
@@ -127,8 +130,9 @@ protected function addStep(array $data, Request $request, ClientResponse $client
toolResults: $toolResults,
providerToolCalls: [],
usage: new Usage(
- data_get($data, 'usage.prompt_tokens'),
- data_get($data, 'usage.completion_tokens'),
+ promptTokens: max(0, (int) data_get($data, 'usage.prompt_tokens', 0) - (int) data_get($data, 'usage.prompt_tokens_details.cached_tokens', 0)),
+ completionTokens: (int) data_get($data, 'usage.completion_tokens', 0),
+ cacheReadInputTokens: (int) data_get($data, 'usage.prompt_tokens_details.cached_tokens', 0) ?: null,
),
meta: new Meta(
id: data_get($data, 'id'),
@@ -148,10 +152,28 @@ protected function addStep(array $data, Request $request, ClientResponse $client
*/
protected function mapToolCalls(array $toolCalls): array
{
- return array_map(fn (array $toolCall): ToolCall => new ToolCall(
- id: data_get($toolCall, 'id'),
- name: data_get($toolCall, 'function.name'),
- arguments: data_get($toolCall, 'function.arguments'),
- ), $toolCalls);
+ return array_map(function (array $toolCall): ToolCall {
+ $name = data_get($toolCall, 'function.name', '');
+ $arguments = data_get($toolCall, 'function.arguments', '{}');
+
+ // Some Llama models (e.g. llama-3.3-70b-versatile on Groq) embed
+ // the arguments JSON directly in the function name field, separated
+ // by a comma: "tool_name,{\"arg\":\"val\"}". Groq then rejects the
+ // follow-up turn because the mangled name is not in request.tools.
+ // Detect this pattern and split the name from the inline arguments.
+ if (is_string($name) && str_contains($name, ',{')) {
+ [$extractedName, $inlineArgs] = explode(',', $name, 2);
+ if (json_decode($inlineArgs) !== null) {
+ $name = $extractedName;
+ $arguments = $inlineArgs;
+ }
+ }
+
+ return new ToolCall(
+ id: data_get($toolCall, 'id'),
+ name: $name,
+ arguments: $arguments ?: '{}',
+ );
+ }, $toolCalls);
}
}
diff --git a/src/Providers/Groq/Maps/ToolMap.php b/src/Providers/Groq/Maps/ToolMap.php
index 7f665f4cc..5c23b9ed8 100644
--- a/src/Providers/Groq/Maps/ToolMap.php
+++ b/src/Providers/Groq/Maps/ToolMap.php
@@ -14,17 +14,50 @@ class ToolMap
*/
public static function Map(array $tools): array
{
- return array_map(fn (Tool $tool): array => [
- 'type' => 'function',
- 'function' => [
- 'name' => $tool->name(),
- 'description' => $tool->description(),
- 'parameters' => [
- 'type' => 'object',
- 'properties' => $tool->parametersAsArray(),
- 'required' => $tool->requiredParameters(),
+ return array_map(function (Tool $tool): array {
+ $properties = static::relaxTypes($tool->parametersAsArray());
+
+ return [
+ 'type' => 'function',
+ 'function' => [
+ 'name' => $tool->name(),
+ 'description' => $tool->description(),
+ 'parameters' => [
+ 'type' => 'object',
+ 'properties' => $properties === [] ? new \stdClass : $properties,
+ 'required' => $tool->requiredParameters(),
+ ],
],
- ],
- ], $tools);
+ ];
+ }, $tools);
+ }
+
+ /**
+ * Llama models running on Groq consistently return all tool argument
+ * values as JSON strings, even when the schema declares boolean or number.
+ * Groq validates the model's output against our schema and rejects it:
+ *
+ * Groq Error [400]: tool call validation failed: parameters for tool
+ * get_transactions did not match schema: `/limit`: expected number,
+ * but got string
+ *
+ * Wrapping non-string scalar types in anyOf accepts both the declared type
+ * and a plain string, so Groq passes the response through to the caller
+ * regardless of how the model serialised the value.
+ *
+ * @param array> $properties
+ * @return array>
+ */
+ protected static function relaxTypes(array $properties): array
+ {
+ return array_map(function (array $prop): array {
+ $type = $prop['type'] ?? null;
+ if (is_string($type) && in_array($type, ['boolean', 'number', 'integer'], true)) {
+ unset($prop['type']);
+ $prop['anyOf'] = [['type' => $type], ['type' => 'string']];
+ }
+
+ return $prop;
+ }, $properties);
}
}
diff --git a/src/Providers/Mistral/Handlers/Audio.php b/src/Providers/Mistral/Handlers/Audio.php
index 86828c5b5..94d9f31a7 100644
--- a/src/Providers/Mistral/Handlers/Audio.php
+++ b/src/Providers/Mistral/Handlers/Audio.php
@@ -37,6 +37,7 @@ public function handleSpeechToText(SpeechToTextRequest $request): TextResponse
'language' => $request->providerOptions('language') ?? null,
'prompt' => $request->providerOptions('prompt') ?? null,
'response_format' => $request->providerOptions('response_format') ?? null,
+ 'diarize' => $request->providerOptions('diarize') ?? null,
'temperature' => $request->providerOptions('temperature') ?? null,
'timestamp_granularities' => $request->providerOptions('timestamp_granularities') ?? null,
]));
diff --git a/src/Providers/Mistral/Handlers/Fim.php b/src/Providers/Mistral/Handlers/Fim.php
new file mode 100644
index 000000000..8b10cb40a
--- /dev/null
+++ b/src/Providers/Mistral/Handlers/Fim.php
@@ -0,0 +1,79 @@
+sendRequest($request);
+
+ $this->validateResponse($response);
+
+ $data = $response->json();
+
+ return new Response(
+ text: data_get($data, 'choices.0.message.content', ''),
+ finishReason: $this->mapFinishReason($data),
+ usage: new Usage(
+ data_get($data, 'usage.prompt_tokens', 0),
+ data_get($data, 'usage.completion_tokens', 0),
+ ),
+ meta: new Meta(
+ id: data_get($data, 'id', ''),
+ model: data_get($data, 'model', ''),
+ rateLimits: $this->processRateLimits($response),
+ )
+ );
+ }
+
+ protected function sendRequest(Request $request): ClientResponse
+ {
+ /** @var ClientResponse $response */
+ $response = $this->client->post(
+ 'fim/completions',
+ array_merge([
+ 'model' => $request->model(),
+ 'prompt' => $request->prompt(),
+ ], Arr::whereNotNull([
+ 'suffix' => $request->suffix(),
+ 'max_tokens' => $request->maxTokens(),
+ 'temperature' => $request->temperature(),
+ 'top_p' => $request->topP(),
+ 'stop' => $request->stop() === [] ? null : $request->stop(),
+ ]))
+ );
+
+ return $response;
+ }
+
+ /**
+ * @param array $data
+ */
+ protected function mapFinishReason(array $data): FinishReason
+ {
+ return match (data_get($data, 'choices.0.finish_reason')) {
+ 'stop' => FinishReason::Stop,
+ 'length' => FinishReason::Length,
+ default => FinishReason::Unknown,
+ };
+ }
+}
diff --git a/src/Providers/Mistral/Handlers/Stream.php b/src/Providers/Mistral/Handlers/Stream.php
index 00e890c3e..285b82261 100644
--- a/src/Providers/Mistral/Handlers/Stream.php
+++ b/src/Providers/Mistral/Handlers/Stream.php
@@ -59,6 +59,8 @@ public function __construct(
*/
public function handle(Request $request): Generator
{
+ yield from $this->resolveToolApprovalsAndYieldEvents($request, EventID::generate());
+
$response = $this->sendRequest($request);
yield from $this->processStream($response, $request);
@@ -321,7 +323,17 @@ protected function handleToolCalls(
}
$toolResults = [];
- yield from $this->callToolsAndYieldEvents($request->tools(), $mappedToolCalls, $this->state->messageId(), $toolResults);
+ $hasPendingToolCalls = false;
+ yield from $this->callToolsAndYieldEventsWithPending($request->tools(), $mappedToolCalls, $this->state->messageId(), $toolResults, $hasPendingToolCalls);
+
+ if ($hasPendingToolCalls) {
+ // Client-executed or approval-required tool calls: end the stream
+ // with FinishReason::ToolCalls so the consumer resolves and resumes.
+ $this->state->markStepFinished();
+ yield from $this->yieldToolCallsFinishEvents($this->state);
+
+ return;
+ }
$this->state->markStepFinished();
yield new StepFinishEvent(
@@ -406,6 +418,7 @@ protected function sendRequest(Request $request): Response
], Arr::whereNotNull([
'temperature' => $request->temperature(),
'top_p' => $request->topP(),
+ 'reasoning_effort' => $request->providerOptions('reasoning_effort'),
'tools' => ToolMap::map($request->tools()),
'tool_choice' => ToolChoiceMap::map($request->toolChoice()),
]))
diff --git a/src/Providers/Mistral/Handlers/Structured.php b/src/Providers/Mistral/Handlers/Structured.php
index 79006c049..d6fe27a20 100644
--- a/src/Providers/Mistral/Handlers/Structured.php
+++ b/src/Providers/Mistral/Handlers/Structured.php
@@ -7,22 +7,32 @@
use Illuminate\Http\Client\PendingRequest;
use Illuminate\Http\Client\Response as ClientResponse;
use Illuminate\Support\Arr;
+use Prism\Prism\Concerns\CallsTools;
+use Prism\Prism\Enums\FinishReason;
+use Prism\Prism\Providers\Mistral\Concerns\ExtractsText;
+use Prism\Prism\Providers\Mistral\Concerns\ExtractsThinking;
use Prism\Prism\Providers\Mistral\Concerns\MapsFinishReason;
use Prism\Prism\Providers\Mistral\Concerns\ProcessRateLimits;
use Prism\Prism\Providers\Mistral\Concerns\ValidatesResponse;
-use Prism\Prism\Providers\Mistral\Maps\FinishReasonMap;
use Prism\Prism\Providers\Mistral\Maps\MessageMap;
+use Prism\Prism\Providers\Mistral\Maps\ToolChoiceMap;
+use Prism\Prism\Providers\Mistral\Maps\ToolMap;
use Prism\Prism\Structured\Request;
use Prism\Prism\Structured\Response as StructuredResponse;
use Prism\Prism\Structured\ResponseBuilder;
use Prism\Prism\Structured\Step;
use Prism\Prism\ValueObjects\Messages\AssistantMessage;
-use Prism\Prism\ValueObjects\Messages\SystemMessage;
+use Prism\Prism\ValueObjects\Messages\ToolResultMessage;
use Prism\Prism\ValueObjects\Meta;
+use Prism\Prism\ValueObjects\ToolCall;
+use Prism\Prism\ValueObjects\ToolResult;
use Prism\Prism\ValueObjects\Usage;
class Structured
{
+ use CallsTools;
+ use ExtractsText;
+ use ExtractsThinking;
use MapsFinishReason;
use ProcessRateLimits;
use ValidatesResponse;
@@ -36,49 +46,124 @@ public function __construct(protected PendingRequest $client)
public function handle(Request $request): StructuredResponse
{
- $request = $this->appendMessageForJsonMode($request);
+ $this->resolveToolApprovals($request);
- $response = $this->sendRequest($request);
+ return $this->sendAndRespond($request);
+ }
+
+ /**
+ * Send the request and handle the response, looping on tool calls.
+ *
+ * Mistral does not allow response_format and tools in the same request.
+ * When tools are present, we omit response_format and let the model call
+ * tools freely. Once tool calling is done, we re-send without tools but
+ * with response_format to get the final structured JSON response.
+ */
+ protected function sendAndRespond(Request $request): StructuredResponse
+ {
+ $hasTools = count($request->tools()) > 0;
+
+ $response = $this->sendRequest($request, useTools: $hasTools);
$this->validateResponse($response);
$data = $response->json();
- return $this->createResponse($request, $data);
+ if ($this->mapFinishReason($data) === FinishReason::ToolCalls) {
+ return $this->handleToolCalls($data, $request, $response);
+ }
+
+ // If tools were sent but the model chose to stop without calling them,
+ // the response is unconstrained text (no response_format was set).
+ // Discard it and re-send without tools to get proper structured output.
+ if ($hasTools) {
+ return $this->sendStructuredResponse($request);
+ }
+
+ return $this->handleStop($data, $request, $response);
}
- protected function sendRequest(Request $request): ClientResponse
+ /**
+ * Send a final request without tools but with json_schema response format
+ * to get the structured JSON output.
+ */
+ protected function sendStructuredResponse(Request $request): StructuredResponse
{
- /** @var ClientResponse $response */
- $response = $this->client->post(
- 'chat/completions',
- array_merge([
- 'model' => $request->model(),
- 'messages' => (new MessageMap($request->messages(), $request->systemPrompts()))(),
- 'max_tokens' => $request->maxTokens(),
- ], Arr::whereNotNull([
- 'temperature' => $request->temperature(),
- 'top_p' => $request->topP(),
- 'response_format' => ['type' => 'json_object'],
- ]))
- );
+ $response = $this->sendRequest($request, useTools: false);
- return $response;
+ $this->validateResponse($response);
+
+ return $this->handleStop($response->json(), $request, $response);
+ }
+
+ /**
+ * @param array $data
+ */
+ protected function handleToolCalls(array $data, Request $request, ClientResponse $clientResponse): StructuredResponse
+ {
+ $toolCalls = $this->mapToolCalls(data_get($data, 'choices.0.message.tool_calls', []));
+
+ $hasPendingToolCalls = false;
+ $approvalRequests = [];
+ $toolResults = $this->callToolsWithPending($request->tools(), $toolCalls, $hasPendingToolCalls, $approvalRequests);
+
+ $this->addStep($data, $request, $clientResponse, toolCalls: $toolCalls, toolResults: $toolResults);
+
+ $request->addMessage(new AssistantMessage(
+ $this->extractText(data_get($data, 'choices.0.message', [])),
+ $toolCalls,
+ toolApprovalRequests: $approvalRequests,
+ ));
+ $request->addMessage(new ToolResultMessage($toolResults));
+ $request->resetToolChoice();
+
+ if (! $hasPendingToolCalls && $this->shouldContinue($request)) {
+ return $this->sendAndRespond($request);
+ }
+
+ // Max steps exhausted during tool calling. Send one final request
+ // without tools to get structured output.
+ return $this->sendStructuredResponse($request);
}
/**
* @param array $data
*/
- protected function createResponse(Request $request, array $data): StructuredResponse
+ protected function handleStop(array $data, Request $request, ClientResponse $clientResponse): StructuredResponse
{
$text = data_get($data, 'choices.0.message.content') ?? '';
- $responseMessage = new AssistantMessage($text);
- $request->addMessage($responseMessage);
+ $request->addMessage(new AssistantMessage($text));
+
+ $this->addStep($data, $request, $clientResponse);
- $step = new Step(
- text: $text,
- finishReason: FinishReasonMap::map(data_get($data, 'choices.0.finish_reason', '')),
+ return $this->responseBuilder->toResponse();
+ }
+
+ protected function shouldContinue(Request $request): bool
+ {
+ if ($request->maxSteps() === 0) {
+ return true;
+ }
+
+ return $this->responseBuilder->steps->count() < $request->maxSteps();
+ }
+
+ /**
+ * @param array $data
+ * @param ToolCall[] $toolCalls
+ * @param ToolResult[] $toolResults
+ */
+ protected function addStep(
+ array $data,
+ Request $request,
+ ClientResponse $clientResponse,
+ array $toolCalls = [],
+ array $toolResults = [],
+ ): void {
+ $this->responseBuilder->addStep(new Step(
+ text: $this->extractText(data_get($data, 'choices.0.message', [])),
+ finishReason: $this->mapFinishReason($data),
usage: new Usage(
data_get($data, 'usage.prompt_tokens'),
data_get($data, 'usage.completion_tokens'),
@@ -86,23 +171,67 @@ protected function createResponse(Request $request, array $data): StructuredResp
meta: new Meta(
id: data_get($data, 'id'),
model: data_get($data, 'model'),
+ rateLimits: $this->processRateLimits($clientResponse),
),
messages: $request->messages(),
systemPrompts: $request->systemPrompts(),
- additionalContent: [],
- raw: $data
- );
+ additionalContent: $this->extractThinking(data_get($data, 'choices.0.message', [])),
+ toolCalls: $toolCalls,
+ toolResults: $toolResults,
+ raw: $data,
+ ));
+ }
- $this->responseBuilder->addStep($step);
+ /**
+ * Send the request to Mistral, using either tools or json_schema response format.
+ *
+ * Uses json_schema mode (strict server-side schema enforcement) instead of
+ * json_object mode, so Mistral enforces the output schema directly rather
+ * than relying on a system prompt instruction.
+ */
+ protected function sendRequest(Request $request, bool $useTools = false): ClientResponse
+ {
+ /** @var ClientResponse $response */
+ $response = $this->client->post(
+ 'chat/completions',
+ array_merge([
+ 'model' => $request->model(),
+ 'messages' => (new MessageMap($request->messages(), $request->systemPrompts()))(),
+ 'max_tokens' => $request->maxTokens(),
+ ], Arr::whereNotNull([
+ 'temperature' => $request->temperature(),
+ 'top_p' => $request->topP(),
+ 'reasoning_effort' => $request->providerOptions('reasoning_effort'),
+ 'tools' => $useTools ? ToolMap::map($request->tools()) : null,
+ 'tool_choice' => $useTools ? ToolChoiceMap::map($request->toolChoice()) : null,
+ 'response_format' => $useTools ? null : [
+ 'type' => 'json_schema',
+ 'json_schema' => [
+ 'schema' => $request->schema()->toArray(),
+ 'name' => $request->schema()->name(),
+ 'strict' => true,
+ ],
+ ],
+ ]))
+ );
- return $this->responseBuilder->toResponse();
+ return $response;
}
- protected function appendMessageForJsonMode(Request $request): Request
+ /**
+ * @param array|null $toolCalls
+ * @return ToolCall[]
+ */
+ protected function mapToolCalls(?array $toolCalls): array
{
- return $request->addMessage(new SystemMessage(sprintf(
- "Respond with JSON that matches the following schema: \n %s",
- json_encode($request->schema()->toArray(), JSON_PRETTY_PRINT)
- )));
+ if (! $toolCalls) {
+ return [];
+ }
+
+ return array_map(fn ($toolCall): ToolCall => new ToolCall(
+ id: data_get($toolCall, 'id'),
+ name: data_get($toolCall, 'function.name'),
+ arguments: data_get($toolCall, 'function.arguments'),
+ ), $toolCalls);
}
}
diff --git a/src/Providers/Mistral/Handlers/Text.php b/src/Providers/Mistral/Handlers/Text.php
index ebb8fc6b9..49809a892 100644
--- a/src/Providers/Mistral/Handlers/Text.php
+++ b/src/Providers/Mistral/Handlers/Text.php
@@ -9,7 +9,6 @@
use Illuminate\Support\Arr;
use Prism\Prism\Concerns\CallsTools;
use Prism\Prism\Enums\FinishReason;
-use Prism\Prism\Exceptions\PrismException;
use Prism\Prism\Providers\Mistral\Concerns\ExtractsText;
use Prism\Prism\Providers\Mistral\Concerns\ExtractsThinking;
use Prism\Prism\Providers\Mistral\Concerns\MapsFinishReason;
@@ -47,6 +46,8 @@ public function __construct(protected PendingRequest $client)
public function handle(Request $request): Response
{
+ $this->resolveToolApprovals($request);
+
$response = $this->sendRequest($request);
$this->validateResponse($response);
@@ -55,8 +56,7 @@ public function handle(Request $request): Response
return match ($this->mapFinishReason($data)) {
FinishReason::ToolCalls => $this->handleToolCalls($data, $request, $response),
- FinishReason::Stop => $this->handleStop($data, $request, $response),
- default => throw PrismException::providerResponseError('Invalid tool choice'),
+ default => $this->handleStop($data, $request, $response),
};
}
@@ -67,18 +67,21 @@ protected function handleToolCalls(array $data, Request $request, ClientResponse
{
$toolCalls = $this->mapToolCalls(data_get($data, 'choices.0.message.tool_calls', []));
- $toolResults = $this->callTools($request->tools(), $toolCalls);
+ $hasPendingToolCalls = false;
+ $approvalRequests = [];
+ $toolResults = $this->callToolsWithPending($request->tools(), $toolCalls, $hasPendingToolCalls, $approvalRequests);
$this->addStep($data, $request, $clientResponse, $toolResults);
$request->addMessage(new AssistantMessage(
$this->extractText(data_get($data, 'choices.0.message', [])),
$toolCalls,
+ toolApprovalRequests: $approvalRequests,
));
$request->addMessage(new ToolResultMessage($toolResults));
$request->resetToolChoice();
- if ($this->shouldContinue($request)) {
+ if (! $hasPendingToolCalls && $this->shouldContinue($request)) {
return $this->handle($request);
}
@@ -144,6 +147,7 @@ protected function sendRequest(Request $request): ClientResponse
], Arr::whereNotNull([
'temperature' => $request->temperature(),
'top_p' => $request->topP(),
+ 'reasoning_effort' => $request->providerOptions('reasoning_effort'),
'tools' => ToolMap::map($request->tools()),
'tool_choice' => ToolChoiceMap::map($request->toolChoice()),
]))
diff --git a/src/Providers/Mistral/Maps/ToolMap.php b/src/Providers/Mistral/Maps/ToolMap.php
index 1b8a0e532..2c7092c59 100644
--- a/src/Providers/Mistral/Maps/ToolMap.php
+++ b/src/Providers/Mistral/Maps/ToolMap.php
@@ -14,17 +14,21 @@ class ToolMap
*/
public static function map(array $tools): array
{
- return array_map(fn (Tool $tool): array => [
- 'type' => 'function',
- 'function' => [
- 'name' => $tool->name(),
- 'description' => $tool->description(),
- 'parameters' => [
- 'type' => 'object',
- 'properties' => $tool->parametersAsArray(),
- 'required' => $tool->requiredParameters(),
+ return array_map(function (Tool $tool): array {
+ $properties = $tool->parametersAsArray();
+
+ return [
+ 'type' => 'function',
+ 'function' => [
+ 'name' => $tool->name(),
+ 'description' => $tool->description(),
+ 'parameters' => [
+ 'type' => 'object',
+ 'properties' => $properties === [] ? new \stdClass : $properties,
+ 'required' => $tool->requiredParameters(),
+ ],
],
- ],
- ], $tools);
+ ];
+ }, $tools);
}
}
diff --git a/src/Providers/Mistral/Mistral.php b/src/Providers/Mistral/Mistral.php
index 9287e454e..fb75d1f81 100644
--- a/src/Providers/Mistral/Mistral.php
+++ b/src/Providers/Mistral/Mistral.php
@@ -17,9 +17,12 @@
use Prism\Prism\Exceptions\PrismProviderOverloadedException;
use Prism\Prism\Exceptions\PrismRateLimitedException;
use Prism\Prism\Exceptions\PrismRequestTooLargeException;
+use Prism\Prism\Fim\Request as FimRequest;
+use Prism\Prism\Fim\Response as FimResponse;
use Prism\Prism\Providers\Mistral\Concerns\ProcessRateLimits;
use Prism\Prism\Providers\Mistral\Handlers\Audio;
use Prism\Prism\Providers\Mistral\Handlers\Embeddings;
+use Prism\Prism\Providers\Mistral\Handlers\Fim;
use Prism\Prism\Providers\Mistral\Handlers\OCR;
use Prism\Prism\Providers\Mistral\Handlers\Stream;
use Prism\Prism\Providers\Mistral\Handlers\Structured;
@@ -66,6 +69,19 @@ public function structured(StructuredRequest $request): StructuredResponse
return $handler->handle($request);
}
+ #[\Override]
+ public function fim(FimRequest $request): FimResponse
+ {
+ $handler = new Fim(
+ $this->client(
+ $request->clientOptions(),
+ $request->clientRetry()
+ )
+ );
+
+ return $handler->handle($request);
+ }
+
#[\Override]
public function embeddings(EmbeddingRequest $request): EmbeddingResponse
{
diff --git a/src/Providers/Ollama/Handlers/Embeddings.php b/src/Providers/Ollama/Handlers/Embeddings.php
index 475cfadee..95e2a85c0 100644
--- a/src/Providers/Ollama/Handlers/Embeddings.php
+++ b/src/Providers/Ollama/Handlers/Embeddings.php
@@ -43,6 +43,8 @@ public function handle(Request $request): EmbeddingsResponse
protected function sendRequest(Request $request): Response
{
+ $options = Arr::except($request->providerOptions(), ['dimensions', 'keep_alive']);
+
/** @var Response $response */
$response = $this->client->post(
'api/embed',
@@ -51,7 +53,7 @@ protected function sendRequest(Request $request): Response
'input' => $request->inputs(),
'dimensions' => $request->providerOptions('dimensions'),
'keep_alive' => $request->providerOptions('keep_alive'),
- 'options' => $request->providerOptions() ?: null,
+ 'options' => $options ?: null,
])
);
diff --git a/src/Providers/Ollama/Handlers/Stream.php b/src/Providers/Ollama/Handlers/Stream.php
index b5544c111..d5806e594 100644
--- a/src/Providers/Ollama/Handlers/Stream.php
+++ b/src/Providers/Ollama/Handlers/Stream.php
@@ -53,6 +53,8 @@ public function __construct(protected PendingRequest $client)
*/
public function handle(Request $request): Generator
{
+ yield from $this->resolveToolApprovalsAndYieldEvents($request, EventID::generate());
+
$response = $this->sendRequest($request);
yield from $this->processStream($response, $request);
@@ -291,7 +293,17 @@ protected function handleToolCalls(
// Execute tools and emit results
$toolResults = [];
- yield from $this->callToolsAndYieldEvents($request->tools(), $mappedToolCalls, $this->state->messageId(), $toolResults);
+ $hasPendingToolCalls = false;
+ yield from $this->callToolsAndYieldEventsWithPending($request->tools(), $mappedToolCalls, $this->state->messageId(), $toolResults, $hasPendingToolCalls);
+
+ if ($hasPendingToolCalls) {
+ // Client-executed or approval-required tool calls: end the stream
+ // with FinishReason::ToolCalls so the consumer resolves and resumes.
+ $this->state->markStepFinished();
+ yield from $this->yieldToolCallsFinishEvents($this->state);
+
+ return;
+ }
// Emit step finish after tool calls
$this->state->markStepFinished();
@@ -339,7 +351,9 @@ protected function sendRequest(Request $request): Response
'tools' => ToolMap::map($request->tools()),
'stream' => true,
...Arr::whereNotNull([
- 'think' => $request->providerOptions('thinking'),
+ 'think' => $request->providerOptions('thinking') ?? (
+ $request->reasoningEnabled() === false ? false : null
+ ),
'keep_alive' => $request->providerOptions('keep_alive'),
]),
'options' => Arr::whereNotNull(array_merge([
diff --git a/src/Providers/Ollama/Handlers/Structured.php b/src/Providers/Ollama/Handlers/Structured.php
index 20ff096f1..5ed94bae0 100644
--- a/src/Providers/Ollama/Handlers/Structured.php
+++ b/src/Providers/Ollama/Handlers/Structured.php
@@ -84,6 +84,9 @@ protected function sendRequest(Request $request): array
'format' => $request->schema()->toArray(),
'stream' => false,
...Arr::whereNotNull([
+ 'think' => $request->providerOptions('thinking') ?? (
+ $request->reasoningEnabled() === false ? false : null
+ ),
'keep_alive' => $request->providerOptions('keep_alive'),
]),
'options' => Arr::whereNotNull(array_merge([
diff --git a/src/Providers/Ollama/Handlers/Text.php b/src/Providers/Ollama/Handlers/Text.php
index 1c4928fa9..5b6a5a986 100644
--- a/src/Providers/Ollama/Handlers/Text.php
+++ b/src/Providers/Ollama/Handlers/Text.php
@@ -7,8 +7,6 @@
use Illuminate\Http\Client\PendingRequest;
use Illuminate\Support\Arr;
use Prism\Prism\Concerns\CallsTools;
-use Prism\Prism\Enums\FinishReason;
-use Prism\Prism\Exceptions\PrismException;
use Prism\Prism\Providers\Ollama\Concerns\MapsFinishReason;
use Prism\Prism\Providers\Ollama\Concerns\ValidatesResponse;
use Prism\Prism\Providers\Ollama\Maps\MessageMap;
@@ -39,6 +37,8 @@ public function __construct(protected PendingRequest $client)
public function handle(Request $request): Response
{
+ $this->resolveToolApprovals($request);
+
$data = $this->sendRequest($request);
$this->validateResponse($data);
@@ -48,16 +48,7 @@ public function handle(Request $request): Response
return $this->handleToolCalls($data, $request);
}
- $finishReason = $this->mapFinishReason($data);
-
- return match ($finishReason) {
- FinishReason::Stop,
- FinishReason::Length,
- FinishReason::Unknown,
- FinishReason::ContentFilter,
- FinishReason::Other => $this->handleStop($data, $request),
- default => throw new PrismException('Ollama: unknown finish reason'),
- };
+ return $this->handleStop($data, $request);
}
/**
@@ -77,7 +68,9 @@ protected function sendRequest(Request $request): array
'tools' => ToolMap::map($request->tools()),
'stream' => false,
...Arr::whereNotNull([
- 'think' => $request->providerOptions('thinking'),
+ 'think' => $request->providerOptions('thinking') ?? (
+ $request->reasoningEnabled() === false ? false : null
+ ),
'keep_alive' => $request->providerOptions('keep_alive'),
]),
'options' => Arr::whereNotNull(array_merge([
@@ -97,18 +90,21 @@ protected function handleToolCalls(array $data, Request $request): Response
{
$toolCalls = $this->mapToolCalls(data_get($data, 'message.tool_calls', []));
- $toolResults = $this->callTools($request->tools(), $toolCalls);
+ $hasPendingToolCalls = false;
+ $approvalRequests = [];
+ $toolResults = $this->callToolsWithPending($request->tools(), $toolCalls, $hasPendingToolCalls, $approvalRequests);
$this->addStep($data, $request, $toolResults);
$request->addMessage(new AssistantMessage(
data_get($data, 'message.content') ?? '',
$toolCalls,
+ toolApprovalRequests: $approvalRequests,
));
$request->addMessage(new ToolResultMessage($toolResults));
$request->resetToolChoice();
- if ($this->shouldContinue($request)) {
+ if (! $hasPendingToolCalls && $this->shouldContinue($request)) {
return $this->handle($request);
}
diff --git a/src/Providers/OpenAI/Concerns/BuildsRequestBody.php b/src/Providers/OpenAI/Concerns/BuildsRequestBody.php
new file mode 100644
index 000000000..7af95411a
--- /dev/null
+++ b/src/Providers/OpenAI/Concerns/BuildsRequestBody.php
@@ -0,0 +1,44 @@
+
+ */
+ protected function buildRequestBody(Request $request): array
+ {
+ return array_merge([
+ 'model' => $request->model(),
+ 'input' => (new MessageMap($request->messages(), $request->systemPrompts()))(),
+ 'max_output_tokens' => $request->maxTokens(),
+ ], Arr::whereNotNull([
+ 'temperature' => $request->temperature(),
+ 'top_p' => $request->topP(),
+ 'metadata' => $request->providerOptions('metadata'),
+ 'tools' => $this->buildTools($request) ?: null,
+ 'tool_choice' => ToolChoiceMap::map($request->toolChoice()),
+ 'parallel_tool_calls' => $request->providerOptions('parallel_tool_calls'),
+ 'previous_response_id' => $request->providerOptions('previous_response_id'),
+ 'service_tier' => $request->providerOptions('service_tier'),
+ 'store' => $request->providerOptions('store'),
+ 'text' => $request->providerOptions('text_verbosity') ? [
+ 'verbosity' => $request->providerOptions('text_verbosity'),
+ ] : null,
+ 'truncation' => $request->providerOptions('truncation'),
+ 'reasoning' => $request->providerOptions('reasoning') ?? (
+ $request->reasoningEnabled() === false ? ['effort' => 'minimal'] : null
+ ),
+ ]));
+ }
+}
diff --git a/src/Providers/OpenAI/Concerns/HandlesBatchResponse.php b/src/Providers/OpenAI/Concerns/HandlesBatchResponse.php
new file mode 100644
index 000000000..ff5054e51
--- /dev/null
+++ b/src/Providers/OpenAI/Concerns/HandlesBatchResponse.php
@@ -0,0 +1,87 @@
+|null $data
+ */
+ protected function handleResponseErrors(?array $data): void
+ {
+ if ($data && data_get($data, 'error')) {
+ $message = data_get($data, 'error.message');
+ $message = is_array($message) ? implode(', ', $message) : $message;
+
+ throw PrismException::providerResponseError(vsprintf(
+ 'OpenAI Error: [%s] %s',
+ [
+ data_get($data, 'error.type', 'unknown'),
+ $message,
+ ]
+ ));
+ }
+ }
+
+ /**
+ * @param array $data
+ */
+ protected static function mapBatchJob(array $data): BatchJob
+ {
+ return new BatchJob(
+ id: data_get($data, 'id', ''),
+ status: self::mapStatus(data_get($data, 'status', '')),
+ requestCounts: new BatchJobRequestCounts(
+ processing: (int) data_get($data, 'request_counts.total', 0)
+ - (int) data_get($data, 'request_counts.completed', 0)
+ - (int) data_get($data, 'request_counts.failed', 0),
+ succeeded: (int) data_get($data, 'request_counts.completed', 0),
+ failed: (int) data_get($data, 'request_counts.failed', 0),
+ total: (int) data_get($data, 'request_counts.total', 0),
+ ),
+ createdAt: data_get($data, 'created_at') !== null
+ ? date('c', (int) data_get($data, 'created_at'))
+ : null,
+ expiresAt: data_get($data, 'expires_at') !== null
+ ? date('c', (int) data_get($data, 'expires_at'))
+ : null,
+ endedAt: data_get($data, 'completed_at') !== null
+ ? date('c', (int) data_get($data, 'completed_at'))
+ : null,
+ inputFileId: data_get($data, 'input_file_id'),
+ outputFileId: data_get($data, 'output_file_id'),
+ errorFileId: data_get($data, 'error_file_id'),
+ errors: array_values(array_map(
+ static fn (array $e): array => [
+ 'code' => (string) data_get($e, 'code', ''),
+ 'message' => (string) data_get($e, 'message', ''),
+ 'line' => data_get($e, 'line') !== null ? (int) data_get($e, 'line') : null,
+ 'param' => data_get($e, 'param') !== null ? (string) data_get($e, 'param') : null,
+ ],
+ data_get($data, 'errors.data', []) ?? []
+ )),
+ );
+ }
+
+ protected static function mapStatus(string $status): BatchStatus
+ {
+ return match ($status) {
+ 'validating' => BatchStatus::Validating,
+ 'failed' => BatchStatus::Failed,
+ 'in_progress' => BatchStatus::InProgress,
+ 'finalizing' => BatchStatus::Finalizing,
+ 'completed' => BatchStatus::Completed,
+ 'expired' => BatchStatus::Expired,
+ 'cancelling' => BatchStatus::Cancelling,
+ 'cancelled' => BatchStatus::Cancelled,
+ default => throw new PrismException("Unknown OpenAI batch status: {$status}"),
+ };
+ }
+}
diff --git a/src/Providers/OpenAI/Concerns/HandlesFileResponse.php b/src/Providers/OpenAI/Concerns/HandlesFileResponse.php
new file mode 100644
index 000000000..5d2248e2a
--- /dev/null
+++ b/src/Providers/OpenAI/Concerns/HandlesFileResponse.php
@@ -0,0 +1,48 @@
+|null $data
+ */
+ protected function handleResponseErrors(?array $data): void
+ {
+ if ($data && data_get($data, 'error')) {
+ $message = data_get($data, 'error.message');
+ $message = is_array($message) ? implode(', ', $message) : $message;
+
+ throw PrismException::providerResponseError(vsprintf(
+ 'OpenAI Error: [%s] %s',
+ [
+ data_get($data, 'error.type', 'unknown'),
+ $message,
+ ]
+ ));
+ }
+ }
+
+ /**
+ * @param array $data
+ */
+ protected static function mapFileData(array $data): FileData
+ {
+ return new FileData(
+ id: data_get($data, 'id', ''),
+ filename: data_get($data, 'filename'),
+ mimeType: data_get($data, 'mime_type'),
+ sizeBytes: data_get($data, 'bytes'),
+ createdAt: data_get($data, 'created_at') !== null
+ ? date('c', (int) data_get($data, 'created_at'))
+ : null,
+ purpose: data_get($data, 'purpose'),
+ raw: $data,
+ );
+ }
+}
diff --git a/src/Providers/OpenAI/Concerns/MapsBatchResults.php b/src/Providers/OpenAI/Concerns/MapsBatchResults.php
new file mode 100644
index 000000000..f8232a42a
--- /dev/null
+++ b/src/Providers/OpenAI/Concerns/MapsBatchResults.php
@@ -0,0 +1,121 @@
+ $data
+ */
+ protected static function mapResultItem(array $data): BatchResultItem
+ {
+ $customId = data_get($data, 'custom_id', '');
+ $error = data_get($data, 'error');
+ $response = data_get($data, 'response');
+
+ if ($error !== null) {
+ $errorCode = data_get($error, 'code', '');
+
+ if ($errorCode === 'batch_expired') {
+ return new BatchResultItem(
+ customId: $customId,
+ status: BatchResultStatus::Expired,
+ errorType: $errorCode,
+ errorMessage: data_get($error, 'message'),
+ );
+ }
+
+ return new BatchResultItem(
+ customId: $customId,
+ status: BatchResultStatus::Errored,
+ errorType: $errorCode,
+ errorMessage: data_get($error, 'message'),
+ );
+ }
+
+ if ($response === null) {
+ return new BatchResultItem(
+ customId: $customId,
+ status: BatchResultStatus::Errored,
+ errorType: 'unknown',
+ errorMessage: 'No response or error in batch result.',
+ );
+ }
+
+ $statusCode = data_get($response, 'status_code', 0);
+ if ($statusCode !== 200) {
+ return new BatchResultItem(
+ customId: $customId,
+ status: BatchResultStatus::Errored,
+ errorType: 'http_error',
+ errorMessage: sprintf('HTTP %d: %s', $statusCode, json_encode(data_get($response, 'body.error'))),
+ );
+ }
+
+ $body = data_get($response, 'body', []);
+
+ return new BatchResultItem(
+ customId: $customId,
+ status: BatchResultStatus::Succeeded,
+ text: self::extractText($body),
+ usage: self::extractUsage($body),
+ messageId: data_get($body, 'id'),
+ model: data_get($body, 'model'),
+ );
+ }
+
+ /**
+ * @param array $body
+ */
+ protected static function extractText(array $body): string
+ {
+ $output = data_get($body, 'output', []);
+
+ if (is_array($output)) {
+ foreach (array_reverse($output) as $item) {
+ if (data_get($item, 'type') === 'message') {
+ $content = data_get($item, 'content', []);
+ foreach ($content as $part) {
+ if (data_get($part, 'type') === 'output_text') {
+ return data_get($part, 'text', '');
+ }
+ }
+ }
+ }
+ }
+
+ $choices = data_get($body, 'choices', []);
+ if (! empty($choices)) {
+ return data_get($choices, '0.message.content', '');
+ }
+
+ return '';
+ }
+
+ /**
+ * @param array $body
+ */
+ protected static function extractUsage(array $body): Usage
+ {
+ return new Usage(
+ promptTokens: (int) data_get($body, 'usage.input_tokens',
+ data_get($body, 'usage.prompt_tokens', 0)
+ ),
+ completionTokens: (int) data_get($body, 'usage.output_tokens',
+ data_get($body, 'usage.completion_tokens', 0)
+ ),
+ cacheReadInputTokens: data_get($body, 'usage.input_tokens_details.cached_tokens') !== null
+ ? (int) data_get($body, 'usage.input_tokens_details.cached_tokens')
+ : null,
+ thoughtTokens: data_get($body, 'usage.output_tokens_details.reasoning_tokens') !== null
+ ? (int) data_get($body, 'usage.output_tokens_details.reasoning_tokens')
+ : null,
+ );
+ }
+}
diff --git a/src/Providers/OpenAI/Handlers/Batch/Cancel.php b/src/Providers/OpenAI/Handlers/Batch/Cancel.php
new file mode 100644
index 000000000..3e0a03da5
--- /dev/null
+++ b/src/Providers/OpenAI/Handlers/Batch/Cancel.php
@@ -0,0 +1,30 @@
+client->post("batches/{$batchId}/cancel");
+ $data = $response->json();
+ $this->handleResponseErrors($data);
+
+ return self::mapBatchJob($data);
+ }
+}
diff --git a/src/Providers/OpenAI/Handlers/Batch/Create.php b/src/Providers/OpenAI/Handlers/Batch/Create.php
new file mode 100644
index 000000000..13799e785
--- /dev/null
+++ b/src/Providers/OpenAI/Handlers/Batch/Create.php
@@ -0,0 +1,83 @@
+inputFileId !== null && $request->items !== null) {
+ throw new PrismException('OpenAI batch requires either "inputFileId" or "items", not both.');
+ }
+
+ if ($request->inputFileId === null && $request->items === null) {
+ throw new PrismException('OpenAI batch requires either "inputFileId" or "items".');
+ }
+
+ $inputFileId = $request->inputFileId ?? $this->buildAndUploadFile($request->items ?? []);
+
+ $response = $this->client->post('batches', [
+ 'input_file_id' => $inputFileId,
+ 'endpoint' => '/v1/responses',
+ 'completion_window' => $request->providerOptions('completion_window') ?? '24h',
+ ]);
+
+ $data = $response->json();
+ $this->handleResponseErrors($data);
+
+ return self::mapBatchJob($data);
+ }
+
+ /**
+ * @param BatchRequestItem[] $items
+ */
+ protected function buildAndUploadFile(array $items): string
+ {
+ $jsonl = implode("\n", array_map(
+ fn (BatchRequestItem $item): string => (string) json_encode([
+ 'custom_id' => $item->customId,
+ 'method' => 'POST',
+ 'url' => '/v1/responses',
+ 'body' => $this->buildRequestBody($item->request),
+ ]),
+ $items
+ ));
+
+ $fileName = 'prism-batch-'.Str::uuid()->toString().'.jsonl';
+
+ $file = ($this->uploadFile)($jsonl, $fileName);
+
+ return $file->id;
+ }
+}
diff --git a/src/Providers/OpenAI/Handlers/Batch/ListBatches.php b/src/Providers/OpenAI/Handlers/Batch/ListBatches.php
new file mode 100644
index 000000000..a650f94bd
--- /dev/null
+++ b/src/Providers/OpenAI/Handlers/Batch/ListBatches.php
@@ -0,0 +1,46 @@
+ $request->limit,
+ 'after' => $request->afterId,
+ ]);
+
+ $response = $this->client->get('batches', $query ?: null);
+ $data = $response->json();
+ $this->handleResponseErrors($data);
+
+ $jobs = array_map(
+ self::mapBatchJob(...),
+ data_get($data, 'data', [])
+ );
+
+ return new BatchListResult(
+ data: $jobs,
+ hasMore: (bool) data_get($data, 'has_more', false),
+ lastId: data_get($data, 'last_id'),
+ );
+ }
+}
diff --git a/src/Providers/OpenAI/Handlers/Batch/Results.php b/src/Providers/OpenAI/Handlers/Batch/Results.php
new file mode 100644
index 000000000..0da05f570
--- /dev/null
+++ b/src/Providers/OpenAI/Handlers/Batch/Results.php
@@ -0,0 +1,62 @@
+retrieveBatch)($batchId);
+
+ if ($batch->outputFileId === null && $batch->errorFileId === null) {
+ return [];
+ }
+
+ $items = [];
+
+ foreach (array_filter([$batch->outputFileId, $batch->errorFileId]) as $fileId) {
+ /** @var string $body */
+ $body = ($this->downloadFile)($fileId);
+
+ foreach (explode("\n", $body) as $line) {
+ $line = trim($line);
+ if ($line === '') {
+ continue;
+ }
+
+ $decoded = json_decode($line, true);
+ if (! is_array($decoded)) {
+ continue;
+ }
+
+ $items[] = self::mapResultItem($decoded);
+ }
+ }
+
+ return $items;
+ }
+}
diff --git a/src/Providers/OpenAI/Handlers/Batch/Retrieve.php b/src/Providers/OpenAI/Handlers/Batch/Retrieve.php
new file mode 100644
index 000000000..7d1cd8e7c
--- /dev/null
+++ b/src/Providers/OpenAI/Handlers/Batch/Retrieve.php
@@ -0,0 +1,30 @@
+client->get("batches/{$batchId}");
+ $data = $response->json();
+ $this->handleResponseErrors($data);
+
+ return self::mapBatchJob($data);
+ }
+}
diff --git a/src/Providers/OpenAI/Handlers/ChatCompletions/Stream.php b/src/Providers/OpenAI/Handlers/ChatCompletions/Stream.php
new file mode 100644
index 000000000..571d11357
--- /dev/null
+++ b/src/Providers/OpenAI/Handlers/ChatCompletions/Stream.php
@@ -0,0 +1,486 @@
+state = new StreamState;
+ }
+
+ /**
+ * @return Generator
+ */
+ public function handle(Request $request): Generator
+ {
+ yield from $this->resolveToolApprovalsAndYieldEvents($request, EventID::generate());
+
+ $response = $this->sendRequest($request);
+
+ yield from $this->processStream($response, $request);
+ }
+
+ /**
+ * @return Generator
+ */
+ protected function processStream(Response $response, Request $request, int $depth = 0): Generator
+ {
+ if ($depth >= $request->maxSteps()) {
+ throw new PrismException('Maximum tool call chain depth exceeded');
+ }
+
+ // Only reset state on the first call (depth 0)
+ if ($depth === 0) {
+ $this->state->reset();
+ }
+
+ $text = '';
+ $toolCalls = [];
+
+ while (! $response->getBody()->eof()) {
+ $data = $this->parseNextDataLine($response->getBody());
+
+ if ($data === null) {
+ continue;
+ }
+
+ // Emit stream start event if not already started
+ if ($this->state->shouldEmitStreamStart()) {
+ $this->state->withMessageId(EventID::generate())->markStreamStarted();
+
+ yield new StreamStartEvent(
+ id: EventID::generate(),
+ timestamp: time(),
+ model: $request->model(),
+ provider: 'openai'
+ );
+ }
+
+ // Emit step start event once per step
+ if ($this->state->shouldEmitStepStart()) {
+ $this->state->markStepStarted();
+
+ yield new StepStartEvent(
+ id: EventID::generate(),
+ timestamp: time()
+ );
+ }
+
+ if ($this->hasError($data)) {
+ yield from $this->handleErrors($data, $request);
+
+ continue;
+ }
+
+ if ($this->hasToolCalls($data)) {
+ $toolCalls = $this->extractToolCalls($data, $toolCalls);
+
+ continue;
+ }
+
+ if ($this->mapFinishReason($data) === FinishReason::ToolCalls) {
+ // Complete any ongoing text
+ if ($this->state->hasTextStarted() && $text !== '') {
+ $this->state->markTextCompleted();
+
+ yield new TextCompleteEvent(
+ id: EventID::generate(),
+ timestamp: time(),
+ messageId: $this->state->messageId()
+ );
+ }
+
+ yield from $this->handleToolCalls($request, $text, $toolCalls, $depth);
+
+ return;
+ }
+
+ $content = data_get($data, 'choices.0.delta.content', '') ?? '';
+
+ if ($content !== '') {
+ if ($this->state->shouldEmitTextStart()) {
+ $this->state->markTextStarted();
+
+ yield new TextStartEvent(
+ id: EventID::generate(),
+ timestamp: time(),
+ messageId: $this->state->messageId()
+ );
+ }
+
+ $text .= $content;
+
+ yield new TextDeltaEvent(
+ id: EventID::generate(),
+ timestamp: time(),
+ delta: $content,
+ messageId: $this->state->messageId()
+ );
+ }
+
+ // Extract citations once from providers with search capabilities
+ if ($this->state->citations() === [] && isset($data['citations'])) {
+ $citationsPart = ChatCompletionsCitationsMapper::map(
+ $data['citations'],
+ $data['search_results'] ?? []
+ );
+
+ if ($citationsPart instanceof MessagePartWithCitations) {
+ $this->state->addCitation($citationsPart);
+ }
+ }
+
+ // Only emit completion events when we actually have a finish reason (not null)
+ $rawFinishReason = data_get($data, 'choices.0.finish_reason');
+ if ($rawFinishReason !== null) {
+ $finishReason = $this->mapFinishReason($data);
+
+ if ($this->state->hasTextStarted() && $text !== '') {
+ $this->state->markTextCompleted();
+
+ yield new TextCompleteEvent(
+ id: EventID::generate(),
+ timestamp: time(),
+ messageId: $this->state->messageId()
+ );
+ }
+
+ $this->state->withFinishReason($finishReason);
+
+ $usage = $this->extractUsage($data);
+ if ($usage instanceof Usage) {
+ $this->state->addUsage($usage);
+ }
+ }
+ }
+
+ // Emit step finish before stream end
+ $this->state->markStepFinished();
+ yield new StepFinishEvent(
+ id: EventID::generate(),
+ timestamp: time()
+ );
+
+ yield $this->emitStreamEndEvent();
+ }
+
+ protected function emitStreamEndEvent(): StreamEndEvent
+ {
+ $citations = $this->state->citations();
+
+ return new StreamEndEvent(
+ id: EventID::generate(),
+ timestamp: time(),
+ finishReason: $this->state->finishReason() ?? FinishReason::Stop,
+ usage: $this->state->usage() ?? new Usage(0, 0),
+ citations: $citations !== [] ? $citations : null,
+ );
+ }
+
+ /**
+ * @return array|null Parsed JSON data or null if line should be skipped
+ */
+ protected function parseNextDataLine(StreamInterface $stream): ?array
+ {
+ $line = $this->readLine($stream);
+
+ if (! str_starts_with($line, 'data:')) {
+ return null;
+ }
+
+ $line = trim(substr($line, strlen('data: ')));
+
+ if ($line === '' || $line === '[DONE]') {
+ return null;
+ }
+
+ try {
+ return json_decode($line, true, flags: JSON_THROW_ON_ERROR);
+ } catch (Throwable $e) {
+ throw new PrismStreamDecodeException('OpenAI', $e);
+ }
+ }
+
+ /**
+ * @param array $data
+ * @param array> $toolCalls
+ * @return array>
+ */
+ protected function extractToolCalls(array $data, array $toolCalls): array
+ {
+ foreach (data_get($data, 'choices.0.delta.tool_calls', []) as $index => $toolCall) {
+ if ($name = data_get($toolCall, 'function.name')) {
+ $toolCalls[$index]['name'] = $name;
+ $toolCalls[$index]['arguments'] = '';
+ $toolCalls[$index]['id'] = data_get($toolCall, 'id');
+ }
+
+ $arguments = data_get($toolCall, 'function.arguments');
+
+ if (! is_null($arguments)) {
+ if (! isset($toolCalls[$index]['arguments'])) {
+ $toolCalls[$index]['arguments'] = '';
+ }
+ $toolCalls[$index]['arguments'] .= $arguments;
+ }
+ }
+
+ return $toolCalls;
+ }
+
+ /**
+ * @param array> $toolCalls
+ * @return Generator
+ */
+ protected function handleToolCalls(
+ Request $request,
+ string $text,
+ array $toolCalls,
+ int $depth
+ ): Generator {
+ $mappedToolCalls = $this->mapToolCalls($toolCalls);
+
+ // Emit tool call events
+ foreach ($mappedToolCalls as $toolCall) {
+ yield new ToolCallEvent(
+ id: EventID::generate(),
+ timestamp: time(),
+ toolCall: $toolCall,
+ messageId: $this->state->messageId()
+ );
+ }
+
+ $toolResults = [];
+ $hasPendingToolCalls = false;
+ yield from $this->callToolsAndYieldEventsWithPending($request->tools(), $mappedToolCalls, $this->state->messageId(), $toolResults, $hasPendingToolCalls);
+
+ if ($hasPendingToolCalls) {
+ // Client-executed or approval-required tool calls: end the stream
+ // with FinishReason::ToolCalls so the consumer resolves and resumes.
+ $this->state->markStepFinished();
+ yield from $this->yieldToolCallsFinishEvents($this->state);
+
+ return;
+ }
+
+ // Emit step finish after tool calls
+ $this->state->markStepFinished();
+ yield new StepFinishEvent(
+ id: EventID::generate(),
+ timestamp: time()
+ );
+
+ $request->addMessage(new AssistantMessage($text, $mappedToolCalls));
+ $request->addMessage(new ToolResultMessage($toolResults));
+ $request->resetToolChoice();
+
+ // Reset text state for next response
+ $this->state->resetTextState();
+ $this->state->withMessageId(EventID::generate());
+
+ $depth++;
+ if ($depth < $request->maxSteps()) {
+ $nextResponse = $this->sendRequest($request);
+ yield from $this->processStream($nextResponse, $request, $depth);
+ } else {
+ yield $this->emitStreamEndEvent();
+ }
+ }
+
+ /**
+ * Convert raw tool call data to ToolCall objects.
+ *
+ * @param array> $toolCalls
+ * @return array
+ */
+ protected function mapToolCalls(array $toolCalls): array
+ {
+ return collect($toolCalls)
+ ->map(function ($toolCall): ToolCall {
+ $arguments = data_get($toolCall, 'arguments', '');
+
+ // Parse JSON arguments if needed
+ if (is_string($arguments) && $arguments !== '') {
+ try {
+ $parsedArguments = json_decode($arguments, true, flags: JSON_THROW_ON_ERROR);
+ $arguments = $parsedArguments;
+ } catch (Throwable) {
+ // If JSON parsing fails, use the raw string
+ $arguments = ['raw' => $arguments];
+ }
+ }
+
+ return new ToolCall(
+ data_get($toolCall, 'id'),
+ data_get($toolCall, 'name'),
+ $arguments,
+ );
+ })
+ ->all();
+ }
+
+ /**
+ * @param array $data
+ */
+ protected function hasToolCalls(array $data): bool
+ {
+ return (bool) data_get($data, 'choices.0.delta.tool_calls');
+ }
+
+ /**
+ * @param array $data
+ */
+ protected function hasError(array $data): bool
+ {
+ return data_get($data, 'error') !== null;
+ }
+
+ /**
+ * @param array $data
+ */
+ protected function mapFinishReason(array $data): FinishReason
+ {
+ return ChatCompletionsFinishReasonMap::map(data_get($data, 'choices.0.finish_reason'));
+ }
+
+ /**
+ * @param array $data
+ */
+ protected function extractUsage(array $data): ?Usage
+ {
+ $usage = data_get($data, 'usage');
+
+ if (! $usage) {
+ return null;
+ }
+
+ return new Usage(
+ promptTokens: max(0, (int) data_get($usage, 'prompt_tokens', 0) - (int) data_get($usage, 'prompt_tokens_details.cached_tokens', 0)),
+ completionTokens: (int) data_get($usage, 'completion_tokens', 0),
+ cacheReadInputTokens: (int) data_get($usage, 'prompt_tokens_details.cached_tokens', 0) ?: null,
+ );
+ }
+
+ protected function sendRequest(Request $request): Response
+ {
+ try {
+ /** @var Response $response */
+ $response = $this
+ ->client
+ ->withOptions(['stream' => true])
+ ->throw()
+ ->post('chat/completions',
+ array_merge([
+ 'stream' => true,
+ 'model' => $request->model(),
+ 'messages' => (new ChatCompletionsMessageMap($request->messages(), $request->systemPrompts()))(),
+ 'max_tokens' => $request->maxTokens(),
+ ], Arr::whereNotNull([
+ 'temperature' => $request->temperature(),
+ 'top_p' => $request->topP(),
+ 'tools' => ChatCompletionsToolMap::map($request->tools()),
+ 'tool_choice' => ChatCompletionsToolChoiceMap::map($request->toolChoice()),
+ ]))
+ );
+
+ return $response;
+ } catch (RequestException $e) {
+ if ($e->response->getStatusCode() === 429) {
+ throw new PrismRateLimitedException(
+ $this->processRateLimits($e->response),
+ (int) $e->response->header('retry-after')
+ );
+ }
+
+ throw PrismException::providerRequestError($request->model(), $e);
+ }
+ }
+
+ protected function readLine(StreamInterface $stream): string
+ {
+ $buffer = '';
+
+ while (! $stream->eof()) {
+ $byte = $stream->read(1);
+
+ if ($byte === '') {
+ return $buffer;
+ }
+
+ $buffer .= $byte;
+
+ if ($byte === "\n") {
+ break;
+ }
+ }
+
+ return $buffer;
+ }
+
+ /**
+ * @param array $data
+ * @return Generator
+ */
+ protected function handleErrors(array $data, Request $request): Generator
+ {
+ $error = data_get($data, 'error', []);
+ $type = data_get($error, 'type', 'unknown_error');
+ $message = data_get($error, 'message', 'No error message provided');
+
+ if ($type === 'rate_limit_exceeded') {
+ throw new PrismRateLimitedException([]);
+ }
+
+ yield new ErrorEvent(
+ id: EventID::generate(),
+ timestamp: time(),
+ errorType: $type,
+ message: $message,
+ recoverable: false
+ );
+ }
+}
diff --git a/src/Providers/OpenAI/Handlers/ChatCompletions/Structured.php b/src/Providers/OpenAI/Handlers/ChatCompletions/Structured.php
new file mode 100644
index 000000000..b49e7aa40
--- /dev/null
+++ b/src/Providers/OpenAI/Handlers/ChatCompletions/Structured.php
@@ -0,0 +1,106 @@
+responseBuilder = new ResponseBuilder;
+ }
+
+ public function handle(Request $request): StructuredResponse
+ {
+ $request = $this->appendMessageForJsonMode($request);
+
+ $response = $this->sendRequest($request);
+
+ $this->validateResponse($response);
+
+ $data = $response->json();
+
+ return $this->createResponse($request, $data, $response);
+ }
+
+ protected function sendRequest(Request $request): ClientResponse
+ {
+ /** @var ClientResponse $response */
+ $response = $this->client->post(
+ 'chat/completions',
+ array_merge([
+ 'model' => $request->model(),
+ 'messages' => (new ChatCompletionsMessageMap($request->messages(), $request->systemPrompts()))(),
+ 'max_tokens' => $request->maxTokens(),
+ ], Arr::whereNotNull([
+ 'temperature' => $request->temperature(),
+ 'top_p' => $request->topP(),
+ 'response_format' => ['type' => 'json_object'],
+ 'verbosity' => $request->providerOptions('text_verbosity'),
+ ]))
+ );
+
+ return $response;
+ }
+
+ /**
+ * @param array $data
+ */
+ protected function createResponse(Request $request, array $data, ClientResponse $clientResponse): StructuredResponse
+ {
+ $text = data_get($data, 'choices.0.message.content') ?? '';
+
+ $responseMessage = new AssistantMessage($text);
+ $request->addMessage($responseMessage);
+
+ $step = new Step(
+ text: $text,
+ finishReason: ChatCompletionsFinishReasonMap::map(data_get($data, 'choices.0.finish_reason', '')),
+ usage: new Usage(
+ promptTokens: max(0, (int) data_get($data, 'usage.prompt_tokens', 0) - (int) data_get($data, 'usage.prompt_tokens_details.cached_tokens', 0)),
+ completionTokens: (int) data_get($data, 'usage.completion_tokens', 0),
+ cacheReadInputTokens: (int) data_get($data, 'usage.prompt_tokens_details.cached_tokens', 0) ?: null,
+ ),
+ meta: new Meta(
+ id: data_get($data, 'id'),
+ model: data_get($data, 'model'),
+ rateLimits: $this->processRateLimits($clientResponse),
+ ),
+ messages: $request->messages(),
+ systemPrompts: $request->systemPrompts(),
+ additionalContent: [],
+ raw: $data,
+ );
+
+ $this->responseBuilder->addStep($step);
+
+ return $this->responseBuilder->toResponse();
+ }
+
+ protected function appendMessageForJsonMode(Request $request): Request
+ {
+ return $request->addMessage(new SystemMessage(sprintf(
+ "Respond with JSON that matches the following schema: \n %s",
+ json_encode($request->schema()->toArray(), JSON_PRETTY_PRINT)
+ )));
+ }
+}
diff --git a/src/Providers/OpenAI/Handlers/ChatCompletions/Text.php b/src/Providers/OpenAI/Handlers/ChatCompletions/Text.php
new file mode 100644
index 000000000..44cfaa3dd
--- /dev/null
+++ b/src/Providers/OpenAI/Handlers/ChatCompletions/Text.php
@@ -0,0 +1,164 @@
+responseBuilder = new ResponseBuilder;
+ }
+
+ public function handle(Request $request): TextResponse
+ {
+ $this->resolveToolApprovals($request);
+
+ $response = $this->sendRequest($request);
+
+ $this->validateResponse($response);
+
+ $data = $response->json();
+
+ $finishReason = ChatCompletionsFinishReasonMap::map(data_get($data, 'choices.0.finish_reason', ''));
+
+ return match ($finishReason) {
+ FinishReason::ToolCalls => $this->handleToolCalls($data, $request, $response),
+ FinishReason::Stop, FinishReason::Length => $this->handleStop($data, $request, $response, $finishReason),
+ default => throw new PrismException('OpenAI: unhandled finish reason'),
+ };
+ }
+
+ protected function sendRequest(Request $request): ClientResponse
+ {
+ /** @var ClientResponse $response */
+ $response = $this->client->post(
+ 'chat/completions',
+ Arr::whereNotNull([
+ 'model' => $request->model(),
+ 'messages' => (new ChatCompletionsMessageMap($request->messages(), $request->systemPrompts()))(),
+ 'max_tokens' => $request->maxTokens(),
+ 'temperature' => $request->temperature(),
+ 'top_p' => $request->topP(),
+ 'tools' => ChatCompletionsToolMap::map($request->tools()),
+ 'tool_choice' => ChatCompletionsToolChoiceMap::map($request->toolChoice()),
+ 'verbosity' => $request->providerOptions('text_verbosity'),
+ ])
+ );
+
+ return $response;
+ }
+
+ /**
+ * @param array $data
+ */
+ protected function handleToolCalls(array $data, Request $request, ClientResponse $clientResponse): TextResponse
+ {
+ $toolCalls = $this->mapToolCalls(data_get($data, 'choices.0.message.tool_calls', []) ?? []);
+
+ $hasPendingToolCalls = false;
+ $approvalRequests = [];
+ $toolResults = $this->callToolsWithPending($request->tools(), $toolCalls, $hasPendingToolCalls, $approvalRequests);
+
+ $this->addStep($data, $request, $clientResponse, FinishReason::ToolCalls, $toolResults);
+
+ $request->addMessage(new AssistantMessage(
+ data_get($data, 'choices.0.message.content') ?? '',
+ $toolCalls,
+ toolApprovalRequests: $approvalRequests,
+ ));
+ $request->addMessage(new ToolResultMessage($toolResults));
+ $request->resetToolChoice();
+
+ if (! $hasPendingToolCalls && $this->shouldContinue($request)) {
+ return $this->handle($request);
+ }
+
+ return $this->responseBuilder->toResponse();
+ }
+
+ /**
+ * @param array $data
+ */
+ protected function handleStop(array $data, Request $request, ClientResponse $clientResponse, FinishReason $finishReason): TextResponse
+ {
+ $this->addStep($data, $request, $clientResponse, $finishReason);
+
+ return $this->responseBuilder->toResponse();
+ }
+
+ protected function shouldContinue(Request $request): bool
+ {
+ return $this->responseBuilder->steps->count() < $request->maxSteps();
+ }
+
+ /**
+ * @param array $data
+ * @param ToolResult[] $toolResults
+ */
+ protected function addStep(array $data, Request $request, ClientResponse $clientResponse, FinishReason $finishReason, array $toolResults = []): void
+ {
+ $this->responseBuilder->addStep(new Step(
+ text: data_get($data, 'choices.0.message.content') ?? '',
+ finishReason: $finishReason,
+ toolCalls: $this->mapToolCalls(data_get($data, 'choices.0.message.tool_calls', []) ?? []),
+ toolResults: $toolResults,
+ providerToolCalls: [],
+ usage: new Usage(
+ promptTokens: max(0, (int) data_get($data, 'usage.prompt_tokens', 0) - (int) data_get($data, 'usage.prompt_tokens_details.cached_tokens', 0)),
+ completionTokens: (int) data_get($data, 'usage.completion_tokens', 0),
+ cacheReadInputTokens: (int) data_get($data, 'usage.prompt_tokens_details.cached_tokens', 0) ?: null,
+ ),
+ meta: new Meta(
+ id: data_get($data, 'id'),
+ model: data_get($data, 'model'),
+ rateLimits: $this->processRateLimits($clientResponse),
+ ),
+ messages: $request->messages(),
+ systemPrompts: $request->systemPrompts(),
+ additionalContent: [],
+ raw: $data,
+ ));
+ }
+
+ /**
+ * @param array> $toolCalls
+ * @return array
+ */
+ protected function mapToolCalls(array $toolCalls): array
+ {
+ return array_map(fn (array $toolCall): ToolCall => new ToolCall(
+ id: data_get($toolCall, 'id'),
+ name: data_get($toolCall, 'function.name'),
+ arguments: data_get($toolCall, 'function.arguments'),
+ ), $toolCalls);
+ }
+}
diff --git a/src/Providers/OpenAI/Handlers/Files/Delete.php b/src/Providers/OpenAI/Handlers/Files/Delete.php
new file mode 100644
index 000000000..e3df46aa8
--- /dev/null
+++ b/src/Providers/OpenAI/Handlers/Files/Delete.php
@@ -0,0 +1,35 @@
+client->delete("files/{$request->fileId}");
+
+ $data = $response->json();
+ $this->handleResponseErrors($data);
+
+ return new DeleteFileResult(
+ id: data_get($data, 'id', ''),
+ deleted: data_get($data, 'deleted', false),
+ );
+ }
+}
diff --git a/src/Providers/OpenAI/Handlers/Files/Download.php b/src/Providers/OpenAI/Handlers/Files/Download.php
new file mode 100644
index 000000000..fb9b25349
--- /dev/null
+++ b/src/Providers/OpenAI/Handlers/Files/Download.php
@@ -0,0 +1,25 @@
+client->get("files/{$request->fileId}/content");
+
+ return $response->body();
+ }
+}
diff --git a/src/Providers/OpenAI/Handlers/Files/GetMetadata.php b/src/Providers/OpenAI/Handlers/Files/GetMetadata.php
new file mode 100644
index 000000000..931c3de9f
--- /dev/null
+++ b/src/Providers/OpenAI/Handlers/Files/GetMetadata.php
@@ -0,0 +1,32 @@
+client->get("files/{$request->fileId}");
+
+ $data = $response->json();
+ $this->handleResponseErrors($data);
+
+ return self::mapFileData($data);
+ }
+}
diff --git a/src/Providers/OpenAI/Handlers/Files/ListFiles.php b/src/Providers/OpenAI/Handlers/Files/ListFiles.php
new file mode 100644
index 000000000..7fb03e1a4
--- /dev/null
+++ b/src/Providers/OpenAI/Handlers/Files/ListFiles.php
@@ -0,0 +1,49 @@
+ $request->limit,
+ 'after' => $request->afterId,
+ 'purpose' => $request->providerOptions('purpose'),
+ ]);
+
+ $response = $this->client->get('files', $query);
+
+ $data = $response->json();
+ $this->handleResponseErrors($data);
+
+ $files = array_map(
+ self::mapFileData(...),
+ data_get($data, 'data', [])
+ );
+
+ return new FileListResult(
+ data: $files,
+ hasMore: data_get($data, 'has_more', false),
+ firstId: data_get($data, 'first_id'),
+ lastId: data_get($data, 'last_id'),
+ );
+ }
+}
diff --git a/src/Providers/OpenAI/Handlers/Files/Upload.php b/src/Providers/OpenAI/Handlers/Files/Upload.php
new file mode 100644
index 000000000..0074ac8a4
--- /dev/null
+++ b/src/Providers/OpenAI/Handlers/Files/Upload.php
@@ -0,0 +1,37 @@
+client
+ ->asMultipart()
+ ->attach('file', $request->content, $request->filename)
+ ->post('files', [
+ 'purpose' => $request->providerOptions('purpose'),
+ ]);
+
+ $data = $response->json();
+ $this->handleResponseErrors($data);
+
+ return self::mapFileData($data);
+ }
+}
diff --git a/src/Providers/OpenAI/Handlers/Stream.php b/src/Providers/OpenAI/Handlers/Stream.php
index adcbadaf1..7be2ed6ac 100644
--- a/src/Providers/OpenAI/Handlers/Stream.php
+++ b/src/Providers/OpenAI/Handlers/Stream.php
@@ -14,11 +14,9 @@
use Prism\Prism\Exceptions\PrismException;
use Prism\Prism\Exceptions\PrismRateLimitedException;
use Prism\Prism\Exceptions\PrismStreamDecodeException;
-use Prism\Prism\Providers\OpenAI\Concerns\BuildsTools;
+use Prism\Prism\Providers\OpenAI\Concerns\BuildsRequestBody;
use Prism\Prism\Providers\OpenAI\Concerns\ProcessRateLimits;
use Prism\Prism\Providers\OpenAI\Maps\FinishReasonMap;
-use Prism\Prism\Providers\OpenAI\Maps\MessageMap;
-use Prism\Prism\Providers\OpenAI\Maps\ToolChoiceMap;
use Prism\Prism\Streaming\EventID;
use Prism\Prism\Streaming\Events\ProviderToolEvent;
use Prism\Prism\Streaming\Events\StepFinishEvent;
@@ -45,7 +43,7 @@
class Stream
{
- use BuildsTools;
+ use BuildsRequestBody;
use CallsTools;
use ProcessRateLimits;
@@ -61,6 +59,8 @@ public function __construct(protected PendingRequest $client)
*/
public function handle(Request $request): Generator
{
+ yield from $this->resolveToolApprovalsAndYieldEvents($request, EventID::generate());
+
$response = $this->sendRequest($request);
yield from $this->processStream($response, $request);
@@ -396,7 +396,17 @@ protected function handleToolCalls(Request $request, int $depth): Generator
{
$mappedToolCalls = $this->mapToolCalls($this->state->toolCalls());
$toolResults = [];
- yield from $this->callToolsAndYieldEvents($request->tools(), $mappedToolCalls, $this->state->messageId(), $toolResults);
+ $hasPendingToolCalls = false;
+ yield from $this->callToolsAndYieldEventsWithPending($request->tools(), $mappedToolCalls, $this->state->messageId(), $toolResults, $hasPendingToolCalls);
+
+ if ($hasPendingToolCalls) {
+ // Client-executed or approval-required tool calls: end the stream
+ // with FinishReason::ToolCalls so the consumer resolves and resumes.
+ $this->state->markStepFinished();
+ yield from $this->yieldToolCallsFinishEvents($this->state);
+
+ return;
+ }
// Emit step finish after tool calls
$this->state->markStepFinished();
@@ -570,26 +580,7 @@ protected function sendRequest(Request $request): Response
->withOptions(['stream' => true])
->post(
'responses',
- array_merge([
- 'stream' => true,
- 'model' => $request->model(),
- 'input' => (new MessageMap($request->messages(), $request->systemPrompts()))(),
- ], Arr::whereNotNull([
- 'max_output_tokens' => $request->maxTokens(),
- 'temperature' => $request->temperature(),
- 'top_p' => $request->topP(),
- 'metadata' => $request->providerOptions('metadata'),
- 'tools' => $this->buildTools($request),
- 'tool_choice' => ToolChoiceMap::map($request->toolChoice()),
- 'parallel_tool_calls' => $request->providerOptions('parallel_tool_calls'),
- 'previous_response_id' => $request->providerOptions('previous_response_id'),
- 'service_tier' => $request->providerOptions('service_tier'),
- 'text' => $request->providerOptions('text_verbosity') ? [
- 'verbosity' => $request->providerOptions('text_verbosity'),
- ] : null,
- 'truncation' => $request->providerOptions('truncation'),
- 'reasoning' => $request->providerOptions('reasoning'),
- ]))
+ array_merge(['stream' => true], $this->buildRequestBody($request)),
);
return $response;
diff --git a/src/Providers/OpenAI/Handlers/Structured.php b/src/Providers/OpenAI/Handlers/Structured.php
index ffa2f0e53..ce332230f 100644
--- a/src/Providers/OpenAI/Handlers/Structured.php
+++ b/src/Providers/OpenAI/Handlers/Structured.php
@@ -31,6 +31,7 @@
use Prism\Prism\ValueObjects\Messages\ToolResultMessage;
use Prism\Prism\ValueObjects\Meta;
use Prism\Prism\ValueObjects\ProviderTool;
+use Prism\Prism\ValueObjects\ToolApprovalRequest;
use Prism\Prism\ValueObjects\ToolResult;
use Prism\Prism\ValueObjects\Usage;
@@ -53,6 +54,8 @@ public function __construct(protected PendingRequest $client)
public function handle(Request $request): StructuredResponse
{
+ $this->resolveToolApprovals($request);
+
$response = match ($request->mode()) {
StructuredMode::Auto => $this->handleAutoMode($request),
StructuredMode::Structured => $this->handleStructuredMode($request),
@@ -80,18 +83,12 @@ public function handle(Request $request): StructuredResponse
return match ($finishReason = $this->mapFinishReason($data)) {
FinishReason::ToolCalls => $this->handleToolCalls($data, $request, $response),
- FinishReason::Stop => $this->handleFinalStop($data, $request, $response),
FinishReason::Length => throw new PrismException(sprintf(
'OpenAI: max tokens exceeded (status: %s, type: %s). If using a reasoning model, increase max_tokens to account for internal reasoning token usage.',
data_get($data, 'output.{last}.status', 'n/a'),
data_get($data, 'output.{last}.type', 'n/a'),
)),
- default => throw new PrismException(sprintf(
- 'OpenAI: unhandled finish reason "%s" (status: %s, type: %s)',
- $finishReason->value,
- data_get($data, 'output.{last}.status', 'n/a'),
- data_get($data, 'output.{last}.type', 'n/a'),
- )),
+ default => $this->handleFinalStop($data, $request, $response),
};
}
@@ -100,17 +97,31 @@ public function handle(Request $request): StructuredResponse
*/
protected function handleToolCalls(array $data, Request $request, ClientResponse $clientResponse): StructuredResponse
{
- $toolResults = $this->callTools(
- $request->tools(),
- ToolCallMap::map($this->extractFunctionCalls($data)),
- );
+ $toolCalls = ToolCallMap::map($this->extractFunctionCalls($data));
+
+ $hasPendingToolCalls = false;
+ $approvalRequests = [];
+ $toolResults = $this->callToolsWithPending($request->tools(), $toolCalls, $hasPendingToolCalls, $approvalRequests);
+
+ if ($approvalRequests !== []) {
+ // Replace the assistant message appended in handle() with one
+ // carrying the approval requests so the resume pass correlates them.
+ $messages = $request->messages();
+ array_pop($messages);
+ $request->setMessages($messages);
+ $request->addMessage(new AssistantMessage(
+ content: data_get($data, 'output.{last}.content.0.text') ?? '',
+ toolCalls: $toolCalls,
+ toolApprovalRequests: $approvalRequests,
+ ));
+ }
$request->addMessage(new ToolResultMessage($toolResults));
$request->resetToolChoice();
- $this->addStep($data, $request, $clientResponse, $toolResults);
+ $this->addStep($data, $request, $clientResponse, $toolResults, $approvalRequests);
- if ($this->shouldContinue($request)) {
+ if (! $hasPendingToolCalls && $this->shouldContinue($request)) {
return $this->handle($request);
}
@@ -130,8 +141,9 @@ protected function handleFinalStop(array $data, Request $request, ClientResponse
/**
* @param array $data
* @param ToolResult[] $toolResults
+ * @param ToolApprovalRequest[] $toolApprovalRequests
*/
- protected function addStep(array $data, Request $request, ClientResponse $clientResponse, array $toolResults = []): void
+ protected function addStep(array $data, Request $request, ClientResponse $clientResponse, array $toolResults = [], array $toolApprovalRequests = []): void
{
$finishReason = $this->mapFinishReason($data);
$isStructuredStep = $finishReason !== FinishReason::ToolCalls;
@@ -167,6 +179,7 @@ protected function addStep(array $data, Request $request, ClientResponse $client
toolCalls: $toolCalls,
toolResults: $toolResults,
raw: $data,
+ toolApprovalRequests: $toolApprovalRequests,
));
}
@@ -216,11 +229,14 @@ protected function sendRequest(Request $request, array $responseFormat): ClientR
'previous_response_id' => $request->providerOptions('previous_response_id'),
'service_tier' => $request->providerOptions('service_tier'),
'truncation' => $request->providerOptions('truncation'),
- 'reasoning' => $request->providerOptions('reasoning'),
+ 'reasoning' => $request->providerOptions('reasoning') ?? (
+ $request->reasoningEnabled() === false ? ['effort' => 'minimal'] : null
+ ),
'store' => $request->providerOptions('store'),
- 'text' => [
+ 'text' => Arr::whereNotNull([
'format' => $responseFormat,
- ],
+ 'verbosity' => $request->providerOptions('text_verbosity'),
+ ]),
]))
);
diff --git a/src/Providers/OpenAI/Handlers/Text.php b/src/Providers/OpenAI/Handlers/Text.php
index e370964dd..c42a9e2c9 100644
--- a/src/Providers/OpenAI/Handlers/Text.php
+++ b/src/Providers/OpenAI/Handlers/Text.php
@@ -10,15 +10,13 @@
use Prism\Prism\Concerns\CallsTools;
use Prism\Prism\Enums\FinishReason;
use Prism\Prism\Exceptions\PrismException;
-use Prism\Prism\Providers\OpenAI\Concerns\BuildsTools;
+use Prism\Prism\Providers\OpenAI\Concerns\BuildsRequestBody;
use Prism\Prism\Providers\OpenAI\Concerns\ExtractsCitations;
use Prism\Prism\Providers\OpenAI\Concerns\MapsFinishReason;
use Prism\Prism\Providers\OpenAI\Concerns\ProcessRateLimits;
use Prism\Prism\Providers\OpenAI\Concerns\ValidatesResponse;
-use Prism\Prism\Providers\OpenAI\Maps\MessageMap;
use Prism\Prism\Providers\OpenAI\Maps\ProviderToolCallMap;
use Prism\Prism\Providers\OpenAI\Maps\ToolCallMap;
-use Prism\Prism\Providers\OpenAI\Maps\ToolChoiceMap;
use Prism\Prism\Text\Request;
use Prism\Prism\Text\Response;
use Prism\Prism\Text\ResponseBuilder;
@@ -27,12 +25,13 @@
use Prism\Prism\ValueObjects\Messages\AssistantMessage;
use Prism\Prism\ValueObjects\Messages\ToolResultMessage;
use Prism\Prism\ValueObjects\Meta;
+use Prism\Prism\ValueObjects\ToolApprovalRequest;
use Prism\Prism\ValueObjects\ToolResult;
use Prism\Prism\ValueObjects\Usage;
class Text
{
- use BuildsTools;
+ use BuildsRequestBody;
use CallsTools;
use ExtractsCitations;
use MapsFinishReason;
@@ -51,6 +50,8 @@ public function __construct(protected PendingRequest $client)
public function handle(Request $request): Response
{
+ $this->resolveToolApprovals($request);
+
$response = $this->sendRequest($request);
$this->validateResponse($response);
@@ -61,18 +62,12 @@ public function handle(Request $request): Response
return match ($finishReason = $this->mapFinishReason($data)) {
FinishReason::ToolCalls => $this->handleToolCalls($data, $request, $response),
- FinishReason::Stop => $this->handleStop($data, $request, $response),
FinishReason::Length => throw new PrismException(sprintf(
'OpenAI: max tokens exceeded (status: %s, type: %s). If using a reasoning model, increase max_tokens to account for internal reasoning token usage.',
data_get($data, 'output.{last}.status', 'n/a'),
data_get($data, 'output.{last}.type', 'n/a'),
)),
- default => throw new PrismException(sprintf(
- 'OpenAI: unhandled finish reason "%s" (status: %s, type: %s)',
- $finishReason->value,
- data_get($data, 'output.{last}.status', 'n/a'),
- data_get($data, 'output.{last}.type', 'n/a'),
- )),
+ default => $this->handleStop($data, $request, $response),
};
}
@@ -86,9 +81,11 @@ protected function handleToolCalls(array $data, Request $request, ClientResponse
array_filter(data_get($data, 'output', []), fn (array $output): bool => $output['type'] === 'reasoning'),
);
- $toolResults = $this->callTools($request->tools(), $toolCalls);
+ $hasPendingToolCalls = false;
+ $approvalRequests = [];
+ $toolResults = $this->callToolsWithPending($request->tools(), $toolCalls, $hasPendingToolCalls, $approvalRequests);
- $this->addStep($data, $request, $clientResponse, $toolResults);
+ $this->addStep($data, $request, $clientResponse, $toolResults, $approvalRequests);
$providerToolCalls = ProviderToolCallMap::map(data_get($data, 'output', []));
@@ -99,11 +96,12 @@ protected function handleToolCalls(array $data, Request $request, ClientResponse
'citations' => $this->citations,
'provider_tool_calls' => $providerToolCalls === [] ? null : $providerToolCalls,
]),
+ toolApprovalRequests: $approvalRequests,
));
$request->addMessage(new ToolResultMessage($toolResults));
$request->resetToolChoice();
- if ($this->shouldContinue($request)) {
+ if (! $hasPendingToolCalls && $this->shouldContinue($request)) {
return $this->handle($request);
}
@@ -130,26 +128,7 @@ protected function sendRequest(Request $request): ClientResponse
/** @var ClientResponse $response */
$response = $this->client->post(
'responses',
- array_merge([
- 'model' => $request->model(),
- 'input' => (new MessageMap($request->messages(), $request->systemPrompts()))(),
- ], Arr::whereNotNull([
- 'max_output_tokens' => $request->maxTokens(),
- 'temperature' => $request->temperature(),
- 'top_p' => $request->topP(),
- 'metadata' => $request->providerOptions('metadata'),
- 'tools' => $this->buildTools($request),
- 'tool_choice' => ToolChoiceMap::map($request->toolChoice()),
- 'parallel_tool_calls' => $request->providerOptions('parallel_tool_calls'),
- 'previous_response_id' => $request->providerOptions('previous_response_id'),
- 'service_tier' => $request->providerOptions('service_tier'),
- 'text' => $request->providerOptions('text_verbosity') ? [
- 'verbosity' => $request->providerOptions('text_verbosity'),
- ] : null,
- 'truncation' => $request->providerOptions('truncation'),
- 'reasoning' => $request->providerOptions('reasoning'),
- 'store' => $request->providerOptions('store'),
- ]))
+ $this->buildRequestBody($request),
);
return $response;
@@ -158,12 +137,14 @@ protected function sendRequest(Request $request): ClientResponse
/**
* @param array $data
* @param ToolResult[] $toolResults
+ * @param ToolApprovalRequest[] $toolApprovalRequests
*/
protected function addStep(
array $data,
Request $request,
ClientResponse $clientResponse,
- array $toolResults = []
+ array $toolResults = [],
+ array $toolApprovalRequests = []
): void {
/** @var array> $output */
$output = data_get($data, 'output', []);
@@ -221,6 +202,7 @@ protected function addStep(
->toArray(),
]),
raw: $data,
+ toolApprovalRequests: $toolApprovalRequests,
));
}
}
diff --git a/src/Providers/OpenAI/Maps/ChatCompletionsCitationsMapper.php b/src/Providers/OpenAI/Maps/ChatCompletionsCitationsMapper.php
new file mode 100644
index 000000000..8a7395946
--- /dev/null
+++ b/src/Providers/OpenAI/Maps/ChatCompletionsCitationsMapper.php
@@ -0,0 +1,50 @@
+ $citations
+ * @param array> $searchResults
+ */
+ public static function map(array $citations, array $searchResults = []): ?MessagePartWithCitations
+ {
+ if ($citations === []) {
+ return null;
+ }
+
+ $mapped = [];
+
+ foreach ($citations as $index => $url) {
+ $searchResult = $searchResults[$index] ?? [];
+
+ $mapped[] = new Citation(
+ sourceType: CitationSourceType::Url,
+ source: $url,
+ sourceText: $searchResult['snippet'] ?? null,
+ sourceTitle: $searchResult['title'] ?? null,
+ additionalContent: array_filter([
+ 'date' => $searchResult['date'] ?? null,
+ 'last_updated' => $searchResult['last_updated'] ?? null,
+ 'source' => $searchResult['source'] ?? null,
+ ]),
+ );
+ }
+
+ return new MessagePartWithCitations(
+ outputText: '',
+ citations: $mapped,
+ );
+ }
+}
diff --git a/src/Providers/OpenAI/Maps/ChatCompletionsFinishReasonMap.php b/src/Providers/OpenAI/Maps/ChatCompletionsFinishReasonMap.php
new file mode 100644
index 000000000..d67409cf1
--- /dev/null
+++ b/src/Providers/OpenAI/Maps/ChatCompletionsFinishReasonMap.php
@@ -0,0 +1,22 @@
+ FinishReason::Stop,
+ 'tool_calls' => FinishReason::ToolCalls,
+ 'length' => FinishReason::Length,
+ 'content_filter' => FinishReason::ContentFilter,
+ null => FinishReason::Unknown,
+ default => FinishReason::Other,
+ };
+ }
+}
diff --git a/src/Providers/OpenAI/Maps/ChatCompletionsImageMapper.php b/src/Providers/OpenAI/Maps/ChatCompletionsImageMapper.php
new file mode 100644
index 000000000..a3fc57e68
--- /dev/null
+++ b/src/Providers/OpenAI/Maps/ChatCompletionsImageMapper.php
@@ -0,0 +1,42 @@
+
+ */
+ public function toPayload(): array
+ {
+ return [
+ 'type' => 'image_url',
+ 'image_url' => [
+ 'url' => $this->media->isUrl()
+ ? $this->media->url()
+ : sprintf('data:%s;base64,%s', $this->media->mimeType(), $this->media->base64()),
+ ],
+ ];
+ }
+
+ protected function provider(): string|Provider
+ {
+ return Provider::OpenAI;
+ }
+
+ protected function validateMedia(): bool
+ {
+ if ($this->media->isUrl()) {
+ return true;
+ }
+
+ return $this->media->hasRawContent();
+ }
+}
diff --git a/src/Providers/OpenAI/Maps/ChatCompletionsMessageMap.php b/src/Providers/OpenAI/Maps/ChatCompletionsMessageMap.php
new file mode 100644
index 000000000..6c2527bd0
--- /dev/null
+++ b/src/Providers/OpenAI/Maps/ChatCompletionsMessageMap.php
@@ -0,0 +1,108 @@
+ */
+ protected array $mappedMessages = [];
+
+ /**
+ * @param array $messages
+ * @param SystemMessage[] $systemPrompts
+ */
+ public function __construct(
+ protected array $messages,
+ protected array $systemPrompts
+ ) {
+ $this->messages = array_merge(
+ $this->systemPrompts,
+ $this->messages
+ );
+ }
+
+ /**
+ * @return array
+ */
+ public function __invoke(): array
+ {
+ array_map(
+ $this->mapMessage(...),
+ $this->messages
+ );
+
+ return $this->mappedMessages;
+ }
+
+ protected function mapMessage(Message $message): void
+ {
+ match ($message::class) {
+ UserMessage::class => $this->mapUserMessage($message),
+ AssistantMessage::class => $this->mapAssistantMessage($message),
+ ToolResultMessage::class => $this->mapToolResultMessage($message),
+ SystemMessage::class => $this->mapSystemMessage($message),
+ default => throw new Exception('Could not map message type '.$message::class),
+ };
+ }
+
+ protected function mapSystemMessage(SystemMessage $message): void
+ {
+ $this->mappedMessages[] = [
+ 'role' => 'system',
+ 'content' => $message->content,
+ ];
+ }
+
+ protected function mapToolResultMessage(ToolResultMessage $message): void
+ {
+ foreach ($message->toolResults as $toolResult) {
+ $this->mappedMessages[] = [
+ 'role' => 'tool',
+ 'tool_call_id' => $toolResult->toolCallId,
+ 'content' => $toolResult->result,
+ ];
+ }
+ }
+
+ protected function mapUserMessage(UserMessage $message): void
+ {
+ $imageParts = array_map(fn (Image $image): array => (new ChatCompletionsImageMapper($image))->toPayload(), $message->images());
+
+ $this->mappedMessages[] = [
+ 'role' => 'user',
+ 'content' => [
+ ['type' => 'text', 'text' => $message->text()],
+ ...$imageParts,
+ ],
+ ];
+ }
+
+ protected function mapAssistantMessage(AssistantMessage $message): void
+ {
+ $toolCalls = array_map(fn (ToolCall $toolCall): array => [
+ 'id' => $toolCall->id,
+ 'type' => 'function',
+ 'function' => [
+ 'name' => $toolCall->name,
+ 'arguments' => json_encode($toolCall->arguments()),
+ ],
+ ], $message->toolCalls);
+
+ $this->mappedMessages[] = array_filter([
+ 'role' => 'assistant',
+ 'content' => $message->content,
+ 'tool_calls' => $toolCalls,
+ ]);
+ }
+}
diff --git a/src/Providers/OpenAI/Maps/ChatCompletionsToolChoiceMap.php b/src/Providers/OpenAI/Maps/ChatCompletionsToolChoiceMap.php
new file mode 100644
index 000000000..f6607f200
--- /dev/null
+++ b/src/Providers/OpenAI/Maps/ChatCompletionsToolChoiceMap.php
@@ -0,0 +1,32 @@
+|string|null
+ */
+ public static function map(string|ToolChoice|null $toolChoice): string|array|null
+ {
+ if (is_string($toolChoice)) {
+ return [
+ 'type' => 'function',
+ 'function' => [
+ 'name' => $toolChoice,
+ ],
+ ];
+ }
+
+ return match ($toolChoice) {
+ ToolChoice::Auto => 'auto',
+ ToolChoice::Any => 'required',
+ ToolChoice::None => 'none',
+ null => $toolChoice,
+ };
+ }
+}
diff --git a/src/Providers/OpenAI/Maps/ChatCompletionsToolMap.php b/src/Providers/OpenAI/Maps/ChatCompletionsToolMap.php
new file mode 100644
index 000000000..5bc7afcde
--- /dev/null
+++ b/src/Providers/OpenAI/Maps/ChatCompletionsToolMap.php
@@ -0,0 +1,34 @@
+|null
+ */
+ public static function map(array $tools): ?array
+ {
+ if ($tools === []) {
+ return null;
+ }
+
+ return array_map(fn (Tool $tool): array => [
+ 'type' => 'function',
+ 'function' => [
+ 'name' => $tool->name(),
+ 'description' => $tool->description(),
+ 'parameters' => [
+ 'type' => 'object',
+ 'properties' => $tool->parametersAsArray(),
+ 'required' => $tool->requiredParameters(),
+ ],
+ ],
+ ], $tools);
+ }
+}
diff --git a/src/Providers/OpenAI/Maps/MessageMap.php b/src/Providers/OpenAI/Maps/MessageMap.php
index 0d3fffdc6..3a071a79e 100644
--- a/src/Providers/OpenAI/Maps/MessageMap.php
+++ b/src/Providers/OpenAI/Maps/MessageMap.php
@@ -117,6 +117,33 @@ protected static function mapDocumentParts(array $documents): array
protected function mapAssistantMessage(AssistantMessage $message): void
{
+ if ($message->toolCalls !== []) {
+ $grouped = collect($message->toolCalls)
+ ->groupBy(fn (ToolCall $toolCall): string => $toolCall->reasoningId ?? '');
+
+ foreach ($grouped as $reasoningId => $toolCalls) {
+ $first = $toolCalls->first();
+
+ if ($reasoningId !== '' && $first instanceof ToolCall) {
+ $this->mappedMessages[] = [
+ 'type' => 'reasoning',
+ 'id' => $first->reasoningId,
+ 'summary' => $first->reasoningSummary,
+ ];
+ }
+
+ foreach ($toolCalls as $toolCall) {
+ $this->mappedMessages[] = [
+ 'id' => $toolCall->id,
+ 'call_id' => $toolCall->resultId,
+ 'type' => 'function_call',
+ 'name' => $toolCall->name,
+ 'arguments' => json_encode($toolCall->arguments() ?: (object) []),
+ ];
+ }
+ }
+ }
+
if ($message->content !== '' && $message->content !== '0') {
$mappedMessage = [
'role' => 'assistant',
@@ -134,30 +161,5 @@ protected function mapAssistantMessage(AssistantMessage $message): void
$this->mappedMessages[] = $mappedMessage;
}
-
- if ($message->toolCalls !== []) {
- $reasoningBlocks = collect($message->toolCalls)
- ->whereNotNull('reasoningId')
- ->unique('reasoningId')
- ->map(fn (ToolCall $toolCall): array => [
- 'type' => 'reasoning',
- 'id' => $toolCall->reasoningId,
- 'summary' => $toolCall->reasoningSummary,
- ])
- ->values()
- ->all();
-
- array_push(
- $this->mappedMessages,
- ...$reasoningBlocks,
- ...array_map(fn (ToolCall $toolCall): array => [
- 'id' => $toolCall->id,
- 'call_id' => $toolCall->resultId,
- 'type' => 'function_call',
- 'name' => $toolCall->name,
- 'arguments' => json_encode($toolCall->arguments() ?: (object) []),
- ], $message->toolCalls)
- );
- }
}
}
diff --git a/src/Providers/OpenAI/OpenAI.php b/src/Providers/OpenAI/OpenAI.php
index 985ba6ad3..7f48efe1d 100644
--- a/src/Providers/OpenAI/OpenAI.php
+++ b/src/Providers/OpenAI/OpenAI.php
@@ -11,6 +11,14 @@
use Prism\Prism\Audio\SpeechToTextRequest;
use Prism\Prism\Audio\TextResponse as SpeechToTextResponse;
use Prism\Prism\Audio\TextToSpeechRequest;
+use Prism\Prism\Batch\BatchJob;
+use Prism\Prism\Batch\BatchListResult;
+use Prism\Prism\Batch\BatchRequest;
+use Prism\Prism\Batch\BatchResultItem;
+use Prism\Prism\Batch\CancelBatchRequest;
+use Prism\Prism\Batch\GetBatchResultsRequest;
+use Prism\Prism\Batch\ListBatchesRequest;
+use Prism\Prism\Batch\RetrieveBatchRequest;
use Prism\Prism\Concerns\InitializesClient;
use Prism\Prism\Embeddings\Request as EmbeddingsRequest;
use Prism\Prism\Embeddings\Response as EmbeddingsResponse;
@@ -19,13 +27,34 @@
use Prism\Prism\Exceptions\PrismProviderOverloadedException;
use Prism\Prism\Exceptions\PrismRateLimitedException;
use Prism\Prism\Exceptions\PrismRequestTooLargeException;
+use Prism\Prism\Files\DeleteFileRequest;
+use Prism\Prism\Files\DeleteFileResult;
+use Prism\Prism\Files\DownloadFileRequest;
+use Prism\Prism\Files\FileData;
+use Prism\Prism\Files\FileListResult;
+use Prism\Prism\Files\GetFileMetadataRequest;
+use Prism\Prism\Files\ListFilesRequest;
+use Prism\Prism\Files\UploadFileRequest;
use Prism\Prism\Images\Request as ImagesRequest;
use Prism\Prism\Images\Response as ImagesResponse;
use Prism\Prism\Moderation\Request as ModerationRequest;
use Prism\Prism\Moderation\Response as ModerationResponse;
use Prism\Prism\Providers\OpenAI\Concerns\ProcessRateLimits;
use Prism\Prism\Providers\OpenAI\Handlers\Audio;
+use Prism\Prism\Providers\OpenAI\Handlers\Batch\Cancel;
+use Prism\Prism\Providers\OpenAI\Handlers\Batch\Create;
+use Prism\Prism\Providers\OpenAI\Handlers\Batch\ListBatches;
+use Prism\Prism\Providers\OpenAI\Handlers\Batch\Results;
+use Prism\Prism\Providers\OpenAI\Handlers\Batch\Retrieve;
+use Prism\Prism\Providers\OpenAI\Handlers\ChatCompletions\Stream as ChatCompletionsStream;
+use Prism\Prism\Providers\OpenAI\Handlers\ChatCompletions\Structured as ChatCompletionsStructured;
+use Prism\Prism\Providers\OpenAI\Handlers\ChatCompletions\Text as ChatCompletionsText;
use Prism\Prism\Providers\OpenAI\Handlers\Embeddings;
+use Prism\Prism\Providers\OpenAI\Handlers\Files\Delete as FileDelete;
+use Prism\Prism\Providers\OpenAI\Handlers\Files\Download as FileDownload;
+use Prism\Prism\Providers\OpenAI\Handlers\Files\GetMetadata as FileGetMetadata;
+use Prism\Prism\Providers\OpenAI\Handlers\Files\ListFiles as FileListFiles;
+use Prism\Prism\Providers\OpenAI\Handlers\Files\Upload as FileUpload;
use Prism\Prism\Providers\OpenAI\Handlers\Images;
use Prism\Prism\Providers\OpenAI\Handlers\Moderation;
use Prism\Prism\Providers\OpenAI\Handlers\Stream;
@@ -47,15 +76,15 @@ public function __construct(
public readonly string $url,
public readonly ?string $organization,
public readonly ?string $project,
+ public readonly string $apiFormat = 'responses',
) {}
#[\Override]
public function text(TextRequest $request): TextResponse
{
- $handler = new Text($this->client(
- $request->clientOptions(),
- $request->clientRetry()
- ));
+ $handler = $this->apiFormat === 'chat_completions'
+ ? new ChatCompletionsText($this->client($request->clientOptions(), $request->clientRetry()))
+ : new Text($this->client($request->clientOptions(), $request->clientRetry()));
return $handler->handle($request);
}
@@ -63,10 +92,9 @@ public function text(TextRequest $request): TextResponse
#[\Override]
public function structured(StructuredRequest $request): StructuredResponse
{
- $handler = new Structured($this->client(
- $request->clientOptions(),
- $request->clientRetry()
- ));
+ $handler = $this->apiFormat === 'chat_completions'
+ ? new ChatCompletionsStructured($this->client($request->clientOptions(), $request->clientRetry()))
+ : new Structured($this->client($request->clientOptions(), $request->clientRetry()));
return $handler->handle($request);
}
@@ -129,14 +157,97 @@ public function speechToText(SpeechToTextRequest $request): SpeechToTextResponse
#[\Override]
public function stream(TextRequest $request): Generator
{
- $handler = new Stream($this->client(
- $request->clientOptions(),
- $request->clientRetry()
- ));
+ $handler = $this->apiFormat === 'chat_completions'
+ ? new ChatCompletionsStream($this->client($request->clientOptions(), $request->clientRetry()))
+ : new Stream($this->client($request->clientOptions(), $request->clientRetry()));
return $handler->handle($request);
}
+ #[\Override]
+ public function batch(BatchRequest $request): BatchJob
+ {
+ return (new Create(
+ client: $this->client($request->clientOptions(), $request->clientRetry()),
+ uploadFile: fn (string $content, string $filename): FileData => $this->uploadFile(
+ (new UploadFileRequest(filename: $filename, content: $content))
+ ->withClientOptions($request->clientOptions())
+ ->withClientRetry(...$request->clientRetry())
+ ->withProviderOptions(['purpose' => 'batch'])
+ ),
+ ))->handle($request);
+ }
+
+ #[\Override]
+ public function retrieveBatch(RetrieveBatchRequest $request): BatchJob
+ {
+ return (new Retrieve($this->client($request->clientOptions(), $request->clientRetry())))->handle($request->batchId);
+ }
+
+ #[\Override]
+ public function listBatches(ListBatchesRequest $request): BatchListResult
+ {
+ return (new ListBatches($this->client($request->clientOptions(), $request->clientRetry())))->handle($request);
+ }
+
+ /**
+ * @return BatchResultItem[]
+ */
+ #[\Override]
+ public function getBatchResults(GetBatchResultsRequest $request): array
+ {
+ return (new Results(
+ retrieveBatch: fn (string $batchId): BatchJob => $this->retrieveBatch(
+ (new RetrieveBatchRequest($batchId))
+ ->withClientOptions($request->clientOptions())
+ ->withClientRetry(...$request->clientRetry())
+ ->withProviderOptions($request->providerOptions())
+ ),
+ downloadFile: fn (string $fileId): string => $this->downloadFile(
+ (new DownloadFileRequest($fileId))
+ ->withClientOptions($request->clientOptions())
+ ->withClientRetry(...$request->clientRetry())
+ ->withProviderOptions($request->providerOptions())
+ ),
+ ))->handle($request->batchId);
+ }
+
+ #[\Override]
+ public function cancelBatch(CancelBatchRequest $request): BatchJob
+ {
+ return (new Cancel($this->client($request->clientOptions(), $request->clientRetry())))->handle($request->batchId);
+ }
+
+ #[\Override]
+ public function uploadFile(UploadFileRequest $request): FileData
+ {
+ return (new FileUpload($this->client($request->clientOptions(), $request->clientRetry())))->handle($request);
+ }
+
+ #[\Override]
+ public function listFiles(ListFilesRequest $request): FileListResult
+ {
+ return (new FileListFiles($this->client($request->clientOptions(), $request->clientRetry())))->handle($request);
+ }
+
+ #[\Override]
+ public function getFileMetadata(GetFileMetadataRequest $request): FileData
+ {
+ return (new FileGetMetadata($this->client($request->clientOptions(), $request->clientRetry())))->handle($request);
+ }
+
+ #[\Override]
+ public function deleteFile(DeleteFileRequest $request): DeleteFileResult
+ {
+ return (new FileDelete($this->client($request->clientOptions(), $request->clientRetry())))->handle($request);
+ }
+
+ #[\Override]
+ public function downloadFile(DownloadFileRequest $request): string
+ {
+ return (new FileDownload($this->client($request->clientOptions(), $request->clientRetry())))->handle($request);
+ }
+
public function handleRequestException(string $model, RequestException $e): never
{
match ($e->response->getStatusCode()) {
diff --git a/src/Providers/OpenAI/Support/StructuredModeResolver.php b/src/Providers/OpenAI/Support/StructuredModeResolver.php
index ea77ba961..3ad41a928 100644
--- a/src/Providers/OpenAI/Support/StructuredModeResolver.php
+++ b/src/Providers/OpenAI/Support/StructuredModeResolver.php
@@ -11,39 +11,53 @@ class StructuredModeResolver
{
public static function forModel(string $model): StructuredMode
{
- if (self::unsupported($model)) {
+ $baseModel = self::resolveBaseModel($model);
+
+ if (self::unsupported($baseModel)) {
throw new PrismException(sprintf('Structured output is not supported for %s', $model));
}
- if (self::supportsStructuredMode($model)) {
+ if (self::supportsStructuredMode($baseModel)) {
return StructuredMode::Structured;
}
return StructuredMode::Json;
}
+ /**
+ * Resolve the base model name, stripping the ft: prefix for fine-tuned models.
+ *
+ * Fine-tuned models use the format: ft::::
+ */
+ protected static function resolveBaseModel(string $model): string
+ {
+ if (str_starts_with($model, 'ft:')) {
+ $parts = explode(':', $model, 3);
+
+ return $parts[1] ?? $model;
+ }
+
+ return $model;
+ }
+
protected static function supportsStructuredMode(string $model): bool
{
- return in_array($model, [
- 'gpt-4o-mini',
- 'gpt-4o-mini-2024-07-18',
- 'gpt-4o-2024-08-06',
+ // Prefix matching for model families where structured output is a
+ // baseline feature — all versions and dated snapshots qualify.
+ foreach ([
'gpt-4o',
- 'chatgpt-4o-latest',
- 'o3-mini',
- 'o3-mini-2025-01-31',
'gpt-4.1',
- 'gpt-4.1-nano',
- 'gpt-4.1-mini',
- 'gpt-4.5-preview',
- 'gpt-4.5-preview-2025-02-27',
+ 'gpt-4.5',
'gpt-5',
- 'gpt-5-mini',
- 'gpt-5-nano',
- 'gpt-5.1',
- 'gpt-5.2',
- 'gpt-5.4',
- ]);
+ 'chatgpt-4o',
+ 'o3-mini',
+ ] as $prefix) {
+ if (str_starts_with($model, $prefix)) {
+ return true;
+ }
+ }
+
+ return false;
}
protected static function supportsJsonMode(string $model): bool
diff --git a/src/Providers/OpenRouter/Concerns/BuildsRequestOptions.php b/src/Providers/OpenRouter/Concerns/BuildsRequestOptions.php
index 3124581a5..2759952f6 100644
--- a/src/Providers/OpenRouter/Concerns/BuildsRequestOptions.php
+++ b/src/Providers/OpenRouter/Concerns/BuildsRequestOptions.php
@@ -31,6 +31,10 @@ protected function buildRequestOptions(TextRequest|StructuredRequest $request, a
'tool_choice' => ToolChoiceMap::map($request->toolChoice()),
]));
+ if ($request->reasoningEnabled() === false && ! isset($options['reasoning'])) {
+ $options['reasoning'] = ['exclude' => true];
+ }
+
return Arr::whereNotNull(array_merge($options, $additional));
}
}
diff --git a/src/Providers/OpenRouter/Concerns/ExtractsErrorDetails.php b/src/Providers/OpenRouter/Concerns/ExtractsErrorDetails.php
new file mode 100644
index 000000000..3696dd824
--- /dev/null
+++ b/src/Providers/OpenRouter/Concerns/ExtractsErrorDetails.php
@@ -0,0 +1,80 @@
+ $errorData The "error" key from the response.
+ * @return array{message: string, providerName: ?string}
+ */
+ protected function extractErrorDetails(array $errorData): array
+ {
+ $topMessage = data_get($errorData, 'message');
+ $metadata = data_get($errorData, 'metadata', []);
+ $providerName = data_get($metadata, 'provider_name');
+ $raw = data_get($metadata, 'raw');
+
+ $rawMessage = $this->extractMessageFromRaw($raw);
+
+ $message = $rawMessage
+ ?? (is_string($topMessage) && $topMessage !== '' ? $topMessage : 'Unknown error');
+
+ return [
+ 'message' => $message,
+ 'providerName' => is_string($providerName) && $providerName !== '' ? $providerName : null,
+ ];
+ }
+
+ protected function extractMessageFromRaw(mixed $raw): ?string
+ {
+ if (! is_string($raw) || $raw === '') {
+ return null;
+ }
+
+ try {
+ $decoded = json_decode($raw, true, 512, JSON_THROW_ON_ERROR);
+ } catch (JsonException) {
+ return $raw;
+ }
+
+ if (is_string($decoded) && $decoded !== '') {
+ return $decoded;
+ }
+
+ if (! is_array($decoded)) {
+ return $raw;
+ }
+
+ foreach (['error.message', 'message', 'Message', 'error_message', 'detail'] as $path) {
+ $candidate = data_get($decoded, $path);
+
+ if (is_string($candidate) && $candidate !== '') {
+ return $candidate;
+ }
+ }
+
+ $error = data_get($decoded, 'error');
+
+ if (is_string($error) && $error !== '') {
+ return $error;
+ }
+
+ return null;
+ }
+
+ protected function formatProviderLabel(?string $providerName): string
+ {
+ return $providerName !== null ? sprintf(' (%s)', $providerName) : '';
+ }
+}
diff --git a/src/Providers/OpenRouter/Concerns/ExtractsReasoning.php b/src/Providers/OpenRouter/Concerns/ExtractsReasoning.php
new file mode 100644
index 000000000..ad6684a64
--- /dev/null
+++ b/src/Providers/OpenRouter/Concerns/ExtractsReasoning.php
@@ -0,0 +1,32 @@
+ $data
+ * @return array
+ */
+ protected function extractReasoning(array $data): array
+ {
+ return Arr::whereNotNull([
+ 'reasoning' => data_get($data, 'choices.0.message.reasoning'),
+ 'reasoning_details' => data_get($data, 'choices.0.message.reasoning_details'),
+ ]);
+ }
+
+ /**
+ * @param array $data
+ */
+ protected function extractThoughtTokens(array $data): ?int
+ {
+ $tokens = data_get($data, 'usage.completion_tokens_details.reasoning_tokens');
+
+ return $tokens !== null ? (int) $tokens : null;
+ }
+}
diff --git a/src/Providers/OpenRouter/Concerns/ValidatesResponses.php b/src/Providers/OpenRouter/Concerns/ValidatesResponses.php
index f330e4323..fd66c0989 100644
--- a/src/Providers/OpenRouter/Concerns/ValidatesResponses.php
+++ b/src/Providers/OpenRouter/Concerns/ValidatesResponses.php
@@ -8,6 +8,8 @@
trait ValidatesResponses
{
+ use ExtractsErrorDetails;
+
/**
* @param array $data
*/
@@ -27,23 +29,28 @@ protected function validateResponse(array $data): void
*/
protected function handleOpenRouterError(array $data): void
{
- $error = data_get($data, 'error', []);
- $code = data_get($error, 'code', 'unknown');
- $message = data_get($error, 'message', 'Unknown error');
- $metadata = data_get($error, 'metadata', []);
+ $errorData = data_get($data, 'error', []);
+ $errorData = is_array($errorData) ? $errorData : [];
+
+ $code = data_get($errorData, 'code', 'unknown');
+ $metadata = data_get($errorData, 'metadata', []);
+ $details = $this->extractErrorDetails($errorData);
+ $message = $details['message'];
+ $providerLabel = $this->formatProviderLabel($details['providerName']);
if ($code === 403 && isset($metadata['reasons'])) {
throw PrismException::providerResponseError(sprintf(
- 'OpenRouter Moderation Error: %s. Flagged input: %s',
+ 'OpenRouter Moderation Error%s: %s. Flagged input: %s',
+ $providerLabel,
$message,
data_get($metadata, 'flagged_input', 'N/A')
));
}
- if (isset($metadata['provider_name'])) {
+ if ($details['providerName'] !== null) {
throw PrismException::providerResponseError(sprintf(
- 'OpenRouter Provider Error (%s): %s',
- data_get($metadata, 'provider_name'),
+ 'OpenRouter Provider Error%s: %s',
+ $providerLabel,
$message
));
}
diff --git a/src/Providers/OpenRouter/Handlers/Stream.php b/src/Providers/OpenRouter/Handlers/Stream.php
index b9a895388..282168091 100644
--- a/src/Providers/OpenRouter/Handlers/Stream.php
+++ b/src/Providers/OpenRouter/Handlers/Stream.php
@@ -15,6 +15,7 @@
use Prism\Prism\Providers\OpenRouter\Concerns\MapsFinishReason;
use Prism\Prism\Providers\OpenRouter\Concerns\ValidatesResponses;
use Prism\Prism\Providers\OpenRouter\Maps\MessageMap;
+use Prism\Prism\Providers\OpenRouter\ValueObjects\OpenRouterStreamState;
use Prism\Prism\Streaming\EventID;
use Prism\Prism\Streaming\Events\StepFinishEvent;
use Prism\Prism\Streaming\Events\StepStartEvent;
@@ -28,7 +29,6 @@
use Prism\Prism\Streaming\Events\ThinkingEvent;
use Prism\Prism\Streaming\Events\ThinkingStartEvent;
use Prism\Prism\Streaming\Events\ToolCallEvent;
-use Prism\Prism\Streaming\StreamState;
use Prism\Prism\Text\Request;
use Prism\Prism\ValueObjects\Messages\AssistantMessage;
use Prism\Prism\ValueObjects\Messages\ToolResultMessage;
@@ -44,11 +44,11 @@ class Stream
use MapsFinishReason;
use ValidatesResponses;
- protected StreamState $state;
+ protected OpenRouterStreamState $state;
public function __construct(protected PendingRequest $client)
{
- $this->state = new StreamState;
+ $this->state = new OpenRouterStreamState;
}
/**
@@ -56,6 +56,8 @@ public function __construct(protected PendingRequest $client)
*/
public function handle(Request $request): Generator
{
+ yield from $this->resolveToolApprovalsAndYieldEvents($request, EventID::generate());
+
$response = $this->sendRequest($request);
yield from $this->processStream($response, $request);
@@ -158,7 +160,7 @@ protected function processStream(Response $response, Request $request, int $dept
return;
}
- if ($this->hasReasoningDelta($data)) {
+ if ($this->hasReasoningData($data)) {
$reasoningDelta = $this->extractReasoningDelta($data);
if ($reasoningDelta !== '') {
@@ -182,9 +184,11 @@ protected function processStream(Response $response, Request $request, int $dept
delta: $reasoningDelta,
reasoningId: $this->state->reasoningId()
);
-
- continue;
}
+
+ $this->captureReasoningDetails($data);
+
+ continue;
}
$content = $this->extractContentDelta($data);
@@ -258,6 +262,7 @@ protected function emitStreamEndEvent(): StreamEndEvent
timestamp: time(),
finishReason: $this->state->finishReason() ?? FinishReason::Stop,
usage: $this->state->usage() ?? new Usage(0, 0),
+ additionalContent: $this->buildAdditionalContent(),
);
}
@@ -332,9 +337,10 @@ protected function extractToolCalls(array $data, array $toolCalls): array
/**
* @param array $data
*/
- protected function hasReasoningDelta(array $data): bool
+ protected function hasReasoningData(array $data): bool
{
- return isset($data['choices'][0]['delta']['reasoning']);
+ return isset($data['choices'][0]['delta']['reasoning'])
+ || ! empty($data['choices'][0]['delta']['reasoning_details']);
}
/**
@@ -342,7 +348,38 @@ protected function hasReasoningDelta(array $data): bool
*/
protected function extractReasoningDelta(array $data): string
{
- return data_get($data, 'choices.0.delta.reasoning', '');
+ return data_get($data, 'choices.0.delta.reasoning') ?? '';
+ }
+
+ /**
+ * @param array $data
+ */
+ protected function captureReasoningDetails(array $data): void
+ {
+ $details = data_get($data, 'choices.0.delta.reasoning_details', []);
+
+ foreach ($details as $detail) {
+ $this->state->addReasoningDetail($detail);
+ }
+ }
+
+ /**
+ * @return array
+ */
+ protected function buildAdditionalContent(): array
+ {
+ $additionalContent = [];
+
+ $thinking = $this->state->currentThinking();
+ if ($thinking !== '') {
+ $additionalContent['reasoning'] = $thinking;
+ }
+
+ if ($this->state->reasoningDetails() !== []) {
+ $additionalContent['reasoning_details'] = $this->state->reasoningDetails();
+ }
+
+ return $additionalContent;
}
/**
@@ -389,7 +426,17 @@ protected function handleToolCalls(
}
$toolResults = [];
- yield from $this->callToolsAndYieldEvents($request->tools(), $mappedToolCalls, $this->state->messageId(), $toolResults);
+ $hasPendingToolCalls = false;
+ yield from $this->callToolsAndYieldEventsWithPending($request->tools(), $mappedToolCalls, $this->state->messageId(), $toolResults, $hasPendingToolCalls);
+
+ if ($hasPendingToolCalls) {
+ // Client-executed or approval-required tool calls: end the stream
+ // with FinishReason::ToolCalls so the consumer resolves and resumes.
+ $this->state->markStepFinished();
+ yield from $this->yieldToolCallsFinishEvents($this->state);
+
+ return;
+ }
$this->state->markStepFinished();
yield new StepFinishEvent(
@@ -397,7 +444,7 @@ protected function handleToolCalls(
timestamp: time()
);
- $request->addMessage(new AssistantMessage($text, $mappedToolCalls));
+ $request->addMessage(new AssistantMessage($text, $mappedToolCalls, $this->buildAdditionalContent()));
$request->addMessage(new ToolResultMessage($toolResults));
$request->resetToolChoice();
@@ -430,7 +477,7 @@ protected function mapToolCalls(array $toolCalls): array
if (is_string($arguments) && $arguments !== '') {
try {
$parsedArguments = json_decode($arguments, true, flags: JSON_THROW_ON_ERROR);
- $arguments = $parsedArguments;
+ $arguments = is_array($parsedArguments) ? $parsedArguments : ['raw' => $arguments];
} catch (Throwable) {
$arguments = ['raw' => $arguments];
}
@@ -499,10 +546,13 @@ protected function extractUsage(array $data): ?Usage
}
return new Usage(
- promptTokens: (int) data_get($usage, 'prompt_tokens', 0),
+ promptTokens: max(0, (int) data_get($usage, 'prompt_tokens', 0) - (int) data_get($usage, 'prompt_tokens_details.cached_tokens', 0)),
completionTokens: (int) data_get($usage, 'completion_tokens', 0),
- cacheReadInputTokens: (int) data_get($usage, 'prompt_tokens_details.cached_tokens', 0),
- thoughtTokens: (int) data_get($usage, 'completion_tokens_details.reasoning_tokens', 0)
+ // OpenRouter: usage.prompt_tokens_details.cache_write_tokens / cached_tokens
+ cacheWriteInputTokens: (int) data_get($usage, 'prompt_tokens_details.cache_write_tokens', 0) ?: null,
+ cacheReadInputTokens: (int) data_get($usage, 'prompt_tokens_details.cached_tokens', 0) ?: null,
+ thoughtTokens: (int) data_get($usage, 'completion_tokens_details.reasoning_tokens', 0) ?: null,
+ cost: ($cost = data_get($usage, 'cost')) !== null ? (float) $cost : null,
);
}
}
diff --git a/src/Providers/OpenRouter/Handlers/Structured.php b/src/Providers/OpenRouter/Handlers/Structured.php
index 43b8376fd..eecf77e11 100644
--- a/src/Providers/OpenRouter/Handlers/Structured.php
+++ b/src/Providers/OpenRouter/Handlers/Structured.php
@@ -12,6 +12,7 @@
use Prism\Prism\Exceptions\PrismStructuredDecodingException;
use Prism\Prism\Providers\DeepSeek\Maps\ToolCallMap;
use Prism\Prism\Providers\OpenRouter\Concerns\BuildsRequestOptions;
+use Prism\Prism\Providers\OpenRouter\Concerns\ExtractsReasoning;
use Prism\Prism\Providers\OpenRouter\Concerns\MapsFinishReason;
use Prism\Prism\Providers\OpenRouter\Concerns\ValidatesResponses;
use Prism\Prism\Providers\OpenRouter\Maps\MessageMap;
@@ -29,6 +30,7 @@ class Structured
{
use BuildsRequestOptions;
use CallsTools;
+ use ExtractsReasoning;
use MapsFinishReason;
use ValidatesResponses;
@@ -41,14 +43,15 @@ public function __construct(protected PendingRequest $client)
public function handle(Request $request): StructuredResponse
{
+ $this->resolveToolApprovals($request);
+
$data = $this->sendRequest($request);
$this->validateResponse($data);
return match ($this->mapFinishReason($data)) {
FinishReason::ToolCalls => $this->handleToolCalls($data, $request),
- FinishReason::Stop, FinishReason::Length => $this->handleStop($data, $request),
- default => throw new PrismException('OpenRouter: unknown finish reason'),
+ default => $this->handleStop($data, $request),
};
}
@@ -99,19 +102,22 @@ protected function handleToolCalls(array $data, Request $request): StructuredRes
{
$toolCalls = ToolCallMap::map(data_get($data, 'choices.0.message.tool_calls', []));
- $toolResults = $this->callTools($request->tools(), $toolCalls);
+ $hasPendingToolCalls = false;
+ $approvalRequests = [];
+ $toolResults = $this->callToolsWithPending($request->tools(), $toolCalls, $hasPendingToolCalls, $approvalRequests);
$this->addStep($data, $request, $toolResults);
$request = $request->addMessage(new AssistantMessage(
data_get($data, 'choices.0.message.content') ?? '',
$toolCalls,
- []
+ $this->extractReasoning($data),
+ toolApprovalRequests: $approvalRequests,
));
$request = $request->addMessage(new ToolResultMessage($toolResults));
$request->resetToolChoice();
- if ($this->shouldContinue($request)) {
+ if (! $hasPendingToolCalls && $this->shouldContinue($request)) {
return $this->handle($request);
}
@@ -154,8 +160,12 @@ protected function addStep(array $data, Request $request, array $toolResults = [
text: data_get($data, 'choices.0.message.content') ?? '',
finishReason: $this->mapFinishReason($data),
usage: new Usage(
- (int) data_get($data, 'usage.prompt_tokens', 0),
- (int) data_get($data, 'usage.completion_tokens', 0),
+ promptTokens: max(0, (int) data_get($data, 'usage.prompt_tokens', 0) - (int) data_get($data, 'usage.prompt_tokens_details.cached_tokens', 0)),
+ completionTokens: (int) data_get($data, 'usage.completion_tokens', 0),
+ cacheWriteInputTokens: (int) data_get($data, 'usage.prompt_tokens_details.cache_write_tokens', 0) ?: null,
+ cacheReadInputTokens: (int) data_get($data, 'usage.prompt_tokens_details.cached_tokens', 0) ?: null,
+ thoughtTokens: $this->extractThoughtTokens($data),
+ cost: ($cost = data_get($data, 'usage.cost')) !== null ? (float) $cost : null,
),
meta: new Meta(
id: data_get($data, 'id', ''),
@@ -163,7 +173,7 @@ protected function addStep(array $data, Request $request, array $toolResults = [
),
messages: $request->messages(),
systemPrompts: $request->systemPrompts(),
- additionalContent: [],
+ additionalContent: $this->extractReasoning($data),
toolCalls: ToolCallMap::map(data_get($data, 'choices.0.message.tool_calls', [])),
providerToolCalls: [],
toolResults: $toolResults,
diff --git a/src/Providers/OpenRouter/Handlers/Text.php b/src/Providers/OpenRouter/Handlers/Text.php
index ac2b78cf0..a7b56e90f 100644
--- a/src/Providers/OpenRouter/Handlers/Text.php
+++ b/src/Providers/OpenRouter/Handlers/Text.php
@@ -8,8 +8,8 @@
use Illuminate\Http\Client\Response;
use Prism\Prism\Concerns\CallsTools;
use Prism\Prism\Enums\FinishReason;
-use Prism\Prism\Exceptions\PrismException;
use Prism\Prism\Providers\OpenRouter\Concerns\BuildsRequestOptions;
+use Prism\Prism\Providers\OpenRouter\Concerns\ExtractsReasoning;
use Prism\Prism\Providers\OpenRouter\Concerns\MapsFinishReason;
use Prism\Prism\Providers\OpenRouter\Concerns\ValidatesResponses;
use Prism\Prism\Providers\OpenRouter\Maps\MessageMap;
@@ -28,6 +28,7 @@ class Text
{
use BuildsRequestOptions;
use CallsTools;
+ use ExtractsReasoning;
use MapsFinishReason;
use ValidatesResponses;
@@ -40,14 +41,15 @@ public function __construct(protected PendingRequest $client)
public function handle(Request $request): TextResponse
{
+ $this->resolveToolApprovals($request);
+
$data = $this->sendRequest($request);
$this->validateResponse($data);
return match ($this->mapFinishReason($data)) {
FinishReason::ToolCalls => $this->handleToolCalls($data, $request),
- FinishReason::Stop, FinishReason::Length => $this->handleStop($data, $request),
- default => throw new PrismException('OpenRouter: unknown finish reason'),
+ default => $this->handleStop($data, $request),
};
}
@@ -58,19 +60,22 @@ protected function handleToolCalls(array $data, Request $request): TextResponse
{
$toolCalls = ToolCallMap::map(data_get($data, 'choices.0.message.tool_calls', []));
- $toolResults = $this->callTools($request->tools(), $toolCalls);
+ $hasPendingToolCalls = false;
+ $approvalRequests = [];
+ $toolResults = $this->callToolsWithPending($request->tools(), $toolCalls, $hasPendingToolCalls, $approvalRequests);
$this->addStep($data, $request, $toolResults);
$request = $request->addMessage(new AssistantMessage(
data_get($data, 'choices.0.message.content') ?? '',
$toolCalls,
- []
+ $this->extractReasoning($data),
+ toolApprovalRequests: $approvalRequests,
));
$request = $request->addMessage(new ToolResultMessage($toolResults));
$request->resetToolChoice();
- if ($this->shouldContinue($request)) {
+ if (! $hasPendingToolCalls && $this->shouldContinue($request)) {
return $this->handle($request);
}
@@ -123,8 +128,12 @@ protected function addStep(array $data, Request $request, array $toolResults = [
toolResults: $toolResults,
providerToolCalls: [],
usage: new Usage(
- (int) data_get($data, 'usage.prompt_tokens', 0),
- (int) data_get($data, 'usage.completion_tokens', 0),
+ promptTokens: max(0, (int) data_get($data, 'usage.prompt_tokens', 0) - (int) data_get($data, 'usage.prompt_tokens_details.cached_tokens', 0)),
+ completionTokens: (int) data_get($data, 'usage.completion_tokens', 0),
+ cacheWriteInputTokens: (int) data_get($data, 'usage.prompt_tokens_details.cache_write_tokens', 0) ?: null,
+ cacheReadInputTokens: (int) data_get($data, 'usage.prompt_tokens_details.cached_tokens', 0) ?: null,
+ thoughtTokens: $this->extractThoughtTokens($data),
+ cost: ($cost = data_get($data, 'usage.cost')) !== null ? (float) $cost : null,
),
meta: new Meta(
id: data_get($data, 'id', ''),
@@ -132,7 +141,7 @@ protected function addStep(array $data, Request $request, array $toolResults = [
),
messages: $request->messages(),
systemPrompts: $request->systemPrompts(),
- additionalContent: [],
+ additionalContent: $this->extractReasoning($data),
raw: $data,
));
}
diff --git a/src/Providers/OpenRouter/Maps/MessageMap.php b/src/Providers/OpenRouter/Maps/MessageMap.php
index 7ceb9451c..7f9bf66f7 100644
--- a/src/Providers/OpenRouter/Maps/MessageMap.php
+++ b/src/Providers/OpenRouter/Maps/MessageMap.php
@@ -64,6 +64,8 @@ protected function mapMessage(Message $message): void
protected function mapSystemMessage(SystemMessage $message): void
{
$cacheType = $message->providerOptions('cacheType');
+ // OpenRouter supports extended cache TTL (e.g. '1h') via cacheTtl provider option
+ $cacheTtl = $message->providerOptions('cacheTtl');
// OpenRouter supports cache_control in content array format (same as Anthropic)
if ($cacheType) {
@@ -73,7 +75,10 @@ protected function mapSystemMessage(SystemMessage $message): void
[
'type' => 'text',
'text' => $message->content,
- 'cache_control' => ['type' => $cacheType instanceof BackedEnum ? $cacheType->value : $cacheType],
+ 'cache_control' => array_filter([
+ 'type' => $cacheType instanceof BackedEnum ? $cacheType->value : $cacheType,
+ 'ttl' => $cacheTtl,
+ ]),
],
],
];
@@ -88,7 +93,11 @@ protected function mapSystemMessage(SystemMessage $message): void
protected function mapToolResultMessage(ToolResultMessage $message): void
{
$cacheType = $message->providerOptions('cacheType');
- $cacheControl = $cacheType ? ['type' => $cacheType instanceof BackedEnum ? $cacheType->value : $cacheType] : null;
+ $cacheTtl = $message->providerOptions('cacheTtl');
+ $cacheControl = $cacheType ? array_filter([
+ 'type' => $cacheType instanceof BackedEnum ? $cacheType->value : $cacheType,
+ 'ttl' => $cacheTtl,
+ ]) : null;
$toolResults = $message->toolResults;
$totalResults = count($toolResults);
@@ -126,7 +135,11 @@ protected function mapToolResultMessage(ToolResultMessage $message): void
protected function mapUserMessage(UserMessage $message): void
{
$cacheType = $message->providerOptions('cacheType');
- $cacheControl = $cacheType ? ['type' => $cacheType instanceof BackedEnum ? $cacheType->value : $cacheType] : null;
+ $cacheTtl = $message->providerOptions('cacheTtl');
+ $cacheControl = $cacheType ? array_filter([
+ 'type' => $cacheType instanceof BackedEnum ? $cacheType->value : $cacheType,
+ 'ttl' => $cacheTtl,
+ ]) : null;
$imageParts = array_map(fn (Image $image): array => (new ImageMapper($image))->toPayload(), $message->images());
// NOTE: mirrored from Gemini's multimodal mapper so we stay consistent across providers.
@@ -153,6 +166,7 @@ protected function mapUserMessage(UserMessage $message): void
protected function mapAssistantMessage(AssistantMessage $message): void
{
$cacheType = $message->providerOptions('cacheType');
+ $cacheTtl = $message->providerOptions('cacheTtl');
$toolCalls = array_map(fn (ToolCall $toolCall): array => [
'id' => $toolCall->id,
@@ -163,6 +177,11 @@ protected function mapAssistantMessage(AssistantMessage $message): void
],
], $message->toolCalls);
+ $reasoning = $message->additionalContent['reasoning'] ?? null;
+ $reasoningDetails = empty($message->additionalContent['reasoning_details'])
+ ? null
+ : $message->additionalContent['reasoning_details'];
+
// OpenRouter supports cache_control on assistant messages
if ($cacheType && $message->content !== '' && $message->content !== '0') {
$this->mappedMessages[] = array_filter([
@@ -171,16 +190,23 @@ protected function mapAssistantMessage(AssistantMessage $message): void
[
'type' => 'text',
'text' => $message->content,
- 'cache_control' => ['type' => $cacheType instanceof BackedEnum ? $cacheType->value : $cacheType],
+ 'cache_control' => array_filter([
+ 'type' => $cacheType instanceof BackedEnum ? $cacheType->value : $cacheType,
+ 'ttl' => $cacheTtl,
+ ]),
],
],
'tool_calls' => $toolCalls,
+ 'reasoning' => $reasoning,
+ 'reasoning_details' => $reasoningDetails,
]);
} else {
$this->mappedMessages[] = array_filter([
'role' => 'assistant',
'content' => $message->content,
'tool_calls' => $toolCalls,
+ 'reasoning' => $reasoning,
+ 'reasoning_details' => $reasoningDetails,
]);
}
}
diff --git a/src/Providers/OpenRouter/Maps/ToolCallMap.php b/src/Providers/OpenRouter/Maps/ToolCallMap.php
index 3a0e3b4cc..11b606d33 100644
--- a/src/Providers/OpenRouter/Maps/ToolCallMap.php
+++ b/src/Providers/OpenRouter/Maps/ToolCallMap.php
@@ -17,7 +17,7 @@ public static function map(array $toolCalls): array
return array_map(fn (array $toolCall): ToolCall => new ToolCall(
id: $toolCall['id'],
name: $toolCall['function']['name'],
- arguments: json_decode((string) $toolCall['function']['arguments'], true),
+ arguments: (string) ($toolCall['function']['arguments'] ?? ''),
), $toolCalls);
}
}
diff --git a/src/Providers/OpenRouter/Maps/ToolMap.php b/src/Providers/OpenRouter/Maps/ToolMap.php
index 3d8f3dbf4..bff3656b5 100644
--- a/src/Providers/OpenRouter/Maps/ToolMap.php
+++ b/src/Providers/OpenRouter/Maps/ToolMap.php
@@ -30,11 +30,6 @@ public static function map(array $tools): array
];
})(),
] : [],
- 'parameters' => [
- 'type' => 'object',
- 'properties' => $tool->hasParameters() ? $tool->parametersAsArray() : (object) [],
- 'required' => $tool->requiredParameters(),
- ],
],
'strict' => $tool->providerOptions('strict'),
]), $tools);
diff --git a/src/Providers/OpenRouter/OpenRouter.php b/src/Providers/OpenRouter/OpenRouter.php
index 5df15b0e9..02ed591e2 100644
--- a/src/Providers/OpenRouter/OpenRouter.php
+++ b/src/Providers/OpenRouter/OpenRouter.php
@@ -7,7 +7,6 @@
use Generator;
use Illuminate\Http\Client\PendingRequest;
use Illuminate\Http\Client\RequestException;
-use JsonException;
use Prism\Prism\Concerns\InitializesClient;
use Prism\Prism\Embeddings\Request as EmbeddingRequest;
use Prism\Prism\Embeddings\Response as EmbeddingResponse;
@@ -16,6 +15,7 @@
use Prism\Prism\Exceptions\PrismProviderOverloadedException;
use Prism\Prism\Exceptions\PrismRateLimitedException;
use Prism\Prism\Exceptions\PrismRequestTooLargeException;
+use Prism\Prism\Providers\OpenRouter\Concerns\ExtractsErrorDetails;
use Prism\Prism\Providers\OpenRouter\Handlers\Embeddings;
use Prism\Prism\Providers\OpenRouter\Handlers\Stream;
use Prism\Prism\Providers\OpenRouter\Handlers\Structured;
@@ -28,6 +28,7 @@
class OpenRouter extends Provider
{
+ use ExtractsErrorDetails;
use InitializesClient;
public function __construct(
@@ -84,37 +85,39 @@ public function stream(TextRequest $request): Generator
public function handleRequestException(string $model, RequestException $e): never
{
$statusCode = $e->response->getStatusCode();
+ $responseBody = (string) $e->response->body();
$responseData = $e->response->json();
- $rawMetadata = data_get($responseData, 'error.metadata.raw');
-
- try {
- $jsonMetadata = $rawMetadata ? json_decode((string) $rawMetadata, true, 512, JSON_THROW_ON_ERROR) : [];
- } catch (JsonException) {
- $jsonMetadata = [];
- }
-
- $errorMessage = data_get($jsonMetadata, 'error.message');
-
- if (! $errorMessage) {
- $errorMessage = data_get($responseData, 'error.message', 'Unknown error');
- }
+ $errorData = data_get($responseData, 'error', []);
+ $details = $this->extractErrorDetails(is_array($errorData) ? $errorData : []);
+ $errorMessage = $details['message'];
+ $providerLabel = $this->formatProviderLabel($details['providerName']);
match ($statusCode) {
400 => throw PrismException::providerResponseError(
- sprintf('OpenRouter Bad Request: %s', $errorMessage)
+ sprintf('OpenRouter Bad Request%s: %s', $providerLabel, $errorMessage),
+ $statusCode,
+ $responseBody,
),
401 => throw PrismException::providerResponseError(
- sprintf('OpenRouter Authentication Error: %s', $errorMessage)
+ sprintf('OpenRouter Authentication Error%s: %s', $providerLabel, $errorMessage),
+ $statusCode,
+ $responseBody,
),
402 => throw PrismException::providerResponseError(
- sprintf('OpenRouter Insufficient Credits: %s', $errorMessage)
+ sprintf('OpenRouter Insufficient Credits%s: %s', $providerLabel, $errorMessage),
+ $statusCode,
+ $responseBody,
),
403 => throw PrismException::providerResponseError(
- sprintf('OpenRouter Moderation Error: %s', $errorMessage)
+ sprintf('OpenRouter Moderation Error%s: %s', $providerLabel, $errorMessage),
+ $statusCode,
+ $responseBody,
),
408 => throw PrismException::providerResponseError(
- sprintf('OpenRouter Request Timeout: %s', $errorMessage)
+ sprintf('OpenRouter Request Timeout%s: %s', $providerLabel, $errorMessage),
+ $statusCode,
+ $responseBody,
),
413 => throw PrismRequestTooLargeException::make(ProviderName::OpenRouter),
429 => throw PrismRateLimitedException::make(
@@ -124,7 +127,9 @@ public function handleRequestException(string $model, RequestException $e): neve
: null
),
502 => throw PrismException::providerResponseError(
- sprintf('OpenRouter Model Error: %s', $errorMessage)
+ sprintf('OpenRouter Model Error%s: %s', $providerLabel, $errorMessage),
+ $statusCode,
+ $responseBody,
),
503 => throw PrismProviderOverloadedException::make(ProviderName::OpenRouter),
default => throw PrismException::providerRequestError($model, $e),
diff --git a/src/Providers/OpenRouter/ValueObjects/OpenRouterStreamState.php b/src/Providers/OpenRouter/ValueObjects/OpenRouterStreamState.php
new file mode 100644
index 000000000..21c772f42
--- /dev/null
+++ b/src/Providers/OpenRouter/ValueObjects/OpenRouterStreamState.php
@@ -0,0 +1,47 @@
+> */
+ protected array $reasoningDetails = [];
+
+ /**
+ * @param array $detail
+ */
+ public function addReasoningDetail(array $detail): self
+ {
+ $this->reasoningDetails[] = $detail;
+
+ return $this;
+ }
+
+ /**
+ * @return array>
+ */
+ public function reasoningDetails(): array
+ {
+ return $this->reasoningDetails;
+ }
+
+ public function reset(): self
+ {
+ parent::reset();
+ $this->reasoningDetails = [];
+
+ return $this;
+ }
+
+ public function resetTextState(): self
+ {
+ parent::resetTextState();
+ $this->reasoningDetails = [];
+
+ return $this;
+ }
+}
diff --git a/src/Providers/Perplexity/Concerns/HandlesHttpRequests.php b/src/Providers/Perplexity/Concerns/HandlesHttpRequests.php
index 0022e0ea0..1c063ffed 100644
--- a/src/Providers/Perplexity/Concerns/HandlesHttpRequests.php
+++ b/src/Providers/Perplexity/Concerns/HandlesHttpRequests.php
@@ -49,7 +49,9 @@ protected function buildHttpRequestPayload(TextRequest|StructuredRequest $reques
'temperature' => $request->temperature(),
'top_p' => $request->topP(),
'top_k' => $request->providerOptions('top_k'),
- 'reasoning_effort' => $request->providerOptions('reasoning_effort'),
+ 'reasoning_effort' => $request->providerOptions('reasoning_effort') ?? (
+ $request->reasoningEnabled() === false ? 'low' : null
+ ),
'web_search_options' => $request->providerOptions('web_search_options'),
'search_mode' => $request->providerOptions('search_mode'),
'language_preference' => $request->providerOptions('language_preference'),
diff --git a/src/Providers/Provider.php b/src/Providers/Provider.php
index 3f70a1c9a..c8bf9b74f 100644
--- a/src/Providers/Provider.php
+++ b/src/Providers/Provider.php
@@ -7,15 +7,35 @@
use Generator;
use Illuminate\Http\Client\RequestException;
use Prism\Prism\Audio\AudioResponse as TextToSpeechResponse;
+use Prism\Prism\Audio\ProviderIdResponse;
+use Prism\Prism\Audio\SpeechToTextAsyncRequest;
use Prism\Prism\Audio\SpeechToTextRequest;
use Prism\Prism\Audio\TextResponse as SpeechToTextResponse;
use Prism\Prism\Audio\TextToSpeechRequest;
+use Prism\Prism\Batch\BatchJob;
+use Prism\Prism\Batch\BatchListResult;
+use Prism\Prism\Batch\BatchRequest;
+use Prism\Prism\Batch\BatchResultItem;
+use Prism\Prism\Batch\CancelBatchRequest;
+use Prism\Prism\Batch\GetBatchResultsRequest;
+use Prism\Prism\Batch\ListBatchesRequest;
+use Prism\Prism\Batch\RetrieveBatchRequest;
use Prism\Prism\Embeddings\Request as EmbeddingsRequest;
use Prism\Prism\Embeddings\Response as EmbeddingsResponse;
use Prism\Prism\Exceptions\PrismException;
use Prism\Prism\Exceptions\PrismProviderOverloadedException;
use Prism\Prism\Exceptions\PrismRateLimitedException;
use Prism\Prism\Exceptions\PrismRequestTooLargeException;
+use Prism\Prism\Files\DeleteFileRequest;
+use Prism\Prism\Files\DeleteFileResult;
+use Prism\Prism\Files\DownloadFileRequest;
+use Prism\Prism\Files\FileData;
+use Prism\Prism\Files\FileListResult;
+use Prism\Prism\Files\GetFileMetadataRequest;
+use Prism\Prism\Files\ListFilesRequest;
+use Prism\Prism\Files\UploadFileRequest;
+use Prism\Prism\Fim\Request as FimRequest;
+use Prism\Prism\Fim\Response as FimResponse;
use Prism\Prism\Images\Request as ImagesRequest;
use Prism\Prism\Images\Response as ImagesResponse;
use Prism\Prism\Moderation\Request as ModerationRequest;
@@ -63,6 +83,21 @@ public function speechToText(SpeechToTextRequest $request): SpeechToTextResponse
throw PrismException::unsupportedProviderAction('speechToText', class_basename($this));
}
+ public function speechToTextProviderId(SpeechToTextRequest $request): ProviderIdResponse
+ {
+ throw PrismException::unsupportedProviderAction('speechToTextProviderId', class_basename($this));
+ }
+
+ public function speechToTextAsync(SpeechToTextAsyncRequest $request): SpeechToTextResponse
+ {
+ throw PrismException::unsupportedProviderAction('speechToTextAsync', class_basename($this));
+ }
+
+ public function fim(FimRequest $request): FimResponse
+ {
+ throw PrismException::unsupportedProviderAction('fim', class_basename($this));
+ }
+
/**
* @return Generator
*/
@@ -71,6 +106,59 @@ public function stream(TextRequest $request): Generator
throw PrismException::unsupportedProviderAction(__METHOD__, class_basename($this));
}
+ public function batch(BatchRequest $request): BatchJob
+ {
+ throw PrismException::unsupportedProviderAction('batch', class_basename($this));
+ }
+
+ public function retrieveBatch(RetrieveBatchRequest $request): BatchJob
+ {
+ throw PrismException::unsupportedProviderAction('retrieveBatch', class_basename($this));
+ }
+
+ public function listBatches(ListBatchesRequest $request): BatchListResult
+ {
+ throw PrismException::unsupportedProviderAction('listBatches', class_basename($this));
+ }
+
+ /**
+ * @return BatchResultItem[]
+ */
+ public function getBatchResults(GetBatchResultsRequest $request): array
+ {
+ throw PrismException::unsupportedProviderAction('getBatchResults', class_basename($this));
+ }
+
+ public function cancelBatch(CancelBatchRequest $request): BatchJob
+ {
+ throw PrismException::unsupportedProviderAction('cancelBatch', class_basename($this));
+ }
+
+ public function uploadFile(UploadFileRequest $request): FileData
+ {
+ throw PrismException::unsupportedProviderAction('uploadFile', class_basename($this));
+ }
+
+ public function listFiles(ListFilesRequest $request): FileListResult
+ {
+ throw PrismException::unsupportedProviderAction('listFiles', class_basename($this));
+ }
+
+ public function getFileMetadata(GetFileMetadataRequest $request): FileData
+ {
+ throw PrismException::unsupportedProviderAction('getFileMetadata', class_basename($this));
+ }
+
+ public function deleteFile(DeleteFileRequest $request): DeleteFileResult
+ {
+ throw PrismException::unsupportedProviderAction('deleteFile', class_basename($this));
+ }
+
+ public function downloadFile(DownloadFileRequest $request): string
+ {
+ throw PrismException::unsupportedProviderAction('downloadFile', class_basename($this));
+ }
+
public function handleRequestException(string $model, RequestException $e): never
{
match ($e->response->getStatusCode()) {
diff --git a/src/Providers/Qwen/Concerns/MapsFinishReason.php b/src/Providers/Qwen/Concerns/MapsFinishReason.php
new file mode 100644
index 000000000..c00ccb507
--- /dev/null
+++ b/src/Providers/Qwen/Concerns/MapsFinishReason.php
@@ -0,0 +1,19 @@
+ $data
+ */
+ protected function mapFinishReason(array $data): FinishReason
+ {
+ return FinishReasonMap::map(data_get($data, 'output.choices.0.finish_reason', ''));
+ }
+}
diff --git a/src/Providers/Qwen/Concerns/ValidatesResponses.php b/src/Providers/Qwen/Concerns/ValidatesResponses.php
new file mode 100644
index 000000000..b1f3bf0ee
--- /dev/null
+++ b/src/Providers/Qwen/Concerns/ValidatesResponses.php
@@ -0,0 +1,27 @@
+ $data
+ */
+ protected function validateResponse(array $data): void
+ {
+ if ($data === []) {
+ throw PrismException::providerResponseError('Qwen Error: Empty response');
+ }
+
+ $code = data_get($data, 'code');
+ if ($code !== null && $code !== '') {
+ throw PrismException::providerResponseError(
+ sprintf('Qwen Error [%s]: %s', $code, data_get($data, 'message', 'Unknown error'))
+ );
+ }
+ }
+}
diff --git a/src/Providers/Qwen/Handlers/Embeddings.php b/src/Providers/Qwen/Handlers/Embeddings.php
new file mode 100644
index 000000000..e62a18057
--- /dev/null
+++ b/src/Providers/Qwen/Handlers/Embeddings.php
@@ -0,0 +1,66 @@
+sendRequest($request);
+
+ $data = $response->json();
+
+ $this->validateResponse($data);
+
+ return new EmbeddingsResponse(
+ embeddings: array_map(
+ fn (array $item): Embedding => Embedding::fromArray($item['embedding']),
+ data_get($data, 'output.embeddings', [])
+ ),
+ usage: new EmbeddingsUsage(data_get($data, 'usage.total_tokens')),
+ meta: new Meta(
+ id: data_get($data, 'request_id', ''),
+ model: $request->model(),
+ ),
+ raw: $data,
+ );
+ }
+
+ protected function sendRequest(Request $request): Response
+ {
+ $payload = [
+ 'model' => $request->model(),
+ 'input' => [
+ 'texts' => $request->inputs(),
+ ],
+ ];
+
+ $providerOptions = $request->providerOptions() ?? [];
+ if ($providerOptions !== []) {
+ $payload['parameters'] = $providerOptions;
+ }
+
+ /** @var Response $response */
+ $response = $this->client->post(
+ 'services/embeddings/text-embedding/text-embedding',
+ $payload
+ );
+
+ return $response;
+ }
+}
diff --git a/src/Providers/Qwen/Handlers/Images.php b/src/Providers/Qwen/Handlers/Images.php
new file mode 100644
index 000000000..f1dd62ac6
--- /dev/null
+++ b/src/Providers/Qwen/Handlers/Images.php
@@ -0,0 +1,102 @@
+sendRequest($request);
+
+ $this->validateResponse($response);
+
+ $data = $response->json();
+
+ $images = $this->extractImages($data);
+
+ $responseBuilder = new ResponseBuilder(
+ usage: new Usage(
+ promptTokens: 0,
+ completionTokens: 0,
+ ),
+ meta: new Meta(
+ id: data_get($data, 'request_id', ''),
+ model: $request->model(),
+ ),
+ images: $images,
+ additionalContent: Arr::whereNotNull([
+ 'image_count' => data_get($data, 'usage.image_count'),
+ 'width' => data_get($data, 'usage.width'),
+ 'height' => data_get($data, 'usage.height'),
+ ]),
+ raw: $data,
+ );
+
+ return $responseBuilder->toResponse();
+ }
+
+ protected function sendRequest(Request $request): ClientResponse
+ {
+ /** @var ClientResponse $response */
+ $response = $this->client->post(
+ 'services/aigc/multimodal-generation/generation',
+ ImageRequestMap::map($request)
+ );
+
+ return $response;
+ }
+
+ protected function validateResponse(ClientResponse $response): void
+ {
+ if (! $response->successful()) {
+ $data = $response->json() ?? [];
+ $errorMessage = data_get($data, 'message', $response->body());
+ $errorCode = data_get($data, 'code', 'Unknown');
+
+ throw PrismException::providerResponseError(
+ sprintf('Qwen Image generation failed [%s]: %s', $errorCode, $errorMessage)
+ );
+ }
+ }
+
+ /**
+ * @param array $data
+ * @return GeneratedImage[]
+ */
+ protected function extractImages(array $data): array
+ {
+ $images = [];
+
+ $choices = data_get($data, 'output.choices', []);
+
+ foreach ($choices as $choice) {
+ $content = data_get($choice, 'message.content', []);
+
+ foreach ($content as $item) {
+ if (isset($item['image'])) {
+ $images[] = new GeneratedImage(
+ url: $item['image'],
+ );
+ }
+ }
+ }
+
+ return $images;
+ }
+}
diff --git a/src/Providers/Qwen/Handlers/Stream.php b/src/Providers/Qwen/Handlers/Stream.php
new file mode 100644
index 000000000..bd984b1a1
--- /dev/null
+++ b/src/Providers/Qwen/Handlers/Stream.php
@@ -0,0 +1,482 @@
+state = new StreamState;
+ }
+
+ /**
+ * @return Generator
+ */
+ public function handle(Request $request): Generator
+ {
+ yield from $this->resolveToolApprovalsAndYieldEvents($request, EventID::generate());
+
+ $response = $this->sendRequest($request);
+
+ yield from $this->processStream($response, $request);
+ }
+
+ /**
+ * @return Generator
+ */
+ protected function processStream(Response $response, Request $request, int $depth = 0): Generator
+ {
+ if ($depth >= $request->maxSteps()) {
+ throw new PrismException('Maximum tool call chain depth exceeded');
+ }
+
+ if ($depth === 0) {
+ $this->state->reset();
+ }
+
+ $text = '';
+ $toolCalls = [];
+
+ while (! $response->getBody()->eof()) {
+ $data = $this->parseNextDataLine($response->getBody());
+
+ if ($data === null) {
+ continue;
+ }
+
+ if ($this->state->shouldEmitStreamStart()) {
+ $this->state->withMessageId(EventID::generate())->markStreamStarted();
+
+ yield new StreamStartEvent(
+ id: EventID::generate(),
+ timestamp: time(),
+ model: $request->model(),
+ provider: 'qwen'
+ );
+ }
+
+ if ($this->state->shouldEmitStepStart()) {
+ $this->state->markStepStarted();
+
+ yield new StepStartEvent(
+ id: EventID::generate(),
+ timestamp: time()
+ );
+ }
+
+ if ($this->hasToolCalls($data)) {
+ $toolCalls = $this->extractToolCalls($data, $toolCalls);
+
+ $rawFinishReason = data_get($data, 'output.choices.0.finish_reason');
+ if ($rawFinishReason === 'tool_calls') {
+ if ($this->state->hasTextStarted() && $text !== '') {
+ $this->state->markTextCompleted();
+
+ yield new TextCompleteEvent(
+ id: EventID::generate(),
+ timestamp: time(),
+ messageId: $this->state->messageId()
+ );
+ }
+
+ if ($this->state->hasThinkingStarted()) {
+ yield new ThinkingCompleteEvent(
+ id: EventID::generate(),
+ timestamp: time(),
+ reasoningId: $this->state->reasoningId()
+ );
+ }
+
+ yield from $this->handleToolCalls($request, $text, $toolCalls, $depth);
+
+ return;
+ }
+
+ continue;
+ }
+
+ $reasoningDelta = $this->extractReasoningDelta($data);
+ if ($reasoningDelta !== '' && $reasoningDelta !== '0') {
+ if ($this->state->shouldEmitThinkingStart()) {
+ $this->state->withReasoningId(EventID::generate())->markThinkingStarted();
+
+ yield new ThinkingStartEvent(
+ id: EventID::generate(),
+ timestamp: time(),
+ reasoningId: $this->state->reasoningId()
+ );
+ }
+
+ yield new ThinkingEvent(
+ id: EventID::generate(),
+ timestamp: time(),
+ delta: $reasoningDelta,
+ reasoningId: $this->state->reasoningId()
+ );
+
+ continue;
+ }
+
+ if ($this->state->hasThinkingStarted() && $reasoningDelta === '') {
+ yield new ThinkingCompleteEvent(
+ id: EventID::generate(),
+ timestamp: time(),
+ reasoningId: $this->state->reasoningId()
+ );
+ }
+
+ $content = $this->extractContentDelta($data);
+ if ($content !== '' && $content !== '0') {
+ if ($this->state->shouldEmitTextStart()) {
+ $this->state->markTextStarted();
+
+ yield new TextStartEvent(
+ id: EventID::generate(),
+ timestamp: time(),
+ messageId: $this->state->messageId()
+ );
+ }
+
+ $text .= $content;
+
+ yield new TextDeltaEvent(
+ id: EventID::generate(),
+ timestamp: time(),
+ delta: $content,
+ messageId: $this->state->messageId()
+ );
+
+ continue;
+ }
+
+ $rawFinishReason = data_get($data, 'output.choices.0.finish_reason');
+ if ($rawFinishReason !== null && $rawFinishReason !== 'null') {
+ $finishReason = FinishReasonMap::map($rawFinishReason);
+
+ if ($this->state->hasTextStarted() && $text !== '') {
+ $this->state->markTextCompleted();
+
+ yield new TextCompleteEvent(
+ id: EventID::generate(),
+ timestamp: time(),
+ messageId: $this->state->messageId()
+ );
+ }
+
+ if ($this->state->hasThinkingStarted()) {
+ yield new ThinkingCompleteEvent(
+ id: EventID::generate(),
+ timestamp: time(),
+ reasoningId: $this->state->reasoningId()
+ );
+ }
+
+ $this->state->withFinishReason($finishReason);
+
+ $usage = $this->extractUsage($data);
+ if ($usage instanceof Usage) {
+ $this->state->addUsage($usage);
+ }
+ }
+ }
+
+ if ($toolCalls !== []) {
+ yield from $this->handleToolCalls($request, $text, $toolCalls, $depth);
+
+ return;
+ }
+
+ $this->state->markStepFinished();
+ yield new StepFinishEvent(
+ id: EventID::generate(),
+ timestamp: time()
+ );
+
+ yield $this->emitStreamEndEvent();
+ }
+
+ protected function emitStreamEndEvent(): StreamEndEvent
+ {
+ return new StreamEndEvent(
+ id: EventID::generate(),
+ timestamp: time(),
+ finishReason: $this->state->finishReason() ?? FinishReason::Stop,
+ usage: $this->state->usage()
+ );
+ }
+
+ /**
+ * @return array|null
+ *
+ * @throws PrismStreamDecodeException
+ */
+ protected function parseNextDataLine(StreamInterface $stream): ?array
+ {
+ $line = $this->readLine($stream);
+
+ // DashScope SSE format has id:, event:, :HTTP_STATUS/ lines before data:
+ // Skip all non-data lines
+ if (! str_starts_with($line, 'data:')) {
+ return null;
+ }
+
+ $line = trim(substr($line, strlen('data:')));
+
+ if ($line === '') {
+ return null;
+ }
+
+ try {
+ return json_decode($line, true, flags: JSON_THROW_ON_ERROR);
+ } catch (Throwable $e) {
+ throw new PrismStreamDecodeException('Qwen', $e);
+ }
+ }
+
+ /**
+ * @param array $data
+ */
+ protected function hasToolCalls(array $data): bool
+ {
+ return ! empty(data_get($data, 'output.choices.0.message.tool_calls', []));
+ }
+
+ /**
+ * @param array $data
+ * @param array> $toolCalls
+ * @return array>
+ */
+ protected function extractToolCalls(array $data, array $toolCalls): array
+ {
+ $messageToolCalls = data_get($data, 'output.choices.0.message.tool_calls', []);
+
+ foreach ($messageToolCalls as $toolCall) {
+ $index = data_get($toolCall, 'index', 0);
+
+ if (! isset($toolCalls[$index])) {
+ $toolCalls[$index] = [
+ 'id' => '',
+ 'name' => '',
+ 'arguments' => '',
+ ];
+ }
+
+ if ($id = data_get($toolCall, 'id')) {
+ $toolCalls[$index]['id'] = $id;
+ }
+
+ if ($name = data_get($toolCall, 'function.name')) {
+ $toolCalls[$index]['name'] = $name;
+ }
+
+ $arguments = data_get($toolCall, 'function.arguments', '');
+ if ($arguments !== '') {
+ $toolCalls[$index]['arguments'] .= $arguments;
+ }
+ }
+
+ return $toolCalls;
+ }
+
+ /**
+ * @param array $data
+ */
+ protected function extractReasoningDelta(array $data): string
+ {
+ return data_get($data, 'output.choices.0.message.reasoning_content') ?? '';
+ }
+
+ /**
+ * @param array $data
+ */
+ protected function extractContentDelta(array $data): string
+ {
+ return data_get($data, 'output.choices.0.message.content') ?? '';
+ }
+
+ /**
+ * @param array $data
+ */
+ protected function extractUsage(array $data): ?Usage
+ {
+ $usage = data_get($data, 'usage');
+
+ if (! $usage) {
+ return null;
+ }
+
+ return new Usage(
+ promptTokens: max(0, (int) data_get($usage, 'input_tokens', 0) - (int) data_get($usage, 'prompt_tokens_details.cached_tokens', 0)),
+ completionTokens: (int) data_get($usage, 'output_tokens', 0),
+ cacheReadInputTokens: (int) data_get($usage, 'prompt_tokens_details.cached_tokens', 0) ?: null,
+ );
+ }
+
+ /**
+ * @param array> $toolCalls
+ * @return Generator
+ */
+ protected function handleToolCalls(Request $request, string $text, array $toolCalls, int $depth): Generator
+ {
+ $mappedToolCalls = $this->mapToolCalls($toolCalls);
+
+ foreach ($mappedToolCalls as $toolCall) {
+ yield new ToolCallEvent(
+ id: EventID::generate(),
+ timestamp: time(),
+ toolCall: $toolCall,
+ messageId: $this->state->messageId()
+ );
+ }
+
+ $toolResults = [];
+ $hasPendingToolCalls = false;
+ yield from $this->callToolsAndYieldEventsWithPending($request->tools(), $mappedToolCalls, $this->state->messageId(), $toolResults, $hasPendingToolCalls);
+
+ if ($hasPendingToolCalls) {
+ // Client-executed or approval-required tool calls: end the stream
+ // with FinishReason::ToolCalls so the consumer resolves and resumes.
+ $this->state->markStepFinished();
+ yield from $this->yieldToolCallsFinishEvents($this->state);
+
+ return;
+ }
+
+ $request->addMessage(new AssistantMessage($text, $mappedToolCalls));
+ $request->addMessage(new ToolResultMessage($toolResults));
+ $request->resetToolChoice();
+
+ $this->state->markStepFinished();
+ yield new StepFinishEvent(
+ id: EventID::generate(),
+ timestamp: time()
+ );
+
+ $this->state->resetTextState();
+ $this->state->withMessageId(EventID::generate());
+
+ $depth++;
+ if ($depth < $request->maxSteps()) {
+ $nextResponse = $this->sendRequest($request);
+ yield from $this->processStream($nextResponse, $request, $depth);
+ } else {
+ yield $this->emitStreamEndEvent();
+ }
+ }
+
+ /**
+ * @param array> $toolCalls
+ * @return array
+ */
+ protected function mapToolCalls(array $toolCalls): array
+ {
+ return array_map(fn (array $toolCall): ToolCall => new ToolCall(
+ id: data_get($toolCall, 'id'),
+ name: data_get($toolCall, 'name'),
+ arguments: data_get($toolCall, 'arguments'),
+ ), $toolCalls);
+ }
+
+ /**
+ * @throws ConnectionException
+ */
+ protected function sendRequest(Request $request): Response
+ {
+ $messageMap = new MessageMap($request->messages(), $request->systemPrompts());
+
+ $input = [
+ 'messages' => $messageMap(),
+ ];
+
+ $parameters = Arr::whereNotNull([
+ 'result_format' => 'message',
+ 'incremental_output' => true,
+ 'max_tokens' => $request->maxTokens(),
+ 'temperature' => $request->temperature(),
+ 'top_p' => $request->topP(),
+ 'tools' => ToolMap::map($request->tools()) ?: null,
+ 'tool_choice' => ToolChoiceMap::map($request->toolChoice()),
+ ]);
+
+ $payload = [
+ 'model' => $request->model(),
+ 'input' => $input,
+ ];
+
+ if ($parameters !== []) {
+ $payload['parameters'] = $parameters;
+ }
+
+ // VL (multimodal) models use the multimodal-generation endpoint
+ $endpoint = $messageMap->hasImages()
+ ? 'services/aigc/multimodal-generation/generation'
+ : 'services/aigc/text-generation/generation';
+
+ /** @var Response $response */
+ $response = $this->client->withOptions(['stream' => true])->post($endpoint, $payload);
+
+ return $response;
+ }
+
+ protected function readLine(StreamInterface $stream): string
+ {
+ $buffer = '';
+
+ while (! $stream->eof()) {
+ $byte = $stream->read(1);
+
+ if ($byte === '') {
+ return $buffer;
+ }
+
+ $buffer .= $byte;
+
+ if ($byte === "\n") {
+ break;
+ }
+ }
+
+ return $buffer;
+ }
+}
diff --git a/src/Providers/Qwen/Handlers/Structured.php b/src/Providers/Qwen/Handlers/Structured.php
new file mode 100644
index 000000000..f4c9c75b3
--- /dev/null
+++ b/src/Providers/Qwen/Handlers/Structured.php
@@ -0,0 +1,156 @@
+responseBuilder = new ResponseBuilder;
+ }
+
+ public function handle(Request $request): StructuredResponse
+ {
+ $mode = $request->mode();
+
+ // Structured mode: use response_format with json_schema (no system message needed)
+ // Auto/Json mode: use response_format with json_object + system message with schema
+ if ($mode !== StructuredMode::Structured) {
+ $request = $this->appendMessageForJsonMode($request);
+ }
+
+ $data = $this->sendRequest($request, $mode);
+
+ $this->validateResponse($data);
+
+ return $this->createResponse($request, $data);
+ }
+
+ /**
+ * @return array
+ */
+ protected function sendRequest(Request $request, StructuredMode $mode): array
+ {
+ $messageMap = new MessageMap($request->messages(), $request->systemPrompts());
+
+ $input = [
+ 'messages' => $messageMap(),
+ ];
+
+ $parameters = Arr::whereNotNull([
+ 'result_format' => 'message',
+ 'max_tokens' => $request->maxTokens(),
+ 'temperature' => $request->temperature(),
+ 'top_p' => $request->topP(),
+ 'response_format' => $this->buildResponseFormat($request, $mode),
+ ]);
+
+ $payload = [
+ 'model' => $request->model(),
+ 'input' => $input,
+ ];
+
+ if ($parameters !== []) {
+ $payload['parameters'] = $parameters;
+ }
+
+ // VL (multimodal) models use the multimodal-generation endpoint
+ $endpoint = $messageMap->hasImages()
+ ? 'services/aigc/multimodal-generation/generation'
+ : 'services/aigc/text-generation/generation';
+
+ /** @var Response $response */
+ $response = $this->client->post($endpoint, $payload);
+
+ return $response->json();
+ }
+
+ /**
+ * Build the response_format parameter based on the structured mode.
+ *
+ * - Structured mode: {"type": "json_schema", "json_schema": {"name": ..., "schema": ..., "strict": true}}
+ * - Auto/Json mode: {"type": "json_object"}
+ *
+ * @return array
+ */
+ protected function buildResponseFormat(Request $request, StructuredMode $mode): array
+ {
+ if ($mode === StructuredMode::Structured) {
+ return [
+ 'type' => 'json_schema',
+ 'json_schema' => Arr::whereNotNull([
+ 'name' => $request->schema()->name(),
+ 'schema' => $request->schema()->toArray(),
+ 'strict' => $request->providerOptions('schema.strict') ?? true,
+ ]),
+ ];
+ }
+
+ return ['type' => 'json_object'];
+ }
+
+ /**
+ * @param array $data
+ */
+ protected function createResponse(Request $request, array $data): StructuredResponse
+ {
+ $text = data_get($data, 'output.choices.0.message.content') ?? '';
+
+ $responseMessage = new AssistantMessage($text);
+ $request->addMessage($responseMessage);
+
+ $step = new Step(
+ text: $text,
+ finishReason: FinishReasonMap::map(data_get($data, 'output.choices.0.finish_reason', '')),
+ usage: new Usage(
+ promptTokens: max(0, (int) data_get($data, 'usage.input_tokens', 0) - (int) data_get($data, 'usage.prompt_tokens_details.cached_tokens', 0)),
+ completionTokens: (int) data_get($data, 'usage.output_tokens', 0),
+ cacheReadInputTokens: (int) data_get($data, 'usage.prompt_tokens_details.cached_tokens', 0) ?: null,
+ ),
+ meta: new Meta(
+ id: data_get($data, 'request_id'),
+ model: $request->model(),
+ ),
+ messages: $request->messages(),
+ systemPrompts: $request->systemPrompts(),
+ additionalContent: [],
+ raw: $data,
+ );
+
+ $this->responseBuilder->addStep($step);
+
+ return $this->responseBuilder->toResponse();
+ }
+
+ protected function appendMessageForJsonMode(Request $request): Request
+ {
+ return $request->addMessage(new SystemMessage(sprintf(
+ "You MUST respond EXCLUSIVELY with a JSON object that strictly adheres to the following schema. \n Do NOT explain or add other content. Validate your response against this schema \n %s",
+ json_encode($request->schema()->toArray(), JSON_PRETTY_PRINT)
+ )));
+ }
+}
diff --git a/src/Providers/Qwen/Handlers/Text.php b/src/Providers/Qwen/Handlers/Text.php
new file mode 100644
index 000000000..11ac89869
--- /dev/null
+++ b/src/Providers/Qwen/Handlers/Text.php
@@ -0,0 +1,168 @@
+responseBuilder = new ResponseBuilder;
+ }
+
+ public function handle(Request $request): TextResponse
+ {
+ $this->resolveToolApprovals($request);
+
+ $data = $this->sendRequest($request);
+
+ $this->validateResponse($data);
+
+ return match ($this->mapFinishReason($data)) {
+ FinishReason::ToolCalls => $this->handleToolCalls($data, $request),
+ FinishReason::Stop => $this->handleStop($data, $request),
+ default => throw new PrismException('Qwen: unknown finish reason'),
+ };
+ }
+
+ /**
+ * @param array $data
+ */
+ protected function handleToolCalls(array $data, Request $request): TextResponse
+ {
+ $toolCalls = ToolCallMap::map(data_get($data, 'output.choices.0.message.tool_calls', []));
+
+ $hasPendingToolCalls = false;
+ $approvalRequests = [];
+ $toolResults = $this->callToolsWithPending($request->tools(), $toolCalls, $hasPendingToolCalls, $approvalRequests);
+
+ $this->addStep($data, $request, $toolResults);
+
+ $request = $request->addMessage(new AssistantMessage(
+ data_get($data, 'output.choices.0.message.content') ?? '',
+ $toolCalls,
+ [],
+ toolApprovalRequests: $approvalRequests,
+ ));
+ $request = $request->addMessage(new ToolResultMessage($toolResults));
+ $request->resetToolChoice();
+
+ if (! $hasPendingToolCalls && $this->shouldContinue($request)) {
+ return $this->handle($request);
+ }
+
+ return $this->responseBuilder->toResponse();
+ }
+
+ /**
+ * @param array $data
+ */
+ protected function handleStop(array $data, Request $request): TextResponse
+ {
+ $this->addStep($data, $request);
+
+ return $this->responseBuilder->toResponse();
+ }
+
+ protected function shouldContinue(Request $request): bool
+ {
+ return $this->responseBuilder->steps->count() < $request->maxSteps();
+ }
+
+ /**
+ * @return array
+ */
+ protected function sendRequest(Request $request): array
+ {
+ $messageMap = new MessageMap($request->messages(), $request->systemPrompts());
+
+ $input = [
+ 'messages' => $messageMap(),
+ ];
+
+ $parameters = Arr::whereNotNull([
+ 'result_format' => 'message',
+ 'max_tokens' => $request->maxTokens(),
+ 'temperature' => $request->temperature(),
+ 'top_p' => $request->topP(),
+ 'tools' => ToolMap::map($request->tools()) ?: null,
+ 'tool_choice' => ToolChoiceMap::map($request->toolChoice()),
+ ]);
+
+ $payload = [
+ 'model' => $request->model(),
+ 'input' => $input,
+ ];
+
+ if ($parameters !== []) {
+ $payload['parameters'] = $parameters;
+ }
+
+ // VL (multimodal) models use the multimodal-generation endpoint
+ $endpoint = $messageMap->hasImages()
+ ? 'services/aigc/multimodal-generation/generation'
+ : 'services/aigc/text-generation/generation';
+
+ /** @var Response $response */
+ $response = $this->client->post($endpoint, $payload);
+
+ return $response->json();
+ }
+
+ /**
+ * @param array $data
+ * @param array $toolResults
+ */
+ protected function addStep(array $data, Request $request, array $toolResults = []): void
+ {
+ $this->responseBuilder->addStep(new Step(
+ text: data_get($data, 'output.choices.0.message.content') ?? '',
+ finishReason: $this->mapFinishReason($data),
+ toolCalls: ToolCallMap::map(data_get($data, 'output.choices.0.message.tool_calls', [])),
+ toolResults: $toolResults,
+ providerToolCalls: [],
+ usage: new Usage(
+ promptTokens: max(0, (int) data_get($data, 'usage.input_tokens', 0) - (int) data_get($data, 'usage.prompt_tokens_details.cached_tokens', 0)),
+ completionTokens: (int) data_get($data, 'usage.output_tokens', 0),
+ cacheReadInputTokens: (int) data_get($data, 'usage.prompt_tokens_details.cached_tokens', 0) ?: null,
+ ),
+ meta: new Meta(
+ id: data_get($data, 'request_id'),
+ model: $request->model(),
+ ),
+ messages: $request->messages(),
+ systemPrompts: $request->systemPrompts(),
+ additionalContent: [],
+ raw: $data,
+ ));
+ }
+}
diff --git a/src/Providers/Qwen/Maps/FinishReasonMap.php b/src/Providers/Qwen/Maps/FinishReasonMap.php
new file mode 100644
index 000000000..0c98f174e
--- /dev/null
+++ b/src/Providers/Qwen/Maps/FinishReasonMap.php
@@ -0,0 +1,21 @@
+ FinishReason::Stop,
+ 'tool_calls' => FinishReason::ToolCalls,
+ 'length' => FinishReason::Length,
+ 'content_filter' => FinishReason::ContentFilter,
+ default => FinishReason::Unknown,
+ };
+ }
+}
diff --git a/src/Providers/Qwen/Maps/ImageMapper.php b/src/Providers/Qwen/Maps/ImageMapper.php
new file mode 100644
index 000000000..70c35202b
--- /dev/null
+++ b/src/Providers/Qwen/Maps/ImageMapper.php
@@ -0,0 +1,43 @@
+
+ */
+ public function toPayload(): array
+ {
+ return [
+ 'image' => $this->media->isUrl()
+ ? $this->media->url()
+ : sprintf('data:%s;base64,%s', $this->media->mimeType(), $this->media->base64()),
+ ];
+ }
+
+ protected function provider(): string|Provider
+ {
+ return Provider::Qwen;
+ }
+
+ protected function validateMedia(): bool
+ {
+ if ($this->media->isUrl()) {
+ return true;
+ }
+
+ return $this->media->hasRawContent();
+ }
+}
diff --git a/src/Providers/Qwen/Maps/ImageRequestMap.php b/src/Providers/Qwen/Maps/ImageRequestMap.php
new file mode 100644
index 000000000..4f1d46bdc
--- /dev/null
+++ b/src/Providers/Qwen/Maps/ImageRequestMap.php
@@ -0,0 +1,80 @@
+
+ */
+ public static function map(Request $request): array
+ {
+ $providerOptions = $request->providerOptions();
+
+ $parameters = Arr::whereNotNull([
+ 'size' => $providerOptions['size'] ?? null,
+ 'n' => $providerOptions['n'] ?? null,
+ 'negative_prompt' => $providerOptions['negative_prompt'] ?? null,
+ 'prompt_extend' => $providerOptions['prompt_extend'] ?? null,
+ 'watermark' => $providerOptions['watermark'] ?? null,
+ 'seed' => $providerOptions['seed'] ?? null,
+ ]);
+
+ // Include any additional options not explicitly handled above
+ $knownKeys = ['size', 'n', 'negative_prompt', 'prompt_extend', 'watermark', 'seed'];
+ $additionalOptions = array_diff_key($providerOptions, array_flip($knownKeys));
+
+ $parameters = array_merge($parameters, $additionalOptions);
+
+ // Build content array: images first (for image editing), then text prompt
+ $content = [];
+
+ foreach ($request->additionalContent() as $image) {
+ $content[] = [
+ 'image' => self::resolveImageSource($image),
+ ];
+ }
+
+ $content[] = [
+ 'text' => $request->prompt(),
+ ];
+
+ $payload = [
+ 'model' => $request->model(),
+ 'input' => [
+ 'messages' => [
+ [
+ 'role' => 'user',
+ 'content' => $content,
+ ],
+ ],
+ ],
+ ];
+
+ if ($parameters !== []) {
+ $payload['parameters'] = $parameters;
+ }
+
+ return $payload;
+ }
+
+ /**
+ * Resolve an Image to a URL string or base64 data URI for the DashScope API.
+ */
+ protected static function resolveImageSource(Image $image): string
+ {
+ $url = $image->url();
+
+ if ($image->isUrl() && $url !== null) {
+ return $url;
+ }
+
+ return sprintf('data:%s;base64,%s', $image->mimeType(), $image->base64());
+ }
+}
diff --git a/src/Providers/Qwen/Maps/MessageMap.php b/src/Providers/Qwen/Maps/MessageMap.php
new file mode 100644
index 000000000..336274874
--- /dev/null
+++ b/src/Providers/Qwen/Maps/MessageMap.php
@@ -0,0 +1,138 @@
+ */
+ protected array $mappedMessages = [];
+
+ /**
+ * @param array $messages
+ * @param SystemMessage[] $systemPrompts
+ */
+ public function __construct(
+ protected array $messages,
+ protected array $systemPrompts
+ ) {
+ $this->messages = array_merge(
+ $this->systemPrompts,
+ $this->messages
+ );
+ }
+
+ /**
+ * @return array
+ */
+ public function __invoke(): array
+ {
+ array_map(
+ $this->mapMessage(...),
+ $this->messages
+ );
+
+ return $this->mappedMessages;
+ }
+
+ /**
+ * Check if any user messages contain images (for multimodal/VL models).
+ */
+ public function hasImages(): bool
+ {
+ foreach ($this->messages as $message) {
+ if ($message instanceof UserMessage && $message->images() !== []) {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ protected function mapMessage(Message $message): void
+ {
+ match ($message::class) {
+ UserMessage::class => $this->mapUserMessage($message),
+ AssistantMessage::class => $this->mapAssistantMessage($message),
+ ToolResultMessage::class => $this->mapToolResultMessage($message),
+ SystemMessage::class => $this->mapSystemMessage($message),
+ default => throw new Exception('Could not map message type '.$message::class),
+ };
+ }
+
+ protected function mapSystemMessage(SystemMessage $message): void
+ {
+ $this->mappedMessages[] = [
+ 'role' => 'system',
+ 'content' => $message->content,
+ ];
+ }
+
+ protected function mapToolResultMessage(ToolResultMessage $message): void
+ {
+ foreach ($message->toolResults as $toolResult) {
+ $this->mappedMessages[] = [
+ 'role' => 'tool',
+ 'tool_call_id' => $toolResult->toolCallId,
+ 'content' => $toolResult->result,
+ ];
+ }
+ }
+
+ protected function mapUserMessage(UserMessage $message): void
+ {
+ $images = $message->images();
+
+ if ($images === []) {
+ // Text-only: DashScope native format uses content as a string
+ $this->mappedMessages[] = [
+ 'role' => 'user',
+ 'content' => $message->text(),
+ ];
+
+ return;
+ }
+
+ // Multimodal (VL): DashScope native format uses {"image": "url"} and {"text": "text"}
+ $content = [];
+
+ foreach ($images as $image) {
+ $content[] = (new ImageMapper($image))->toPayload();
+ }
+
+ $content[] = ['text' => $message->text()];
+
+ $this->mappedMessages[] = [
+ 'role' => 'user',
+ 'content' => $content,
+ ];
+ }
+
+ protected function mapAssistantMessage(AssistantMessage $message): void
+ {
+ $toolCalls = array_map(fn (ToolCall $toolCall): array => [
+ 'id' => $toolCall->id,
+ 'type' => 'function',
+ 'function' => [
+ 'name' => $toolCall->name,
+ 'arguments' => json_encode($toolCall->arguments()),
+ ],
+ ], $message->toolCalls);
+
+ $this->mappedMessages[] = array_filter([
+ 'role' => 'assistant',
+ 'content' => $message->content,
+ 'tool_calls' => $toolCalls,
+ ]);
+ }
+}
diff --git a/src/Providers/Qwen/Maps/ToolCallMap.php b/src/Providers/Qwen/Maps/ToolCallMap.php
new file mode 100644
index 000000000..de3241335
--- /dev/null
+++ b/src/Providers/Qwen/Maps/ToolCallMap.php
@@ -0,0 +1,23 @@
+> $toolCalls
+ * @return array
+ */
+ public static function map(array $toolCalls): array
+ {
+ return array_map(fn (array $toolCall): ToolCall => new ToolCall(
+ id: data_get($toolCall, 'id'),
+ name: data_get($toolCall, 'function.name'),
+ arguments: data_get($toolCall, 'function.arguments'),
+ ), $toolCalls);
+ }
+}
diff --git a/src/Providers/Qwen/Maps/ToolChoiceMap.php b/src/Providers/Qwen/Maps/ToolChoiceMap.php
new file mode 100644
index 000000000..276cd4ffc
--- /dev/null
+++ b/src/Providers/Qwen/Maps/ToolChoiceMap.php
@@ -0,0 +1,24 @@
+ 'auto',
+ null => $toolChoice,
+ default => throw new InvalidArgumentException('Invalid tool choice for Qwen. Only "auto" and "none" are supported.'),
+ };
+ }
+}
diff --git a/src/Providers/Qwen/Maps/ToolMap.php b/src/Providers/Qwen/Maps/ToolMap.php
new file mode 100644
index 000000000..a72c428a4
--- /dev/null
+++ b/src/Providers/Qwen/Maps/ToolMap.php
@@ -0,0 +1,33 @@
+
+ */
+ public static function map(array $tools): array
+ {
+ return array_map(fn (Tool $tool): array => array_filter([
+ 'type' => 'function',
+ 'function' => [
+ 'name' => $tool->name(),
+ 'description' => $tool->description(),
+ ...$tool->hasParameters() ? [
+ 'parameters' => [
+ 'type' => 'object',
+ 'properties' => $tool->parametersAsArray(),
+ 'required' => $tool->requiredParameters(),
+ ],
+ ] : [],
+ ],
+ 'strict' => $tool->providerOptions('strict'),
+ ]), $tools);
+ }
+}
diff --git a/src/Providers/Qwen/Qwen.php b/src/Providers/Qwen/Qwen.php
new file mode 100644
index 000000000..21f01379b
--- /dev/null
+++ b/src/Providers/Qwen/Qwen.php
@@ -0,0 +1,142 @@
+client(
+ $request->clientOptions(),
+ $request->clientRetry()
+ ));
+
+ return $handler->handle($request);
+ }
+
+ #[\Override]
+ public function structured(StructuredRequest $request): StructuredResponse
+ {
+ $handler = new Structured($this->client(
+ $request->clientOptions(),
+ $request->clientRetry()
+ ));
+
+ return $handler->handle($request);
+ }
+
+ #[\Override]
+ public function embeddings(EmbeddingsRequest $request): EmbeddingsResponse
+ {
+ $handler = new Embeddings($this->client(
+ $request->clientOptions(),
+ $request->clientRetry()
+ ));
+
+ return $handler->handle($request);
+ }
+
+ #[\Override]
+ public function images(ImagesRequest $request): ImagesResponse
+ {
+ $handler = new Images($this->client(
+ $request->clientOptions(),
+ $request->clientRetry()
+ ));
+
+ return $handler->handle($request);
+ }
+
+ #[\Override]
+ public function stream(TextRequest $request): Generator
+ {
+ $handler = new Stream($this->client(
+ $request->clientOptions(),
+ $request->clientRetry(),
+ ['X-DashScope-SSE' => 'enable']
+ ));
+
+ return $handler->handle($request);
+ }
+
+ public function handleRequestException(string $model, RequestException $e): never
+ {
+ $statusCode = $e->response->getStatusCode();
+ $data = $e->response->json() ?? [];
+ $errorCode = data_get($data, 'code', '');
+
+ match (true) {
+ $errorCode === 'Arrearage' => throw PrismException::providerResponseError(
+ sprintf('Qwen Account Arrearage: %s', data_get($data, 'message', 'Unknown error'))
+ ),
+ $errorCode === 'DataInspectionFailed' || $errorCode === 'data_inspection_failed' => throw PrismException::providerResponseError(
+ sprintf('Qwen Content Moderation Failed: %s', data_get($data, 'message', 'Unknown error'))
+ ),
+ $statusCode === 429 => throw PrismRateLimitedException::make([]),
+ $statusCode === 503 => throw PrismProviderOverloadedException::make('Qwen'),
+ default => $this->handleResponseErrors($e),
+ };
+ }
+
+ protected function handleResponseErrors(RequestException $e): never
+ {
+ $data = $e->response->json() ?? [];
+
+ $errorCode = data_get($data, 'code');
+
+ throw PrismException::providerRequestErrorWithDetails(
+ provider: 'Qwen',
+ statusCode: $e->response->getStatusCode(),
+ errorType: $errorCode !== '' ? $errorCode : null,
+ errorMessage: data_get($data, 'message'),
+ previous: $e
+ );
+ }
+
+ /**
+ * @param array $options
+ * @param array $retry
+ * @param array $headers
+ */
+ protected function client(array $options = [], array $retry = [], array $headers = []): PendingRequest
+ {
+ return $this->baseClient()
+ ->when($this->apiKey, fn ($client) => $client->withToken($this->apiKey))
+ ->withOptions($options)
+ ->when($retry !== [], fn ($client) => $client->retry(...$retry))
+ ->when($headers !== [], fn ($client) => $client->withHeaders($headers))
+ ->baseUrl($this->url);
+ }
+}
diff --git a/src/Providers/Replicate/Concerns/HandlesPredictions.php b/src/Providers/Replicate/Concerns/HandlesPredictions.php
new file mode 100644
index 000000000..6758f35fc
--- /dev/null
+++ b/src/Providers/Replicate/Concerns/HandlesPredictions.php
@@ -0,0 +1,145 @@
+ $payload
+ * @param bool $wait Whether to use sync mode (Prefer: wait header)
+ * @param int $waitTimeout Timeout in seconds for sync mode (max 60)
+ *
+ * @throws RequestException
+ */
+ protected function createPrediction(
+ PendingRequest $client,
+ array $payload,
+ bool $wait = false,
+ int $waitTimeout = 60
+ ): ReplicatePrediction {
+ // If sync mode is enabled, add the Prefer: wait header
+ if ($wait) {
+ // Replicate allows max 60 seconds for the Prefer: wait header
+ $timeout = min($waitTimeout, 60);
+ $client = $client->withHeaders([
+ 'Prefer' => "wait={$timeout}",
+ ]);
+ }
+
+ $response = $client->post('/predictions', $payload);
+
+ if ($response->failed()) {
+ throw new RequestException($response);
+ }
+
+ return ReplicatePrediction::fromArray($response->json());
+ }
+
+ /**
+ * Get the status of a prediction.
+ *
+ * @throws RequestException
+ */
+ protected function getPrediction(PendingRequest $client, string $predictionId): ReplicatePrediction
+ {
+ $response = $client->get("/predictions/{$predictionId}");
+
+ if ($response->failed()) {
+ throw new RequestException($response);
+ }
+
+ return ReplicatePrediction::fromArray($response->json());
+ }
+
+ /**
+ * Wait for a prediction to complete by polling.
+ *
+ * @throws PrismException
+ */
+ protected function waitForPrediction(
+ PendingRequest $client,
+ string $predictionId,
+ int $pollingInterval = 1000,
+ int $maxWaitTime = 60
+ ): ReplicatePrediction {
+ $startTime = time();
+ $maxWaitSeconds = $maxWaitTime;
+
+ while (true) {
+ $prediction = $this->getPrediction($client, $predictionId);
+
+ if ($prediction->isComplete()) {
+ return $prediction;
+ }
+
+ if (time() - $startTime > $maxWaitSeconds) {
+ throw new PrismException(
+ "Replicate: prediction timed out after {$maxWaitSeconds} seconds"
+ );
+ }
+
+ // Convert milliseconds to microseconds for usleep
+ usleep($pollingInterval * 1000);
+ }
+ }
+
+ /**
+ * Cancel a prediction.
+ *
+ * @throws RequestException
+ */
+ protected function cancelPrediction(PendingRequest $client, string $predictionId): ReplicatePrediction
+ {
+ $response = $client->post("/predictions/{$predictionId}/cancel");
+
+ if ($response->failed()) {
+ throw new RequestException($response);
+ }
+
+ return ReplicatePrediction::fromArray($response->json());
+ }
+
+ /**
+ * Create a prediction and wait for completion.
+ * Uses sync mode (Prefer: wait) if enabled, otherwise polls.
+ *
+ * @param array $payload
+ * @param bool $useSyncMode Whether to use Prefer: wait header
+ *
+ * @throws RequestException
+ * @throws PrismException
+ */
+ protected function createAndWaitForPrediction(
+ PendingRequest $client,
+ array $payload,
+ bool $useSyncMode = true,
+ int $pollingInterval = 1000,
+ int $maxWaitTime = 60
+ ): ReplicatePrediction {
+ if ($useSyncMode) {
+ // Use sync mode: Prefer: wait header
+ $prediction = $this->createPrediction($client, $payload, wait: true, waitTimeout: $maxWaitTime);
+
+ // If prediction is still not complete (timed out), fall back to polling
+ if (! $prediction->isComplete()) {
+ return $this->waitForPrediction($client, $prediction->id, $pollingInterval, $maxWaitTime);
+ }
+
+ return $prediction;
+ }
+
+ // Use async mode: create then poll
+ $prediction = $this->createPrediction($client, $payload);
+
+ return $this->waitForPrediction($client, $prediction->id, $pollingInterval, $maxWaitTime);
+ }
+}
diff --git a/src/Providers/Replicate/Handlers/Audio.php b/src/Providers/Replicate/Handlers/Audio.php
new file mode 100644
index 000000000..86fe480cd
--- /dev/null
+++ b/src/Providers/Replicate/Handlers/Audio.php
@@ -0,0 +1,225 @@
+ $request->input(),
+ ];
+
+ // Add provider-specific options (voice, speed, etc.)
+ $providerOptions = $request->providerOptions();
+ if (! empty($providerOptions)) {
+ $input = array_merge($input, $providerOptions);
+ }
+
+ // Create prediction
+ $payload = [
+ 'version' => $this->extractVersionFromModel($request->model()),
+ 'input' => $input,
+ ];
+
+ // Create prediction and wait for completion (uses sync mode if enabled)
+ $prediction = $this->createAndWaitForPrediction(
+ $this->client,
+ $payload,
+ $this->useSyncMode,
+ $this->pollingInterval,
+ $this->maxWaitTime
+ );
+
+ // Check for errors
+ if ($prediction->isFailed()) {
+ throw new PrismException(
+ "Replicate TTS prediction failed: {$prediction->error}"
+ );
+ }
+
+ // Extract audio URL from output
+ $audioUrl = $this->extractAudioUrl($prediction->output);
+
+ if (! $audioUrl) {
+ throw new PrismException('No audio output found in Replicate response');
+ }
+
+ // Download audio content
+ $audioContent = $this->client->get($audioUrl)->body();
+ $base64Audio = base64_encode($audioContent);
+
+ return new AudioResponse(
+ audio: new GeneratedAudio(
+ base64: $base64Audio,
+ ),
+ );
+ }
+
+ public function handleSpeechToText(SpeechToTextRequest $request): TextResponse
+ {
+ $audioInput = $request->input()->url();
+
+ // If "URL" is actually a local file path, or if we have raw content, convert to data URL
+ if ($audioInput && is_file($audioInput)) {
+ // URL is actually a local file path
+ $content = file_get_contents($audioInput);
+ if ($content === false) {
+ throw new PrismException("Failed to read audio file: {$audioInput}");
+ }
+ $base64 = base64_encode($content);
+ $mimeType = mime_content_type($audioInput) ?: 'audio/mpeg';
+ $audioInput = "data:{$mimeType};base64,{$base64}";
+ } elseif (! $audioInput && $request->input()->rawContent()) {
+ // No URL but we have content (using fromLocalPath)
+ $base64 = $request->input()->base64();
+ $mimeType = $request->input()->mimeType() ?? 'audio/mpeg';
+ $audioInput = "data:{$mimeType};base64,{$base64}";
+ }
+
+ // Build input parameters for speech-to-text
+ $input = [
+ 'audio' => $audioInput,
+ 'task' => 'transcribe',
+ ];
+
+ // Add provider-specific options (language, etc.)
+ $providerOptions = $request->providerOptions();
+ if (! empty($providerOptions)) {
+ $input = array_merge($input, $providerOptions);
+ }
+
+ // Create prediction
+ $payload = [
+ 'version' => $this->extractVersionFromModel($request->model()),
+ 'input' => $input,
+ ];
+
+ // Create prediction and wait for completion (uses sync mode if enabled)
+ $prediction = $this->createAndWaitForPrediction(
+ $this->client,
+ $payload,
+ $this->useSyncMode,
+ $this->pollingInterval,
+ $this->maxWaitTime
+ );
+
+ // Check for errors
+ if ($prediction->isFailed()) {
+ throw new PrismException(
+ "Replicate STT prediction failed: {$prediction->error}"
+ );
+ }
+
+ // Extract text from output
+ $text = $this->extractTextFromOutput($prediction->output);
+
+ return new TextResponse(
+ text: $text,
+ usage: new Usage(
+ promptTokens: 0,
+ completionTokens: 0,
+ ),
+ additionalContent: [
+ 'metrics' => $prediction->metrics,
+ ],
+ );
+ }
+
+ /**
+ * Extract version ID from model string.
+ */
+ protected function extractVersionFromModel(string $model): string
+ {
+ // Otherwise, return as-is and let Replicate use the latest version
+ return $model;
+ }
+
+ /**
+ * Extract audio URL from Replicate output.
+ */
+ protected function extractAudioUrl(mixed $output): ?string
+ {
+ if (is_string($output)) {
+ return $output;
+ }
+
+ if (is_array($output)) {
+ // Output might be an array with a URL
+ if (isset($output[0]) && is_string($output[0])) {
+ return $output[0];
+ }
+
+ // Or it might have an 'audio' or 'url' key
+ if (isset($output['audio'])) {
+ return is_string($output['audio']) ? $output['audio'] : null;
+ }
+
+ if (isset($output['url'])) {
+ return is_string($output['url']) ? $output['url'] : null;
+ }
+ }
+
+ return null;
+ }
+
+ /**
+ * Extract text from Replicate output.
+ */
+ protected function extractTextFromOutput(mixed $output): string
+ {
+ if (is_string($output)) {
+ return $output;
+ }
+
+ if (is_array($output)) {
+ // Check for common keys
+ if (isset($output['text'])) {
+ return (string) $output['text'];
+ }
+
+ if (isset($output['transcription'])) {
+ return (string) $output['transcription'];
+ }
+
+ if (isset($output['segments'])) {
+ // Whisper-style output with segments
+ /** @var array $segments */
+ $segments = $output['segments'];
+
+ return collect($segments)
+ ->pluck('text')
+ ->join(' ');
+ }
+
+ // If it's an array of strings, join them
+ if (isset($output[0]) && is_string($output[0])) {
+ return implode('', $output);
+ }
+ }
+
+ return '';
+ }
+}
diff --git a/src/Providers/Replicate/Handlers/Embeddings.php b/src/Providers/Replicate/Handlers/Embeddings.php
new file mode 100644
index 000000000..85b5dba86
--- /dev/null
+++ b/src/Providers/Replicate/Handlers/Embeddings.php
@@ -0,0 +1,103 @@
+inputs() as $input) {
+ $payload = [
+ 'version' => $this->extractVersionFromModel($request->model()),
+ 'input' => array_merge(
+ ['text' => $input],
+ $this->buildInputParameters($request)
+ ),
+ ];
+
+ // Create prediction and wait for completion (uses sync mode if enabled)
+ $completedPrediction = $this->createAndWaitForPrediction(
+ $this->client,
+ $payload,
+ $this->useSyncMode,
+ $this->pollingInterval,
+ $this->maxWaitTime
+ );
+
+ // Extract embedding from output
+ $vectors = $completedPrediction->output['vectors'] ?? [];
+ if (! empty($vectors)) {
+ $embeddings[] = Embedding::fromArray($vectors);
+ $totalTokens += $this->estimateTokens($input);
+ }
+ }
+
+ return new EmbeddingsResponse(
+ embeddings: $embeddings,
+ usage: new EmbeddingsUsage($totalTokens),
+ meta: new Meta(
+ id: '',
+ model: $request->model(),
+ ),
+ );
+ }
+
+ /**
+ * Build input parameters from request.
+ *
+ * @return array
+ */
+ protected function buildInputParameters(Request $request): array
+ {
+ $params = [];
+
+ // Map provider options
+ foreach ($request->providerOptions() as $key => $value) {
+ $params[$key] = $value;
+ }
+
+ return $params;
+ }
+
+ /**
+ * Estimate tokens for usage tracking.
+ * Rough approximation: ~4 characters per token.
+ */
+ protected function estimateTokens(string $text): int
+ {
+ return (int) ceil(mb_strlen($text) / 4);
+ }
+
+ /**
+ * Extract version from model string.
+ * Supports formats like "owner/model:version" or just "owner/model".
+ */
+ protected function extractVersionFromModel(string $model): string
+ {
+ // Return as-is and let Replicate use the latest version or resolve the format
+ return $model;
+ }
+}
diff --git a/src/Providers/Replicate/Handlers/Images.php b/src/Providers/Replicate/Handlers/Images.php
new file mode 100644
index 000000000..d1570441c
--- /dev/null
+++ b/src/Providers/Replicate/Handlers/Images.php
@@ -0,0 +1,185 @@
+ $this->extractVersionFromModel($request->model()),
+ 'input' => array_merge(
+ ['prompt' => $request->prompt()],
+ $this->buildInputParameters($request)
+ ),
+ ];
+
+ // Create prediction and wait for completion (uses sync mode if enabled)
+ $completedPrediction = $this->createAndWaitForPrediction(
+ $this->client,
+ $payload,
+ $this->useSyncMode,
+ $this->pollingInterval,
+ $this->maxWaitTime
+ );
+
+ // Extract images from output
+ $images = $this->extractImages($completedPrediction->output ?? []);
+
+ $responseBuilder = new ResponseBuilder(
+ usage: new Usage(
+ promptTokens: 0, // Replicate doesn't provide token usage for image generation
+ completionTokens: 0,
+ ),
+ meta: new Meta(
+ id: $completedPrediction->id,
+ model: $request->model(),
+ ),
+ images: $images,
+ );
+
+ return $responseBuilder->toResponse();
+ }
+
+ /**
+ * Build input parameters from request.
+ *
+ * @return array
+ */
+ protected function buildInputParameters(Request $request): array
+ {
+ $params = [];
+
+ // Map provider options
+ foreach ($request->providerOptions() as $key => $value) {
+ $params[$key] = $value;
+ }
+
+ return $params;
+ }
+
+ /**
+ * Extract images from prediction output.
+ *
+ * @return GeneratedImage[]
+ */
+ protected function extractImages(mixed $output): array
+ {
+ $images = [];
+
+ // Replicate returns either a single URL string or an array of URLs
+ if (is_string($output)) {
+ $output = [$output];
+ }
+
+ if (! is_array($output)) {
+ return $images;
+ }
+
+ foreach ($output as $imageUrl) {
+ if (is_string($imageUrl)) {
+ // Download the image and convert to base64
+ $base64 = $this->downloadImageAsBase64($imageUrl);
+
+ $images[] = new GeneratedImage(
+ url: $imageUrl,
+ base64: $base64, // Replicate doesn't provide revised prompts
+ );
+ }
+ }
+
+ return $images;
+ }
+
+ /**
+ * Maximum bytes accepted when downloading a generated image.
+ */
+ protected const MAX_DOWNLOAD_BYTES = 25 * 1024 * 1024;
+
+ /**
+ * Download an image from URL and convert to base64.
+ *
+ * The URL originates from the provider response, so it is only fetched
+ * when it is https on an allowlisted host (defense against SSRF via a
+ * compromised provider path), with an explicit timeout and a size cap.
+ * On any rejection or failure the caller falls back to the URL.
+ */
+ protected function downloadImageAsBase64(string $url): ?string
+ {
+ if (! $this->isDownloadableUrl($url)) {
+ return null;
+ }
+
+ try {
+ /** @var \Illuminate\Http\Client\Response $response */
+ $response = Http::timeout(30)->get($url);
+
+ if ($response->successful() && strlen($response->body()) <= self::MAX_DOWNLOAD_BYTES) {
+ return base64_encode($response->body());
+ }
+ } catch (\Exception) {
+ // If download fails, return null and rely on URL
+ }
+
+ return null;
+ }
+
+ protected function isDownloadableUrl(string $url): bool
+ {
+ $parts = parse_url($url);
+
+ if (($parts['scheme'] ?? '') !== 'https') {
+ return false;
+ }
+
+ $host = strtolower((string) ($parts['host'] ?? ''));
+
+ /** @var array $allowedHosts */
+ $allowedHosts = config('prism.providers.replicate.download_hosts', ['replicate.delivery']);
+
+ foreach ($allowedHosts as $allowedHost) {
+ $allowedHost = strtolower($allowedHost);
+
+ if ($host === $allowedHost || str_ends_with($host, '.'.$allowedHost)) {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ /**
+ * Extract version from model string.
+ */
+ /**
+ * Extract version ID from model string.
+ * Supports formats like "owner/model:version" or just "owner/model".
+ */
+ protected function extractVersionFromModel(string $model): string
+ {
+ // Return as-is and let Replicate use the latest version or resolve the format
+ return $model;
+ }
+}
diff --git a/src/Providers/Replicate/Handlers/Stream.php b/src/Providers/Replicate/Handlers/Stream.php
new file mode 100644
index 000000000..66b94ca92
--- /dev/null
+++ b/src/Providers/Replicate/Handlers/Stream.php
@@ -0,0 +1,329 @@
+state = new StreamState;
+ }
+
+ /**
+ * @return Generator
+ */
+ public function handle(Request $request): Generator
+ {
+ // Tool calling is not supported with streaming
+ if ($request->tools() !== []) {
+ throw new PrismException(
+ 'Replicate: Tool calling is not supported with streaming. '
+ .'Use ->generate() instead of ->stream()'
+ );
+ }
+
+ $this->state->reset()->withMessageId(EventID::generate());
+
+ // Build the prompt from messages
+ $prompt = MessageMap::map($request->messages());
+
+ // Prepare the prediction payload with stream enabled
+ $payload = [
+ 'version' => $this->extractVersionFromModel($request->model()),
+ 'input' => array_merge(
+ ['prompt' => $prompt],
+ $this->buildInputParameters($request)
+ ),
+ 'stream' => true, // Enable streaming
+ ];
+
+ // Create prediction
+ $prediction = $this->createPrediction($this->client, $payload);
+
+ // Emit stream start
+ yield new StreamStartEvent(
+ id: EventID::generate(),
+ timestamp: time(),
+ model: $request->model(),
+ provider: 'replicate',
+ );
+
+ // Check if streaming URL is available
+ $streamUrl = $prediction->urls['stream'] ?? null;
+
+ if ($streamUrl !== null) {
+ // Use real-time SSE streaming
+ yield from $this->processSSEStream($streamUrl, $prediction->id);
+ } else {
+ // Fallback to simulated streaming (poll + tokenize)
+ $completedPrediction = $this->waitForPrediction(
+ $this->client,
+ $prediction->id,
+ $this->pollingInterval,
+ $this->maxWaitTime
+ );
+
+ yield from $this->processTokenizedOutput($completedPrediction);
+ }
+ }
+
+ /**
+ * Process real-time SSE stream from Replicate.
+ *
+ * @return Generator
+ */
+ protected function processSSEStream(string $streamUrl, string $predictionId): Generator
+ {
+ // Connect to the SSE stream with proper headers
+ $response = $this->client
+ ->withHeaders(['Accept' => 'text/event-stream'])
+ ->withOptions(['stream' => true])
+ ->get($streamUrl);
+
+ $stream = $response->getBody();
+
+ $textStarted = false;
+ $finalStatus = 'succeeded';
+ $metrics = [];
+ $currentEvent = null;
+
+ try {
+ while (! $stream->eof()) {
+ $line = $this->readLine($stream);
+ // Skip empty lines and comments
+ if ($line === '') {
+ continue;
+ }
+ if ($line === "\n") {
+ continue;
+ }
+ if (str_starts_with($line, ':')) {
+ continue;
+ }
+
+ // Parse SSE field
+ if (str_starts_with($line, 'event:')) {
+ $currentEvent = trim(substr($line, strlen('event:')));
+ } elseif (str_starts_with($line, 'data:')) {
+ $data = substr($line, strlen('data:'));
+ // Remove leading space if present (SSE spec)
+ if (str_starts_with($data, ' ')) {
+ $data = substr($data, 1);
+ }
+ // Remove trailing newline
+ $data = rtrim($data, "\n");
+
+ // Handle event based on type
+ if ($currentEvent === 'output') {
+ // Text output event (data is plain text)
+ if (! $textStarted) {
+ yield new TextStartEvent(
+ id: EventID::generate(),
+ timestamp: time(),
+ messageId: $this->state->messageId()
+ );
+ $textStarted = true;
+ }
+
+ if ($data !== '') {
+ $this->state->appendText($data);
+
+ yield new TextDeltaEvent(
+ id: EventID::generate(),
+ timestamp: time(),
+ delta: $data,
+ messageId: $this->state->messageId()
+ );
+ }
+ } elseif ($currentEvent === 'done') {
+ // Stream completion event (data is JSON)
+ try {
+ $doneData = json_decode($data, true, flags: JSON_THROW_ON_ERROR);
+ $finalStatus = $doneData['status'] ?? 'succeeded';
+ $metrics = $doneData['metrics'] ?? [];
+ } catch (Throwable) {
+ // Empty done event
+ $finalStatus = 'succeeded';
+ }
+ break;
+ } elseif ($currentEvent === 'error') {
+ // Error event (data is JSON)
+ try {
+ $errorData = json_decode($data, true, flags: JSON_THROW_ON_ERROR);
+ $errorMessage = $errorData['detail'] ?? $data;
+ } catch (Throwable) {
+ $errorMessage = $data;
+ }
+ throw new PrismException("Replicate streaming error: {$errorMessage}");
+ }
+
+ // Reset event type after processing
+ $currentEvent = null;
+ }
+ }
+ } finally {
+ $stream->close();
+ }
+
+ // Emit text complete if text was started
+ if ($textStarted) {
+ yield new TextCompleteEvent(
+ id: EventID::generate(),
+ timestamp: time(),
+ messageId: $this->state->messageId()
+ );
+ }
+
+ // Emit stream end
+ yield new StreamEndEvent(
+ id: EventID::generate(),
+ timestamp: time(),
+ finishReason: FinishReasonMap::map($finalStatus),
+ usage: new Usage(
+ promptTokens: $metrics['input_token_count'] ?? 0,
+ completionTokens: $metrics['output_token_count'] ?? 0,
+ ),
+ );
+ }
+
+ /**
+ * Read a single line from the stream.
+ */
+ protected function readLine(StreamInterface $stream): string
+ {
+ $buffer = '';
+
+ while (! $stream->eof()) {
+ $byte = $stream->read(1);
+
+ if ($byte === '') {
+ return $buffer;
+ }
+
+ $buffer .= $byte;
+
+ if ($byte === "\n") {
+ break;
+ }
+ }
+
+ return $buffer;
+ }
+
+ /**
+ * Process tokenized output as streaming events (fallback method).
+ *
+ * @param object{id: string, status: string, output: mixed, error: string|null, metrics: array} $prediction
+ * @return Generator
+ */
+ protected function processTokenizedOutput(object $prediction): Generator
+ {
+ $output = $prediction->output ?? [];
+
+ if (! is_array($output)) {
+ $output = [$output];
+ }
+
+ // Emit text start
+ yield new TextStartEvent(
+ id: EventID::generate(),
+ timestamp: time(),
+ messageId: $this->state->messageId()
+ );
+
+ // Stream each token as a delta
+ foreach ($output as $token) {
+ if (is_string($token) && $token !== '') {
+ $this->state->appendText($token);
+
+ yield new TextDeltaEvent(
+ id: EventID::generate(),
+ timestamp: time(),
+ delta: $token,
+ messageId: $this->state->messageId()
+ );
+ }
+ }
+
+ // Emit text complete
+ yield new TextCompleteEvent(
+ id: EventID::generate(),
+ timestamp: time(),
+ messageId: $this->state->messageId()
+ );
+
+ // Emit stream end
+ yield new StreamEndEvent(
+ id: EventID::generate(),
+ timestamp: time(),
+ finishReason: FinishReasonMap::map($prediction->status),
+ usage: new Usage(
+ promptTokens: $prediction->metrics['input_token_count'] ?? 0,
+ completionTokens: $prediction->metrics['output_token_count'] ?? 0,
+ ),
+ );
+ }
+
+ /**
+ * Build input parameters from request.
+ *
+ * @return array
+ */
+ protected function buildInputParameters(Request $request): array
+ {
+ $params = [];
+
+ if ($request->maxTokens()) {
+ $params['max_tokens'] = $request->maxTokens();
+ }
+
+ // Map provider options
+ foreach ($request->providerOptions() as $key => $value) {
+ $params[$key] = $value;
+ }
+
+ return $params;
+ }
+
+ /**
+ * Extract version from model string.
+ */
+ /**
+ * Extract version ID from model string.
+ * Supports formats like "owner/model:version" or just "owner/model".
+ */
+ protected function extractVersionFromModel(string $model): string
+ {
+ // Return as-is and let Replicate use the latest version or resolve the format
+ return $model;
+ }
+}
diff --git a/src/Providers/Replicate/Handlers/Structured.php b/src/Providers/Replicate/Handlers/Structured.php
new file mode 100644
index 000000000..29b11bbdc
--- /dev/null
+++ b/src/Providers/Replicate/Handlers/Structured.php
@@ -0,0 +1,184 @@
+responseBuilder = new ResponseBuilder;
+ }
+
+ public function handle(Request $request): Response
+ {
+ // Build the prompt from messages with JSON instruction
+ $prompt = $this->buildStructuredPrompt($request);
+
+ // Prepare the prediction payload
+ $payload = [
+ 'version' => $this->extractVersionFromModel($request->model()),
+ 'input' => array_merge(
+ ['prompt' => $prompt],
+ $this->buildInputParameters($request)
+ ),
+ ];
+
+ // Create prediction and wait for completion (uses sync mode if enabled)
+ $completedPrediction = $this->createAndWaitForPrediction(
+ $this->client,
+ $payload,
+ $this->useSyncMode,
+ $this->pollingInterval,
+ $this->maxWaitTime
+ );
+
+ // Extract and parse JSON output
+ $text = $this->extractTextFromOutput($completedPrediction->output ?? []);
+ $structured = $this->parseStructuredOutput($text, $request);
+
+ $responseMessage = new AssistantMessage($text);
+ $request->addMessage($responseMessage);
+
+ $this->addStep($completedPrediction, $text, $structured, $request);
+
+ return $this->responseBuilder->toResponse();
+ }
+
+ /**
+ * Build prompt with JSON instructions based on mode.
+ */
+ protected function buildStructuredPrompt(Request $request): string
+ {
+ $basePrompt = MessageMap::map($request->messages());
+
+ // Add JSON instruction based on mode
+ $schemaJson = json_encode($request->schema()->toArray(), JSON_PRETTY_PRINT);
+ $jsonInstruction = match ($request->mode()) {
+ StructuredMode::Json, StructuredMode::Auto => "\n\nRespond ONLY with valid JSON that matches this schema: ".$schemaJson,
+ StructuredMode::Structured => "\n\nRespond ONLY with valid JSON that matches this schema: ".$schemaJson,
+ };
+
+ return $basePrompt.$jsonInstruction;
+ }
+
+ /**
+ * Build input parameters from request.
+ *
+ * @return array
+ */
+ protected function buildInputParameters(Request $request): array
+ {
+ $params = ['max_tokens' => 4096]; // Default for structured output
+
+ // Map provider options
+ foreach ($request->providerOptions() as $key => $value) {
+ $params[$key] = $value;
+ }
+
+ return $params;
+ }
+
+ /**
+ * Extract text from prediction output.
+ */
+ protected function extractTextFromOutput(mixed $output): string
+ {
+ if (is_string($output)) {
+ return $output;
+ }
+
+ if (is_array($output)) {
+ return implode('', $output);
+ }
+
+ return '';
+ }
+
+ /**
+ * Parse structured output based on mode.
+ *
+ * @return array
+ */
+ protected function parseStructuredOutput(string $text, Request $request): array
+ {
+ // Try to extract JSON from the response
+ $jsonMatch = [];
+ if (preg_match('/\{.*\}/s', $text, $jsonMatch)) {
+ $json = json_decode($jsonMatch[0], true);
+
+ if (json_last_error() === JSON_ERROR_NONE && is_array($json)) {
+ return $json;
+ }
+ }
+
+ // If we can't parse JSON in structured modes, throw an exception
+ if (in_array($request->mode(), [StructuredMode::Json, StructuredMode::Structured])) {
+ throw new PrismException('Replicate: Failed to parse structured JSON output');
+ }
+
+ return [];
+ }
+
+ /**
+ * Add step to response builder.
+ *
+ * @param object{id: string, status: string, output: mixed, error: string|null, metrics: array} $prediction
+ * @param array $structured
+ */
+ protected function addStep(object $prediction, string $text, array $structured, Request $request): void
+ {
+ $this->responseBuilder->addStep(new Step(
+ text: $text,
+ finishReason: FinishReasonMap::map($prediction->status),
+ usage: new Usage(
+ promptTokens: $prediction->metrics['input_token_count'] ?? 0,
+ completionTokens: $prediction->metrics['output_token_count'] ?? 0,
+ ),
+ meta: new Meta(
+ id: $prediction->id,
+ model: $request->model(),
+ ),
+ messages: $request->messages(),
+ systemPrompts: $request->systemPrompts(),
+ additionalContent: [],
+ structured: $structured,
+ ));
+ }
+
+ /**
+ * Extract version from model string.
+ */
+ /**
+ * Extract version ID from model string.
+ * Supports formats like "owner/model:version" or just "owner/model".
+ */
+ protected function extractVersionFromModel(string $model): string
+ {
+ // Return as-is and let Replicate use the latest version or resolve the format
+ return $model;
+ }
+}
diff --git a/src/Providers/Replicate/Handlers/Text.php b/src/Providers/Replicate/Handlers/Text.php
new file mode 100644
index 000000000..c71838181
--- /dev/null
+++ b/src/Providers/Replicate/Handlers/Text.php
@@ -0,0 +1,200 @@
+responseBuilder = new ResponseBuilder;
+ }
+
+ public function handle(Request $request): Response
+ {
+ // Build payload
+ $payload = $this->buildPayload($request);
+
+ // Create prediction and wait for completion (uses sync mode if enabled)
+ $prediction = $this->createAndWaitForPrediction(
+ $this->client,
+ $payload,
+ $this->useSyncMode,
+ $this->pollingInterval,
+ $this->maxWaitTime
+ );
+
+ // Check for errors
+ if ($prediction->isFailed()) {
+ throw new PrismException(
+ "Replicate prediction failed: {$prediction->error}"
+ );
+ }
+
+ // Extract the text output
+ $text = $this->extractTextFromOutput($prediction->output);
+
+ // Create assistant message
+ $responseMessage = new AssistantMessage(
+ content: $text,
+ );
+
+ $request->addMessage($responseMessage);
+
+ // Add step to response builder
+ $this->responseBuilder->addStep(new Step(
+ text: $text,
+ finishReason: FinishReasonMap::map($prediction->status),
+ toolCalls: [],
+ toolResults: [],
+ providerToolCalls: [],
+ usage: new Usage(
+ promptTokens: 0, // Replicate doesn't provide token counts
+ completionTokens: 0,
+ ),
+ meta: new Meta(
+ id: $prediction->id,
+ model: $request->model(),
+ ),
+ messages: $request->messages(),
+ systemPrompts: $request->systemPrompts(),
+ additionalContent: [
+ 'metrics' => $prediction->metrics,
+ ],
+ ));
+
+ return $this->responseBuilder->toResponse();
+ }
+
+ /**
+ * Build the prediction payload.
+ *
+ * @return array
+ */
+ protected function buildPayload(Request $request): array
+ {
+ return [
+ 'version' => $this->extractVersionFromModel($request->model()),
+ 'input' => $this->buildInput($request),
+ ];
+ }
+
+ /**
+ * Build input parameters.
+ *
+ * @return array
+ */
+ protected function buildInput(Request $request): array
+ {
+ $input = ['prompt' => MessageMap::map($request->messages())];
+
+ // Build system prompt
+ if ($request->systemPrompts() !== []) {
+ $input['system_prompt'] = implode("\n\n", array_map(
+ fn ($prompt): string => $prompt->content,
+ $request->systemPrompts()
+ ));
+ }
+
+ // Add other parameters
+ $params = $this->buildInputParameters($request);
+
+ return array_merge($input, $params);
+ }
+
+ /**
+ * Extract version from model string for the Replicate API.
+ * The version field accepts:
+ * - "owner/model:version" format (uses specific version)
+ * - "owner/model" format (uses latest version)
+ * - Just "version_hash" (64-char hash)
+ */
+ protected function extractVersionFromModel(string $model): string
+ {
+ // Replicate API accepts the full string as-is
+ // It handles owner/model, owner/model:version, or version hash formats
+ return $model;
+ }
+
+ /**
+ * Build input parameters from request.
+ *
+ * @return array
+ */
+ protected function buildInputParameters(Request $request): array
+ {
+ $params = [];
+
+ if ($request->maxTokens() !== null) {
+ $params['max_tokens'] = $request->maxTokens();
+ $params['max_length'] = $request->maxTokens(); // Some models use max_length
+ }
+
+ if ($request->temperature() !== null) {
+ $params['temperature'] = $request->temperature();
+ }
+
+ if ($request->topP() !== null) {
+ $params['top_p'] = $request->topP();
+ }
+
+ // Add any provider-specific options
+ $providerOptions = $request->providerOptions();
+ if (! empty($providerOptions)) {
+ return array_merge($params, $providerOptions);
+ }
+
+ return $params;
+ }
+
+ /**
+ * Extract text from Replicate output.
+ * Replicate outputs can be strings, arrays, or objects.
+ */
+ protected function extractTextFromOutput(mixed $output): string
+ {
+ if (is_string($output)) {
+ return $output;
+ }
+
+ if (is_array($output)) {
+ // If it's an array of strings, join them
+ if (isset($output[0]) && is_string($output[0])) {
+ return implode('', $output);
+ }
+
+ // If it has a 'text' or 'output' key
+ if (isset($output['text'])) {
+ return (string) $output['text'];
+ }
+
+ if (isset($output['output'])) {
+ return $this->extractTextFromOutput($output['output']);
+ }
+ }
+
+ return '';
+ }
+}
diff --git a/src/Providers/Replicate/Maps/FinishReasonMap.php b/src/Providers/Replicate/Maps/FinishReasonMap.php
new file mode 100644
index 000000000..f17dfbd64
--- /dev/null
+++ b/src/Providers/Replicate/Maps/FinishReasonMap.php
@@ -0,0 +1,19 @@
+ FinishReason::Stop,
+ 'failed', 'canceled' => FinishReason::Error,
+ default => FinishReason::Unknown,
+ };
+ }
+}
diff --git a/src/Providers/Replicate/Maps/MessageMap.php b/src/Providers/Replicate/Maps/MessageMap.php
new file mode 100644
index 000000000..c32858520
--- /dev/null
+++ b/src/Providers/Replicate/Maps/MessageMap.php
@@ -0,0 +1,70 @@
+ $messages
+ */
+ public static function map(array $messages): string
+ {
+ $prompt = '';
+
+ foreach ($messages as $message) {
+ $prompt .= match ($message::class) {
+ SystemMessage::class => self::mapSystemMessage($message),
+ UserMessage::class => self::mapUserMessage($message),
+ AssistantMessage::class => self::mapAssistantMessage($message),
+ ToolResultMessage::class => self::mapToolResultMessage($message),
+ default => '',
+ };
+ }
+
+ return trim($prompt);
+ }
+
+ protected static function mapSystemMessage(SystemMessage $message): string
+ {
+ return "System: {$message->content}\n\n";
+ }
+
+ protected static function mapUserMessage(UserMessage $message): string
+ {
+ return "User: {$message->text()}\n\n";
+ }
+
+ protected static function mapAssistantMessage(AssistantMessage $message): string
+ {
+ return "Assistant: {$message->content}\n\n";
+ }
+
+ protected static function mapToolResultMessage(ToolResultMessage $message): string
+ {
+ $results = [];
+
+ foreach ($message->toolResults as $result) {
+ $resultText = is_string($result->result)
+ ? $result->result
+ : json_encode($result->result);
+
+ $results[] = sprintf(
+ 'Tool: %s\nResult: %s',
+ $result->toolName,
+ $resultText
+ );
+ }
+
+ return "Tool Results:\n".implode("\n\n", $results)."\n\n";
+ }
+}
diff --git a/src/Providers/Replicate/Replicate.php b/src/Providers/Replicate/Replicate.php
new file mode 100644
index 000000000..300aac662
--- /dev/null
+++ b/src/Providers/Replicate/Replicate.php
@@ -0,0 +1,163 @@
+client($request->clientOptions(), $request->clientRetry()),
+ $this->useSyncMode,
+ $this->pollingInterval,
+ $this->maxWaitTime
+ );
+
+ return $handler->handle($request);
+ }
+
+ #[\Override]
+ public function textToSpeech(TextToSpeechRequest $request): TextToSpeechResponse
+ {
+ $handler = new Audio(
+ $this->client($request->clientOptions(), $request->clientRetry()),
+ $this->useSyncMode,
+ $this->pollingInterval,
+ $this->maxWaitTime
+ );
+
+ return $handler->handleTextToSpeech($request);
+ }
+
+ #[\Override]
+ public function speechToText(SpeechToTextRequest $request): SpeechToTextResponse
+ {
+ $handler = new Audio(
+ $this->client($request->clientOptions(), $request->clientRetry()),
+ $this->useSyncMode,
+ $this->pollingInterval,
+ $this->maxWaitTime
+ );
+
+ return $handler->handleSpeechToText($request);
+ }
+
+ #[\Override]
+ public function images(ImagesRequest $request): ImagesResponse
+ {
+ $handler = new Images(
+ $this->client($request->clientOptions(), $request->clientRetry()),
+ $this->useSyncMode,
+ $this->pollingInterval,
+ $this->maxWaitTime
+ );
+
+ return $handler->handle($request);
+ }
+
+ #[\Override]
+ public function structured(StructuredRequest $request): StructuredResponse
+ {
+ $handler = new StructuredHandler(
+ $this->client($request->clientOptions(), $request->clientRetry()),
+ $this->useSyncMode,
+ $this->pollingInterval,
+ $this->maxWaitTime
+ );
+
+ return $handler->handle($request);
+ }
+
+ #[\Override]
+ public function stream(TextRequest $request): Generator
+ {
+ $handler = new Stream(
+ $this->client($request->clientOptions(), $request->clientRetry()),
+ $this->useSyncMode,
+ $this->pollingInterval,
+ $this->maxWaitTime
+ );
+
+ return $handler->handle($request);
+ }
+
+ #[\Override]
+ public function embeddings(EmbeddingsRequest $request): EmbeddingsResponse
+ {
+ $handler = new Embeddings(
+ $this->client($request->clientOptions(), $request->clientRetry()),
+ $this->useSyncMode,
+ $this->pollingInterval,
+ $this->maxWaitTime
+ );
+
+ return $handler->handle($request);
+ }
+
+ #[\Override]
+ public function handleRequestException(string $model, RequestException $e): never
+ {
+ match ($e->response->getStatusCode()) {
+ 429 => throw PrismRateLimitedException::make([]),
+ 529 => throw PrismProviderOverloadedException::make(ProviderName::Replicate),
+ 413 => throw PrismRequestTooLargeException::make(ProviderName::Replicate),
+ default => throw PrismException::providerRequestError($model, $e),
+ };
+ }
+
+ /**
+ * @param array $options
+ * @param array $retry
+ */
+ protected function client(array $options = [], array $retry = [], ?string $baseUrl = null): PendingRequest
+ {
+ return $this->baseClient()
+ ->withToken($this->apiKey)
+ ->withOptions($options)
+ ->when($retry !== [], fn ($client) => $client->retry(...$retry))
+ ->baseUrl($baseUrl ?? $this->url);
+ }
+}
diff --git a/src/Providers/Replicate/ValueObjects/ReplicatePrediction.php b/src/Providers/Replicate/ValueObjects/ReplicatePrediction.php
new file mode 100644
index 000000000..2fe4ee09b
--- /dev/null
+++ b/src/Providers/Replicate/ValueObjects/ReplicatePrediction.php
@@ -0,0 +1,56 @@
+ $input
+ * @param array $urls
+ * @param array $metrics
+ */
+ public function __construct(
+ public string $id,
+ public string $status,
+ public array $input,
+ public mixed $output,
+ public ?string $error,
+ public ?string $logs,
+ public array $urls,
+ public array $metrics = [],
+ ) {}
+
+ /**
+ * @param array $data
+ */
+ public static function fromArray(array $data): self
+ {
+ return new self(
+ id: $data['id'],
+ status: $data['status'],
+ input: $data['input'] ?? [],
+ output: $data['output'] ?? null,
+ error: $data['error'] ?? null,
+ logs: $data['logs'] ?? null,
+ urls: $data['urls'] ?? [],
+ metrics: $data['metrics'] ?? [],
+ );
+ }
+
+ public function isComplete(): bool
+ {
+ return in_array($this->status, ['succeeded', 'failed', 'canceled']);
+ }
+
+ public function isSuccessful(): bool
+ {
+ return $this->status === 'succeeded';
+ }
+
+ public function isFailed(): bool
+ {
+ return in_array($this->status, ['failed', 'canceled']);
+ }
+}
diff --git a/src/Providers/Requesty/Concerns/BuildsRequestOptions.php b/src/Providers/Requesty/Concerns/BuildsRequestOptions.php
new file mode 100644
index 000000000..17ac68390
--- /dev/null
+++ b/src/Providers/Requesty/Concerns/BuildsRequestOptions.php
@@ -0,0 +1,36 @@
+ $additional
+ * @return array
+ */
+ protected function buildRequestOptions(TextRequest|StructuredRequest $request, array $additional = []): array
+ {
+ // Keep Requesty option surface flexible; reference https://docs.requesty.ai for supported keys.
+ $options = $request->providerOptions() ?? [];
+
+ $options = array_merge($options, Arr::whereNotNull([
+ 'temperature' => $request->temperature(),
+ 'top_p' => $request->topP(),
+ 'max_tokens' => $request->maxTokens(),
+ 'tools' => ToolMap::map($request->tools()),
+ 'tool_choice' => ToolChoiceMap::map($request->toolChoice()),
+ ]));
+
+ return Arr::whereNotNull(array_merge($options, $additional));
+ }
+}
diff --git a/src/Providers/Requesty/Concerns/MapsFinishReason.php b/src/Providers/Requesty/Concerns/MapsFinishReason.php
new file mode 100644
index 000000000..a1715a387
--- /dev/null
+++ b/src/Providers/Requesty/Concerns/MapsFinishReason.php
@@ -0,0 +1,21 @@
+ $data
+ */
+ protected function mapFinishReason(array $data): FinishReason
+ {
+ $finishReason = data_get($data, 'choices.0.finish_reason', '');
+
+ return FinishReasonMap::map($finishReason ?? '');
+ }
+}
diff --git a/src/Providers/Requesty/Concerns/ValidatesResponses.php b/src/Providers/Requesty/Concerns/ValidatesResponses.php
new file mode 100644
index 000000000..b148781a5
--- /dev/null
+++ b/src/Providers/Requesty/Concerns/ValidatesResponses.php
@@ -0,0 +1,57 @@
+ $data
+ */
+ protected function validateResponse(array $data): void
+ {
+ if ($data === []) {
+ throw PrismException::providerResponseError('Requesty Error: Empty response');
+ }
+
+ if (data_get($data, 'error')) {
+ $this->handleRequestyError($data);
+ }
+ }
+
+ /**
+ * @param array $data
+ */
+ protected function handleRequestyError(array $data): void
+ {
+ $error = data_get($data, 'error', []);
+ $code = data_get($error, 'code', 'unknown');
+ $message = data_get($error, 'message', 'Unknown error');
+ $metadata = data_get($error, 'metadata', []);
+
+ if ($code === 403 && isset($metadata['reasons'])) {
+ throw PrismException::providerResponseError(sprintf(
+ 'Requesty Moderation Error: %s. Flagged input: %s',
+ $message,
+ data_get($metadata, 'flagged_input', 'N/A')
+ ));
+ }
+
+ if (isset($metadata['provider_name'])) {
+ throw PrismException::providerResponseError(sprintf(
+ 'Requesty Provider Error (%s): %s',
+ data_get($metadata, 'provider_name'),
+ $message
+ ));
+ }
+
+ throw PrismException::providerResponseError(sprintf(
+ 'Requesty Error [%s]: %s',
+ $code,
+ $message
+ ));
+ }
+}
diff --git a/src/Providers/Requesty/Handlers/Embeddings.php b/src/Providers/Requesty/Handlers/Embeddings.php
new file mode 100644
index 000000000..9be9973b7
--- /dev/null
+++ b/src/Providers/Requesty/Handlers/Embeddings.php
@@ -0,0 +1,54 @@
+sendRequest($request);
+
+ $this->validateResponse($data);
+
+ return new EmbeddingsResponse(
+ embeddings: array_map(fn (array $item): Embedding => Embedding::fromArray($item['embedding']), data_get($data, 'data', [])),
+ usage: new EmbeddingsUsage(data_get($data, 'usage.total_tokens')),
+ meta: new Meta(
+ id: data_get($data, 'id', ''),
+ model: data_get($data, 'model', ''),
+ ),
+ raw: $data,
+ );
+ }
+
+ /**
+ * @return array
+ */
+ protected function sendRequest(Request $request): array
+ {
+ /** @var Response $response */
+ $response = $this->client->post(
+ 'embeddings',
+ [
+ 'model' => $request->model(),
+ 'input' => $request->inputs(),
+ ...($request->providerOptions() ?? []),
+ ]
+ );
+
+ return $response->json();
+ }
+}
diff --git a/src/Providers/Requesty/Handlers/Stream.php b/src/Providers/Requesty/Handlers/Stream.php
new file mode 100644
index 000000000..accfba93e
--- /dev/null
+++ b/src/Providers/Requesty/Handlers/Stream.php
@@ -0,0 +1,520 @@
+state = new StreamState;
+ }
+
+ /**
+ * @return Generator
+ */
+ public function handle(Request $request): Generator
+ {
+ yield from $this->resolveToolApprovalsAndYieldEvents($request, EventID::generate());
+
+ $response = $this->sendRequest($request);
+
+ yield from $this->processStream($response, $request);
+ }
+
+ /**
+ * @return Generator
+ */
+ protected function processStream(Response $response, Request $request, int $depth = 0): Generator
+ {
+ if ($depth === 0) {
+ $this->state->reset();
+ }
+
+ $text = '';
+ $toolCalls = [];
+
+ while (! $response->getBody()->eof()) {
+ $data = $this->parseNextDataLine($response->getBody());
+
+ if ($data === null) {
+ continue;
+ }
+
+ if ($this->state->shouldEmitStreamStart()) {
+ $this->state
+ ->withMessageId(EventID::generate('msg'))
+ ->markStreamStarted();
+
+ yield new StreamStartEvent(
+ id: EventID::generate(),
+ timestamp: time(),
+ model: $data['model'] ?? $request->model(),
+ provider: 'requesty'
+ );
+ }
+
+ if ($this->state->shouldEmitStepStart()) {
+ $this->state->markStepStarted();
+
+ yield new StepStartEvent(
+ id: EventID::generate(),
+ timestamp: time()
+ );
+ }
+
+ if ($this->hasToolCalls($data)) {
+ $toolCalls = $this->extractToolCalls($data, $toolCalls);
+
+ $finishReason = data_get($data, 'choices.0.finish_reason');
+ if ($finishReason !== null && $this->mapFinishReason($data) === FinishReason::ToolCalls) {
+ if ($this->state->hasTextStarted() && $text !== '') {
+ $this->state->markTextCompleted();
+
+ yield new TextCompleteEvent(
+ id: EventID::generate(),
+ timestamp: time(),
+ messageId: $this->state->messageId()
+ );
+ }
+
+ if ($this->state->hasThinkingStarted() && $this->state->currentThinking() !== '') {
+ yield new ThinkingCompleteEvent(
+ id: EventID::generate(),
+ timestamp: time(),
+ reasoningId: $this->state->reasoningId()
+ );
+ }
+
+ yield from $this->handleToolCalls($request, $text, $toolCalls, $depth);
+
+ return;
+ }
+
+ continue;
+ }
+
+ $finishReasonValue = data_get($data, 'choices.0.finish_reason');
+ if ($finishReasonValue !== null && $this->mapFinishReason($data) === FinishReason::ToolCalls) {
+ if ($this->state->hasTextStarted() && $text !== '') {
+ $this->state->markTextCompleted();
+
+ yield new TextCompleteEvent(
+ id: EventID::generate(),
+ timestamp: time(),
+ messageId: $this->state->messageId()
+ );
+ }
+
+ if ($this->state->hasThinkingStarted() && $this->state->currentThinking() !== '') {
+ yield new ThinkingCompleteEvent(
+ id: EventID::generate(),
+ timestamp: time(),
+ reasoningId: $this->state->reasoningId()
+ );
+ }
+
+ yield from $this->handleToolCalls($request, $text, $toolCalls, $depth);
+
+ return;
+ }
+
+ if ($this->hasReasoningDelta($data)) {
+ $reasoningDelta = $this->extractReasoningDelta($data);
+
+ if ($reasoningDelta !== '') {
+ if ($this->state->shouldEmitThinkingStart()) {
+ $this->state
+ ->withReasoningId(EventID::generate('reasoning'))
+ ->markThinkingStarted();
+
+ yield new ThinkingStartEvent(
+ id: EventID::generate(),
+ timestamp: time(),
+ reasoningId: $this->state->reasoningId()
+ );
+ }
+
+ $this->state->appendThinking($reasoningDelta);
+
+ yield new ThinkingEvent(
+ id: EventID::generate(),
+ timestamp: time(),
+ delta: $reasoningDelta,
+ reasoningId: $this->state->reasoningId()
+ );
+
+ continue;
+ }
+ }
+
+ $content = $this->extractContentDelta($data);
+ if ($content !== '') {
+ if ($this->state->shouldEmitTextStart()) {
+ $this->state->markTextStarted();
+
+ yield new TextStartEvent(
+ id: EventID::generate(),
+ timestamp: time(),
+ messageId: $this->state->messageId()
+ );
+ }
+
+ $text .= $content;
+
+ yield new TextDeltaEvent(
+ id: EventID::generate(),
+ timestamp: time(),
+ delta: $content,
+ messageId: $this->state->messageId()
+ );
+ }
+
+ $rawFinishReason = data_get($data, 'choices.0.finish_reason');
+ if ($rawFinishReason !== null) {
+ $finishReason = $this->mapFinishReason($data);
+
+ if ($this->state->hasTextStarted() && $text !== '') {
+ $this->state->markTextCompleted();
+
+ yield new TextCompleteEvent(
+ id: EventID::generate(),
+ timestamp: time(),
+ messageId: $this->state->messageId()
+ );
+ }
+
+ if ($this->state->hasThinkingStarted() && $this->state->currentThinking() !== '') {
+ yield new ThinkingCompleteEvent(
+ id: EventID::generate(),
+ timestamp: time(),
+ reasoningId: $this->state->reasoningId()
+ );
+ }
+
+ $this->state->withFinishReason($finishReason);
+ }
+
+ // Extract usage from any chunk that has it
+ // Requesty sends usage in a separate final chunk when stream_options.include_usage=true
+ $usage = $this->extractUsage($data);
+ if ($usage instanceof Usage) {
+ $this->state->addUsage($usage);
+ }
+ }
+
+ $this->state->markStepFinished();
+ yield new StepFinishEvent(
+ id: EventID::generate(),
+ timestamp: time()
+ );
+
+ yield $this->emitStreamEndEvent();
+ }
+
+ protected function emitStreamEndEvent(): StreamEndEvent
+ {
+ return new StreamEndEvent(
+ id: EventID::generate(),
+ timestamp: time(),
+ finishReason: $this->state->finishReason() ?? FinishReason::Stop,
+ usage: $this->state->usage() ?? new Usage(0, 0),
+ );
+ }
+
+ /**
+ * @return array|null
+ */
+ protected function parseNextDataLine(StreamInterface $stream): ?array
+ {
+ $line = $this->readLine($stream);
+
+ if (! str_starts_with($line, 'data:')) {
+ return null;
+ }
+
+ $line = trim(substr($line, strlen('data: ')));
+
+ if (Str::contains($line, '[DONE]')) {
+ return null;
+ }
+
+ try {
+ return json_decode($line, true, flags: JSON_THROW_ON_ERROR);
+ } catch (Throwable $e) {
+ throw new PrismStreamDecodeException('Requesty', $e);
+ }
+ }
+
+ /**
+ * @param array $data
+ */
+ protected function hasToolCalls(array $data): bool
+ {
+ return ! empty($data['choices'][0]['delta']['tool_calls']);
+ }
+
+ /**
+ * @param array $data
+ * @param array> $toolCalls
+ * @return array>
+ */
+ protected function extractToolCalls(array $data, array $toolCalls): array
+ {
+ $deltaToolCalls = data_get($data, 'choices.0.delta.tool_calls', []);
+
+ foreach ($deltaToolCalls as $deltaToolCall) {
+ $index = $deltaToolCall['index'];
+
+ if (isset($deltaToolCall['id'])) {
+ $toolCalls[$index]['id'] = $deltaToolCall['id'];
+ }
+
+ if (isset($deltaToolCall['type'])) {
+ $toolCalls[$index]['type'] = $deltaToolCall['type'];
+ }
+
+ if (isset($deltaToolCall['function'])) {
+ if (isset($deltaToolCall['function']['name'])) {
+ $toolCalls[$index]['function']['name'] = $deltaToolCall['function']['name'];
+ }
+
+ if (isset($deltaToolCall['function']['arguments'])) {
+ $toolCalls[$index]['function']['arguments'] =
+ ($toolCalls[$index]['function']['arguments'] ?? '').
+ $deltaToolCall['function']['arguments'];
+ }
+ }
+ }
+
+ return $toolCalls;
+ }
+
+ /**
+ * @param array $data
+ */
+ protected function hasReasoningDelta(array $data): bool
+ {
+ return isset($data['choices'][0]['delta']['reasoning']);
+ }
+
+ /**
+ * @param array $data
+ */
+ protected function extractReasoningDelta(array $data): string
+ {
+ return data_get($data, 'choices.0.delta.reasoning', '');
+ }
+
+ /**
+ * @param array $data
+ */
+ protected function extractContentDelta(array $data): string
+ {
+ return data_get($data, 'choices.0.delta.content', '');
+ }
+
+ /**
+ * @param array $data
+ */
+ protected function extractFinishReason(array $data): FinishReason
+ {
+ $finishReason = data_get($data, 'choices.0.finish_reason');
+
+ if ($finishReason === null) {
+ return FinishReason::Unknown;
+ }
+
+ return $this->mapFinishReason($data);
+ }
+
+ /**
+ * @param array> $toolCalls
+ * @return Generator
+ */
+ protected function handleToolCalls(
+ Request $request,
+ string $text,
+ array $toolCalls,
+ int $depth
+ ): Generator {
+ $mappedToolCalls = $this->mapToolCalls($toolCalls);
+
+ foreach ($mappedToolCalls as $toolCall) {
+ yield new ToolCallEvent(
+ id: EventID::generate(),
+ timestamp: time(),
+ toolCall: $toolCall,
+ messageId: $this->state->messageId()
+ );
+ }
+
+ $toolResults = [];
+ $hasPendingToolCalls = false;
+ yield from $this->callToolsAndYieldEventsWithPending($request->tools(), $mappedToolCalls, $this->state->messageId(), $toolResults, $hasPendingToolCalls);
+
+ if ($hasPendingToolCalls) {
+ // Client-executed or approval-required tool calls: end the stream
+ // with FinishReason::ToolCalls so the consumer resolves and resumes.
+ $this->state->markStepFinished();
+ yield from $this->yieldToolCallsFinishEvents($this->state);
+
+ return;
+ }
+
+ $this->state->markStepFinished();
+ yield new StepFinishEvent(
+ id: EventID::generate(),
+ timestamp: time()
+ );
+
+ $request->addMessage(new AssistantMessage($text, $mappedToolCalls));
+ $request->addMessage(new ToolResultMessage($toolResults));
+ $request->resetToolChoice();
+
+ $this->state
+ ->resetTextState()
+ ->withMessageId(EventID::generate('msg'));
+
+ $depth++;
+
+ if ($depth < $request->maxSteps()) {
+ $nextResponse = $this->sendRequest($request);
+ yield from $this->processStream($nextResponse, $request, $depth);
+ } else {
+ yield $this->emitStreamEndEvent();
+ }
+ }
+
+ /**
+ * Convert raw tool call data to ToolCall objects.
+ *
+ * @param array> $toolCalls
+ * @return array
+ */
+ protected function mapToolCalls(array $toolCalls): array
+ {
+ return collect($toolCalls)
+ ->map(function ($toolCall): ToolCall {
+ $arguments = data_get($toolCall, 'function.arguments', '');
+
+ if (is_string($arguments) && $arguments !== '') {
+ try {
+ $parsedArguments = json_decode($arguments, true, flags: JSON_THROW_ON_ERROR);
+ $arguments = $parsedArguments;
+ } catch (Throwable) {
+ $arguments = ['raw' => $arguments];
+ }
+ }
+
+ return new ToolCall(
+ data_get($toolCall, 'id'),
+ data_get($toolCall, 'function.name'),
+ $arguments,
+ );
+ })
+ ->all();
+ }
+
+ protected function sendRequest(Request $request): Response
+ {
+ /** @var Response $response */
+ $response = $this
+ ->client
+ ->withOptions(['stream' => true])
+ ->post(
+ 'chat/completions',
+ array_merge([
+ 'stream' => true,
+ 'model' => $request->model(),
+ 'messages' => (new MessageMap($request->messages(), $request->systemPrompts()))(),
+ 'max_tokens' => $request->maxTokens(),
+ ], $this->buildRequestOptions($request, [
+ 'stream_options' => ['include_usage' => true],
+ ]))
+ );
+
+ return $response;
+ }
+
+ protected function readLine(StreamInterface $stream): string
+ {
+ $buffer = '';
+
+ while (! $stream->eof()) {
+ $byte = $stream->read(1);
+
+ if ($byte === '') {
+ return $buffer;
+ }
+
+ $buffer .= $byte;
+
+ if ($byte === "\n") {
+ break;
+ }
+ }
+
+ return $buffer;
+ }
+
+ /**
+ * @param array $data
+ */
+ protected function extractUsage(array $data): ?Usage
+ {
+ $usage = data_get($data, 'usage');
+
+ if (! $usage) {
+ return null;
+ }
+
+ return new Usage(
+ promptTokens: max(0, (int) data_get($usage, 'prompt_tokens', 0) - (int) data_get($usage, 'prompt_tokens_details.cached_tokens', 0)),
+ completionTokens: (int) data_get($usage, 'completion_tokens', 0),
+ cacheReadInputTokens: (int) data_get($usage, 'prompt_tokens_details.cached_tokens', 0) ?: null,
+ thoughtTokens: (int) data_get($usage, 'completion_tokens_details.reasoning_tokens', 0)
+ );
+ }
+}
diff --git a/src/Providers/Requesty/Handlers/Structured.php b/src/Providers/Requesty/Handlers/Structured.php
new file mode 100644
index 000000000..5bd07d3a9
--- /dev/null
+++ b/src/Providers/Requesty/Handlers/Structured.php
@@ -0,0 +1,179 @@
+responseBuilder = new ResponseBuilder;
+ }
+
+ public function handle(Request $request): StructuredResponse
+ {
+ $this->resolveToolApprovals($request);
+
+ $data = $this->sendRequest($request);
+
+ $this->validateResponse($data);
+
+ return match ($this->mapFinishReason($data)) {
+ FinishReason::ToolCalls => $this->handleToolCalls($data, $request),
+ // Unknown finish reasons resolve gracefully (prism-php/prism#996).
+ default => $this->handleStop($data, $request),
+ };
+ }
+
+ /**
+ * @see https://docs.requesty.ai
+ *
+ * @return array
+ */
+ protected function sendRequest(Request $request): array
+ {
+ /** @var Response $response */
+ $response = $this->client->post(
+ 'chat/completions',
+ array_merge([
+ 'model' => $request->model(),
+ 'messages' => (new MessageMap($request->messages(), $request->systemPrompts()))(),
+ 'max_tokens' => $request->maxTokens(),
+ 'structured_outputs' => true,
+ ], $this->buildRequestOptions($request, [
+ 'response_format' => [
+ 'type' => 'json_schema',
+ 'json_schema' => [
+ 'name' => $request->schema()->name(),
+ 'strict' => true,
+ 'schema' => $request->schema()->toArray(),
+ ],
+ ],
+ ]))
+ );
+
+ return $response->json() ?? [];
+ }
+
+ /**
+ * @param array $data
+ */
+ protected function validateResponse(array $data): void
+ {
+ if ($data === []) {
+ throw PrismException::providerResponseError('Requesty Error: Empty response');
+ }
+ }
+
+ /**
+ * @param array $data
+ */
+ protected function handleToolCalls(array $data, Request $request): StructuredResponse
+ {
+ $toolCalls = ToolCallMap::map(data_get($data, 'choices.0.message.tool_calls', []));
+
+ $hasPendingToolCalls = false;
+ $approvalRequests = [];
+ $toolResults = $this->callToolsWithPending($request->tools(), $toolCalls, $hasPendingToolCalls, $approvalRequests);
+
+ $this->addStep($data, $request, $toolResults);
+
+ $request = $request->addMessage(new AssistantMessage(
+ data_get($data, 'choices.0.message.content') ?? '',
+ $toolCalls,
+ [],
+ toolApprovalRequests: $approvalRequests,
+ ));
+ $request = $request->addMessage(new ToolResultMessage($toolResults));
+ $request->resetToolChoice();
+
+ if (! $hasPendingToolCalls && $this->shouldContinue($request)) {
+ return $this->handle($request);
+ }
+
+ return $this->responseBuilder->toResponse();
+ }
+
+ /**
+ * @param array $data
+ */
+ protected function handleStop(array $data, Request $request): StructuredResponse
+ {
+ $this->addStep($data, $request);
+
+ try {
+ return $this->responseBuilder->toResponse();
+ } catch (PrismStructuredDecodingException $e) {
+ $context = sprintf(
+ "\nModel: %s\nFinish reason: %s\nRaw choices: %s",
+ data_get($data, 'model', 'unknown'),
+ data_get($data, 'choices.0.finish_reason', 'unknown'),
+ json_encode(data_get($data, 'choices'), JSON_PRETTY_PRINT)
+ );
+
+ throw new PrismStructuredDecodingException($e->getMessage().$context);
+ }
+ }
+
+ protected function shouldContinue(Request $request): bool
+ {
+ return $this->responseBuilder->steps->count() < $request->maxSteps();
+ }
+
+ /**
+ * @param array $data
+ * @param array $toolResults
+ */
+ protected function addStep(array $data, Request $request, array $toolResults = []): void
+ {
+ $this->responseBuilder->addStep(new Step(
+ text: data_get($data, 'choices.0.message.content') ?? '',
+ finishReason: $this->mapFinishReason($data),
+ usage: new Usage(
+ promptTokens: max(0, (int) data_get($data, 'usage.prompt_tokens', 0) - (int) data_get($data, 'usage.prompt_tokens_details.cached_tokens', 0)),
+ completionTokens: (int) data_get($data, 'usage.completion_tokens', 0),
+ cacheReadInputTokens: (int) data_get($data, 'usage.prompt_tokens_details.cached_tokens', 0) ?: null,
+ ),
+ meta: new Meta(
+ id: data_get($data, 'id', ''),
+ model: data_get($data, 'model', $request->model()),
+ ),
+ messages: $request->messages(),
+ systemPrompts: $request->systemPrompts(),
+ additionalContent: [],
+ toolCalls: ToolCallMap::map(data_get($data, 'choices.0.message.tool_calls', [])),
+ providerToolCalls: [],
+ toolResults: $toolResults,
+ raw: $data,
+ ));
+ }
+}
diff --git a/src/Providers/Requesty/Handlers/Text.php b/src/Providers/Requesty/Handlers/Text.php
new file mode 100644
index 000000000..68d4a2010
--- /dev/null
+++ b/src/Providers/Requesty/Handlers/Text.php
@@ -0,0 +1,144 @@
+responseBuilder = new ResponseBuilder;
+ }
+
+ public function handle(Request $request): TextResponse
+ {
+ $this->resolveToolApprovals($request);
+
+ $data = $this->sendRequest($request);
+
+ $this->validateResponse($data);
+
+ return match ($this->mapFinishReason($data)) {
+ FinishReason::ToolCalls => $this->handleToolCalls($data, $request),
+ // Unknown finish reasons resolve gracefully (prism-php/prism#996).
+ default => $this->handleStop($data, $request),
+ };
+ }
+
+ /**
+ * @param array $data
+ */
+ protected function handleToolCalls(array $data, Request $request): TextResponse
+ {
+ $toolCalls = ToolCallMap::map(data_get($data, 'choices.0.message.tool_calls', []));
+
+ $hasPendingToolCalls = false;
+ $approvalRequests = [];
+ $toolResults = $this->callToolsWithPending($request->tools(), $toolCalls, $hasPendingToolCalls, $approvalRequests);
+
+ $this->addStep($data, $request, $toolResults);
+
+ $request = $request->addMessage(new AssistantMessage(
+ data_get($data, 'choices.0.message.content') ?? '',
+ $toolCalls,
+ [],
+ toolApprovalRequests: $approvalRequests,
+ ));
+ $request = $request->addMessage(new ToolResultMessage($toolResults));
+ $request->resetToolChoice();
+
+ if (! $hasPendingToolCalls && $this->shouldContinue($request)) {
+ return $this->handle($request);
+ }
+
+ return $this->responseBuilder->toResponse();
+ }
+
+ /**
+ * @param array $data
+ */
+ protected function handleStop(array $data, Request $request): TextResponse
+ {
+ $this->addStep($data, $request);
+
+ return $this->responseBuilder->toResponse();
+ }
+
+ protected function shouldContinue(Request $request): bool
+ {
+ return $this->responseBuilder->steps->count() < $request->maxSteps();
+ }
+
+ /**
+ * @return array
+ */
+ protected function sendRequest(Request $request): array
+ {
+ /** @var Response $response */
+ $response = $this->client->post(
+ 'chat/completions',
+ array_merge([
+ 'model' => $request->model(),
+ 'messages' => (new MessageMap($request->messages(), $request->systemPrompts()))(),
+ 'max_tokens' => $request->maxTokens(),
+ ], $this->buildRequestOptions($request))
+ );
+
+ return $response->json() ?? [];
+ }
+
+ /**
+ * @param array $data
+ * @param array $toolResults
+ */
+ protected function addStep(array $data, Request $request, array $toolResults = []): void
+ {
+ $this->responseBuilder->addStep(new Step(
+ text: data_get($data, 'choices.0.message.content') ?? '',
+ finishReason: $this->mapFinishReason($data),
+ toolCalls: ToolCallMap::map(data_get($data, 'choices.0.message.tool_calls', [])),
+ toolResults: $toolResults,
+ providerToolCalls: [],
+ usage: new Usage(
+ promptTokens: max(0, (int) data_get($data, 'usage.prompt_tokens', 0) - (int) data_get($data, 'usage.prompt_tokens_details.cached_tokens', 0)),
+ completionTokens: (int) data_get($data, 'usage.completion_tokens', 0),
+ cacheReadInputTokens: (int) data_get($data, 'usage.prompt_tokens_details.cached_tokens', 0) ?: null,
+ ),
+ meta: new Meta(
+ id: data_get($data, 'id', ''),
+ model: data_get($data, 'model', $request->model()),
+ ),
+ messages: $request->messages(),
+ systemPrompts: $request->systemPrompts(),
+ additionalContent: [],
+ raw: $data,
+ ));
+ }
+}
diff --git a/src/Providers/Requesty/Maps/AudioMapper.php b/src/Providers/Requesty/Maps/AudioMapper.php
new file mode 100644
index 000000000..d37d7239c
--- /dev/null
+++ b/src/Providers/Requesty/Maps/AudioMapper.php
@@ -0,0 +1,51 @@
+
+ */
+ public function toPayload(): array
+ {
+ return [
+ 'type' => 'input_audio',
+ 'input_audio' => [
+ 'data' => $this->media->base64(),
+ 'format' => $this->determineFormat(),
+ ],
+ ];
+ }
+
+ protected function provider(): string|Provider
+ {
+ return Provider::Requesty;
+ }
+
+ protected function validateMedia(): bool
+ {
+ return $this->media->hasRawContent();
+ }
+
+ protected function determineFormat(): string
+ {
+ $mimeType = $this->media->mimeType();
+
+ return match ($mimeType) {
+ 'audio/wav', 'audio/x-wav', 'audio/wave' => 'wav',
+ 'audio/mpeg', 'audio/mp3' => 'mp3',
+ 'audio/ogg' => 'ogg',
+ 'audio/webm' => 'webm',
+ default => 'wav',
+ };
+ }
+}
diff --git a/src/Providers/Requesty/Maps/DocumentMapper.php b/src/Providers/Requesty/Maps/DocumentMapper.php
new file mode 100644
index 000000000..2335a7875
--- /dev/null
+++ b/src/Providers/Requesty/Maps/DocumentMapper.php
@@ -0,0 +1,55 @@
+
+ */
+ public function toPayload(): array
+ {
+ return [
+ 'type' => 'file',
+ 'file' => [
+ 'filename' => $this->media->documentTitle() ?? 'document',
+ 'file_data' => $this->media->isUrl()
+ ? $this->media->url()
+ : sprintf('data:%s;base64,%s', $this->media->mimeType(), $this->media->base64()),
+ ],
+ ];
+ }
+
+ protected function provider(): string|Provider
+ {
+ return Provider::Requesty;
+ }
+
+ protected function validateMedia(): bool
+ {
+ // Chunks are Anthropic-specific, not supported via Requesty
+ if ($this->media->isChunks()) {
+ return false;
+ }
+
+ // File IDs are not supported by Requesty
+ if ($this->media->isFileId()) {
+ return false;
+ }
+
+ if ($this->media->isUrl()) {
+ return true;
+ }
+
+ return $this->media->hasRawContent();
+ }
+}
diff --git a/src/Providers/Requesty/Maps/FinishReasonMap.php b/src/Providers/Requesty/Maps/FinishReasonMap.php
new file mode 100644
index 000000000..56755149d
--- /dev/null
+++ b/src/Providers/Requesty/Maps/FinishReasonMap.php
@@ -0,0 +1,21 @@
+ FinishReason::Stop,
+ 'tool_calls' => FinishReason::ToolCalls,
+ 'length' => FinishReason::Length,
+ 'content_filter' => FinishReason::ContentFilter,
+ default => FinishReason::Unknown,
+ };
+ }
+}
diff --git a/src/Providers/Requesty/Maps/ImageMapper.php b/src/Providers/Requesty/Maps/ImageMapper.php
new file mode 100644
index 000000000..5b08324c2
--- /dev/null
+++ b/src/Providers/Requesty/Maps/ImageMapper.php
@@ -0,0 +1,44 @@
+
+ */
+ public function toPayload(): array
+ {
+ return [
+ 'type' => 'image_url',
+ 'image_url' => [
+ 'url' => $this->media->isUrl()
+ ? $this->media->url()
+ : sprintf('data:%s;base64,%s', $this->media->mimeType(), $this->media->base64()),
+ ],
+ ];
+ }
+
+ protected function provider(): string|Provider
+ {
+ return Provider::Requesty;
+ }
+
+ protected function validateMedia(): bool
+ {
+ if ($this->media->isUrl()) {
+ return true;
+ }
+
+ return $this->media->hasRawContent();
+ }
+}
diff --git a/src/Providers/Requesty/Maps/MessageMap.php b/src/Providers/Requesty/Maps/MessageMap.php
new file mode 100644
index 000000000..6ff2b1d5c
--- /dev/null
+++ b/src/Providers/Requesty/Maps/MessageMap.php
@@ -0,0 +1,187 @@
+ */
+ protected array $mappedMessages = [];
+
+ /**
+ * @param array $messages
+ * @param SystemMessage[] $systemPrompts
+ */
+ public function __construct(
+ protected array $messages,
+ protected array $systemPrompts
+ ) {
+ $this->messages = array_merge(
+ $this->systemPrompts,
+ $this->messages
+ );
+ }
+
+ /**
+ * @return array
+ */
+ public function __invoke(): array
+ {
+ array_map(
+ $this->mapMessage(...),
+ $this->messages
+ );
+
+ return $this->mappedMessages;
+ }
+
+ protected function mapMessage(Message $message): void
+ {
+ match ($message::class) {
+ UserMessage::class => $this->mapUserMessage($message),
+ AssistantMessage::class => $this->mapAssistantMessage($message),
+ ToolResultMessage::class => $this->mapToolResultMessage($message),
+ SystemMessage::class => $this->mapSystemMessage($message),
+ default => throw new Exception('Could not map message type '.$message::class),
+ };
+ }
+
+ protected function mapSystemMessage(SystemMessage $message): void
+ {
+ $cacheType = $message->providerOptions('cacheType');
+
+ // Requesty supports cache_control in content array format (same as Anthropic)
+ if ($cacheType) {
+ $this->mappedMessages[] = [
+ 'role' => 'system',
+ 'content' => [
+ [
+ 'type' => 'text',
+ 'text' => $message->content,
+ 'cache_control' => ['type' => $cacheType instanceof BackedEnum ? $cacheType->value : $cacheType],
+ ],
+ ],
+ ];
+ } else {
+ $this->mappedMessages[] = [
+ 'role' => 'system',
+ 'content' => $message->content,
+ ];
+ }
+ }
+
+ protected function mapToolResultMessage(ToolResultMessage $message): void
+ {
+ $cacheType = $message->providerOptions('cacheType');
+ $cacheControl = $cacheType ? ['type' => $cacheType instanceof BackedEnum ? $cacheType->value : $cacheType] : null;
+
+ $toolResults = $message->toolResults;
+ $totalResults = count($toolResults);
+
+ // Requesty supports cache_control in content array format
+ if ($cacheControl) {
+ $content = array_map(function (ToolResult $toolResult, int $index) use ($cacheControl, $totalResults): array {
+ // Only add cache_control to the last tool result
+ $isLastResult = $index === $totalResults - 1;
+
+ return array_filter([
+ 'type' => 'tool_result',
+ 'tool_call_id' => $toolResult->toolCallId,
+ 'content' => $toolResult->result,
+ 'cache_control' => $isLastResult ? $cacheControl : null,
+ ]);
+ }, $toolResults, array_keys($toolResults));
+
+ $this->mappedMessages[] = [
+ 'role' => 'tool',
+ 'content' => $content,
+ ];
+ } else {
+ // Legacy format without caching
+ foreach ($toolResults as $toolResult) {
+ $this->mappedMessages[] = [
+ 'role' => 'tool',
+ 'tool_call_id' => $toolResult->toolCallId,
+ 'content' => $toolResult->result,
+ ];
+ }
+ }
+ }
+
+ protected function mapUserMessage(UserMessage $message): void
+ {
+ $cacheType = $message->providerOptions('cacheType');
+ $cacheControl = $cacheType ? ['type' => $cacheType instanceof BackedEnum ? $cacheType->value : $cacheType] : null;
+
+ $imageParts = array_map(fn (Image $image): array => (new ImageMapper($image))->toPayload(), $message->images());
+ // NOTE: mirrored from Gemini's multimodal mapper so we stay consistent across providers.
+ $audioParts = array_map(fn (Audio $audio): array => (new AudioMapper($audio))->toPayload(), $message->audios());
+ $videoParts = array_map(fn (Video $video): array => (new VideoMapper($video))->toPayload(), $message->videos());
+ $documentParts = array_map(fn (Document $document): array => (new DocumentMapper($document))->toPayload(), $message->documents());
+
+ $this->mappedMessages[] = [
+ 'role' => 'user',
+ 'content' => [
+ array_filter([
+ 'type' => 'text',
+ 'text' => $message->text(),
+ 'cache_control' => $cacheControl,
+ ]),
+ ...$imageParts,
+ ...$audioParts,
+ ...$videoParts,
+ ...$documentParts,
+ ],
+ ];
+ }
+
+ protected function mapAssistantMessage(AssistantMessage $message): void
+ {
+ $cacheType = $message->providerOptions('cacheType');
+
+ $toolCalls = array_map(fn (ToolCall $toolCall): array => [
+ 'id' => $toolCall->id,
+ 'type' => 'function',
+ 'function' => [
+ 'name' => $toolCall->name,
+ 'arguments' => json_encode($toolCall->arguments() ?: (object) []),
+ ],
+ ], $message->toolCalls);
+
+ // Requesty supports cache_control on assistant messages
+ if ($cacheType && $message->content !== '' && $message->content !== '0') {
+ $this->mappedMessages[] = array_filter([
+ 'role' => 'assistant',
+ 'content' => [
+ [
+ 'type' => 'text',
+ 'text' => $message->content,
+ 'cache_control' => ['type' => $cacheType instanceof BackedEnum ? $cacheType->value : $cacheType],
+ ],
+ ],
+ 'tool_calls' => $toolCalls,
+ ]);
+ } else {
+ $this->mappedMessages[] = array_filter([
+ 'role' => 'assistant',
+ 'content' => $message->content,
+ 'tool_calls' => $toolCalls,
+ ]);
+ }
+ }
+}
diff --git a/src/Providers/Requesty/Maps/ToolCallMap.php b/src/Providers/Requesty/Maps/ToolCallMap.php
new file mode 100644
index 000000000..1b1f7a725
--- /dev/null
+++ b/src/Providers/Requesty/Maps/ToolCallMap.php
@@ -0,0 +1,23 @@
+ $toolCalls
+ * @return array
+ */
+ public static function map(array $toolCalls): array
+ {
+ return array_map(fn (array $toolCall): ToolCall => new ToolCall(
+ id: $toolCall['id'],
+ name: $toolCall['function']['name'],
+ arguments: json_decode((string) $toolCall['function']['arguments'], true),
+ ), $toolCalls);
+ }
+}
diff --git a/src/Providers/Requesty/Maps/ToolChoiceMap.php b/src/Providers/Requesty/Maps/ToolChoiceMap.php
new file mode 100644
index 000000000..2e7c5dd57
--- /dev/null
+++ b/src/Providers/Requesty/Maps/ToolChoiceMap.php
@@ -0,0 +1,32 @@
+|string|null
+ */
+ public static function map(string|ToolChoice|null $toolChoice): string|array|null
+ {
+ if (is_string($toolChoice)) {
+ return [
+ 'type' => 'function',
+ 'function' => [
+ 'name' => $toolChoice,
+ ],
+ ];
+ }
+
+ return match ($toolChoice) {
+ ToolChoice::Auto => 'auto',
+ null => $toolChoice,
+ default => throw new InvalidArgumentException('Invalid tool choice')
+ };
+ }
+}
diff --git a/src/Providers/Requesty/Maps/ToolMap.php b/src/Providers/Requesty/Maps/ToolMap.php
new file mode 100644
index 000000000..0181f021b
--- /dev/null
+++ b/src/Providers/Requesty/Maps/ToolMap.php
@@ -0,0 +1,37 @@
+ $tools
+ * @return array
+ */
+ public static function map(array $tools): array
+ {
+ return array_map(fn (Tool $tool): array => array_filter([
+ 'type' => 'function',
+ 'function' => [
+ 'name' => $tool->name(),
+ 'description' => $tool->description(),
+ ...$tool->hasParameters() ? [
+ 'parameters' => (function () use ($tool): array {
+ $properties = $tool->parametersAsArray();
+
+ return [
+ 'type' => 'object',
+ 'properties' => $properties === [] ? new \stdClass : $properties,
+ 'required' => $tool->requiredParameters(),
+ ];
+ })(),
+ ] : [],
+ ],
+ 'strict' => $tool->providerOptions('strict'),
+ ]), $tools);
+ }
+}
diff --git a/src/Providers/Requesty/Maps/VideoMapper.php b/src/Providers/Requesty/Maps/VideoMapper.php
new file mode 100644
index 000000000..2c34bcbc7
--- /dev/null
+++ b/src/Providers/Requesty/Maps/VideoMapper.php
@@ -0,0 +1,54 @@
+
+ */
+ public function toPayload(): array
+ {
+ if ($this->media->isUrl()) {
+ return [
+ 'type' => 'video_url',
+ 'video_url' => [
+ 'url' => $this->media->url(),
+ ],
+ ];
+ }
+
+ $dataUrl = "data:{$this->media->mimeType()};base64,".$this->media->base64();
+
+ return [
+ 'type' => 'video_url',
+ 'video_url' => [
+ 'url' => $dataUrl,
+ ],
+ ];
+ }
+
+ protected function provider(): string|Provider
+ {
+ return Provider::Requesty;
+ }
+
+ protected function validateMedia(): bool
+ {
+ if ($this->media->hasRawContent()) {
+ return true;
+ }
+
+ return $this->media->isUrl();
+ }
+}
diff --git a/src/Providers/Requesty/Requesty.php b/src/Providers/Requesty/Requesty.php
new file mode 100644
index 000000000..62f8d96ee
--- /dev/null
+++ b/src/Providers/Requesty/Requesty.php
@@ -0,0 +1,150 @@
+client(
+ $request->clientOptions(),
+ $request->clientRetry()
+ ));
+
+ return $handler->handle($request);
+ }
+
+ #[\Override]
+ public function structured(StructuredRequest $request): StructuredResponse
+ {
+ $handler = new Structured($this->client(
+ $request->clientOptions(),
+ $request->clientRetry()
+ ));
+
+ return $handler->handle($request);
+ }
+
+ #[\Override]
+ public function embeddings(EmbeddingRequest $request): EmbeddingResponse
+ {
+ $handler = new Embeddings($this->client(
+ $request->clientOptions(),
+ $request->clientRetry()
+ ));
+
+ return $handler->handle($request);
+ }
+
+ #[\Override]
+ public function stream(TextRequest $request): Generator
+ {
+ $handler = new Stream($this->client(
+ $request->clientOptions(),
+ $request->clientRetry()
+ ));
+
+ return $handler->handle($request);
+ }
+
+ public function handleRequestException(string $model, RequestException $e): never
+ {
+ $statusCode = $e->response->getStatusCode();
+ $responseData = $e->response->json();
+
+ $rawMetadata = data_get($responseData, 'error.metadata.raw');
+
+ try {
+ $jsonMetadata = $rawMetadata ? json_decode((string) $rawMetadata, true, 512, JSON_THROW_ON_ERROR) : [];
+ } catch (JsonException) {
+ $jsonMetadata = [];
+ }
+
+ $errorMessage = data_get($jsonMetadata, 'error.message');
+
+ if (! $errorMessage) {
+ $errorMessage = data_get($responseData, 'error.message', 'Unknown error');
+ }
+
+ match ($statusCode) {
+ 400 => throw PrismException::providerResponseError(
+ sprintf('Requesty Bad Request: %s', $errorMessage)
+ ),
+ 401 => throw PrismException::providerResponseError(
+ sprintf('Requesty Authentication Error: %s', $errorMessage)
+ ),
+ 402 => throw PrismException::providerResponseError(
+ sprintf('Requesty Insufficient Credits: %s', $errorMessage)
+ ),
+ 403 => throw PrismException::providerResponseError(
+ sprintf('Requesty Moderation Error: %s', $errorMessage)
+ ),
+ 408 => throw PrismException::providerResponseError(
+ sprintf('Requesty Request Timeout: %s', $errorMessage)
+ ),
+ 413 => throw PrismRequestTooLargeException::make(ProviderName::Requesty),
+ 429 => throw PrismRateLimitedException::make(
+ rateLimits: [],
+ retryAfter: $e->response->hasHeader('retry-after')
+ ? (int) $e->response->header('retry-after')
+ : null
+ ),
+ 502 => throw PrismException::providerResponseError(
+ sprintf('Requesty Model Error: %s', $errorMessage)
+ ),
+ 503 => throw PrismProviderOverloadedException::make(ProviderName::Requesty),
+ default => throw PrismException::providerRequestError($model, $e),
+ };
+ }
+
+ /**
+ * @param array $options
+ * @param array $retry
+ */
+ protected function client(array $options = [], array $retry = [], ?string $baseUrl = null): PendingRequest
+ {
+ return $this->baseClient()
+ ->withHeaders(array_filter([
+ 'HTTP-Referer' => $this->httpReferer,
+ 'X-Title' => $this->xTitle,
+ ]))
+ ->when($this->apiKey, fn ($client) => $client->withToken($this->apiKey))
+ ->withOptions($options)
+ ->when($retry !== [], fn ($client) => $client->retry(...$retry))
+ ->baseUrl($baseUrl ?? $this->url);
+ }
+}
diff --git a/src/Providers/Vertex/Concerns/ValidatesResponse.php b/src/Providers/Vertex/Concerns/ValidatesResponse.php
new file mode 100644
index 000000000..ff015c4db
--- /dev/null
+++ b/src/Providers/Vertex/Concerns/ValidatesResponse.php
@@ -0,0 +1,26 @@
+json();
+
+ if (! $data || data_get($data, 'error')) {
+ throw PrismException::providerResponseError(vsprintf(
+ 'Vertex Error: [%s] %s',
+ [
+ data_get($data, 'error.code', 'unknown'),
+ data_get($data, 'error.message', 'unknown'),
+ ]
+ ));
+ }
+ }
+}
diff --git a/src/Providers/Vertex/Handlers/Embeddings.php b/src/Providers/Vertex/Handlers/Embeddings.php
new file mode 100644
index 000000000..2912b72fa
--- /dev/null
+++ b/src/Providers/Vertex/Handlers/Embeddings.php
@@ -0,0 +1,74 @@
+inputs()) > 1) {
+ throw new PrismException('Vertex Error: Prism currently only supports one input at a time with Vertex AI.');
+ }
+
+ $response = $this->sendRequest($request);
+
+ $data = $response->json();
+
+ if (! isset($data['predictions'][0]['embeddings']['values'])) {
+ throw PrismException::providerResponseError(
+ 'Vertex Error: Invalid response format or missing embedding data'
+ );
+ }
+
+ return new EmbeddingsResponse(
+ embeddings: [Embedding::fromArray(data_get($data, 'predictions.0.embeddings.values', []))],
+ usage: new EmbeddingsUsage(
+ data_get($data, 'metadata.billableCharacterCount', 0)
+ ),
+ meta: new Meta(
+ id: '',
+ model: $this->model,
+ ),
+ raw: $data,
+ );
+ }
+
+ protected function sendRequest(Request $request): Response
+ {
+ $providerOptions = $request->providerOptions();
+
+ /** @var Response $response */
+ $response = $this->client->post(
+ "{$this->model}:predict",
+ Arr::whereNotNull([
+ 'instances' => [
+ [
+ 'content' => $request->inputs()[0],
+ ],
+ ],
+ 'parameters' => Arr::whereNotNull([
+ 'outputDimensionality' => $providerOptions['outputDimensionality'] ?? null,
+ ]) ?: null,
+ ])
+ );
+
+ return $response;
+ }
+}
diff --git a/src/Providers/Vertex/Handlers/Stream.php b/src/Providers/Vertex/Handlers/Stream.php
new file mode 100644
index 000000000..cfd71a620
--- /dev/null
+++ b/src/Providers/Vertex/Handlers/Stream.php
@@ -0,0 +1,529 @@
+state = new StreamState;
+ }
+
+ /**
+ * @return Generator
+ */
+ public function handle(Request $request): Generator
+ {
+ yield from $this->resolveToolApprovalsAndYieldEvents($request, EventID::generate());
+
+ $this->state->reset();
+ $this->currentThoughtSignature = null;
+ $response = $this->sendRequest($request);
+
+ yield from $this->processStream($response, $request);
+ }
+
+ /**
+ * @return Generator
+ */
+ protected function processStream(Response $response, Request $request, int $depth = 0): Generator
+ {
+ if ($depth >= $request->maxSteps()) {
+ throw new PrismException('Maximum tool call chain depth exceeded');
+ }
+
+ while (! $response->getBody()->eof()) {
+ $data = $this->parseNextDataLine($response->getBody());
+
+ if ($data === null) {
+ continue;
+ }
+
+ if ($this->state->shouldEmitStreamStart()) {
+ $this->state->withMessageId(EventID::generate());
+
+ yield new StreamStartEvent(
+ id: EventID::generate(),
+ timestamp: time(),
+ model: data_get($data, 'modelVersion', 'unknown'),
+ provider: 'vertex'
+ );
+ $this->state->markStreamStarted();
+ }
+
+ if ($this->state->shouldEmitStepStart()) {
+ $this->state->markStepStarted();
+
+ yield new StepStartEvent(
+ id: EventID::generate(),
+ timestamp: time()
+ );
+ }
+
+ $this->state->withUsage($this->extractUsage($data));
+
+ if ($this->hasToolCalls($data)) {
+ $existingIndices = array_keys($this->state->toolCalls());
+
+ $toolCalls = $this->extractToolCalls($data, $this->state->toolCalls());
+ foreach ($toolCalls as $index => $toolCall) {
+ $this->state->addToolCall($index, $toolCall);
+ }
+
+ foreach ($this->state->toolCalls() as $index => $toolCallData) {
+ if (! in_array($index, $existingIndices, true)) {
+ yield new ToolCallEvent(
+ id: EventID::generate(),
+ timestamp: time(),
+ toolCall: $this->mapToolCall($toolCallData),
+ messageId: $this->state->messageId()
+ );
+ }
+ }
+
+ if ($this->mapFinishReason($data) === FinishReason::ToolCalls) {
+ yield from $this->handleToolCalls($request, $depth, $data);
+
+ return;
+ }
+
+ continue;
+ }
+
+ $parts = data_get($data, 'candidates.0.content.parts', []);
+
+ foreach ($parts as $part) {
+ if (isset($part['thought']) && $part['thought'] === true) {
+ $thinkingContent = $part['text'] ?? '';
+
+ if ($thinkingContent !== '') {
+ if ($this->state->reasoningId() === '') {
+ $this->state->withReasoningId(EventID::generate());
+
+ yield new ThinkingStartEvent(
+ id: EventID::generate(),
+ timestamp: time(),
+ reasoningId: $this->state->reasoningId()
+ );
+ }
+
+ $this->state->appendThinking($thinkingContent);
+
+ yield new ThinkingEvent(
+ id: EventID::generate(),
+ timestamp: time(),
+ delta: $thinkingContent,
+ reasoningId: $this->state->reasoningId()
+ );
+ }
+ } elseif (isset($part['text']) && (! isset($part['thought']) || $part['thought'] === false)) {
+ $content = $part['text'];
+
+ if ($content !== '') {
+ if ($this->state->shouldEmitTextStart()) {
+ yield new TextStartEvent(
+ id: EventID::generate(),
+ timestamp: time(),
+ messageId: $this->state->messageId()
+ );
+ $this->state->markTextStarted();
+ }
+
+ $this->state->appendText($content);
+
+ yield new TextDeltaEvent(
+ id: EventID::generate(),
+ timestamp: time(),
+ delta: $content,
+ messageId: $this->state->messageId()
+ );
+ }
+ }
+ }
+
+ $finishReason = $this->mapFinishReason($data);
+
+ if ($finishReason !== FinishReason::Unknown) {
+ if ($this->state->reasoningId() !== '') {
+ yield new ThinkingCompleteEvent(
+ id: EventID::generate(),
+ timestamp: time(),
+ reasoningId: $this->state->reasoningId()
+ );
+ }
+
+ if ($this->state->hasTextStarted()) {
+ $this->state->markTextCompleted();
+
+ yield new TextCompleteEvent(
+ id: EventID::generate(),
+ timestamp: time(),
+ messageId: $this->state->messageId()
+ );
+ }
+
+ $this->state->withFinishReason($finishReason);
+ $this->state->withMetadata([
+ 'grounding_metadata' => $this->extractGroundingMetadata($data),
+ ]);
+ }
+ }
+
+ if ($this->state->hasToolCalls()) {
+ yield from $this->handleToolCalls($request, $depth);
+
+ return;
+ }
+
+ $this->state->markStepFinished();
+ yield new StepFinishEvent(
+ id: EventID::generate(),
+ timestamp: time()
+ );
+
+ yield $this->emitStreamEndEvent();
+ }
+
+ protected function emitStreamEndEvent(): StreamEndEvent
+ {
+ return new StreamEndEvent(
+ id: EventID::generate(),
+ timestamp: time(),
+ finishReason: $this->state->finishReason() ?? FinishReason::Stop,
+ usage: $this->state->usage(),
+ additionalContent: Arr::whereNotNull([
+ 'grounding_metadata' => $this->state->metadata()['grounding_metadata'] ?? null,
+ 'thoughtSummaries' => $this->state->thinkingSummaries() === [] ? null : $this->state->thinkingSummaries(),
+ ])
+ );
+ }
+
+ /**
+ * @return array|null
+ */
+ protected function parseNextDataLine(StreamInterface $stream): ?array
+ {
+ $line = $this->readLine($stream);
+
+ if (! str_starts_with($line, 'data:')) {
+ return null;
+ }
+
+ $line = trim(substr($line, strlen('data: ')));
+
+ if ($line === '' || $line === '[DONE]') {
+ return null;
+ }
+
+ try {
+ return json_decode($line, true, flags: JSON_THROW_ON_ERROR);
+ } catch (Throwable $e) {
+ throw new PrismStreamDecodeException('Vertex', $e);
+ }
+ }
+
+ /**
+ * @param array $data
+ * @param array> $toolCalls
+ * @return array>
+ */
+ protected function extractToolCalls(array $data, array $toolCalls): array
+ {
+ $parts = data_get($data, 'candidates.0.content.parts', []);
+ $nextIndex = $toolCalls === [] ? 0 : max(array_keys($toolCalls)) + 1;
+
+ foreach ($parts as $part) {
+ if (isset($part['functionCall'])) {
+ if (isset($part['thoughtSignature'])) {
+ $this->currentThoughtSignature = $part['thoughtSignature'];
+ }
+
+ $toolCalls[$nextIndex] = [
+ 'id' => EventID::generate('vx'),
+ 'name' => data_get($part, 'functionCall.name'),
+ 'arguments' => data_get($part, 'functionCall.args', []),
+ 'reasoningId' => $part['thoughtSignature'] ?? $this->currentThoughtSignature,
+ ];
+ $nextIndex++;
+ }
+ }
+
+ return $toolCalls;
+ }
+
+ /**
+ * @param array $data
+ * @return Generator
+ */
+ protected function handleToolCalls(
+ Request $request,
+ int $depth,
+ array $data = []
+ ): Generator {
+ $mappedToolCalls = [];
+
+ foreach ($this->state->toolCalls() as $toolCallData) {
+ $mappedToolCalls[] = $this->mapToolCall($toolCallData);
+ }
+
+ $toolResults = [];
+ $hasPendingToolCalls = false;
+ yield from $this->callToolsAndYieldEventsWithPending($request->tools(), $mappedToolCalls, $this->state->messageId(), $toolResults, $hasPendingToolCalls);
+
+ if ($hasPendingToolCalls) {
+ // Client-executed or approval-required tool calls: end the stream
+ // with FinishReason::ToolCalls so the consumer resolves and resumes.
+ $this->state->markStepFinished();
+ yield from $this->yieldToolCallsFinishEvents($this->state);
+
+ return;
+ }
+
+ if ($toolResults !== []) {
+ $this->state->markStepFinished();
+ yield new StepFinishEvent(
+ id: EventID::generate(),
+ timestamp: time()
+ );
+
+ $request->addMessage(new AssistantMessage($this->state->currentText(), $mappedToolCalls));
+ $request->addMessage(new ToolResultMessage($toolResults));
+ $request->resetToolChoice();
+
+ $depth++;
+ if ($depth < $request->maxSteps()) {
+ $previousUsage = $this->state->usage();
+ $this->state->reset();
+ $this->currentThoughtSignature = null;
+ $nextResponse = $this->sendRequest($request);
+ yield from $this->processStream($nextResponse, $request, $depth);
+
+ if ($previousUsage instanceof Usage && $this->state->usage() instanceof Usage) {
+ $this->state->withUsage(new Usage(
+ promptTokens: $previousUsage->promptTokens + $this->state->usage()->promptTokens,
+ completionTokens: $previousUsage->completionTokens + $this->state->usage()->completionTokens,
+ cacheWriteInputTokens: ($previousUsage->cacheWriteInputTokens ?? 0) + ($this->state->usage()->cacheWriteInputTokens ?? 0),
+ cacheReadInputTokens: ($previousUsage->cacheReadInputTokens ?? 0) + ($this->state->usage()->cacheReadInputTokens ?? 0),
+ thoughtTokens: ($previousUsage->thoughtTokens ?? 0) + ($this->state->usage()->thoughtTokens ?? 0)
+ ));
+ }
+ } else {
+ yield $this->emitStreamEndEvent();
+ }
+ }
+ }
+
+ /**
+ * @param array $toolCallData
+ */
+ protected function mapToolCall(array $toolCallData): ToolCall
+ {
+ $arguments = data_get($toolCallData, 'arguments', []);
+
+ if (is_string($arguments) && $arguments !== '') {
+ $decoded = json_decode($arguments, true);
+ $arguments = json_last_error() === JSON_ERROR_NONE ? $decoded : ['input' => $arguments];
+ }
+
+ return new ToolCall(
+ id: empty($toolCallData['id']) ? EventID::generate('vx') : $toolCallData['id'],
+ name: data_get($toolCallData, 'name', 'unknown'),
+ arguments: $arguments,
+ reasoningId: data_get($toolCallData, 'reasoningId')
+ );
+ }
+
+ /**
+ * @param array $data
+ */
+ protected function hasToolCalls(array $data): bool
+ {
+ $parts = data_get($data, 'candidates.0.content.parts', []);
+
+ foreach ($parts as $part) {
+ if (isset($part['functionCall'])) {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ /**
+ * @param array $data
+ */
+ protected function extractUsage(array $data): Usage
+ {
+ return new Usage(
+ promptTokens: max(0, (int) data_get($data, 'usageMetadata.promptTokenCount', 0) - (int) data_get($data, 'usageMetadata.cachedContentTokenCount', 0)),
+ completionTokens: data_get($data, 'usageMetadata.candidatesTokenCount', 0),
+ cacheReadInputTokens: data_get($data, 'usageMetadata.cachedContentTokenCount'),
+ thoughtTokens: data_get($data, 'usageMetadata.thoughtsTokenCount'),
+ );
+ }
+
+ /**
+ * @param array $data
+ */
+ protected function mapFinishReason(array $data): FinishReason
+ {
+ $finishReason = data_get($data, 'candidates.0.finishReason');
+
+ if (! $finishReason) {
+ return FinishReason::Unknown;
+ }
+
+ $isToolCall = $this->hasToolCalls($data);
+
+ return FinishReasonMap::map($finishReason, $isToolCall);
+ }
+
+ protected function sendRequest(Request $request): Response
+ {
+ $providerOptions = $request->providerOptions();
+
+ if ($request->tools() !== [] && $request->providerTools() !== []) {
+ throw new PrismException('Use of provider tools with custom tools is not currently supported by Vertex.');
+ }
+
+ if ($request->tools() !== [] && ($providerOptions['searchGrounding'] ?? false)) {
+ throw new PrismException('Use of search grounding with custom tools is not currently supported by Prism.');
+ }
+
+ $tools = [];
+
+ if ($request->providerTools() !== []) {
+ $tools = array_map(
+ fn ($providerTool): array => [
+ $providerTool->type => $providerTool->options !== [] ? $providerTool->options : (object) [],
+ ],
+ $request->providerTools()
+ );
+ } elseif ($providerOptions['searchGrounding'] ?? false) {
+ $tools = [
+ [
+ 'google_search' => (object) [],
+ ],
+ ];
+ } elseif ($request->tools() !== []) {
+ $tools = ['function_declarations' => ToolMap::map($request->tools())];
+ }
+
+ $thinkingConfig = $providerOptions['thinkingConfig'] ?? null;
+
+ if (isset($providerOptions['thinkingBudget'])) {
+ $thinkingConfig = [
+ 'thinkingBudget' => $providerOptions['thinkingBudget'],
+ 'includeThoughts' => true,
+ ];
+ }
+
+ if (isset($providerOptions['thinkingLevel'])) {
+ $thinkingConfig = [
+ 'thinkingLevel' => $providerOptions['thinkingLevel'],
+ 'includeThoughts' => true,
+ ];
+ }
+
+ /** @var Response $response */
+ $response = $this->client
+ ->withOptions(['stream' => true])
+ ->post(
+ "{$this->model}:streamGenerateContent?alt=sse",
+ Arr::whereNotNull([
+ ...(new MessageMap($request->messages(), $request->systemPrompts()))(),
+ 'generationConfig' => Arr::whereNotNull([
+ 'temperature' => $request->temperature(),
+ 'topP' => $request->topP(),
+ 'maxOutputTokens' => $request->maxTokens(),
+ 'thinkingConfig' => $thinkingConfig,
+ ]) ?: null,
+ 'tools' => $tools !== [] ? $tools : null,
+ 'tool_config' => $request->toolChoice() ? ToolChoiceMap::map($request->toolChoice()) : null,
+ 'safetySettings' => $providerOptions['safetySettings'] ?? null,
+ ])
+ );
+
+ return $response;
+ }
+
+ protected function readLine(StreamInterface $stream): string
+ {
+ $buffer = '';
+
+ while (! $stream->eof()) {
+ $byte = $stream->read(1);
+
+ if ($byte === '') {
+ return $buffer;
+ }
+
+ $buffer .= $byte;
+
+ if ($byte === "\n") {
+ break;
+ }
+ }
+
+ return $buffer;
+ }
+
+ /**
+ * @param array $data
+ * @return array|null
+ */
+ protected function extractGroundingMetadata(array $data): ?array
+ {
+ $groundingMetadata = data_get($data, 'candidates.0.groundingMetadata');
+
+ if (! $groundingMetadata) {
+ return null;
+ }
+
+ return $groundingMetadata;
+ }
+}
diff --git a/src/Providers/Vertex/Handlers/Structured.php b/src/Providers/Vertex/Handlers/Structured.php
new file mode 100644
index 000000000..c3e10297a
--- /dev/null
+++ b/src/Providers/Vertex/Handlers/Structured.php
@@ -0,0 +1,325 @@
+responseBuilder = new ResponseBuilder;
+ }
+
+ public function handle(Request $request): StructuredResponse
+ {
+ $this->resolveToolApprovals($request);
+
+ $data = $this->sendRequest($request);
+
+ $this->validateResponse($data);
+
+ $isToolCall = $this->hasToolCalls($data);
+
+ $responseMessage = new AssistantMessage(
+ $this->extractTextContent($data),
+ $isToolCall ? ToolCallMap::map(data_get($data, 'candidates.0.content.parts', [])) : [],
+ );
+
+ $request->addMessage($responseMessage);
+
+ $finishReason = FinishReasonMap::map(
+ data_get($data, 'candidates.0.finishReason'),
+ $isToolCall
+ );
+
+ return match ($finishReason) {
+ FinishReason::ToolCalls => $this->handleToolCalls($data, $request),
+ FinishReason::Stop, FinishReason::Length => $this->handleStop($data, $request, $finishReason),
+ default => throw new PrismException('Vertex: unhandled finish reason'),
+ };
+ }
+
+ /**
+ * @return array
+ */
+ public function sendRequest(Request $request): array
+ {
+ $providerOptions = $request->providerOptions();
+
+ if ($request->tools() !== [] && $request->providerTools() !== []) {
+ throw new PrismException('Use of provider tools with custom tools is not currently supported by Vertex.');
+ }
+
+ $tools = [];
+
+ if ($request->providerTools() !== []) {
+ $tools = [
+ Arr::mapWithKeys(
+ $request->providerTools(),
+ fn (ProviderTool $providerTool): array => [
+ $providerTool->type => $providerTool->options !== [] ? $providerTool->options : (object) [],
+ ]
+ ),
+ ];
+ }
+
+ if ($request->tools() !== []) {
+ $tools = [
+ [
+ 'function_declarations' => ToolMap::map($request->tools()),
+ ],
+ ];
+ }
+
+ $thinkingConfig = $providerOptions['thinkingConfig'] ?? null;
+
+ if (isset($providerOptions['thinkingBudget'])) {
+ $thinkingConfig = Arr::whereNotNull([
+ 'thinkingBudget' => $providerOptions['thinkingBudget'],
+ 'includeThoughts' => $providerOptions['includeThoughts'] ?? null,
+ ]);
+ }
+
+ if (isset($providerOptions['thinkingLevel'])) {
+ $thinkingConfig = Arr::whereNotNull([
+ 'thinkingLevel' => $providerOptions['thinkingLevel'],
+ 'includeThoughts' => $providerOptions['includeThoughts'] ?? null,
+ ]);
+ }
+
+ /** @var Response $response */
+ $response = $this->client->post(
+ "{$this->model}:generateContent",
+ Arr::whereNotNull([
+ ...(new MessageMap($request->messages(), $request->systemPrompts()))(),
+ 'generationConfig' => Arr::whereNotNull([
+ 'response_mime_type' => 'application/json',
+ 'response_schema' => (new SchemaMap($request->schema()))->toArray(),
+ 'temperature' => $request->temperature(),
+ 'topP' => $request->topP(),
+ 'maxOutputTokens' => $request->maxTokens(),
+ 'thinkingConfig' => $thinkingConfig,
+ ]),
+ 'tools' => $tools !== [] ? $tools : null,
+ 'tool_config' => $request->toolChoice() ? ToolChoiceMap::map($request->toolChoice()) : null,
+ 'safetySettings' => $providerOptions['safetySettings'] ?? null,
+ ])
+ );
+
+ return $response->json();
+ }
+
+ /**
+ * @param array $data
+ */
+ protected function validateResponse(array $data): void
+ {
+ if (! $data || data_get($data, 'error')) {
+ throw PrismException::providerResponseError(vsprintf(
+ 'Vertex Error: [%s] %s',
+ [
+ data_get($data, 'error.code', 'unknown'),
+ data_get($data, 'error.message', 'unknown'),
+ ]
+ ));
+ }
+
+ $finishReason = data_get($data, 'candidates.0.finishReason');
+ $content = $this->extractTextContent($data);
+ $thoughtTokens = data_get($data, 'usageMetadata.thoughtsTokenCount', 0);
+
+ if ($finishReason === 'MAX_TOKENS') {
+ $promptTokens = data_get($data, 'usageMetadata.promptTokenCount', 0);
+ $candidatesTokens = data_get($data, 'usageMetadata.candidatesTokenCount', 0);
+ $totalTokens = data_get($data, 'usageMetadata.totalTokenCount', 0);
+ $outputTokens = $candidatesTokens - $thoughtTokens;
+
+ $isEmpty = in_array(trim($content), ['', '0'], true);
+ $isInvalidJson = $content !== '' && $content !== '0' && json_decode($content) === null;
+ $contentLength = strlen($content);
+
+ if (($isEmpty || $isInvalidJson) && $thoughtTokens > 0) {
+ $errorDetail = $isEmpty
+ ? 'no tokens remained for structured output'
+ : "output was truncated at {$contentLength} characters resulting in invalid JSON";
+
+ throw PrismException::providerResponseError(
+ 'Vertex hit token limit with high thinking token usage. '.
+ "Token usage: {$promptTokens} prompt + {$thoughtTokens} thinking + {$outputTokens} output = {$totalTokens} total. ".
+ "The {$errorDetail}. ".
+ 'Try increasing maxTokens to at least '.($totalTokens + 1000).' (suggested: '.($totalTokens * 2).' for comfortable margin).'
+ );
+ }
+ }
+ }
+
+ /**
+ * @param array $data
+ */
+ protected function handleStop(array $data, Request $request, FinishReason $finishReason): StructuredResponse
+ {
+ $this->addStep($data, $request, $finishReason);
+
+ return $this->responseBuilder->toResponse();
+ }
+
+ /**
+ * @param array $data
+ */
+ protected function handleToolCalls(array $data, Request $request): StructuredResponse
+ {
+ $toolCalls = ToolCallMap::map(data_get($data, 'candidates.0.content.parts', []));
+
+ $hasPendingToolCalls = false;
+ $approvalRequests = [];
+ $toolResults = $this->callToolsWithPending($request->tools(), $toolCalls, $hasPendingToolCalls, $approvalRequests);
+
+ if ($approvalRequests !== []) {
+ // Record the tool calls + approval requests so the resume pass
+ // can correlate approval responses.
+ $request->addMessage(new AssistantMessage(
+ $this->extractTextContent($data),
+ $toolCalls,
+ toolApprovalRequests: $approvalRequests,
+ ));
+ }
+
+ $request->addMessage(new ToolResultMessage($toolResults));
+ $request->resetToolChoice();
+
+ $this->addStep($data, $request, FinishReason::ToolCalls, $toolResults);
+
+ if (! $hasPendingToolCalls && $this->shouldContinue($request)) {
+ return $this->handle($request);
+ }
+
+ return $this->responseBuilder->toResponse();
+ }
+
+ /**
+ * @param array $data
+ * @param ToolResult[] $toolResults
+ */
+ protected function addStep(array $data, Request $request, FinishReason $finishReason, array $toolResults = []): void
+ {
+ $isStructuredStep = $finishReason !== FinishReason::ToolCalls;
+ $thoughtSummaries = $this->extractThoughtSummaries($data);
+ $textContent = $this->extractTextContent($data);
+
+ $this->responseBuilder->addStep(
+ new Step(
+ text: $textContent,
+ finishReason: $finishReason,
+ usage: new Usage(
+ promptTokens: max(0, (int) data_get($data, 'usageMetadata.promptTokenCount', 0) - (int) data_get($data, 'usageMetadata.cachedContentTokenCount', 0)),
+ completionTokens: data_get($data, 'usageMetadata.candidatesTokenCount', 0),
+ cacheReadInputTokens: data_get($data, 'usageMetadata.cachedContentTokenCount'),
+ thoughtTokens: data_get($data, 'usageMetadata.thoughtsTokenCount'),
+ ),
+ meta: new Meta(
+ id: data_get($data, 'id', ''),
+ model: data_get($data, 'modelVersion', ''),
+ ),
+ messages: $request->messages(),
+ systemPrompts: $request->systemPrompts(),
+ additionalContent: Arr::whereNotNull([
+ 'thoughtSummaries' => $thoughtSummaries !== [] ? $thoughtSummaries : null,
+ 'citations' => CitationMapper::mapFromGemini(data_get($data, 'candidates.0', [])) ?: null,
+ 'searchEntryPoint' => data_get($data, 'candidates.0.groundingMetadata.searchEntryPoint'),
+ 'searchQueries' => data_get($data, 'candidates.0.groundingMetadata.webSearchQueries'),
+ 'urlMetadata' => data_get($data, 'candidates.0.urlContextMetadata.urlMetadata'),
+ ]),
+ structured: $isStructuredStep ? $this->extractStructuredData(data_get($data, 'candidates.0.content.parts.0.text') ?? '') : [],
+ toolCalls: $finishReason === FinishReason::ToolCalls ? ToolCallMap::map(data_get($data, 'candidates.0.content.parts', [])) : [],
+ toolResults: $toolResults,
+ raw: $data,
+ )
+ );
+ }
+
+ /**
+ * @param array $data
+ */
+ protected function extractTextContent(array $data): string
+ {
+ $parts = data_get($data, 'candidates.0.content.parts', []);
+ $textParts = [];
+
+ foreach ($parts as $part) {
+ if (isset($part['text']) && (! isset($part['thought']) || $part['thought'] === false)) {
+ $textParts[] = $part['text'];
+ }
+ }
+
+ return implode('', $textParts);
+ }
+
+ /**
+ * @param array $data
+ * @return array
+ */
+ protected function extractThoughtSummaries(array $data): array
+ {
+ $parts = data_get($data, 'candidates.0.content.parts', []);
+ $thoughtSummaries = [];
+
+ foreach ($parts as $part) {
+ if (isset($part['thought']) && $part['thought'] === true && isset($part['text'])) {
+ $thoughtSummaries[] = $part['text'];
+ }
+ }
+
+ return $thoughtSummaries;
+ }
+
+ /**
+ * @param array $data
+ */
+ protected function hasToolCalls(array $data): bool
+ {
+ $parts = data_get($data, 'candidates.0.content.parts', []);
+
+ foreach ($parts as $part) {
+ if (isset($part['functionCall'])) {
+ return true;
+ }
+ }
+
+ return false;
+ }
+}
diff --git a/src/Providers/Vertex/Handlers/Text.php b/src/Providers/Vertex/Handlers/Text.php
new file mode 100644
index 000000000..ec5cfc38b
--- /dev/null
+++ b/src/Providers/Vertex/Handlers/Text.php
@@ -0,0 +1,260 @@
+responseBuilder = new ResponseBuilder;
+ }
+
+ public function handle(Request $request): TextResponse
+ {
+ $this->resolveToolApprovals($request);
+
+ $response = $this->sendRequest($request);
+
+ $this->validateResponse($response);
+
+ $data = $response->json();
+
+ $isToolCall = $this->hasToolCalls($data);
+
+ $finishReason = FinishReasonMap::map(
+ data_get($data, 'candidates.0.finishReason'),
+ $isToolCall
+ );
+
+ return match ($finishReason) {
+ FinishReason::ToolCalls => $this->handleToolCalls($data, $request),
+ FinishReason::Stop, FinishReason::Length => $this->handleStop($data, $request, $finishReason),
+ default => throw new PrismException('Vertex: unhandled finish reason'),
+ };
+ }
+
+ protected function sendRequest(Request $request): ClientResponse
+ {
+ $providerOptions = $request->providerOptions();
+
+ $thinkingConfig = $providerOptions['thinkingConfig'] ?? null;
+
+ if (isset($providerOptions['thinkingBudget'])) {
+ $thinkingConfig = Arr::whereNotNull([
+ 'thinkingBudget' => $providerOptions['thinkingBudget'],
+ 'includeThoughts' => $providerOptions['includeThoughts'] ?? null,
+ ]);
+ }
+
+ if (isset($providerOptions['thinkingLevel'])) {
+ $thinkingConfig = Arr::whereNotNull([
+ 'thinkingLevel' => $providerOptions['thinkingLevel'],
+ 'includeThoughts' => $providerOptions['includeThoughts'] ?? null,
+ ]);
+ }
+
+ $generationConfig = Arr::whereNotNull([
+ 'temperature' => $request->temperature(),
+ 'topP' => $request->topP(),
+ 'maxOutputTokens' => $request->maxTokens(),
+ 'thinkingConfig' => $thinkingConfig,
+ ]);
+
+ if ($request->tools() !== [] && $request->providerTools() !== []) {
+ throw new PrismException('Use of provider tools with custom tools is not currently supported by Vertex.');
+ }
+
+ $tools = [];
+
+ if ($request->providerTools() !== []) {
+ $tools = array_map(
+ fn (ProviderTool $providerTool): array => [
+ $providerTool->type => $providerTool->options !== [] ? $providerTool->options : (object) [],
+ ],
+ $request->providerTools()
+ );
+ }
+
+ if ($request->tools() !== []) {
+ $tools['function_declarations'] = ToolMap::map($request->tools());
+ }
+
+ /** @var ClientResponse $response */
+ $response = $this->client->post(
+ "{$this->model}:generateContent",
+ Arr::whereNotNull([
+ ...(new MessageMap($request->messages(), $request->systemPrompts()))(),
+ 'generationConfig' => $generationConfig !== [] ? $generationConfig : null,
+ 'tools' => $tools !== [] ? $tools : null,
+ 'tool_config' => $request->toolChoice() ? ToolChoiceMap::map($request->toolChoice()) : null,
+ 'safetySettings' => $providerOptions['safetySettings'] ?? null,
+ ])
+ );
+
+ return $response;
+ }
+
+ /**
+ * @param array