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 @@

- - Total Downloads + + Total Downloads - - Latest Stable Version + + Latest Stable Version - - License + + License

# 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 $data + */ + protected function handleStop(array $data, Request $request, FinishReason $finishReason): TextResponse + { + $this->addStep($data, $request, $finishReason); + + return $this->responseBuilder->toResponse(); + } + + /** + * @param array $data + */ + protected function handleToolCalls(array $data, Request $request): TextResponse + { + $toolCalls = ToolCallMap::map(data_get($data, 'candidates.0.content.parts', [])); + + $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 (! $hasPendingToolCalls && $this->shouldContinue($request)) { + return $this->handle($request); + } + + 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, FinishReason $finishReason, array $toolResults = []): void + { + $thoughtSummaries = $this->extractThoughtSummaries($data); + + $this->responseBuilder->addStep(new Step( + text: $this->extractTextContent($data), + finishReason: $finishReason, + toolCalls: $finishReason === FinishReason::ToolCalls ? ToolCallMap::map(data_get($data, 'candidates.0.content.parts', [])) : [], + toolResults: $toolResults, + providerToolCalls: [], + 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([ + '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'), + 'thoughtSummaries' => $thoughtSummaries !== [] ? $thoughtSummaries : null, + ]), + 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/Vertex.php b/src/Providers/Vertex/Vertex.php new file mode 100644 index 000000000..4edb9d34a --- /dev/null +++ b/src/Providers/Vertex/Vertex.php @@ -0,0 +1,276 @@ +client($request->clientOptions(), $request->clientRetry()), + $request->model() + ); + + return $handler->handle($request); + } + + #[\Override] + public function structured(StructuredRequest $request): StructuredResponse + { + $handler = new Structured( + $this->client($request->clientOptions(), $request->clientRetry()), + $request->model() + ); + + return $handler->handle($request); + } + + #[\Override] + public function embeddings(EmbeddingRequest $request): EmbeddingResponse + { + $handler = new Embeddings( + $this->client($request->clientOptions(), $request->clientRetry()), + $request->model() + ); + + return $handler->handle($request); + } + + #[\Override] + public function stream(TextRequest $request): Generator + { + $handler = new Stream( + $this->client($request->clientOptions(), $request->clientRetry()), + $request->model() + ); + + return $handler->handle($request); + } + + public function handleRequestException(string $model, RequestException $e): never + { + match ($e->response->getStatusCode()) { + 429 => throw PrismRateLimitedException::make([]), + 503 => throw PrismProviderOverloadedException::make(class_basename($this)), + default => $this->handleResponseErrors($e), + }; + } + + protected function handleResponseErrors(RequestException $e): never + { + $data = $e->response->json() ?? []; + + throw PrismException::providerRequestErrorWithDetails( + provider: 'Vertex', + statusCode: $e->response->getStatusCode(), + errorType: data_get($data, 'error.status'), + errorMessage: data_get($data, 'error.message'), + previous: $e + ); + } + + /** + * @param array $options + * @param array $retry + */ + protected function client(array $options = [], array $retry = []): PendingRequest + { + $accessToken = $this->resolveAccessToken(); + + return $this->baseClient() + ->withToken($accessToken) + ->withOptions($options) + ->when($retry !== [], fn ($client) => $client->retry(...$retry)) + ->baseUrl($this->buildBaseUrl()); + } + + protected function buildBaseUrl(): string + { + if ($this->region === 'global') { + return sprintf( + 'https://aiplatform.googleapis.com/v1/projects/%s/locations/global/publishers/google/models', + $this->projectId + ); + } + + // Multi-region endpoints + if ($this->region === 'eu' || $this->region === 'us') { + return sprintf( + 'https://aiplatform.%s.rep.googleapis.com/v1/projects/%s/locations/%s/publishers/google/models', + $this->region, + $this->projectId, + $this->region + ); + } + + return sprintf( + 'https://%s-aiplatform.googleapis.com/v1/projects/%s/locations/%s/publishers/google/models', + $this->region, + $this->projectId, + $this->region + ); + } + + protected function resolveAccessToken(): string + { + if ($this->accessToken !== null && $this->accessToken !== '') { + return $this->accessToken; + } + + if ($this->credentialsPath !== null && $this->credentialsPath !== '') { + return $this->getAccessTokenFromServiceAccount(); + } + + return $this->getAccessTokenFromApplicationDefaultCredentials(); + } + + protected function getAccessTokenFromServiceAccount(?string $path = null): string + { + $path ??= $this->credentialsPath; + + if ($path === null || ! file_exists($path)) { + throw new PrismException('Vertex AI credentials file not found: '.($path ?? '(none)')); + } + + $contents = file_get_contents($path); + + if ($contents === false) { + throw new PrismException("Vertex AI credentials file could not be read: {$path}"); + } + + $credentials = json_decode($contents, true); + + if (! isset($credentials['client_email'], $credentials['private_key'])) { + throw new PrismException('Invalid Vertex AI service account credentials file'); + } + + return $this->generateJwtToken($credentials); + } + + /** + * @param array $credentials + */ + protected function generateJwtToken(array $credentials): string + { + $header = [ + 'alg' => 'RS256', + 'typ' => 'JWT', + ]; + + $now = time(); + $payload = [ + 'iss' => $credentials['client_email'], + 'sub' => $credentials['client_email'], + 'aud' => 'https://aiplatform.googleapis.com/', + 'iat' => $now, + 'exp' => $now + 3600, + ]; + + $headerEncoded = $this->base64UrlEncode(json_encode($header, JSON_THROW_ON_ERROR)); + $payloadEncoded = $this->base64UrlEncode(json_encode($payload, JSON_THROW_ON_ERROR)); + + $signatureInput = $headerEncoded.'.'.$payloadEncoded; + + $privateKey = openssl_pkey_get_private($credentials['private_key']); + if ($privateKey === false) { + throw new PrismException('Failed to parse Vertex AI private key'); + } + + $signature = ''; + if (! openssl_sign($signatureInput, $signature, $privateKey, OPENSSL_ALGO_SHA256)) { + throw new PrismException('Failed to sign Vertex AI JWT token'); + } + + return $headerEncoded.'.'.$payloadEncoded.'.'.$this->base64UrlEncode($signature); + } + + protected function base64UrlEncode(string $data): string + { + return rtrim(strtr(base64_encode($data), '+/', '-_'), '='); + } + + protected function getAccessTokenFromApplicationDefaultCredentials(): string + { + $adcPath = getenv('GOOGLE_APPLICATION_CREDENTIALS'); + + if ($adcPath !== false && $adcPath !== '' && file_exists($adcPath)) { + return $this->getAccessTokenFromServiceAccount($adcPath); + } + + $defaultPath = $this->getDefaultAdcPath(); + if (file_exists($defaultPath)) { + $contents = file_get_contents($defaultPath); + $credentials = $contents === false ? null : json_decode($contents, true); + + if (isset($credentials['type']) && $credentials['type'] === 'authorized_user') { + return $this->refreshAccessToken($credentials); + } + } + + throw new PrismException( + 'Vertex AI requires authentication. Provide an access_token, credentials_path, '. + 'or set up Application Default Credentials (run: gcloud auth application-default login)' + ); + } + + protected function getDefaultAdcPath(): string + { + if (PHP_OS_FAMILY === 'Windows') { + return getenv('APPDATA').'/gcloud/application_default_credentials.json'; + } + + return getenv('HOME').'/.config/gcloud/application_default_credentials.json'; + } + + /** + * @param array $credentials + */ + protected function refreshAccessToken(array $credentials): string + { + $response = $this->baseClient() + ->asForm() + ->post('https://oauth2.googleapis.com/token', [ + 'client_id' => $credentials['client_id'], + 'client_secret' => $credentials['client_secret'], + 'refresh_token' => $credentials['refresh_token'], + 'grant_type' => 'refresh_token', + ]); + + if (! $response->successful()) { + throw new PrismException('Failed to refresh Vertex AI access token: '.$response->body()); + } + + return $response->json('access_token'); + } +} diff --git a/src/Providers/XAI/Handlers/Images.php b/src/Providers/XAI/Handlers/Images.php new file mode 100644 index 000000000..fdccfe7cd --- /dev/null +++ b/src/Providers/XAI/Handlers/Images.php @@ -0,0 +1,77 @@ +sendRequest($request); + + $this->validateResponse($response); + + $data = $response->json(); + + $images = $this->extractImages($data); + + $responseBuilder = new ResponseBuilder( + usage: new Usage( + promptTokens: data_get($data, 'usage.input_tokens', data_get($data, 'usage.prompt_tokens', 0)), + completionTokens: data_get($data, 'usage.output_tokens', 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, + raw: $data, + ); + + return $responseBuilder->toResponse(); + } + + protected function sendRequest(Request $request): ClientResponse + { + /** @var ClientResponse $response */ + $response = $this->client->post('images/generations', ImageRequestMap::map($request)); + + return $response; + } + + /** + * @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/XAI/Handlers/Stream.php b/src/Providers/XAI/Handlers/Stream.php index 1edac9f2a..e8dad0558 100644 --- a/src/Providers/XAI/Handlers/Stream.php +++ b/src/Providers/XAI/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); @@ -341,8 +343,9 @@ protected function extractUsage(array $data): ?Usage } return new Usage( - promptTokens: data_get($usage, 'prompt_tokens', 0), - completionTokens: 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, ); } @@ -368,7 +371,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( diff --git a/src/Providers/XAI/Handlers/Structured.php b/src/Providers/XAI/Handlers/Structured.php index 75a3527f4..102e3fde4 100644 --- a/src/Providers/XAI/Handlers/Structured.php +++ b/src/Providers/XAI/Handlers/Structured.php @@ -41,9 +41,15 @@ public function handle(Request $request): StructuredResponse $this->handleRefusal(data_get($data, 'choices.0.message', [])); - $content = data_get($data, 'choices.0.message.content') ?? ''; + $rawContent = data_get($data, 'choices.0.message.content') ?? ''; $parsed = data_get($data, 'choices.0.message.parsed'); + // Some OpenAI-compatible providers (e.g. Cloudflare Workers AI) return + // content as a parsed object/array instead of a JSON string. Normalize + // to string for AssistantMessage and extract structured data. + $content = is_array($rawContent) ? json_encode($rawContent) : $rawContent; + $parsed ??= is_array($rawContent) ? $rawContent : null; + $responseMessage = new AssistantMessage($content); $request->addMessage($responseMessage); @@ -59,12 +65,15 @@ public function handle(Request $request): StructuredResponse */ protected function addStep(array $data, Request $request, ?array $parsed): void { + $rawContent = data_get($data, 'choices.0.message.content') ?? ''; + $this->responseBuilder->addStep(new Step( - text: data_get($data, 'choices.0.message.content') ?? '', + text: is_array($rawContent) ? json_encode($rawContent) : $rawContent, finishReason: $this->mapFinishReason($data), usage: new Usage( - promptTokens: data_get($data, 'usage.prompt_tokens', 0), - completionTokens: 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), + 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/XAI/Handlers/Text.php b/src/Providers/XAI/Handlers/Text.php index 036b0e22b..5bbfbdf36 100644 --- a/src/Providers/XAI/Handlers/Text.php +++ b/src/Providers/XAI/Handlers/Text.php @@ -41,6 +41,8 @@ public function __construct(protected PendingRequest $client) public function handle(Request $request): TextResponse { + $this->resolveToolApprovals($request); + $response = $this->sendRequest($request); $this->validateResponse($response); @@ -54,8 +56,7 @@ public function handle(Request $request): TextResponse return match ($finishReason) { FinishReason::ToolCalls => $this->handleToolCalls($data, $request), - FinishReason::Stop, FinishReason::Length => $this->handleStop($data, $request), - default => throw new PrismException('XAI: unknown finish reason'), + default => $this->handleStop($data, $request), }; } @@ -70,18 +71,21 @@ protected function handleToolCalls(array $data, Request $request): TextResponse throw new PrismException('XAI: finish reason is tool_calls but no tool calls found in response'); } - $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, '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); } @@ -158,8 +162,9 @@ protected function addStep(array $data, Request $request, array $toolResults = [ toolResults: $toolResults, providerToolCalls: [], usage: new Usage( - data_get($data, 'usage.prompt_tokens', 0), - 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), + 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/XAI/Maps/ImageRequestMap.php b/src/Providers/XAI/Maps/ImageRequestMap.php new file mode 100644 index 000000000..335b4b16c --- /dev/null +++ b/src/Providers/XAI/Maps/ImageRequestMap.php @@ -0,0 +1,40 @@ + + */ + public static function map(Request $request): array + { + $baseData = [ + 'model' => $request->model(), + 'prompt' => $request->prompt(), + ]; + + $providerOptions = $request->providerOptions(); + + $supportedOptions = [ + 'n' => $providerOptions['n'] ?? null, + 'response_format' => $providerOptions['response_format'] ?? null, + 'aspect_ratio' => $providerOptions['aspect_ratio'] ?? null, + 'resolution' => $providerOptions['resolution'] ?? null, + ]; + + // Include any additional options not explicitly handled above + $additionalOptions = array_diff_key($providerOptions, $supportedOptions); + + return array_merge( + $baseData, + Arr::whereNotNull($supportedOptions), + $additionalOptions + ); + } +} diff --git a/src/Providers/XAI/Maps/MessageMap.php b/src/Providers/XAI/Maps/MessageMap.php index 8590ad1ef..24145e315 100644 --- a/src/Providers/XAI/Maps/MessageMap.php +++ b/src/Providers/XAI/Maps/MessageMap.php @@ -81,10 +81,12 @@ protected function mapUserMessage(UserMessage $message): void $this->mappedMessages[] = [ 'role' => 'user', - 'content' => [ - ['type' => 'text', 'text' => $message->text()], - ...$imageParts, - ], + 'content' => $imageParts === [] + ? $message->text() + : [ + ['type' => 'text', 'text' => $message->text()], + ...$imageParts, + ], ]; } diff --git a/src/Providers/XAI/Maps/ToolMap.php b/src/Providers/XAI/Maps/ToolMap.php index 59ef7466e..50781571f 100644 --- a/src/Providers/XAI/Maps/ToolMap.php +++ b/src/Providers/XAI/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/XAI/XAI.php b/src/Providers/XAI/XAI.php index cd0e39778..0ccd303bb 100644 --- a/src/Providers/XAI/XAI.php +++ b/src/Providers/XAI/XAI.php @@ -10,7 +10,10 @@ use Prism\Prism\Concerns\InitializesClient; use Prism\Prism\Exceptions\PrismException; use Prism\Prism\Exceptions\PrismRateLimitedException; +use Prism\Prism\Images\Request as ImagesRequest; +use Prism\Prism\Images\Response as ImagesResponse; use Prism\Prism\Providers\Provider; +use Prism\Prism\Providers\XAI\Handlers\Images; use Prism\Prism\Providers\XAI\Handlers\Stream; use Prism\Prism\Providers\XAI\Handlers\Structured; use Prism\Prism\Providers\XAI\Handlers\Text; @@ -47,6 +50,17 @@ public function stream(TextRequest $request): Generator 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 structured(StructuredRequest $request): StructuredResponse { diff --git a/src/Providers/Z/Handlers/Stream.php b/src/Providers/Z/Handlers/Stream.php new file mode 100644 index 000000000..fef2e930e --- /dev/null +++ b/src/Providers/Z/Handlers/Stream.php @@ -0,0 +1,429 @@ +state = new StreamState; + } + + /** + * @throws PrismStreamDecodeException + * @throws PrismException + * @throws ConnectionException + */ + public function handle(Request $request): Generator + { + yield from $this->resolveToolApprovalsAndYieldEvents($request, EventID::generate()); + + $response = $this->sendRequest($request); + + yield from $this->processStream($response, $request); + } + + /** + * @throws PrismStreamDecodeException + * @throws PrismException + * @throws ConnectionException + */ + protected function processStream(Response $response, Request $request, int $depth = 0): Generator + { + if ($depth >= $request->maxSteps()) { + throw new PrismException('Maximum tool call chain depth exceeded'); + } + + $this->state->reset()->withMessageId(EventID::generate()); + + $text = ''; + $toolCalls = []; + + while (! $response->getBody()->eof()) { + $data = $this->parseNextDataLine($response->getBody()); + + if ($data === null) { + continue; + } + + if ($this->state->shouldEmitStreamStart()) { + yield new StreamStartEvent( + id: EventID::generate(), + timestamp: time(), + model: $request->model(), + provider: Provider::Z->value, + ); + + $this->state->markStreamStarted(); + + continue; + } + + 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, '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(), + ); + } + + yield from $this->handleToolCalls($request, $text, $toolCalls, $depth); + + return; + } + + 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(), + ); + + continue; + } + + $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); + } + } + } + + 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() ?? new Usage(0, 0), + ); + } + + /** + * @return null|array + * + * @throws PrismStreamDecodeException + */ + 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 (JsonException $e) { + throw new PrismStreamDecodeException(Provider::Z->name, $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 + { + $delta = data_get($data, 'choices.0.delta', []); + + if (is_array($delta)) { + $content = data_get($delta, 'content', ''); + + return is_string($content) ? $content : ''; + } + + return ''; + } + + /** + * @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> $toolCalls + * + * @throws PrismException + * @throws PrismStreamDecodeException + * @throws ConnectionException + */ + 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(); + $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 + { + return $this + ->client + ->withOptions(['stream' => true]) + ->post( + 'chat/completions', + array_merge([ + 'stream' => true, + 'model' => $request->model(), + 'messages' => (new MessageMap($request->messages(), $request->systemPrompts()))(), + ], Arr::whereNotNull([ + 'max_tokens' => $request->maxTokens(), + 'temperature' => $request->temperature(), + 'top_p' => $request->topP(), + 'tools' => ToolMap::map($request->tools()) ?: null, + 'tool_choice' => ToolChoiceMap::map($request->toolChoice()), + ])) + ); + } + + 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/Z/Handlers/Structured.php b/src/Providers/Z/Handlers/Structured.php index 37a94eec7..80ab3fe03 100644 --- a/src/Providers/Z/Handlers/Structured.php +++ b/src/Providers/Z/Handlers/Structured.php @@ -78,8 +78,9 @@ protected function addStep(array $data, Request $request): void text: data_get($data, 'choices.0.message.content') ?? '', finishReason: $this->mapFinishReason($data), usage: new Usage( - promptTokens: data_get($data, 'usage.prompt_tokens', 0), - completionTokens: 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), + 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/Z/Handlers/Text.php b/src/Providers/Z/Handlers/Text.php index dbdc356cf..75540e44b 100644 --- a/src/Providers/Z/Handlers/Text.php +++ b/src/Providers/Z/Handlers/Text.php @@ -42,6 +42,8 @@ public function __construct(protected PendingRequest $client) */ public function handle(Request $request): TextResponse { + $this->resolveToolApprovals($request); + $response = $this->sendRequest($request); $data = $response->json(); @@ -57,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), - default => throw new PrismException('Z: unknown finish reason'), + default => $this->handleStop($data, $request), }; } @@ -75,13 +76,28 @@ protected function handleToolCalls(array $data, Request $request): TextResponse throw new PrismException('Z: finish reason is tool_calls but no tool calls found in response'); } - $toolResults = $this->callTools($request->tools(), $toolCalls); + $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->addMessage(new ToolResultMessage($toolResults)); $this->addStep($data, $request, $toolResults); - if ($this->shouldContinue($request)) { + if (! $hasPendingToolCalls && $this->shouldContinue($request)) { return $this->handle($request); } @@ -148,8 +164,9 @@ protected function addStep(array $data, Request $request, array $toolResults = [ toolResults: $toolResults, providerToolCalls: [], usage: new Usage( - data_get($data, 'usage.prompt_tokens', 0), - 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), + 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/Z/Maps/ToolMap.php b/src/Providers/Z/Maps/ToolMap.php index c2799b1f8..83b255db4 100644 --- a/src/Providers/Z/Maps/ToolMap.php +++ b/src/Providers/Z/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/Z/Z.php b/src/Providers/Z/Z.php index df42a2ca4..62830af3e 100644 --- a/src/Providers/Z/Z.php +++ b/src/Providers/Z/Z.php @@ -4,10 +4,14 @@ namespace Prism\Prism\Providers\Z; +use Generator; +use Illuminate\Http\Client\ConnectionException; use Illuminate\Http\Client\PendingRequest; use Prism\Prism\Concerns\InitializesClient; use Prism\Prism\Exceptions\PrismException; +use Prism\Prism\Exceptions\PrismStreamDecodeException; use Prism\Prism\Providers\Provider; +use Prism\Prism\Providers\Z\Handlers\Stream; use Prism\Prism\Structured\Request as StructuredRequest; use Prism\Prism\Structured\Response as StructuredResponse; use Prism\Prism\Text\Request as TextRequest; @@ -45,6 +49,21 @@ public function structured(StructuredRequest $request): StructuredResponse return $handler->handle($request); } + /** + * @throws PrismStreamDecodeException + * @throws PrismException + * @throws ConnectionException + */ + public function stream(TextRequest $request): Generator + { + $handler = new Stream($this->client( + $request->clientOptions(), + $request->clientRetry() + )); + + return $handler->handle($request); + } + /** * @param array $options * @param array $retry diff --git a/src/Schema/EnumSchema.php b/src/Schema/EnumSchema.php index f97a130a5..ec55fcc4f 100644 --- a/src/Schema/EnumSchema.php +++ b/src/Schema/EnumSchema.php @@ -29,11 +29,25 @@ public function toArray(): array { return [ 'description' => $this->description, - 'enum' => $this->options, + 'enum' => $this->options(), 'type' => $this->types(), ]; } + /** + * @return array + */ + protected function options(): array + { + $options = $this->options; + + if ($this->nullable) { + $options[] = null; + } + + return $options; + } + /** * @return string[]|string */ diff --git a/src/Streaming/Adapters/BroadcastAdapter.php b/src/Streaming/Adapters/BroadcastAdapter.php index bf8b064cb..6bdb60475 100644 --- a/src/Streaming/Adapters/BroadcastAdapter.php +++ b/src/Streaming/Adapters/BroadcastAdapter.php @@ -23,6 +23,7 @@ use Prism\Prism\Events\Broadcasting\ThinkingBroadcast; use Prism\Prism\Events\Broadcasting\ThinkingCompleteBroadcast; use Prism\Prism\Events\Broadcasting\ThinkingStartBroadcast; +use Prism\Prism\Events\Broadcasting\ToolApprovalRequestBroadcast; use Prism\Prism\Events\Broadcasting\ToolCallBroadcast; use Prism\Prism\Events\Broadcasting\ToolCallDeltaBroadcast; use Prism\Prism\Events\Broadcasting\ToolResultBroadcast; @@ -41,6 +42,7 @@ use Prism\Prism\Streaming\Events\ThinkingCompleteEvent; use Prism\Prism\Streaming\Events\ThinkingEvent; use Prism\Prism\Streaming\Events\ThinkingStartEvent; +use Prism\Prism\Streaming\Events\ToolApprovalRequestEvent; use Prism\Prism\Streaming\Events\ToolCallDeltaEvent; use Prism\Prism\Streaming\Events\ToolCallEvent; use Prism\Prism\Streaming\Events\ToolResultEvent; @@ -87,6 +89,7 @@ protected function broadcastEvent(StreamEvent $event): ShouldBroadcast ToolCallEvent::class => new ToolCallBroadcast($event, $this->channels), ToolCallDeltaEvent::class => new ToolCallDeltaBroadcast($event, $this->channels), ToolResultEvent::class => new ToolResultBroadcast($event, $this->channels), + ToolApprovalRequestEvent::class => new ToolApprovalRequestBroadcast($event, $this->channels), ArtifactEvent::class => new ArtifactBroadcast($event, $this->channels), CitationEvent::class => new CitationBroadcast($event, $this->channels), ProviderToolEvent::class => new ProviderToolEventBroadcast($event, $this->channels), diff --git a/src/Streaming/Events/StepFinishEvent.php b/src/Streaming/Events/StepFinishEvent.php index a23984c58..6fd90923c 100644 --- a/src/Streaming/Events/StepFinishEvent.php +++ b/src/Streaming/Events/StepFinishEvent.php @@ -5,9 +5,18 @@ namespace Prism\Prism\Streaming\Events; use Prism\Prism\Enums\StreamEventType; +use Prism\Prism\ValueObjects\Usage; readonly class StepFinishEvent extends StreamEvent { + public function __construct( + string $id, + int $timestamp, + public ?Usage $usage = null, // Token usage information + ) { + parent::__construct($id, $timestamp); + } + public function type(): StreamEventType { return StreamEventType::StepFinish; @@ -18,6 +27,13 @@ public function toArray(): array return [ 'id' => $this->id, 'timestamp' => $this->timestamp, + 'usage' => $this->usage instanceof Usage ? [ + 'prompt_tokens' => $this->usage->promptTokens, + 'completion_tokens' => $this->usage->completionTokens, + 'cache_write_input_tokens' => $this->usage->cacheWriteInputTokens, + 'cache_read_input_tokens' => $this->usage->cacheReadInputTokens, + 'thought_tokens' => $this->usage->thoughtTokens, + ] : null, ]; } } diff --git a/src/Streaming/Events/StreamEndEvent.php b/src/Streaming/Events/StreamEndEvent.php index c06c6c366..d76194786 100644 --- a/src/Streaming/Events/StreamEndEvent.php +++ b/src/Streaming/Events/StreamEndEvent.php @@ -42,13 +42,7 @@ public function toArray(): array 'id' => $this->id, 'timestamp' => $this->timestamp, 'finish_reason' => $this->finishReason->name, - 'usage' => $this->usage instanceof Usage ? [ - 'prompt_tokens' => $this->usage->promptTokens, - 'completion_tokens' => $this->usage->completionTokens, - 'cache_write_input_tokens' => $this->usage->cacheWriteInputTokens, - 'cache_read_input_tokens' => $this->usage->cacheReadInputTokens, - 'thought_tokens' => $this->usage->thoughtTokens, - ] : null, + 'usage' => $this->usage?->toArray(), 'citations' => $this->citations !== null ? array_map( fn (MessagePartWithCitations $citationPart): array => [ 'output_text' => $citationPart->outputText, diff --git a/src/Streaming/Events/ToolApprovalRequestEvent.php b/src/Streaming/Events/ToolApprovalRequestEvent.php new file mode 100644 index 000000000..8379b451b --- /dev/null +++ b/src/Streaming/Events/ToolApprovalRequestEvent.php @@ -0,0 +1,42 @@ + + */ + public function toArray(): array + { + return [ + 'id' => $this->id, + 'timestamp' => $this->timestamp, + 'approval_id' => $this->approvalId, + 'tool_id' => $this->toolCall->id, + 'tool_name' => $this->toolCall->name, + 'arguments' => $this->toolCall->arguments(), + 'message_id' => $this->messageId, + ]; + } +} diff --git a/src/Streaming/StreamState.php b/src/Streaming/StreamState.php index 8839e8124..fceb22a97 100644 --- a/src/Streaming/StreamState.php +++ b/src/Streaming/StreamState.php @@ -245,7 +245,10 @@ public function addUsage(Usage $usage): self completionTokens: $this->usage->completionTokens + $usage->completionTokens, cacheWriteInputTokens: ($this->usage->cacheWriteInputTokens ?? 0) + ($usage->cacheWriteInputTokens ?? 0), cacheReadInputTokens: ($this->usage->cacheReadInputTokens ?? 0) + ($usage->cacheReadInputTokens ?? 0), - thoughtTokens: ($this->usage->thoughtTokens ?? 0) + ($usage->thoughtTokens ?? 0) + thoughtTokens: ($this->usage->thoughtTokens ?? 0) + ($usage->thoughtTokens ?? 0), + cost: ($this->usage->cost !== null || $usage->cost !== null) + ? ($this->usage->cost ?? 0.0) + ($usage->cost ?? 0.0) + : null, ); return $this; diff --git a/src/Structured/PendingRequest.php b/src/Structured/PendingRequest.php index 063a85def..7906dc139 100644 --- a/src/Structured/PendingRequest.php +++ b/src/Structured/PendingRequest.php @@ -15,6 +15,7 @@ use Prism\Prism\Concerns\HasPrompts; use Prism\Prism\Concerns\HasProviderOptions; use Prism\Prism\Concerns\HasProviderTools; +use Prism\Prism\Concerns\HasReasoning; use Prism\Prism\Concerns\HasSchema; use Prism\Prism\Concerns\HasTools; use Prism\Prism\Contracts\Schema; @@ -33,6 +34,7 @@ class PendingRequest use HasPrompts; use HasProviderOptions; use HasProviderTools; + use HasReasoning; use HasSchema; use HasTools; @@ -80,6 +82,7 @@ public function toRequest(): Request maxTokens: $this->maxTokens, temperature: $this->temperature, topP: $this->topP, + topK: $this->topK, clientOptions: $this->clientOptions, clientRetry: $this->clientRetry, schema: $this->schema, @@ -89,6 +92,7 @@ public function toRequest(): Request maxSteps: $this->maxSteps, providerOptions: $this->providerOptions, providerTools: $this->providerTools, + reasoningEnabled: $this->reasoningEnabled, ); } } diff --git a/src/Structured/Request.php b/src/Structured/Request.php index 7f9675392..b5d7002a1 100644 --- a/src/Structured/Request.php +++ b/src/Structured/Request.php @@ -7,6 +7,7 @@ use Closure; use Prism\Prism\Concerns\ChecksSelf; use Prism\Prism\Concerns\HasProviderOptions; +use Prism\Prism\Concerns\HasReasoning; use Prism\Prism\Contracts\Message; use Prism\Prism\Contracts\PrismRequest; use Prism\Prism\Contracts\Schema; @@ -18,7 +19,7 @@ class Request implements PrismRequest { - use ChecksSelf, HasProviderOptions; + use ChecksSelf, HasProviderOptions, HasReasoning; /** * @param SystemMessage[] $systemPrompts @@ -38,6 +39,7 @@ public function __construct( protected ?int $maxTokens, protected int|float|null $temperature, protected int|float|null $topP, + protected ?int $topK, protected array $clientOptions, protected array $clientRetry, protected Schema $schema, @@ -47,8 +49,10 @@ public function __construct( protected int $maxSteps, array $providerOptions = [], protected array $providerTools = [], + ?bool $reasoningEnabled = null, ) { $this->providerOptions = $providerOptions; + $this->reasoningEnabled = $reasoningEnabled; } /** @@ -98,6 +102,11 @@ public function topP(): int|float|null return $this->topP; } + public function topK(): ?int + { + return $this->topK; + } + /** * @return array */ @@ -131,6 +140,16 @@ public function addMessage(Message $message): self return $this; } + /** + * @param Message[] $messages + */ + public function setMessages(array $messages): self + { + $this->messages = $messages; + + return $this; + } + /** * @return array */ diff --git a/src/Structured/ResponseBuilder.php b/src/Structured/ResponseBuilder.php index 1fd3bfe49..701827bd6 100644 --- a/src/Structured/ResponseBuilder.php +++ b/src/Structured/ResponseBuilder.php @@ -136,6 +136,9 @@ protected function calculateTotalUsage(): Usage thoughtTokens: $this->steps->contains(fn (Step $result): bool => $result->usage->thoughtTokens !== null) ? $this->steps->sum(fn (Step $result): int => $result->usage->thoughtTokens ?? 0) : null, + cost: $this->steps->contains(fn (Step $result): bool => $result->usage->cost !== null) + ? (float) $this->steps->sum(fn (Step $result): float => $result->usage->cost ?? 0.0) + : null, ); } } diff --git a/src/Structured/Step.php b/src/Structured/Step.php index 7d1a4fb32..03879c139 100644 --- a/src/Structured/Step.php +++ b/src/Structured/Step.php @@ -13,6 +13,7 @@ use Prism\Prism\ValueObjects\Messages\UserMessage; use Prism\Prism\ValueObjects\Meta; use Prism\Prism\ValueObjects\ProviderToolCall; +use Prism\Prism\ValueObjects\ToolApprovalRequest; use Prism\Prism\ValueObjects\ToolCall; use Prism\Prism\ValueObjects\ToolResult; use Prism\Prism\ValueObjects\Usage; @@ -31,6 +32,7 @@ * @param array $providerToolCalls * @param array $toolResults * @param array|null $raw + * @param ToolApprovalRequest[] $toolApprovalRequests */ public function __construct( public string $text, @@ -44,7 +46,8 @@ public function __construct( public array $toolCalls = [], public array $providerToolCalls = [], public array $toolResults = [], - public ?array $raw = null + public ?array $raw = null, + public array $toolApprovalRequests = [] ) {} /** diff --git a/src/Testing/PrismFake.php b/src/Testing/PrismFake.php index 371576160..a8ef51b21 100644 --- a/src/Testing/PrismFake.php +++ b/src/Testing/PrismFake.php @@ -336,7 +336,8 @@ protected function streamEventsFromTextResponse(TextResponse $response, TextRequ yield new StepFinishEvent( id: EventID::generate(), - timestamp: time() + timestamp: time(), + usage: $response->usage ); yield new StreamEndEvent( diff --git a/src/Text/PendingRequest.php b/src/Text/PendingRequest.php index c9807ee32..2419eac8a 100644 --- a/src/Text/PendingRequest.php +++ b/src/Text/PendingRequest.php @@ -17,6 +17,7 @@ use Prism\Prism\Concerns\HasPrompts; use Prism\Prism\Concerns\HasProviderOptions; use Prism\Prism\Concerns\HasProviderTools; +use Prism\Prism\Concerns\HasReasoning; use Prism\Prism\Concerns\HasTools; use Prism\Prism\Exceptions\PrismException; use Prism\Prism\Streaming\Adapters\BroadcastAdapter; @@ -38,6 +39,7 @@ class PendingRequest use HasPrompts; use HasProviderOptions; use HasProviderTools; + use HasReasoning; use HasTools; /** @@ -140,12 +142,14 @@ public function toRequest(): Request maxTokens: $this->maxTokens, temperature: $this->temperature, topP: $this->topP, + topK: $this->topK, tools: $tools, clientOptions: $this->clientOptions, clientRetry: $this->clientRetry, toolChoice: $this->toolChoice, providerOptions: $this->providerOptions, providerTools: $this->providerTools, + reasoningEnabled: $this->reasoningEnabled, ); } } diff --git a/src/Text/Request.php b/src/Text/Request.php index fc3355513..58ec3b1b7 100644 --- a/src/Text/Request.php +++ b/src/Text/Request.php @@ -7,6 +7,7 @@ use Closure; use Prism\Prism\Concerns\ChecksSelf; use Prism\Prism\Concerns\HasProviderOptions; +use Prism\Prism\Concerns\HasReasoning; use Prism\Prism\Contracts\Message; use Prism\Prism\Contracts\PrismRequest; use Prism\Prism\Enums\ToolChoice; @@ -16,7 +17,7 @@ class Request implements PrismRequest { - use ChecksSelf, HasProviderOptions; + use ChecksSelf, HasProviderOptions, HasReasoning; /** * @param SystemMessage[] $systemPrompts @@ -29,22 +30,25 @@ class Request implements PrismRequest */ public function __construct( protected string $model, - protected string $providerKey, - protected array $systemPrompts, - protected ?string $prompt, - protected array $messages, - protected int $maxSteps, - protected ?int $maxTokens, - protected int|float|null $temperature, - protected int|float|null $topP, - protected array $tools, - protected array $clientOptions, - protected array $clientRetry, - protected string|ToolChoice|null $toolChoice, + protected ?string $providerKey = null, + protected array $systemPrompts = [], + protected ?string $prompt = null, + protected array $messages = [], + protected int $maxSteps = 1, + protected ?int $maxTokens = null, + protected int|float|null $temperature = null, + protected int|float|null $topP = null, + protected ?int $topK = null, + protected array $tools = [], + protected array $clientOptions = [], + protected array $clientRetry = [0], + protected string|ToolChoice|null $toolChoice = null, array $providerOptions = [], protected array $providerTools = [], + ?bool $reasoningEnabled = null, ) { $this->providerOptions = $providerOptions; + $this->reasoningEnabled = $reasoningEnabled; } public function toolChoice(): string|ToolChoice|null @@ -81,6 +85,11 @@ public function topP(): int|float|null return $this->topP; } + public function topK(): ?int + { + return $this->topK; + } + /** * @return array */ @@ -131,7 +140,7 @@ public function model(): string return $this->model; } - public function provider(): string + public function provider(): ?string { return $this->providerKey; } @@ -143,6 +152,16 @@ public function addMessage(Message $message): self return $this; } + /** + * @param Message[] $messages + */ + public function setMessages(array $messages): self + { + $this->messages = $messages; + + return $this; + } + public function resetToolChoice(): self { if (is_string($this->toolChoice) || $this->toolChoice === ToolChoice::Any) { diff --git a/src/Text/Response.php b/src/Text/Response.php index 30ce7fff9..c49cbbc46 100644 --- a/src/Text/Response.php +++ b/src/Text/Response.php @@ -57,7 +57,7 @@ public function toArray(): array 'tool_results' => array_map(fn (ToolResult $toolResult): array => $toolResult->toArray(), $this->toolResults), 'usage' => $this->usage->toArray(), 'meta' => $this->meta->toArray(), - 'messages' => $this->messages->map(fn (Message $message): array => $this->messageToArray($message))->toArray(), + 'messages' => $this->messages->map($this->messageToArray(...))->toArray(), 'additional_content' => $this->additionalContent, 'raw' => $this->raw, ]; diff --git a/src/Text/ResponseBuilder.php b/src/Text/ResponseBuilder.php index 22462476b..20a91a04f 100644 --- a/src/Text/ResponseBuilder.php +++ b/src/Text/ResponseBuilder.php @@ -77,6 +77,9 @@ protected function calculateTotalUsage(): Usage thoughtTokens: $this->steps->contains(fn (Step $result): bool => $result->usage->thoughtTokens !== null) ? $this->steps->sum(fn (Step $result): int => $result->usage->thoughtTokens ?? 0) : null, + cost: $this->steps->contains(fn (Step $result): bool => $result->usage->cost !== null) + ? (float) $this->steps->sum(fn (Step $result): float => $result->usage->cost ?? 0.0) + : null, ); } } diff --git a/src/Text/Step.php b/src/Text/Step.php index c291534f3..58dc0f63e 100644 --- a/src/Text/Step.php +++ b/src/Text/Step.php @@ -13,6 +13,7 @@ use Prism\Prism\ValueObjects\Messages\UserMessage; use Prism\Prism\ValueObjects\Meta; use Prism\Prism\ValueObjects\ProviderToolCall; +use Prism\Prism\ValueObjects\ToolApprovalRequest; use Prism\Prism\ValueObjects\ToolCall; use Prism\Prism\ValueObjects\ToolResult; use Prism\Prism\ValueObjects\Usage; @@ -30,6 +31,7 @@ * @param SystemMessage[] $systemPrompts * @param array $additionalContent * @param array|null $raw + * @param ToolApprovalRequest[] $toolApprovalRequests */ public function __construct( public string $text, @@ -42,7 +44,8 @@ public function __construct( public array $messages, public array $systemPrompts, public array $additionalContent = [], - public ?array $raw = null + public ?array $raw = null, + public array $toolApprovalRequests = [] ) {} /** @@ -63,6 +66,7 @@ public function toArray(): array 'system_prompts' => array_map(fn (SystemMessage $systemMessage): array => $systemMessage->toArray(), $this->systemPrompts), 'additional_content' => $this->additionalContent, 'raw' => $this->raw, + 'tool_approval_requests' => array_map(fn (ToolApprovalRequest $request): array => $request->toArray(), $this->toolApprovalRequests), ]; } diff --git a/src/Tool.php b/src/Tool.php index 99f3f3371..ae1f767e4 100644 --- a/src/Tool.php +++ b/src/Tool.php @@ -39,7 +39,7 @@ class Tool /** @var array */ protected array $requiredParameters = []; - /** @var Closure():mixed|callable():mixed */ + /** @var Closure():mixed|callable():mixed|null */ protected $fn; /** @var null|false|Closure(Throwable,array):string */ @@ -47,6 +47,11 @@ class Tool protected bool $concurrent = false; + protected bool $clientExecuted = false; + + /** @var bool|Closure(array):bool */ + protected bool|Closure $requiresApproval = false; + public function __construct() { // @@ -68,11 +73,72 @@ public function for(string $description): self public function using(Closure|callable $fn): self { + if ($fn === $this) { + return $this; + } + $this->fn = $fn; + $this->clientExecuted = false; + + return $this; + } + + /** + * Mark this tool as client-executed (no server-side handler). + * + * Client-executed tools are sent to the AI model, but their execution is + * handled by the consuming application: the request loop stops and the + * pending tool calls are returned on the response instead of being run. + */ + public function clientExecuted(): self + { + $this->clientExecuted = true; + $this->fn = null; + + return $this; + } + + public function isClientExecuted(): bool + { + return $this->clientExecuted; + } + + /** + * Mark this tool as requiring approval before execution. + * + * When a closure is provided, it receives the tool call arguments and + * should return true if approval is required for that specific call. + * + * @param bool|Closure(array):bool $condition + */ + public function requiresApproval(bool|Closure $condition = true): self + { + $this->requiresApproval = $condition; return $this; } + /** + * Whether this tool has approval configured (static true or dynamic + * closure) — an early-exit check that never invokes the closure. + */ + public function hasApprovalConfigured(): bool + { + return $this->requiresApproval === true || $this->requiresApproval instanceof Closure; + } + + /** + * @param array $arguments + */ + public function needsApproval(array $arguments = []): bool + { + if ($this->requiresApproval instanceof Closure) { + return (bool) ($this->requiresApproval)($arguments); + } + + return $this->requiresApproval; + } + public function make(string|object $tool): Tool { if (is_string($tool)) { @@ -262,7 +328,9 @@ public function failedHandler(): null|false|Closure public function handle(...$args): string|ToolOutput|ToolError { try { - $value = call_user_func($this->fn, ...$args); + $callable = $this->resolveHandler(); + + $value = call_user_func($callable, ...$this->coerceArguments($callable, $args)); if (is_string($value)) { return $value; @@ -281,6 +349,106 @@ public function handle(...$args): string|ToolOutput|ToolError } } + /** + * Coerce model-supplied string arguments into the scalar or BackedEnum + * types declared by the handler's signature. Models routinely serialize + * every argument as a JSON string (notably Llama models on Groq) even + * when the schema declares boolean or number, and under strict types the + * handler would otherwise fail with a TypeError. Arguments that don't + * match a declared parameter pass through untouched so the existing + * validation-error handling still reports them. + * + * @param array $args + * @return array + */ + protected function coerceArguments(callable $callable, array $args): array + { + if (array_is_list($args)) { + return $args; + } + + try { + $reflection = new \ReflectionFunction(Closure::fromCallable($callable)); + } catch (\ReflectionException) { + return $args; + } + + $parameters = collect($reflection->getParameters())->keyBy( + fn (\ReflectionParameter $parameter): string => $parameter->getName() + ); + + foreach ($args as $name => $value) { + /** @var \ReflectionParameter|null $parameter */ + $parameter = $parameters->get($name); + $type = $parameter?->getType(); + + if ($type instanceof \ReflectionNamedType) { + $args[$name] = $this->coerceValue($value, $type); + } + } + + return $args; + } + + protected function coerceValue(mixed $value, \ReflectionNamedType $type): mixed + { + $typeName = $type->getName(); + + if (is_a($typeName, \BackedEnum::class, true)) { + $backingType = (new \ReflectionEnum($typeName))->getBackingType(); + + $candidate = $backingType instanceof \ReflectionNamedType && $backingType->getName() === 'int' + ? (is_numeric($value) ? (int) $value : null) + : (is_string($value) ? $value : null); + + return $candidate === null ? $value : ($typeName::tryFrom($candidate) ?? $value); + } + + if (! is_string($value)) { + return $value; + } + + return match ($typeName) { + 'int' => is_numeric($value) ? (int) $value : $value, + 'float' => is_numeric($value) ? (float) $value : $value, + 'bool' => match (strtolower($value)) { + 'true', '1' => true, + 'false', '0' => false, + default => $value, + }, + default => $value, + }; + } + + /** + * Resolve the callable handler for this tool. + * + * Priority: explicit $fn > invokable subclass (__invoke) > error. + * Also unwraps SerializableClosure wrappers that break named arguments. + */ + protected function resolveHandler(): callable + { + $fn = $this->fn; + + if ($fn === null && method_exists($this, '__invoke')) { + $fn = $this; + } + + if ($fn === null) { + throw new PrismException("Tool handler not defined for tool: {$this->name}"); + } + + // After ProcessDriver deserialization, $fn may become a + // SerializableClosure\Serializers\Native whose __invoke doesn't + // forward PHP 8 named arguments. Unwrap via getClosure() to + // recover the real Closure so named-arg spreading works. + if (is_object($fn) && method_exists($fn, 'getClosure')) { + return $fn->getClosure(); + } + + return $fn; + } + protected function shouldHandleErrors(): bool { return $this->failedHandler !== false; diff --git a/src/Tools/LaravelMcpTool.php b/src/Tools/LaravelMcpTool.php index e722a3380..b89fb563e 100644 --- a/src/Tools/LaravelMcpTool.php +++ b/src/Tools/LaravelMcpTool.php @@ -17,8 +17,7 @@ class LaravelMcpTool extends Tool public function __construct(private readonly \Laravel\Mcp\Server\Tool $tool) { $this->as($tool->name()) - ->for($tool->description()) - ->using($this); + ->for($tool->description()); $data = $tool->toArray(); $properties = $data['inputSchema']['properties'] ?? []; diff --git a/src/ValueObjects/Media/Media.php b/src/ValueObjects/Media/Media.php index 90c4bd096..fe1628c3d 100644 --- a/src/ValueObjects/Media/Media.php +++ b/src/ValueObjects/Media/Media.php @@ -249,14 +249,15 @@ public function base64(): ?string public function mimeType(): ?string { - if ($this->mimeType) { - return $this->mimeType; - } - - if ($content = $this->rawContent()) { + if ($this->mimeType === null && $content = $this->rawContent()) { $this->mimeType = (new finfo(FILEINFO_MIME_TYPE))->buffer($content) ?: null; } + $this->mimeType = match ($this->mimeType) { + 'audio/x-wav', 'audio/wave', 'audio/x-pn-wav', 'audio/vnd.wave' => 'audio/wav', + default => $this->mimeType, + }; + return $this->mimeType; } diff --git a/src/ValueObjects/Messages/AssistantMessage.php b/src/ValueObjects/Messages/AssistantMessage.php index 9217f1704..7221cb9df 100644 --- a/src/ValueObjects/Messages/AssistantMessage.php +++ b/src/ValueObjects/Messages/AssistantMessage.php @@ -7,6 +7,7 @@ use Illuminate\Contracts\Support\Arrayable; use Prism\Prism\Concerns\HasProviderOptions; use Prism\Prism\Contracts\Message; +use Prism\Prism\ValueObjects\ToolApprovalRequest; use Prism\Prism\ValueObjects\ToolCall; /** @@ -19,11 +20,13 @@ class AssistantMessage implements Arrayable, Message /** * @param ToolCall[] $toolCalls * @param array $additionalContent + * @param ToolApprovalRequest[] $toolApprovalRequests Approval requests for approval-required tools (not sent to the LLM; used for correlation) */ public function __construct( public readonly string $content, public readonly array $toolCalls = [], - public readonly array $additionalContent = [] + public readonly array $additionalContent = [], + public readonly array $toolApprovalRequests = [] ) {} /** @@ -37,6 +40,7 @@ public function toArray(): array 'content' => $this->content, 'tool_calls' => array_map(fn (ToolCall $toolCall): array => $toolCall->toArray(), $this->toolCalls), 'additional_content' => $this->additionalContent, + 'tool_approval_requests' => array_map(fn (ToolApprovalRequest $request): array => $request->toArray(), $this->toolApprovalRequests), ]; } } diff --git a/src/ValueObjects/Messages/ToolResultMessage.php b/src/ValueObjects/Messages/ToolResultMessage.php index 51a19f00e..f1e3da058 100644 --- a/src/ValueObjects/Messages/ToolResultMessage.php +++ b/src/ValueObjects/Messages/ToolResultMessage.php @@ -7,6 +7,7 @@ use Illuminate\Contracts\Support\Arrayable; use Prism\Prism\Concerns\HasProviderOptions; use Prism\Prism\Contracts\Message; +use Prism\Prism\ValueObjects\ToolApprovalResponse; use Prism\Prism\ValueObjects\ToolResult; /** @@ -18,11 +19,24 @@ class ToolResultMessage implements Arrayable, Message /** * @param ToolResult[] $toolResults + * @param ToolApprovalResponse[] $toolApprovalResponses Approval decisions supplied by the application for pending approval requests */ public function __construct( - public readonly array $toolResults + public readonly array $toolResults = [], + public readonly array $toolApprovalResponses = [] ) {} + public function findByApprovalId(string $approvalId): ?ToolApprovalResponse + { + foreach ($this->toolApprovalResponses as $response) { + if ($response->approvalId === $approvalId) { + return $response; + } + } + + return null; + } + /** * @return array */ @@ -32,6 +46,7 @@ public function toArray(): array return [ 'type' => 'tool_result', 'tool_results' => array_map(fn (ToolResult $toolResult): array => $toolResult->toArray(), $this->toolResults), + 'tool_approval_responses' => array_map(fn (ToolApprovalResponse $response): array => $response->toArray(), $this->toolApprovalResponses), ]; } } diff --git a/src/ValueObjects/ToolApprovalRequest.php b/src/ValueObjects/ToolApprovalRequest.php new file mode 100644 index 000000000..91963aab2 --- /dev/null +++ b/src/ValueObjects/ToolApprovalRequest.php @@ -0,0 +1,33 @@ + + */ +readonly class ToolApprovalRequest implements Arrayable +{ + public function __construct( + public string $approvalId, + public string $toolCallId, + ) {} + + /** + * @return array + */ + #[\Override] + public function toArray(): array + { + return [ + 'approval_id' => $this->approvalId, + 'tool_call_id' => $this->toolCallId, + ]; + } +} diff --git a/src/ValueObjects/ToolApprovalResponse.php b/src/ValueObjects/ToolApprovalResponse.php new file mode 100644 index 000000000..d3756b4cf --- /dev/null +++ b/src/ValueObjects/ToolApprovalResponse.php @@ -0,0 +1,32 @@ + + */ +readonly class ToolApprovalResponse implements Arrayable +{ + public function __construct( + public string $approvalId, + public bool $approved, + public ?string $reason = null, + ) {} + + /** + * @return array + */ + #[\Override] + public function toArray(): array + { + return [ + 'approval_id' => $this->approvalId, + 'approved' => $this->approved, + 'reason' => $this->reason, + ]; + } +} diff --git a/src/ValueObjects/ToolCall.php b/src/ValueObjects/ToolCall.php index 7329979ed..c429cb682 100644 --- a/src/ValueObjects/ToolCall.php +++ b/src/ValueObjects/ToolCall.php @@ -5,6 +5,8 @@ namespace Prism\Prism\ValueObjects; use Illuminate\Contracts\Support\Arrayable; +use JsonException; +use Prism\Prism\Exceptions\PrismException; /** * @implements Arrayable @@ -34,13 +36,29 @@ public function arguments(): array return []; } - $arguments = $this->arguments; + try { + $decoded = json_decode( + $this->arguments, + true, + flags: JSON_THROW_ON_ERROR + ); + } catch (JsonException) { + // Some providers (e.g. DeepSeek when streaming) emit raw control + // characters inside string values, which RFC 8259 requires to be + // escaped. Escape them in place — rather than stripping them, which + // would corrupt intentional newlines/tabs — and decode again. + try { + $decoded = json_decode( + self::escapeControlCharactersInStrings($this->arguments), + true, + flags: JSON_THROW_ON_ERROR + ); + } catch (JsonException $e) { + throw PrismException::malformedToolCallArguments($this->name, $e); + } + } - return json_decode( - $arguments, - true, - flags: JSON_THROW_ON_ERROR - ); + return is_array($decoded) ? $decoded : []; } /** @var array $arguments */ @@ -64,4 +82,54 @@ public function toArray(): array 'reasoning_summary' => $this->reasoningSummary, ]; } + + /** + * Escape raw control characters (0x00–0x1F) that appear inside JSON string + * literals with their JSON escape sequences, and drop the ones that appear + * outside strings where they can never be valid (raw \t, \n and \r between + * tokens are legal whitespace and are kept). + */ + protected static function escapeControlCharactersInStrings(string $json): string + { + $result = ''; + $inString = false; + $escaped = false; + $length = strlen($json); + + for ($i = 0; $i < $length; $i++) { + $char = $json[$i]; + $ord = ord($char); + + if ($ord <= 0x1F) { + if ($inString) { + $result .= match ($char) { + "\x08" => '\b', + "\x09" => '\t', + "\x0A" => '\n', + "\x0C" => '\f', + "\x0D" => '\r', + default => sprintf('\u%04x', $ord), + }; + } elseif (in_array($char, ["\t", "\n", "\r"], true)) { + $result .= $char; + } + + $escaped = false; + + continue; + } + + $result .= $char; + + if ($escaped) { + $escaped = false; + } elseif ($inString && $char === '\\') { + $escaped = true; + } elseif ($char === '"') { + $inString = ! $inString; + } + } + + return $result; + } } diff --git a/src/ValueObjects/Usage.php b/src/ValueObjects/Usage.php index 5c3e648bc..ecb4b71be 100644 --- a/src/ValueObjects/Usage.php +++ b/src/ValueObjects/Usage.php @@ -17,6 +17,7 @@ public function __construct( public ?int $cacheWriteInputTokens = null, public ?int $cacheReadInputTokens = null, public ?int $thoughtTokens = null, + public ?float $cost = null, ) {} /** @@ -31,6 +32,7 @@ public function toArray(): array 'cache_write_input_tokens' => $this->cacheWriteInputTokens, 'cache_read_input_tokens' => $this->cacheReadInputTokens, 'thought_tokens' => $this->thoughtTokens, + 'cost' => $this->cost, ]; } } diff --git a/tests/Audio/PendingRequestTest.php b/tests/Audio/PendingRequestTest.php new file mode 100644 index 000000000..94295798c --- /dev/null +++ b/tests/Audio/PendingRequestTest.php @@ -0,0 +1,52 @@ +pendingRequest = new PendingRequest; +}); + +test('it generates a provider id response for speech to text', function (): void { + resolve('prism-manager')->extend('test-provider', fn ($config): ProviderContract => new TestProvider); + + $audio = Audio::fromUrl('https://example.com/audio.mp3', 'audio/mpeg'); + + $response = $this->pendingRequest + ->using('test-provider', 'test-model') + ->withInput($audio) + ->asTextProviderId(); + + $provider = $this->pendingRequest->provider(); + + expect($response) + ->toBeInstanceOf(ProviderIdResponse::class) + ->and($response->id)->toBe('provider-id') + ->and($provider->request)->toBeInstanceOf(SpeechToTextRequest::class) + ->and($provider->request->input())->toBe($audio); +}); + +test('it generates a response for async speech to text', function (): void { + resolve('prism-manager')->extend('test-provider', fn ($config): ProviderContract => new TestProvider); + + $providerId = 'provider-id-123'; + + $response = $this->pendingRequest + ->using('test-provider', 'test-model') + ->withInput($providerId) + ->asTextAsync(); + + $provider = $this->pendingRequest->provider(); + + expect($response->text)->toBe('Async transcript') + ->and($provider->request)->toBeInstanceOf(SpeechToTextAsyncRequest::class) + ->and($provider->request->model())->toBe('test-model') + ->and($provider->request->input())->toBe($providerId); +}); diff --git a/tests/Embeddings/ImageEmbeddingsTest.php b/tests/Embeddings/ImageEmbeddingsTest.php index a0146513e..62e1e5159 100644 --- a/tests/Embeddings/ImageEmbeddingsTest.php +++ b/tests/Embeddings/ImageEmbeddingsTest.php @@ -7,7 +7,6 @@ use Prism\Prism\Embeddings\Content; use Prism\Prism\Embeddings\PendingRequest; use Prism\Prism\Embeddings\Request; -use Prism\Prism\Embeddings\Response; use Prism\Prism\Exceptions\PrismException; use Prism\Prism\ValueObjects\Media\Image; @@ -103,7 +102,7 @@ it('throws exception when no embeddings content is provided', function (): void { $pendingRequest = new PendingRequest; - expect(fn (): Response => $pendingRequest->asEmbeddings()) + expect($pendingRequest->asEmbeddings(...)) ->toThrow(PrismException::class, 'Embeddings input is required (text, images, audio, video, documents, or content parts)'); }); diff --git a/tests/Embeddings/MultimodalEmbeddingsTest.php b/tests/Embeddings/MultimodalEmbeddingsTest.php index 6c945235e..06733d24b 100644 --- a/tests/Embeddings/MultimodalEmbeddingsTest.php +++ b/tests/Embeddings/MultimodalEmbeddingsTest.php @@ -7,7 +7,6 @@ use Prism\Prism\Embeddings\Content; use Prism\Prism\Embeddings\PendingRequest; use Prism\Prism\Embeddings\Request; -use Prism\Prism\Embeddings\Response; use Prism\Prism\Exceptions\PrismException; use Prism\Prism\ValueObjects\Media\Audio; use Prism\Prism\ValueObjects\Media\Document; @@ -70,6 +69,6 @@ it('throws exception when no embeddings content is provided', function (): void { $pendingRequest = new PendingRequest; - expect(fn (): Response => $pendingRequest->asEmbeddings()) + expect($pendingRequest->asEmbeddings(...)) ->toThrow(PrismException::class, 'Embeddings input is required (text, images, audio, video, documents, or content parts)'); }); diff --git a/tests/Fixtures/anthropic/batch-cancel-1.json b/tests/Fixtures/anthropic/batch-cancel-1.json new file mode 100644 index 000000000..cabd3d751 --- /dev/null +++ b/tests/Fixtures/anthropic/batch-cancel-1.json @@ -0,0 +1,18 @@ +{ + "id": "msgbatch_013Zva2CMHLNnXjNJJKqJ2EF", + "archived_at": "2024-08-20T18:37:24.100435Z", + "cancel_initiated_at": "2024-08-20T18:37:24.100435Z", + "created_at": "2024-08-20T18:37:24.100435Z", + "ended_at": "2024-08-20T18:37:24.100435Z", + "expires_at": "2024-08-20T18:37:24.100435Z", + "processing_status": "in_progress", + "request_counts": { + "canceled": 10, + "errored": 30, + "expired": 10, + "processing": 100, + "succeeded": 50 + }, + "results_url": "https://api.anthropic.com/v1/messages/batches/msgbatch_013Zva2CMHLNnXjNJJKqJ2EF/results", + "type": "message_batch" +} \ No newline at end of file diff --git a/tests/Fixtures/anthropic/batch-create-1.json b/tests/Fixtures/anthropic/batch-create-1.json new file mode 100644 index 000000000..3ccbfd6ce --- /dev/null +++ b/tests/Fixtures/anthropic/batch-create-1.json @@ -0,0 +1,18 @@ +{ + "id": "msgbatch_01MHsMbyYXTBsr4VGuENCexC", + "type": "message_batch", + "processing_status": "in_progress", + "request_counts": { + "processing": 2, + "succeeded": 0, + "errored": 0, + "canceled": 0, + "expired": 0 + }, + "ended_at": null, + "created_at": "2026-03-14T16:01:43.705811+00:00", + "expires_at": "2026-03-15T16:01:43.705811+00:00", + "archived_at": null, + "cancel_initiated_at": null, + "results_url": null +} \ No newline at end of file diff --git a/tests/Fixtures/anthropic/batch-list-1.json b/tests/Fixtures/anthropic/batch-list-1.json new file mode 100644 index 000000000..fa5acce47 --- /dev/null +++ b/tests/Fixtures/anthropic/batch-list-1.json @@ -0,0 +1,25 @@ +{ + "data": [ + { + "id": "msgbatch_013Zva2CMHLNnXjNJJKqJ2EF", + "archived_at": "2024-08-20T18:37:24.100435Z", + "cancel_initiated_at": "2024-08-20T18:37:24.100435Z", + "created_at": "2024-08-20T18:37:24.100435Z", + "ended_at": "2024-08-20T18:37:24.100435Z", + "expires_at": "2024-08-20T18:37:24.100435Z", + "processing_status": "in_progress", + "request_counts": { + "canceled": 10, + "errored": 30, + "expired": 10, + "processing": 100, + "succeeded": 50 + }, + "results_url": "https://api.anthropic.com/v1/messages/batches/msgbatch_013Zva2CMHLNnXjNJJKqJ2EF/results", + "type": "message_batch" + } + ], + "first_id": "first_id", + "has_more": true, + "last_id": "last_id" +} \ No newline at end of file diff --git a/tests/Fixtures/anthropic/batch-results-1.json b/tests/Fixtures/anthropic/batch-results-1.json new file mode 100644 index 000000000..39214c305 --- /dev/null +++ b/tests/Fixtures/anthropic/batch-results-1.json @@ -0,0 +1,2 @@ +{"custom_id":"request-1","result":{"type":"succeeded","message":{"model":"claude-sonnet-4-20250514","id":"msg_011q6qHe7t97tdeXV5HEaqy3","type":"message","role":"assistant","content":[{"type":"text","text":"Hello! Nice to meet you. How are you doing today?"}],"stop_reason":"end_turn","stop_sequence":null,"usage":{"input_tokens":10,"cache_creation_input_tokens":0,"cache_read_input_tokens":0,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":0},"output_tokens":16,"service_tier":"batch","inference_geo":"not_available"}}}} +{"custom_id":"request-2","result":{"type":"succeeded","message":{"model":"claude-sonnet-4-20250514","id":"msg_01WRCGrkoNXQPJEycL2eEwsM","type":"message","role":"assistant","content":[{"type":"text","text":"Hello! Nice to see you again. How are you doing today?"}],"stop_reason":"end_turn","stop_sequence":null,"usage":{"input_tokens":10,"cache_creation_input_tokens":0,"cache_read_input_tokens":0,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":0},"output_tokens":17,"service_tier":"batch","inference_geo":"not_available"}}}} \ No newline at end of file diff --git a/tests/Fixtures/anthropic/batch-retrieve-1.json b/tests/Fixtures/anthropic/batch-retrieve-1.json new file mode 100644 index 000000000..cabd3d751 --- /dev/null +++ b/tests/Fixtures/anthropic/batch-retrieve-1.json @@ -0,0 +1,18 @@ +{ + "id": "msgbatch_013Zva2CMHLNnXjNJJKqJ2EF", + "archived_at": "2024-08-20T18:37:24.100435Z", + "cancel_initiated_at": "2024-08-20T18:37:24.100435Z", + "created_at": "2024-08-20T18:37:24.100435Z", + "ended_at": "2024-08-20T18:37:24.100435Z", + "expires_at": "2024-08-20T18:37:24.100435Z", + "processing_status": "in_progress", + "request_counts": { + "canceled": 10, + "errored": 30, + "expired": 10, + "processing": 100, + "succeeded": 50 + }, + "results_url": "https://api.anthropic.com/v1/messages/batches/msgbatch_013Zva2CMHLNnXjNJJKqJ2EF/results", + "type": "message_batch" +} \ No newline at end of file diff --git a/tests/Fixtures/anthropic/file-delete-1.json b/tests/Fixtures/anthropic/file-delete-1.json new file mode 100644 index 000000000..8dd25fb26 --- /dev/null +++ b/tests/Fixtures/anthropic/file-delete-1.json @@ -0,0 +1 @@ +{"id":"file_011CZ3bbhcz6rgAXqe8pCLcX","type":"file_deleted"} \ No newline at end of file diff --git a/tests/Fixtures/anthropic/file-download-1.json b/tests/Fixtures/anthropic/file-download-1.json new file mode 100644 index 000000000..d2277ca14 --- /dev/null +++ b/tests/Fixtures/anthropic/file-download-1.json @@ -0,0 +1,3 @@ +{ + "content": "This is a test file content." +} \ No newline at end of file diff --git a/tests/Fixtures/anthropic/file-get-metadata-1.json b/tests/Fixtures/anthropic/file-get-metadata-1.json new file mode 100644 index 000000000..07ff1a252 --- /dev/null +++ b/tests/Fixtures/anthropic/file-get-metadata-1.json @@ -0,0 +1 @@ +{"type":"file","id":"file_011CZ3cYKGMZVroCGkmxwWkz","size_bytes":17,"created_at":"2026-03-14T20:35:54.416000Z","filename":"test1.txt","mime_type":"text/plain","downloadable":false} \ No newline at end of file diff --git a/tests/Fixtures/anthropic/file-list-1.json b/tests/Fixtures/anthropic/file-list-1.json new file mode 100644 index 000000000..73e7ff0ab --- /dev/null +++ b/tests/Fixtures/anthropic/file-list-1.json @@ -0,0 +1,25 @@ +{ + "data": [ + { + "id": "file-abc123", + "type": "file", + "filename": "data.jsonl", + "mime_type": "application/jsonl", + "size_bytes": 1024, + "created_at": "2026-03-14T16:01:43.705811+00:00", + "downloadable": true + }, + { + "id": "file-def456", + "type": "file", + "filename": "output.jsonl", + "mime_type": "application/jsonl", + "size_bytes": 2048, + "created_at": "2026-03-14T16:05:00.000000+00:00", + "downloadable": true + } + ], + "has_more": false, + "first_id": "file-abc123", + "last_id": "file-def456" +} \ No newline at end of file diff --git a/tests/Fixtures/anthropic/file-upload-1.json b/tests/Fixtures/anthropic/file-upload-1.json new file mode 100644 index 000000000..fb86704dd --- /dev/null +++ b/tests/Fixtures/anthropic/file-upload-1.json @@ -0,0 +1 @@ +{"type":"file","id":"file_011CZ3bbhcz6rgAXqe8pCLcX","size_bytes":17,"created_at":"2026-03-14T20:23:33.521000Z","filename":"data.jsonl","mime_type":"application/octet-stream","downloadable":false} \ No newline at end of file diff --git a/tests/Fixtures/anthropic/generate-text-with-pause-turn-1.json b/tests/Fixtures/anthropic/generate-text-with-pause-turn-1.json new file mode 100644 index 000000000..ce50f019b --- /dev/null +++ b/tests/Fixtures/anthropic/generate-text-with-pause-turn-1.json @@ -0,0 +1 @@ +{"id":"msg_01PauseTurnExample","type":"message","role":"assistant","model":"claude-3-5-sonnet-20240620","content":[{"type":"text","text":"Let me look that up for you."}],"stop_reason":"pause_turn","stop_sequence":null,"usage":{"input_tokens":42,"output_tokens":12}} diff --git a/tests/Fixtures/anthropic/generate-text-with-pause-turn-2.json b/tests/Fixtures/anthropic/generate-text-with-pause-turn-2.json new file mode 100644 index 000000000..ecc76e12d --- /dev/null +++ b/tests/Fixtures/anthropic/generate-text-with-pause-turn-2.json @@ -0,0 +1 @@ +{"id":"msg_01PauseTurnExampleResume","type":"message","role":"assistant","model":"claude-3-5-sonnet-20240620","content":[{"type":"text","text":"Here is what I found: the answer is 42."}],"stop_reason":"end_turn","stop_sequence":null,"usage":{"input_tokens":58,"output_tokens":15}} diff --git a/tests/Fixtures/anthropic/generate-text-with-refusal-1.json b/tests/Fixtures/anthropic/generate-text-with-refusal-1.json new file mode 100644 index 000000000..d168be143 --- /dev/null +++ b/tests/Fixtures/anthropic/generate-text-with-refusal-1.json @@ -0,0 +1 @@ +{"id":"msg_01RefusalExample","type":"message","role":"assistant","model":"claude-3-5-sonnet-20240620","content":[{"type":"text","text":""}],"stop_reason":"refusal","stop_sequence":null,"usage":{"input_tokens":12,"output_tokens":0}} diff --git a/tests/Fixtures/anthropic/generate-text-with-web-search-and-tool-call-1.json b/tests/Fixtures/anthropic/generate-text-with-web-search-and-tool-call-1.json new file mode 100644 index 000000000..17feafb7d --- /dev/null +++ b/tests/Fixtures/anthropic/generate-text-with-web-search-and-tool-call-1.json @@ -0,0 +1 @@ +{"id":"msg_01ABC123WebSearchToolCall","type":"message","role":"assistant","model":"claude-3-5-haiku-20241022","content":[{"type":"server_tool_use","id":"srvtoolu_01WebSearch123","name":"web_search","input":{"query":"London weather today"}},{"type":"web_search_tool_result","tool_use_id":"srvtoolu_01WebSearch123","content":[{"type":"web_search_result","title":"London Weather Today","url":"https://www.example.com/london-weather","encrypted_content":"EncryptedContentPlaceholder123","page_age":"1 hour ago"}]},{"type":"text","text":"Based on the search results, London's weather today is around 18°C. ","citations":[{"type":"web_search_result_location","cited_text":"London's current temperature is 18°C with partly cloudy skies.","url":"https://www.example.com/london-weather","title":"London Weather Today","encrypted_index":"EncryptedIndexPlaceholder123"}]},{"type":"text","text":"Let me also check the detailed forecast for you."},{"type":"tool_use","id":"toolu_01WeatherTool123","name":"weather","input":{"city":"London"}}],"stop_reason":"tool_use","stop_sequence":null,"usage":{"input_tokens":500,"output_tokens":150,"cache_creation_input_tokens":0,"cache_read_input_tokens":0,"service_tier":"standard"}} diff --git a/tests/Fixtures/anthropic/generate-text-with-web-search-and-tool-call-2.json b/tests/Fixtures/anthropic/generate-text-with-web-search-and-tool-call-2.json new file mode 100644 index 000000000..f60820aff --- /dev/null +++ b/tests/Fixtures/anthropic/generate-text-with-web-search-and-tool-call-2.json @@ -0,0 +1 @@ +{"id":"msg_01DEF456WebSearchToolCall","type":"message","role":"assistant","model":"claude-3-5-haiku-20241022","content":[{"type":"text","text":"Based on the web search and the detailed forecast, London's weather today is 18°C with partly cloudy skies. The detailed forecast shows a high of 21°C and a low of 14°C, with a chance of light rain in the afternoon."}],"stop_reason":"end_turn","stop_sequence":null,"usage":{"input_tokens":700,"output_tokens":60,"cache_creation_input_tokens":0,"cache_read_input_tokens":0,"service_tier":"standard"}} diff --git a/tests/Fixtures/anthropic/list-files-1.json b/tests/Fixtures/anthropic/list-files-1.json new file mode 100644 index 000000000..43879ddae --- /dev/null +++ b/tests/Fixtures/anthropic/list-files-1.json @@ -0,0 +1,25 @@ +{ + "data": [ + { + "type": "file", + "id": "file_011CZ3cYKGMZVroCGkmxwWkz", + "size_bytes": 17, + "created_at": "2026-03-14T20:35:54.416000Z", + "filename": "test1.txt", + "mime_type": "text/plain", + "downloadable": false + }, + { + "type": "file", + "id": "file_011CZ3bbhcz6rgAXqe8pCLcX", + "size_bytes": 17, + "created_at": "2026-03-14T20:23:33.521000Z", + "filename": "data.jsonl", + "mime_type": "application/octet-stream", + "downloadable": false + } + ], + "has_more": false, + "first_id": "file_011CZ3cYKGMZVroCGkmxwWkz", + "last_id": "file_011CZ3bbhcz6rgAXqe8pCLcX" +} \ No newline at end of file diff --git a/tests/Fixtures/anthropic/list-files-with-pagination-1.json b/tests/Fixtures/anthropic/list-files-with-pagination-1.json new file mode 100644 index 000000000..0cbfd738d --- /dev/null +++ b/tests/Fixtures/anthropic/list-files-with-pagination-1.json @@ -0,0 +1 @@ +{"data":[{"type":"file","id":"file_011CZ3bbhcz6rgAXqe8pCLcX","size_bytes":17,"created_at":"2026-03-14T20:23:33.521000Z","filename":"data.jsonl","mime_type":"application/octet-stream","downloadable":false}],"has_more":false,"first_id":"file_011CZ3bbhcz6rgAXqe8pCLcX","last_id":"file_011CZ3bbhcz6rgAXqe8pCLcX"} \ No newline at end of file diff --git a/tests/Fixtures/anthropic/stream-with-approval-tool-1.sse b/tests/Fixtures/anthropic/stream-with-approval-tool-1.sse new file mode 100644 index 000000000..dbf064d2d --- /dev/null +++ b/tests/Fixtures/anthropic/stream-with-approval-tool-1.sse @@ -0,0 +1,30 @@ +event: message_start +data: {"type":"message_start","message":{"id":"msg_approval_tool_test","type":"message","role":"assistant","model":"claude-3-5-sonnet-20240620","content":[],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":100,"cache_creation_input_tokens":0,"cache_read_input_tokens":0,"output_tokens":1}}} + +event: content_block_start +data: {"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}} + +event: content_block_delta +data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"I'll delete the file for you."}} + +event: content_block_stop +data: {"type":"content_block_stop","index":0} + +event: content_block_start +data: {"type":"content_block_start","index":1,"content_block":{"type":"tool_use","id":"toolu_delete_file_stream","name":"delete_file","input":{}}} + +event: content_block_delta +data: {"type":"content_block_delta","index":1,"delta":{"type":"input_json_delta","partial_json":""}} + +event: content_block_delta +data: {"type":"content_block_delta","index":1,"delta":{"type":"input_json_delta","partial_json":"{\"path\": \"/tmp/test.txt\"}"}} + +event: content_block_stop +data: {"type":"content_block_stop","index":1} + +event: message_delta +data: {"type":"message_delta","delta":{"stop_reason":"tool_use","stop_sequence":null},"usage":{"output_tokens":50}} + +event: message_stop +data: {"type":"message_stop"} + diff --git a/tests/Fixtures/anthropic/stream-with-client-executed-tool-1.sse b/tests/Fixtures/anthropic/stream-with-client-executed-tool-1.sse new file mode 100644 index 000000000..e6fcba676 --- /dev/null +++ b/tests/Fixtures/anthropic/stream-with-client-executed-tool-1.sse @@ -0,0 +1,31 @@ +event: message_start +data: {"type":"message_start","message":{"id":"msg_client_executed_test","type":"message","role":"assistant","model":"claude-3-5-sonnet-20240620","content":[],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":100,"cache_creation_input_tokens":0,"cache_read_input_tokens":0,"output_tokens":1}}} + +event: content_block_start +data: {"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}} + +event: content_block_delta +data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"I'll use the client tool to help you."}} + +event: content_block_stop +data: {"type":"content_block_stop","index":0} + +event: content_block_start +data: {"type":"content_block_start","index":1,"content_block":{"type":"tool_use","id":"toolu_client_tool_stream","name":"client_tool","input":{}}} + +event: content_block_delta +data: {"type":"content_block_delta","index":1,"delta":{"type":"input_json_delta","partial_json":""}} + +event: content_block_delta +data: {"type":"content_block_delta","index":1,"delta":{"type":"input_json_delta","partial_json":"{\"input\": \"test input\"}"}} + +event: content_block_stop +data: {"type":"content_block_stop","index":1} + +event: message_delta +data: {"type":"message_delta","delta":{"stop_reason":"tool_use","stop_sequence":null},"usage":{"output_tokens":50}} + +event: message_stop +data: {"type":"message_stop"} + + diff --git a/tests/Fixtures/anthropic/stream-with-web-search-and-tool-call-1.sse b/tests/Fixtures/anthropic/stream-with-web-search-and-tool-call-1.sse new file mode 100644 index 000000000..7a3bafd0b --- /dev/null +++ b/tests/Fixtures/anthropic/stream-with-web-search-and-tool-call-1.sse @@ -0,0 +1,63 @@ +event: message_start +data: {"type":"message_start","message":{"id":"msg_01StreamWebSearchToolCall","type":"message","role":"assistant","model":"claude-3-5-haiku-20241022","content":[],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":500,"cache_creation_input_tokens":0,"cache_read_input_tokens":0,"output_tokens":2}}} + +event: content_block_start +data: {"type":"content_block_start","index":0,"content_block":{"type":"server_tool_use","id":"srvtoolu_01StreamWebSearch","name":"web_search","input":{}}} + +event: content_block_delta +data: {"type":"content_block_delta","index":0,"delta":{"type":"input_json_delta","partial_json":""}} + +event: content_block_delta +data: {"type":"content_block_delta","index":0,"delta":{"type":"input_json_delta","partial_json":"{\"query\": \"London weather today\"}"}} + +event: content_block_stop +data: {"type":"content_block_stop","index":0} + +event: content_block_start +data: {"type":"content_block_start","index":1,"content_block":{"type":"web_search_tool_result","tool_use_id":"srvtoolu_01StreamWebSearch","content":[{"type":"web_search_result","title":"London Weather Today","url":"https://www.example.com/london-weather","encrypted_content":"EncryptedContentPlaceholder123","page_age":"1 hour ago"}]}} + +event: content_block_stop +data: {"type":"content_block_stop","index":1} + +event: content_block_start +data: {"type":"content_block_start","index":2,"content_block":{"citations":[],"type":"text","text":""}} + +event: content_block_delta +data: {"type":"content_block_delta","index":2,"delta":{"type":"citations_delta","citation":{"type":"web_search_result_location","cited_text":"London's current temperature is 18°C with partly cloudy skies.","url":"https://www.example.com/london-weather","title":"London Weather Today","encrypted_index":"EncryptedIndexPlaceholder123"}}} + +event: content_block_delta +data: {"type":"content_block_delta","index":2,"delta":{"type":"text_delta","text":"Based on the search results, London"}} + +event: content_block_delta +data: {"type":"content_block_delta","index":2,"delta":{"type":"text_delta","text":"'s weather today is around 18°C."}} + +event: content_block_stop +data: {"type":"content_block_stop","index":2} + +event: content_block_start +data: {"type":"content_block_start","index":3,"content_block":{"type":"text","text":""}} + +event: content_block_delta +data: {"type":"content_block_delta","index":3,"delta":{"type":"text_delta","text":" Let me also check the detailed forecast."}} + +event: content_block_stop +data: {"type":"content_block_stop","index":3} + +event: content_block_start +data: {"type":"content_block_start","index":4,"content_block":{"type":"tool_use","id":"toolu_01StreamWeatherTool","name":"weather","input":{}}} + +event: content_block_delta +data: {"type":"content_block_delta","index":4,"delta":{"type":"input_json_delta","partial_json":""}} + +event: content_block_delta +data: {"type":"content_block_delta","index":4,"delta":{"type":"input_json_delta","partial_json":"{\"city\": \"London\"}"}} + +event: content_block_stop +data: {"type":"content_block_stop","index":4} + +event: message_delta +data: {"type":"message_delta","delta":{"stop_reason":"tool_use","stop_sequence":null},"usage":{"output_tokens":150,"server_tool_use":{"web_search_requests":1}}} + +event: message_stop +data: {"type":"message_stop"} + diff --git a/tests/Fixtures/anthropic/stream-with-web-search-and-tool-call-2.sse b/tests/Fixtures/anthropic/stream-with-web-search-and-tool-call-2.sse new file mode 100644 index 000000000..766cf963c --- /dev/null +++ b/tests/Fixtures/anthropic/stream-with-web-search-and-tool-call-2.sse @@ -0,0 +1,24 @@ +event: message_start +data: {"type":"message_start","message":{"id":"msg_02StreamWebSearchToolCall","type":"message","role":"assistant","model":"claude-3-5-haiku-20241022","content":[],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":700,"cache_creation_input_tokens":0,"cache_read_input_tokens":0,"output_tokens":2}}} + +event: content_block_start +data: {"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}} + +event: content_block_delta +data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"Based on the web search and the detailed forecast, London"}} + +event: content_block_delta +data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"'s weather today is 18°C with partly cloudy skies."}} + +event: content_block_delta +data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":" The detailed forecast shows a high of 21°C."}} + +event: content_block_stop +data: {"type":"content_block_stop","index":0} + +event: message_delta +data: {"type":"message_delta","delta":{"stop_reason":"end_turn","stop_sequence":null},"usage":{"output_tokens":60}} + +event: message_stop +data: {"type":"message_stop"} + diff --git a/tests/Fixtures/anthropic/text-with-approval-phase2-1.json b/tests/Fixtures/anthropic/text-with-approval-phase2-1.json new file mode 100644 index 000000000..842ab7d40 --- /dev/null +++ b/tests/Fixtures/anthropic/text-with-approval-phase2-1.json @@ -0,0 +1 @@ +{"id":"msg_01ApprovalPhase2","type":"message","role":"assistant","model":"claude-3-5-sonnet-20240620","content":[{"type":"text","text":"The file has been deleted successfully."}],"stop_reason":"end_turn","stop_sequence":null,"usage":{"input_tokens":150,"output_tokens":10}} diff --git a/tests/Fixtures/anthropic/text-with-approval-tool-1.json b/tests/Fixtures/anthropic/text-with-approval-tool-1.json new file mode 100644 index 000000000..3752697c5 --- /dev/null +++ b/tests/Fixtures/anthropic/text-with-approval-tool-1.json @@ -0,0 +1 @@ +{"id":"msg_01ApprovalToolTest","type":"message","role":"assistant","model":"claude-3-5-sonnet-20240620","content":[{"type":"text","text":"I'll delete the file for you."},{"type":"tool_use","id":"toolu_delete_file_123","name":"delete_file","input":{"path":"/tmp/test.txt"}}],"stop_reason":"tool_use","stop_sequence":null,"usage":{"input_tokens":100,"output_tokens":50}} diff --git a/tests/Fixtures/anthropic/text-with-client-executed-tool-1.json b/tests/Fixtures/anthropic/text-with-client-executed-tool-1.json new file mode 100644 index 000000000..9d409fb32 --- /dev/null +++ b/tests/Fixtures/anthropic/text-with-client-executed-tool-1.json @@ -0,0 +1,2 @@ +{"id":"msg_01ClientExecutedTest","type":"message","role":"assistant","model":"claude-3-5-sonnet-20240620","content":[{"type":"text","text":"I'll use the client tool to help you with that."},{"type":"tool_use","id":"toolu_client_tool_123","name":"client_tool","input":{"input":"test input"}}],"stop_reason":"tool_use","stop_sequence":null,"usage":{"input_tokens":100,"output_tokens":50}} + diff --git a/tests/Fixtures/groq/generate-text-with-mangled-tool-name-1.json b/tests/Fixtures/groq/generate-text-with-mangled-tool-name-1.json new file mode 100644 index 000000000..36e70272c --- /dev/null +++ b/tests/Fixtures/groq/generate-text-with-mangled-tool-name-1.json @@ -0,0 +1 @@ +{"id":"chatcmpl-mangled-1","object":"chat.completion","created":1740311145,"model":"llama-3.3-70b-versatile","choices":[{"index":0,"message":{"role":"assistant","tool_calls":[{"id":"call_m1","type":"function","function":{"name":"weather,{\"city\": \"Detroit\"}","arguments":""}}]},"logprobs":null,"finish_reason":"tool_calls"}],"usage":{"prompt_tokens":310,"completion_tokens":32,"total_tokens":342}} diff --git a/tests/Fixtures/groq/generate-text-with-mangled-tool-name-2.json b/tests/Fixtures/groq/generate-text-with-mangled-tool-name-2.json new file mode 100644 index 000000000..4ad2f0280 --- /dev/null +++ b/tests/Fixtures/groq/generate-text-with-mangled-tool-name-2.json @@ -0,0 +1 @@ +{"id":"chatcmpl-mangled-2","object":"chat.completion","created":1740311146,"model":"llama-3.3-70b-versatile","choices":[{"index":0,"message":{"role":"assistant","content":"The weather in Detroit is 75 and sunny."},"logprobs":null,"finish_reason":"stop"}],"usage":{"prompt_tokens":350,"completion_tokens":12,"total_tokens":362}} diff --git a/tests/Fixtures/mistral/fim-completion-1.json b/tests/Fixtures/mistral/fim-completion-1.json new file mode 100644 index 000000000..d9fb2b367 --- /dev/null +++ b/tests/Fixtures/mistral/fim-completion-1.json @@ -0,0 +1,23 @@ +{ + "id": "447e3e0d457e42e98248b5d2ef52a2a3", + "object": "chat.completion", + "model": "codestral-2405", + "usage": { + "prompt_tokens": 8, + "completion_tokens": 91, + "total_tokens": 99 + }, + "created": 1759496862, + "choices": [ + { + "index": 0, + "message": { + "content": "return a+b", + "tool_calls": null, + "prefix": false, + "role": "assistant" + }, + "finish_reason": "stop" + } + ] +} diff --git a/tests/Fixtures/mistral/structured-with-tool-call-1.json b/tests/Fixtures/mistral/structured-with-tool-call-1.json new file mode 100644 index 000000000..cf38d5b0f --- /dev/null +++ b/tests/Fixtures/mistral/structured-with-tool-call-1.json @@ -0,0 +1,31 @@ +{ + "id": "5b71f6e402c8460dae04b0d05a5eb04c", + "object": "chat.completion", + "created": 1742139900, + "model": "mistral-large-latest", + "choices": [ + { + "index": 0, + "message": { + "role": "assistant", + "content": "", + "tool_calls": [ + { + "id": "F6nM2Bw9x", + "type": "function", + "function": { + "name": "weather", + "arguments": "{\"city\": \"Detroit\"}" + } + } + ] + }, + "finish_reason": "tool_calls" + } + ], + "usage": { + "prompt_tokens": 120, + "total_tokens": 142, + "completion_tokens": 22 + } +} diff --git a/tests/Fixtures/mistral/structured-with-tool-call-2.json b/tests/Fixtures/mistral/structured-with-tool-call-2.json new file mode 100644 index 000000000..15c5a8c0d --- /dev/null +++ b/tests/Fixtures/mistral/structured-with-tool-call-2.json @@ -0,0 +1,22 @@ +{ + "id": "9a2c3b41d6f04e8b8a1c0d9e7f5a3b21", + "object": "chat.completion", + "created": 1742139905, + "model": "mistral-large-latest", + "choices": [ + { + "index": 0, + "message": { + "role": "assistant", + "tool_calls": null, + "content": "{\n \"weather\": \"75º and sunny\",\n \"game_time\": \"3pm\",\n \"coat_required\": false\n}" + }, + "finish_reason": "stop" + } + ], + "usage": { + "prompt_tokens": 175, + "total_tokens": 213, + "completion_tokens": 38 + } +} diff --git a/tests/Fixtures/mistral/structured-with-unused-tools-1.json b/tests/Fixtures/mistral/structured-with-unused-tools-1.json new file mode 100644 index 000000000..7a272d740 --- /dev/null +++ b/tests/Fixtures/mistral/structured-with-unused-tools-1.json @@ -0,0 +1,22 @@ +{ + "id": "e17d4b2a90cf4c66b7a2f3d1c8e9a054", + "object": "chat.completion", + "created": 1742139910, + "model": "mistral-large-latest", + "choices": [ + { + "index": 0, + "message": { + "role": "assistant", + "tool_calls": null, + "content": "The Tigers game is at 3pm in Detroit and it will be 75º and sunny, so you will not need a coat." + }, + "finish_reason": "stop" + } + ], + "usage": { + "prompt_tokens": 118, + "total_tokens": 150, + "completion_tokens": 32 + } +} diff --git a/tests/Fixtures/mistral/structured-with-unused-tools-2.json b/tests/Fixtures/mistral/structured-with-unused-tools-2.json new file mode 100644 index 000000000..3b1953813 --- /dev/null +++ b/tests/Fixtures/mistral/structured-with-unused-tools-2.json @@ -0,0 +1,22 @@ +{ + "id": "0f8c1d2e3a4b5c6d7e8f9a0b1c2d3e4f", + "object": "chat.completion", + "created": 1742139915, + "model": "mistral-large-latest", + "choices": [ + { + "index": 0, + "message": { + "role": "assistant", + "tool_calls": null, + "content": "{\n \"weather\": \"75º and sunny\",\n \"game_time\": \"3pm\",\n \"coat_required\": false\n}" + }, + "finish_reason": "stop" + } + ], + "usage": { + "prompt_tokens": 52, + "total_tokens": 88, + "completion_tokens": 36 + } +} diff --git a/tests/Fixtures/openai-chat-completions/generate-text-1.json b/tests/Fixtures/openai-chat-completions/generate-text-1.json new file mode 100644 index 000000000..d0f3e8196 --- /dev/null +++ b/tests/Fixtures/openai-chat-completions/generate-text-1.json @@ -0,0 +1,23 @@ +{ + "id": "chatcmpl-abc123def456", + "object": "chat.completion", + "created": 1730033815, + "model": "gpt-4o-mini", + "choices": [ + { + "index": 0, + "message": { + "role": "assistant", + "content": "I'm an AI assistant powered by OpenAI. How can I help you today?" + }, + "logprobs": null, + "finish_reason": "stop" + } + ], + "usage": { + "prompt_tokens": 13, + "completion_tokens": 16, + "total_tokens": 29 + }, + "system_fingerprint": "fp_abc123" +} diff --git a/tests/Fixtures/openai-chat-completions/generate-text-with-system-prompt-1.json b/tests/Fixtures/openai-chat-completions/generate-text-with-system-prompt-1.json new file mode 100644 index 000000000..56888d2ed --- /dev/null +++ b/tests/Fixtures/openai-chat-completions/generate-text-with-system-prompt-1.json @@ -0,0 +1,23 @@ +{ + "id": "chatcmpl-sys123def456", + "object": "chat.completion", + "created": 1730033815, + "model": "gpt-4o-mini", + "choices": [ + { + "index": 0, + "message": { + "role": "assistant", + "content": "Greetings, mortal. I am the ancient oracle of the digital realm." + }, + "logprobs": null, + "finish_reason": "stop" + } + ], + "usage": { + "prompt_tokens": 37, + "completion_tokens": 14, + "total_tokens": 51 + }, + "system_fingerprint": "fp_abc123" +} diff --git a/tests/Fixtures/openai-chat-completions/generate-text-with-tool-calls-1.json b/tests/Fixtures/openai-chat-completions/generate-text-with-tool-calls-1.json new file mode 100644 index 000000000..261763e9e --- /dev/null +++ b/tests/Fixtures/openai-chat-completions/generate-text-with-tool-calls-1.json @@ -0,0 +1,33 @@ +{ + "id": "chatcmpl-tool1", + "object": "chat.completion", + "created": 1730033815, + "model": "gpt-4o-mini", + "choices": [ + { + "index": 0, + "message": { + "role": "assistant", + "content": "", + "tool_calls": [ + { + "id": "call_abc123", + "type": "function", + "function": { + "name": "weather", + "arguments": "{\"city\":\"Detroit\"}" + } + } + ] + }, + "logprobs": null, + "finish_reason": "tool_calls" + } + ], + "usage": { + "prompt_tokens": 50, + "completion_tokens": 20, + "total_tokens": 70 + }, + "system_fingerprint": "fp_abc123" +} diff --git a/tests/Fixtures/openai-chat-completions/generate-text-with-tool-calls-2.json b/tests/Fixtures/openai-chat-completions/generate-text-with-tool-calls-2.json new file mode 100644 index 000000000..27b06307f --- /dev/null +++ b/tests/Fixtures/openai-chat-completions/generate-text-with-tool-calls-2.json @@ -0,0 +1,23 @@ +{ + "id": "chatcmpl-tool2", + "object": "chat.completion", + "created": 1730033816, + "model": "gpt-4o-mini", + "choices": [ + { + "index": 0, + "message": { + "role": "assistant", + "content": "The weather in Detroit is 75\u00b0 and sunny. No coat needed!" + }, + "logprobs": null, + "finish_reason": "stop" + } + ], + "usage": { + "prompt_tokens": 100, + "completion_tokens": 15, + "total_tokens": 115 + }, + "system_fingerprint": "fp_abc123" +} diff --git a/tests/Fixtures/openai-chat-completions/stream-basic-text-1.sse b/tests/Fixtures/openai-chat-completions/stream-basic-text-1.sse new file mode 100644 index 000000000..c4e11423b --- /dev/null +++ b/tests/Fixtures/openai-chat-completions/stream-basic-text-1.sse @@ -0,0 +1,29 @@ +data: {"id":"chatcmpl-stream123","object":"chat.completion.chunk","created":1736272055,"model":"gpt-4o-mini","choices":[{"index":0,"delta":{"role":"assistant","content":""},"finish_reason":null}]} + +data: {"id":"chatcmpl-stream123","object":"chat.completion.chunk","created":1736272055,"model":"gpt-4o-mini","choices":[{"index":0,"delta":{"content":"Hello"},"finish_reason":null}]} + +data: {"id":"chatcmpl-stream123","object":"chat.completion.chunk","created":1736272055,"model":"gpt-4o-mini","choices":[{"index":0,"delta":{"content":"!"},"finish_reason":null}]} + +data: {"id":"chatcmpl-stream123","object":"chat.completion.chunk","created":1736272055,"model":"gpt-4o-mini","choices":[{"index":0,"delta":{"content":" I'm"},"finish_reason":null}]} + +data: {"id":"chatcmpl-stream123","object":"chat.completion.chunk","created":1736272055,"model":"gpt-4o-mini","choices":[{"index":0,"delta":{"content":" an"},"finish_reason":null}]} + +data: {"id":"chatcmpl-stream123","object":"chat.completion.chunk","created":1736272055,"model":"gpt-4o-mini","choices":[{"index":0,"delta":{"content":" AI"},"finish_reason":null}]} + +data: {"id":"chatcmpl-stream123","object":"chat.completion.chunk","created":1736272055,"model":"gpt-4o-mini","choices":[{"index":0,"delta":{"content":" assistant"},"finish_reason":null}]} + +data: {"id":"chatcmpl-stream123","object":"chat.completion.chunk","created":1736272055,"model":"gpt-4o-mini","choices":[{"index":0,"delta":{"content":"."},"finish_reason":null}]} + +data: {"id":"chatcmpl-stream123","object":"chat.completion.chunk","created":1736272055,"model":"gpt-4o-mini","choices":[{"index":0,"delta":{"content":" How"},"finish_reason":null}]} + +data: {"id":"chatcmpl-stream123","object":"chat.completion.chunk","created":1736272055,"model":"gpt-4o-mini","choices":[{"index":0,"delta":{"content":" can"},"finish_reason":null}]} + +data: {"id":"chatcmpl-stream123","object":"chat.completion.chunk","created":1736272055,"model":"gpt-4o-mini","choices":[{"index":0,"delta":{"content":" I"},"finish_reason":null}]} + +data: {"id":"chatcmpl-stream123","object":"chat.completion.chunk","created":1736272055,"model":"gpt-4o-mini","choices":[{"index":0,"delta":{"content":" help"},"finish_reason":null}]} + +data: {"id":"chatcmpl-stream123","object":"chat.completion.chunk","created":1736272055,"model":"gpt-4o-mini","choices":[{"index":0,"delta":{"content":"?"},"finish_reason":null}]} + +data: {"id":"chatcmpl-stream123","object":"chat.completion.chunk","created":1736272055,"model":"gpt-4o-mini","choices":[{"index":0,"delta":{"content":""},"finish_reason":"stop"}],"usage":{"prompt_tokens":10,"completion_tokens":13,"total_tokens":23}} + +data: [DONE] diff --git a/tests/Fixtures/openai-chat-completions/stream-with-citations-1.sse b/tests/Fixtures/openai-chat-completions/stream-with-citations-1.sse new file mode 100644 index 000000000..75e1f638e --- /dev/null +++ b/tests/Fixtures/openai-chat-completions/stream-with-citations-1.sse @@ -0,0 +1,17 @@ +data: {"id":"chatcmpl-cite123","object":"chat.completion.chunk","created":1736272055,"model":"sonar","choices":[{"index":0,"delta":{"role":"assistant","content":""},"finish_reason":null}],"citations":["https://example.com/source1","https://example.com/source2"],"search_results":[{"title":"Source One","url":"https://example.com/source1","date":"2025-07-23","snippet":"First source snippet text","source":"web"},{"title":"Source Two","url":"https://example.com/source2","snippet":"Second source snippet text","source":"web"}]} + +data: {"id":"chatcmpl-cite123","object":"chat.completion.chunk","created":1736272055,"model":"sonar","choices":[{"index":0,"delta":{"content":"According"},"finish_reason":null}],"citations":["https://example.com/source1","https://example.com/source2"],"search_results":[{"title":"Source One","url":"https://example.com/source1","date":"2025-07-23","snippet":"First source snippet text","source":"web"},{"title":"Source Two","url":"https://example.com/source2","snippet":"Second source snippet text","source":"web"}]} + +data: {"id":"chatcmpl-cite123","object":"chat.completion.chunk","created":1736272055,"model":"sonar","choices":[{"index":0,"delta":{"content":" to"},"finish_reason":null}],"citations":["https://example.com/source1","https://example.com/source2"],"search_results":[{"title":"Source One","url":"https://example.com/source1","date":"2025-07-23","snippet":"First source snippet text","source":"web"},{"title":"Source Two","url":"https://example.com/source2","snippet":"Second source snippet text","source":"web"}]} + +data: {"id":"chatcmpl-cite123","object":"chat.completion.chunk","created":1736272055,"model":"sonar","choices":[{"index":0,"delta":{"content":" [1]"},"finish_reason":null}],"citations":["https://example.com/source1","https://example.com/source2"],"search_results":[{"title":"Source One","url":"https://example.com/source1","date":"2025-07-23","snippet":"First source snippet text","source":"web"},{"title":"Source Two","url":"https://example.com/source2","snippet":"Second source snippet text","source":"web"}]} + +data: {"id":"chatcmpl-cite123","object":"chat.completion.chunk","created":1736272055,"model":"sonar","choices":[{"index":0,"delta":{"content":" and"},"finish_reason":null}],"citations":["https://example.com/source1","https://example.com/source2"],"search_results":[{"title":"Source One","url":"https://example.com/source1","date":"2025-07-23","snippet":"First source snippet text","source":"web"},{"title":"Source Two","url":"https://example.com/source2","snippet":"Second source snippet text","source":"web"}]} + +data: {"id":"chatcmpl-cite123","object":"chat.completion.chunk","created":1736272055,"model":"sonar","choices":[{"index":0,"delta":{"content":" [2]"},"finish_reason":null}],"citations":["https://example.com/source1","https://example.com/source2"],"search_results":[{"title":"Source One","url":"https://example.com/source1","date":"2025-07-23","snippet":"First source snippet text","source":"web"},{"title":"Source Two","url":"https://example.com/source2","snippet":"Second source snippet text","source":"web"}]} + +data: {"id":"chatcmpl-cite123","object":"chat.completion.chunk","created":1736272055,"model":"sonar","choices":[{"index":0,"delta":{"content":", the answer is clear."},"finish_reason":null}],"citations":["https://example.com/source1","https://example.com/source2"],"search_results":[{"title":"Source One","url":"https://example.com/source1","date":"2025-07-23","snippet":"First source snippet text","source":"web"},{"title":"Source Two","url":"https://example.com/source2","snippet":"Second source snippet text","source":"web"}]} + +data: {"id":"chatcmpl-cite123","object":"chat.completion.chunk","created":1736272055,"model":"sonar","choices":[{"index":0,"delta":{"content":""},"finish_reason":"stop"}],"usage":{"prompt_tokens":25,"completion_tokens":12,"total_tokens":37},"citations":["https://example.com/source1","https://example.com/source2"],"search_results":[{"title":"Source One","url":"https://example.com/source1","date":"2025-07-23","snippet":"First source snippet text","source":"web"},{"title":"Source Two","url":"https://example.com/source2","snippet":"Second source snippet text","source":"web"}]} + +data: [DONE] diff --git a/tests/Fixtures/openai-chat-completions/structured-1.json b/tests/Fixtures/openai-chat-completions/structured-1.json new file mode 100644 index 000000000..c77ca8ce0 --- /dev/null +++ b/tests/Fixtures/openai-chat-completions/structured-1.json @@ -0,0 +1,23 @@ +{ + "id": "chatcmpl-struct123", + "object": "chat.completion", + "created": 1742308176, + "model": "gpt-4o-mini", + "choices": [ + { + "index": 0, + "message": { + "role": "assistant", + "content": "{\n \"game_time\": \"3pm\",\n \"weather\": \"75\u00ba\",\n \"coat_required\": false\n}" + }, + "logprobs": null, + "finish_reason": "stop" + } + ], + "usage": { + "prompt_tokens": 172, + "completion_tokens": 26, + "total_tokens": 198 + }, + "system_fingerprint": "fp_abc123" +} diff --git a/tests/Fixtures/openai/batch-cancel-1.json b/tests/Fixtures/openai/batch-cancel-1.json new file mode 100644 index 000000000..4a07a968c --- /dev/null +++ b/tests/Fixtures/openai/batch-cancel-1.json @@ -0,0 +1,26 @@ +{ + "id": "batch_abc123", + "object": "batch", + "endpoint": "/v1/responses", + "errors": null, + "input_file_id": "file-abc123", + "completion_window": "24h", + "status": "cancelling", + "output_file_id": null, + "error_file_id": null, + "created_at": 1727200644, + "in_progress_at": 1727200650, + "expires_at": 1727287044, + "finalizing_at": null, + "completed_at": null, + "failed_at": null, + "expired_at": null, + "cancelling_at": 1727201100, + "cancelled_at": null, + "request_counts": { + "total": 2, + "completed": 0, + "failed": 0 + }, + "metadata": null +} diff --git a/tests/Fixtures/openai/batch-create-1.json b/tests/Fixtures/openai/batch-create-1.json new file mode 100644 index 000000000..84f32f12c --- /dev/null +++ b/tests/Fixtures/openai/batch-create-1.json @@ -0,0 +1 @@ +{"id":"batch_abc123","object":"batch","endpoint":"/v1/responses","errors":null,"input_file_id":"file-abc123","completion_window":"24h","status":"validating","output_file_id":null,"error_file_id":null,"created_at":1727200644,"in_progress_at":null,"expires_at":1727287044,"finalizing_at":null,"completed_at":null,"failed_at":null,"expired_at":null,"cancelling_at":null,"cancelled_at":null,"request_counts":{"total":2,"completed":0,"failed":0},"metadata":null} \ No newline at end of file diff --git a/tests/Fixtures/openai/batch-file-upload-1.json b/tests/Fixtures/openai/batch-file-upload-1.json new file mode 100644 index 000000000..15e16d8eb --- /dev/null +++ b/tests/Fixtures/openai/batch-file-upload-1.json @@ -0,0 +1 @@ +{"id":"file-abc123","object":"file","bytes":2345,"created_at":1727200644,"filename":"batch_input.jsonl","purpose":"batch"} \ No newline at end of file diff --git a/tests/Fixtures/openai/batch-list-1.json b/tests/Fixtures/openai/batch-list-1.json new file mode 100644 index 000000000..080b6d7d1 --- /dev/null +++ b/tests/Fixtures/openai/batch-list-1.json @@ -0,0 +1 @@ +{"object":"list","data":[{"id":"batch_abc123","object":"batch","endpoint":"/v1/responses","errors":null,"input_file_id":"file-abc123","completion_window":"24h","status":"completed","output_file_id":"file-output-xyz","error_file_id":null,"created_at":1727200644,"in_progress_at":1727200650,"expires_at":1727287044,"finalizing_at":1727201000,"completed_at":1727201200,"failed_at":null,"expired_at":null,"cancelling_at":null,"cancelled_at":null,"request_counts":{"total":2,"completed":2,"failed":0},"metadata":null},{"id":"batch_def456","object":"batch","endpoint":"/v1/responses","errors":null,"input_file_id":"file-def456","completion_window":"24h","status":"in_progress","output_file_id":null,"error_file_id":null,"created_at":1727200700,"in_progress_at":1727200710,"expires_at":1727287100,"finalizing_at":null,"completed_at":null,"failed_at":null,"expired_at":null,"cancelling_at":null,"cancelled_at":null,"request_counts":{"total":3,"completed":0,"failed":0},"metadata":null}],"first_id":"batch_abc123","last_id":"batch_def456","has_more":true} \ No newline at end of file diff --git a/tests/Fixtures/openai/batch-results-1.jsonl b/tests/Fixtures/openai/batch-results-1.jsonl new file mode 100644 index 000000000..e57a56536 --- /dev/null +++ b/tests/Fixtures/openai/batch-results-1.jsonl @@ -0,0 +1,2 @@ +{"id":"response_abc","custom_id":"my-first-request","response":{"status_code":200,"body":{"id":"resp_001","object":"response","model":"gpt-4o-2024-08-06","output":[{"type":"message","id":"msg_001","status":"completed","role":"assistant","content":[{"type":"output_text","text":"Hello! How can I help you today?"}]}],"usage":{"input_tokens":15,"output_tokens":25,"total_tokens":40}}},"error":null} +{"id":"response_def","custom_id":"my-second-request","response":{"status_code":200,"body":{"id":"resp_002","object":"response","model":"gpt-4o-2024-08-06","output":[{"type":"message","id":"msg_002","status":"completed","role":"assistant","content":[{"type":"output_text","text":"Nice to meet you!"}]}],"usage":{"input_tokens":12,"output_tokens":20,"total_tokens":32}}},"error":null} \ No newline at end of file diff --git a/tests/Fixtures/openai/batch-results-mixed-1.jsonl b/tests/Fixtures/openai/batch-results-mixed-1.jsonl new file mode 100644 index 000000000..a337a9812 --- /dev/null +++ b/tests/Fixtures/openai/batch-results-mixed-1.jsonl @@ -0,0 +1,3 @@ +{"id":"response_abc","custom_id":"req-success","response":{"status_code":200,"body":{"id":"resp_001","object":"response","model":"gpt-4o-2024-08-06","output":[{"type":"message","id":"msg_001","status":"completed","role":"assistant","content":[{"type":"output_text","text":"Hello!"}]}],"usage":{"input_tokens":10,"output_tokens":5,"total_tokens":15}}},"error":null} +{"id":"response_def","custom_id":"req-http-error","response":{"status_code":400,"body":{"error":{"message":"Invalid model","type":"invalid_request_error","code":"model_not_found"}}},"error":null} +{"id":"response_ghi","custom_id":"req-expired","response":null,"error":{"code":"batch_expired","message":"This batch has expired."}} \ No newline at end of file diff --git a/tests/Fixtures/openai/batch-retrieve-completed-1.json b/tests/Fixtures/openai/batch-retrieve-completed-1.json new file mode 100644 index 000000000..0087ddc4d --- /dev/null +++ b/tests/Fixtures/openai/batch-retrieve-completed-1.json @@ -0,0 +1 @@ +{"id":"batch_abc123","object":"batch","endpoint":"/v1/responses","errors":null,"input_file_id":"file-abc123","completion_window":"24h","status":"completed","output_file_id":"file-output-xyz","error_file_id":null,"created_at":1727200644,"in_progress_at":1727200650,"expires_at":1727287044,"finalizing_at":1727201000,"completed_at":1727201200,"failed_at":null,"expired_at":null,"cancelling_at":null,"cancelled_at":null,"request_counts":{"total":2,"completed":2,"failed":0},"metadata":null} \ No newline at end of file diff --git a/tests/Fixtures/openai/batch-retrieve-failed-1.json b/tests/Fixtures/openai/batch-retrieve-failed-1.json new file mode 100644 index 000000000..e99a51850 --- /dev/null +++ b/tests/Fixtures/openai/batch-retrieve-failed-1.json @@ -0,0 +1 @@ +{"id":"batch_abc123","object":"batch","endpoint":"/v1/responses","errors":{"object":"list","data":[{"code":"invalid_json_line","message":"Invalid JSON on line 3.","line":3,"param":null},{"code":"token_limit_exceeded","message":"Request exceeds token limit.","line":7,"param":null}]},"input_file_id":"file-abc123","completion_window":"24h","status":"failed","output_file_id":null,"error_file_id":null,"created_at":1727200644,"in_progress_at":null,"expires_at":1727287044,"finalizing_at":null,"completed_at":null,"failed_at":1727200700,"expired_at":null,"cancelling_at":null,"cancelled_at":null,"request_counts":{"total":0,"completed":0,"failed":0},"metadata":null} diff --git a/tests/Fixtures/openai/file-delete-1.json b/tests/Fixtures/openai/file-delete-1.json new file mode 100644 index 000000000..912bfaa3a --- /dev/null +++ b/tests/Fixtures/openai/file-delete-1.json @@ -0,0 +1,5 @@ +{ + "object": "file", + "deleted": true, + "id": "file-DC8kDtzu39Q9PnLWRLVmLN" +} diff --git a/tests/Fixtures/openai/file-download-1.txt b/tests/Fixtures/openai/file-download-1.txt new file mode 100644 index 000000000..f8cc981e3 --- /dev/null +++ b/tests/Fixtures/openai/file-download-1.txt @@ -0,0 +1 @@ +file content here \ No newline at end of file diff --git a/tests/Fixtures/openai/file-get-metadata-1.json b/tests/Fixtures/openai/file-get-metadata-1.json new file mode 100644 index 000000000..562e38886 --- /dev/null +++ b/tests/Fixtures/openai/file-get-metadata-1.json @@ -0,0 +1,11 @@ +{ + "object": "file", + "id": "file-DC8kDtzu39Q9PnLWRLVmLN", + "purpose": "user_data", + "filename": "data.txt", + "bytes": 17, + "created_at": 1773582617, + "expires_at": null, + "status": "processed", + "status_details": null +} diff --git a/tests/Fixtures/openai/file-list-1.json b/tests/Fixtures/openai/file-list-1.json new file mode 100644 index 000000000..651685131 --- /dev/null +++ b/tests/Fixtures/openai/file-list-1.json @@ -0,0 +1,613 @@ +{ + "object": "list", + "data": [ + { + "object": "file", + "id": "file-DC8kDtzu39Q9PnLWRLVmLN", + "purpose": "user_data", + "filename": "data.txt", + "bytes": 17, + "created_at": 1773582617, + "expires_at": null, + "status": "processed", + "status_details": null + }, + { + "object": "file", + "id": "file-gU6ggmSDSp5x6hKcAuUuvx0C", + "purpose": "fine-tune-results", + "filename": "step_metrics.csv", + "bytes": 22292, + "created_at": 1727375176, + "expires_at": null, + "status": "processed", + "status_details": null + }, + { + "object": "file", + "id": "file-HglQkasQcEdyFUsm5tb2MZ1m", + "purpose": "fine-tune", + "filename": "training_set_evaluator_1727374182-26-09-24.jsonl", + "bytes": 640732, + "created_at": 1727374186, + "expires_at": null, + "status": "processed", + "status_details": null + }, + { + "object": "file", + "id": "file-PLDzNWLhLmemUbYdGVyUnnfW", + "purpose": "fine-tune-results", + "filename": "step_metrics.csv", + "bytes": 11364, + "created_at": 1727363137, + "expires_at": null, + "status": "processed", + "status_details": null + }, + { + "object": "file", + "id": "file-B2NiueD1aybSDkffZPKz9YjA", + "purpose": "fine-tune", + "filename": "training_set_evaluator_1727362509-26-09-24.jsonl", + "bytes": 340661, + "created_at": 1727362512, + "expires_at": null, + "status": "processed", + "status_details": null + }, + { + "object": "file", + "id": "file-R9nMlBIbryw9TQ3RTCG68Nrr", + "purpose": "fine-tune-results", + "filename": "step_metrics.csv", + "bytes": 5608, + "created_at": 1727185156, + "expires_at": null, + "status": "processed", + "status_details": null + }, + { + "object": "file", + "id": "file-LWpnXFaKRuDHdNwUh6SEQiu4", + "purpose": "fine-tune", + "filename": "training_set_evaluator_1727184601-24-09-24.jsonl", + "bytes": 130358, + "created_at": 1727184602, + "expires_at": null, + "status": "processed", + "status_details": null + }, + { + "object": "file", + "id": "file-qSZy3qZe8bZrC43F5oHCXErB", + "purpose": "fine-tune-results", + "filename": "step_metrics.csv", + "bytes": 6192, + "created_at": 1726856028, + "expires_at": null, + "status": "processed", + "status_details": null + }, + { + "object": "file", + "id": "file-nX6ishK66EhKnzhwi3dzrZYK", + "purpose": "fine-tune", + "filename": "training_set_evaluator_1726855596-20-09-24.jsonl", + "bytes": 172954, + "created_at": 1726855598, + "expires_at": null, + "status": "processed", + "status_details": null + }, + { + "object": "file", + "id": "file-2PBq5yuAn5jLPzntjkUqYs2r", + "purpose": "fine-tune-results", + "filename": "step_metrics.csv", + "bytes": 5700, + "created_at": 1726847373, + "expires_at": null, + "status": "processed", + "status_details": null + }, + { + "object": "file", + "id": "file-Dzg1w8LxoDIboTe1db4igAe3", + "purpose": "fine-tune", + "filename": "training_set_evaluator_1726846974-20-09-24.jsonl", + "bytes": 132420, + "created_at": 1726846975, + "expires_at": null, + "status": "processed", + "status_details": null + }, + { + "object": "file", + "id": "file-BUziI0QGMIIksUrQv9bMWV1O", + "purpose": "fine-tune-results", + "filename": "step_metrics.csv", + "bytes": 37268, + "created_at": 1726841668, + "expires_at": null, + "status": "processed", + "status_details": null + }, + { + "object": "file", + "id": "file-Q4UGiZMzudgoFcw6mwYAsx77", + "purpose": "fine-tune", + "filename": "training_set_evaluator_1726840187-20-09-24.jsonl", + "bytes": 1352991, + "created_at": 1726840191, + "expires_at": null, + "status": "processed", + "status_details": null + }, + { + "object": "file", + "id": "file-386XiAAnXaXqpH4EFMt3LD1a", + "purpose": "fine-tune-results", + "filename": "step_metrics.csv", + "bytes": 88584, + "created_at": 1726770281, + "expires_at": null, + "status": "processed", + "status_details": null + }, + { + "object": "file", + "id": "file-pd3fyp8bWEHVShNB5Jd4PonQ", + "purpose": "fine-tune", + "filename": "training_set_evaluator_1726766938-19-09-24.jsonl", + "bytes": 2923900, + "created_at": 1726766950, + "expires_at": null, + "status": "processed", + "status_details": null + }, + { + "object": "file", + "id": "file-Vb527spMJzwjfhtGH2h9IQPS", + "purpose": "fine-tune-results", + "filename": "step_metrics.csv", + "bytes": 46612, + "created_at": 1726170147, + "expires_at": null, + "status": "processed", + "status_details": null + }, + { + "object": "file", + "id": "file-Ejwa4yOnqkofSVaPMlyemtCP", + "purpose": "fine-tune", + "filename": "validation_set_evaluator_1726167994-12-09-24.jsonl", + "bytes": 247018, + "created_at": 1726168010, + "expires_at": null, + "status": "processed", + "status_details": null + }, + { + "object": "file", + "id": "file-aZRja0npHTcnrYxHWOxBy4Ds", + "purpose": "fine-tune", + "filename": "training_set_evaluator_1726167994-12-09-24.jsonl", + "bytes": 3607008, + "created_at": 1726168009, + "expires_at": null, + "status": "processed", + "status_details": null + }, + { + "object": "file", + "id": "file-4btuiQaWAbrtHWbzcGMyNXqa", + "purpose": "fine-tune-results", + "filename": "step_metrics.csv", + "bytes": 47268, + "created_at": 1726082941, + "expires_at": null, + "status": "processed", + "status_details": null + }, + { + "object": "file", + "id": "file-FyJL7htDL4fJBAQ5okm9RcBw", + "purpose": "fine-tune", + "filename": "validation_set_generator_1726080555-11-09-24.jsonl", + "bytes": 305967, + "created_at": 1726080570, + "expires_at": null, + "status": "processed", + "status_details": null + }, + { + "object": "file", + "id": "file-Kx5fn5AVQh5sbXnP6LC6AT5z", + "purpose": "fine-tune", + "filename": "training_set_generator_1726080555-11-09-24.jsonl", + "bytes": 3877461, + "created_at": 1726080568, + "expires_at": null, + "status": "processed", + "status_details": null + }, + { + "object": "file", + "id": "file-edFQPtCY2WP2kLsz90uEBFR6", + "purpose": "fine-tune-results", + "filename": "step_metrics.csv", + "bytes": 47920, + "created_at": 1726073011, + "expires_at": null, + "status": "processed", + "status_details": null + }, + { + "object": "file", + "id": "file-UnavyPKY28RYI0MkD33vPgn7", + "purpose": "fine-tune", + "filename": "validation_set_generator_1726071150-11-09-24.jsonl", + "bytes": 193090, + "created_at": 1726071163, + "expires_at": null, + "status": "processed", + "status_details": null + }, + { + "object": "file", + "id": "file-ZvrtRK4wi3rcfgAQrx6uNHBA", + "purpose": "fine-tune", + "filename": "training_set_generator_1726071150-11-09-24.jsonl", + "bytes": 3924169, + "created_at": 1726071161, + "expires_at": null, + "status": "processed", + "status_details": null + }, + { + "object": "file", + "id": "file-tZ9zFkquVjaBu5IIVo6p4PX2", + "purpose": "fine-tune-results", + "filename": "step_metrics.csv", + "bytes": 46604, + "created_at": 1726068920, + "expires_at": null, + "status": "processed", + "status_details": null + }, + { + "object": "file", + "id": "file-PQtVkvOPAs82xD2VBQAeReyC", + "purpose": "fine-tune", + "filename": "validation_set_generator_1726066544-11-09-24.jsonl", + "bytes": 220679, + "created_at": 1726066561, + "expires_at": null, + "status": "processed", + "status_details": null + }, + { + "object": "file", + "id": "file-ZKDYpwc5wHXP4AK6atZqAz7a", + "purpose": "fine-tune", + "filename": "training_set_generator_1726066544-11-09-24.jsonl", + "bytes": 3813773, + "created_at": 1726066559, + "expires_at": null, + "status": "processed", + "status_details": null + }, + { + "object": "file", + "id": "file-mBBoIRCITjLnedWafdzcMRu5", + "purpose": "fine-tune-results", + "filename": "step_metrics.csv", + "bytes": 60616, + "created_at": 1725649806, + "expires_at": null, + "status": "processed", + "status_details": null + }, + { + "object": "file", + "id": "file-rW5UpoqQN3PDn1tsKC07nGdT", + "purpose": "fine-tune", + "filename": "gen_validation_set.jsonl", + "bytes": 405037, + "created_at": 1725647528, + "expires_at": null, + "status": "processed", + "status_details": null + }, + { + "object": "file", + "id": "file-eGeTtcJuCO3ArOB7kvQhhXl7", + "purpose": "fine-tune", + "filename": "gen_training_set.jsonl", + "bytes": 4806602, + "created_at": 1725647526, + "expires_at": null, + "status": "processed", + "status_details": null + }, + { + "object": "file", + "id": "file-vR2qAyJRzfTL7Gl6KprgzOl2", + "purpose": "fine-tune-results", + "filename": "step_metrics.csv", + "bytes": 44020, + "created_at": 1725640082, + "expires_at": null, + "status": "processed", + "status_details": null + }, + { + "object": "file", + "id": "file-MitMER8VjSS5311YDs4AAvcK", + "purpose": "fine-tune", + "filename": "evaluator_validation_set2.jsonl", + "bytes": 548518, + "created_at": 1725637064, + "expires_at": null, + "status": "processed", + "status_details": null + }, + { + "object": "file", + "id": "file-UdEDvtX1a2D24zoae4afjS22", + "purpose": "fine-tune", + "filename": "evaluator_training_set2.jsonl", + "bytes": 3343130, + "created_at": 1725637061, + "expires_at": null, + "status": "processed", + "status_details": null + }, + { + "object": "file", + "id": "file-5ZHEDbjJnPhgWfGbATBqYGF9", + "purpose": "fine-tune-results", + "filename": "step_metrics.csv", + "bytes": 55500, + "created_at": 1725547135, + "expires_at": null, + "status": "processed", + "status_details": null + }, + { + "object": "file", + "id": "file-7SFr9xAC3G3PHHZULf4zZWFA", + "purpose": "fine-tune", + "filename": "evaluator_validation_set1.jsonl", + "bytes": 699407, + "created_at": 1725544510, + "expires_at": null, + "status": "processed", + "status_details": null + }, + { + "object": "file", + "id": "file-SO6GWiIegjg7ppmXM1K62cuq", + "purpose": "fine-tune", + "filename": "evaluator_training_set1.jsonl", + "bytes": 4338817, + "created_at": 1725544507, + "expires_at": null, + "status": "processed", + "status_details": null + }, + { + "object": "file", + "id": "file-vOEauAVOzeZjaJYgJtVef0yl", + "purpose": "fine-tune", + "filename": "evaluator_validation_set1.jsonl", + "bytes": 82955, + "created_at": 1725543970, + "expires_at": null, + "status": "processed", + "status_details": null + }, + { + "object": "file", + "id": "file-RbSJsy9BD6tHQsvLRZhmGpvG", + "purpose": "fine-tune", + "filename": "evaluator_training_set1.jsonl", + "bytes": 1475067, + "created_at": 1725543967, + "expires_at": null, + "status": "processed", + "status_details": null + }, + { + "object": "file", + "id": "file-FXDtQvev9b4urdSs8MxDBlAK", + "purpose": "fine-tune-results", + "filename": "step_metrics.csv", + "bytes": 18668, + "created_at": 1725471693, + "expires_at": null, + "status": "processed", + "status_details": null + }, + { + "object": "file", + "id": "file-4rUfowb8YCO8rFji7UftDN4r", + "purpose": "fine-tune", + "filename": "evaluator_validation_set.jsonl", + "bytes": 169460, + "created_at": 1725462706, + "expires_at": null, + "status": "processed", + "status_details": null + }, + { + "object": "file", + "id": "file-R9HMUFMA2KIDOealtgMwkIpi", + "purpose": "fine-tune", + "filename": "evaluator_training_set.jsonl", + "bytes": 1387867, + "created_at": 1725462697, + "expires_at": null, + "status": "processed", + "status_details": null + }, + { + "object": "file", + "id": "file-g7nFuh5PO4D1o1982datGW0B", + "purpose": "fine-tune-results", + "filename": "step_metrics.csv", + "bytes": 9952, + "created_at": 1724685836, + "expires_at": null, + "status": "processed", + "status_details": null + }, + { + "object": "file", + "id": "file-xuwK0i1LvNhpeH1QxJBernKN", + "purpose": "fine-tune", + "filename": "email_training_samples_200_reiterate_2_valid_frac>0_5_valid.jsonl", + "bytes": 1289628, + "created_at": 1724684984, + "expires_at": null, + "status": "processed", + "status_details": null + }, + { + "object": "file", + "id": "file-al5wUoodJ2ZT7XBhHiyaYsGj", + "purpose": "fine-tune", + "filename": "email_training_samples_200_reiterate_2_valid_frac>0_5_train.jsonl", + "bytes": 5072597, + "created_at": 1724684982, + "expires_at": null, + "status": "processed", + "status_details": null + }, + { + "object": "file", + "id": "file-j3aAaVcM3zfXfI07jQPpn7dU", + "purpose": "fine-tune-results", + "filename": "step_metrics.csv", + "bytes": 9596, + "created_at": 1724683371, + "expires_at": null, + "status": "processed", + "status_details": null + }, + { + "object": "file", + "id": "file-vUrJHrDtd3jFwjXCiSh75K7I", + "purpose": "fine-tune", + "filename": "email_training_samples_200_reiterate_1_valid_frac>0_5_valid.jsonl", + "bytes": 1312763, + "created_at": 1724682343, + "expires_at": null, + "status": "processed", + "status_details": null + }, + { + "object": "file", + "id": "file-nliJMeDf8Gee0sXybCiqbZ8K", + "purpose": "fine-tune", + "filename": "email_training_samples_200_reiterate_1_valid_frac>0_5_train.jsonl", + "bytes": 4897660, + "created_at": 1724682341, + "expires_at": null, + "status": "processed", + "status_details": null + }, + { + "object": "file", + "id": "file-dcKqNy7rqijNSnx4X5bTQyrs", + "purpose": "fine-tune-results", + "filename": "step_metrics.csv", + "bytes": 40632, + "created_at": 1724681517, + "expires_at": null, + "status": "processed", + "status_details": null + }, + { + "object": "file", + "id": "file-lXqwxMqJsrhI3X39lUkzB4qE", + "purpose": "fine-tune", + "filename": "email_training_samples_valid_frac>0_5_valid.jsonl", + "bytes": 1133983, + "created_at": 1724679308, + "expires_at": null, + "status": "processed", + "status_details": null + }, + { + "object": "file", + "id": "file-mCGuEFypFc5WYX41z3DAUsk3", + "purpose": "fine-tune", + "filename": "email_training_samples_valid_frac>0_5_train.jsonl", + "bytes": 4467295, + "created_at": 1724679306, + "expires_at": null, + "status": "processed", + "status_details": null + }, + { + "object": "file", + "id": "file-nO75ZMcXpP09KlcGtjBTMANu", + "purpose": "fine-tune-results", + "filename": "step_metrics.csv", + "bytes": 59896, + "created_at": 1724429572, + "expires_at": null, + "status": "processed", + "status_details": null + }, + { + "object": "file", + "id": "file-auqmjAy6asZkzlTAK7GoNisJ", + "purpose": "fine-tune", + "filename": "email_training_samples_300_valid.jsonl", + "bytes": 1847707, + "created_at": 1724426745, + "expires_at": null, + "status": "processed", + "status_details": null + }, + { + "object": "file", + "id": "file-rR9UO0ZVGjKNEb3pyrF0F0dP", + "purpose": "fine-tune", + "filename": "email_training_samples_300_train.jsonl", + "bytes": 6475829, + "created_at": 1724426743, + "expires_at": null, + "status": "processed", + "status_details": null + }, + { + "object": "file", + "id": "file-yECxke9qdcdI2N8ytsL5e68C", + "purpose": "fine-tune-results", + "filename": "step_metrics.csv", + "bytes": 13868, + "created_at": 1724171617, + "expires_at": null, + "status": "processed", + "status_details": null + }, + { + "object": "file", + "id": "file-EAlawNWTLJFO3VlA0m3T9soM", + "purpose": "fine-tune", + "filename": "training_corrected_message_only.jsonl", + "bytes": 503893, + "created_at": 1724170349, + "expires_at": null, + "status": "processed", + "status_details": null + } + ], + "has_more": false, + "first_id": "file-DC8kDtzu39Q9PnLWRLVmLN", + "last_id": "file-EAlawNWTLJFO3VlA0m3T9soM" +} diff --git a/tests/Fixtures/openai/file-upload-1.json b/tests/Fixtures/openai/file-upload-1.json new file mode 100644 index 000000000..bdf14d87b --- /dev/null +++ b/tests/Fixtures/openai/file-upload-1.json @@ -0,0 +1,11 @@ +{ + "object": "file", + "id": "file-DC8kDtzu39Q9PnLWRLVmLN", + "purpose": "user_data", + "filename": "data.txt", + "bytes": 17, + "created_at": 1773582617, + "expires_at": null, + "status": "processed", + "status_details": null +} \ No newline at end of file diff --git a/tests/Fixtures/openai/text-with-approval-phase2-1.json b/tests/Fixtures/openai/text-with-approval-phase2-1.json new file mode 100644 index 000000000..9f237d82b --- /dev/null +++ b/tests/Fixtures/openai/text-with-approval-phase2-1.json @@ -0,0 +1,34 @@ +{ + "id": "resp_approval_phase2", + "object": "response", + "created_at": 1741989984, + "status": "completed", + "model": "gpt-4o-2024-08-06", + "output": [ + { + "id": "msg_approval_phase2", + "type": "message", + "status": "completed", + "content": [ + { + "type": "output_text", + "text": "The file has been deleted successfully." + } + ], + "role": "assistant" + } + ], + "usage": { + "input_tokens": 150, + "input_tokens_details": { + "cached_tokens": 0 + }, + "output_tokens": 10, + "output_tokens_details": { + "reasoning_tokens": 0 + }, + "total_tokens": 160 + }, + "service_tier": "default", + "system_fingerprint": "fp_test" +} diff --git a/tests/Fixtures/openai/text-with-approval-tool-1.json b/tests/Fixtures/openai/text-with-approval-tool-1.json new file mode 100644 index 000000000..a271ab2ff --- /dev/null +++ b/tests/Fixtures/openai/text-with-approval-tool-1.json @@ -0,0 +1,30 @@ +{ + "id": "resp_approval_tool_test", + "object": "response", + "created_at": 1741989983, + "status": "completed", + "model": "gpt-4o-2024-08-06", + "output": [ + { + "id": "fc_delete_file_123", + "type": "function_call", + "status": "completed", + "arguments": "{\"path\": \"/tmp/test.txt\"}", + "call_id": "call_delete_file_123", + "name": "delete_file" + } + ], + "usage": { + "input_tokens": 100, + "input_tokens_details": { + "cached_tokens": 0 + }, + "output_tokens": 50, + "output_tokens_details": { + "reasoning_tokens": 0 + }, + "total_tokens": 150 + }, + "service_tier": "default", + "system_fingerprint": "fp_test" +} diff --git a/tests/Fixtures/openai/text-with-client-executed-tool-1.json b/tests/Fixtures/openai/text-with-client-executed-tool-1.json new file mode 100644 index 000000000..a62212d13 --- /dev/null +++ b/tests/Fixtures/openai/text-with-client-executed-tool-1.json @@ -0,0 +1,31 @@ +{ + "id": "resp_client_executed_test", + "object": "response", + "created_at": 1741989983, + "status": "completed", + "model": "gpt-4o-2024-08-06", + "output": [ + { + "id": "fc_client_tool_123", + "type": "function_call", + "status": "completed", + "arguments": "{\"input\": \"test input\"}", + "call_id": "call_client_tool_123", + "name": "client_tool" + } + ], + "usage": { + "input_tokens": 100, + "input_tokens_details": { + "cached_tokens": 0 + }, + "output_tokens": 50, + "output_tokens_details": { + "reasoning_tokens": 0 + }, + "total_tokens": 150 + }, + "service_tier": "default", + "system_fingerprint": "fp_test" +} + diff --git a/tests/Fixtures/openrouter/generate-text-with-reasoning-1.json b/tests/Fixtures/openrouter/generate-text-with-reasoning-1.json new file mode 100644 index 000000000..cae4ed34f --- /dev/null +++ b/tests/Fixtures/openrouter/generate-text-with-reasoning-1.json @@ -0,0 +1,35 @@ +{ + "id": "gen-reasoning-text-1", + "object": "chat.completion", + "created": 1737243487, + "model": "anthropic/claude-3.7-sonnet", + "choices": [ + { + "index": 0, + "message": { + "role": "assistant", + "content": "The answer to 2 + 2 is 4.", + "reasoning": "Let me think about this simple math problem. 2 + 2 equals 4. This is basic arithmetic.", + "reasoning_details": [ + { + "type": "reasoning.text", + "text": "Let me think about this simple math problem. 2 + 2 equals 4. This is basic arithmetic.", + "signature": "sha256:abc123def456", + "id": "reasoning-text-1", + "format": "anthropic-claude-v1", + "index": 0 + } + ] + }, + "finish_reason": "stop" + } + ], + "usage": { + "prompt_tokens": 10, + "completion_tokens": 25, + "total_tokens": 35, + "completion_tokens_details": { + "reasoning_tokens": 18 + } + } +} diff --git a/tests/Fixtures/openrouter/stream-text-with-parameterless-tool-1.sse b/tests/Fixtures/openrouter/stream-text-with-parameterless-tool-1.sse new file mode 100644 index 000000000..68675b4b0 --- /dev/null +++ b/tests/Fixtures/openrouter/stream-text-with-parameterless-tool-1.sse @@ -0,0 +1,18 @@ +data: {"id":"gen-1760518100-Kp3RtWnYxQBA2SAbiPRM","provider":"OpenAI","model":"openai/gpt-5","object":"chat.completion.chunk","created":1760518100,"choices":[{"index":0,"delta":{"role":"assistant","content":""},"finish_reason":null,"native_finish_reason":null,"logprobs":null}]} + +: OPENROUTER PROCESSING + +: OPENROUTER PROCESSING + +data: {"id":"gen-1760518100-Kp3RtWnYxQBA2SAbiPRM","provider":"OpenAI","model":"openai/gpt-5","object":"chat.completion.chunk","created":1760518100,"choices":[{"index":0,"delta":{"role":"assistant","content":null,"tool_calls":[{"id":"call_ZQpWJRxU5EIprhJ4cpxTMkNr","index":0,"type":"function","function":{"name":"time","arguments":""}}]},"finish_reason":null,"native_finish_reason":null,"logprobs":null}]} + +data: {"id":"gen-1760518100-Kp3RtWnYxQBA2SAbiPRM","provider":"OpenAI","model":"openai/gpt-5","object":"chat.completion.chunk","created":1760518100,"choices":[{"index":0,"delta":{"role":"assistant","content":null,"tool_calls":[{"index":0,"type":"function","function":{"arguments":"{}"}}]},"finish_reason":null,"native_finish_reason":null,"logprobs":null}]} + +: OPENROUTER PROCESSING + +data: {"id":"gen-1760518100-Kp3RtWnYxQBA2SAbiPRM","provider":"OpenAI","model":"openai/gpt-5","object":"chat.completion.chunk","created":1760518100,"choices":[{"index":0,"delta":{"role":"assistant","content":""},"finish_reason":"tool_calls","native_finish_reason":"completed","logprobs":null}]} + +data: {"id":"gen-1760518100-Kp3RtWnYxQBA2SAbiPRM","provider":"OpenAI","model":"openai/gpt-5","object":"chat.completion.chunk","created":1760518100,"choices":[{"index":0,"delta":{"role":"assistant","content":""},"finish_reason":null,"native_finish_reason":null,"logprobs":null}],"usage":{"prompt_tokens":58,"completion_tokens":21,"total_tokens":79,"cost":0.0003825,"is_byok":false,"prompt_tokens_details":{"cached_tokens":0,"audio_tokens":0},"cost_details":{"upstream_inference_cost":null,"upstream_inference_prompt_cost":0.0000725,"upstream_inference_completions_cost":0.00031},"completion_tokens_details":{"reasoning_tokens":0,"image_tokens":0}}} + +data: [DONE] + diff --git a/tests/Fixtures/openrouter/stream-text-with-parameterless-tool-2.sse b/tests/Fixtures/openrouter/stream-text-with-parameterless-tool-2.sse new file mode 100644 index 000000000..545372707 --- /dev/null +++ b/tests/Fixtures/openrouter/stream-text-with-parameterless-tool-2.sse @@ -0,0 +1,34 @@ +: OPENROUTER PROCESSING + +data: {"id":"gen-1760518110-Vw82mQtRc4L9nYhuiWx1","provider":"OpenAI","model":"openai/gpt-5","object":"chat.completion.chunk","created":1760518110,"choices":[{"index":0,"delta":{"role":"assistant","content":""},"finish_reason":null,"native_finish_reason":null,"logprobs":null}]} + +data: {"id":"gen-1760518110-Vw82mQtRc4L9nYhuiWx1","provider":"OpenAI","model":"openai/gpt-5","object":"chat.completion.chunk","created":1760518110,"choices":[{"index":0,"delta":{"role":"assistant","content":"The"},"finish_reason":null,"native_finish_reason":null,"logprobs":null}]} + +data: {"id":"gen-1760518110-Vw82mQtRc4L9nYhuiWx1","provider":"OpenAI","model":"openai/gpt-5","object":"chat.completion.chunk","created":1760518110,"choices":[{"index":0,"delta":{"role":"assistant","content":" current"},"finish_reason":null,"native_finish_reason":null,"logprobs":null}]} + +data: {"id":"gen-1760518110-Vw82mQtRc4L9nYhuiWx1","provider":"OpenAI","model":"openai/gpt-5","object":"chat.completion.chunk","created":1760518110,"choices":[{"index":0,"delta":{"role":"assistant","content":" time"},"finish_reason":null,"native_finish_reason":null,"logprobs":null}]} + +data: {"id":"gen-1760518110-Vw82mQtRc4L9nYhuiWx1","provider":"OpenAI","model":"openai/gpt-5","object":"chat.completion.chunk","created":1760518110,"choices":[{"index":0,"delta":{"role":"assistant","content":" is"},"finish_reason":null,"native_finish_reason":null,"logprobs":null}]} + +data: {"id":"gen-1760518110-Vw82mQtRc4L9nYhuiWx1","provider":"OpenAI","model":"openai/gpt-5","object":"chat.completion.chunk","created":1760518110,"choices":[{"index":0,"delta":{"role":"assistant","content":" "},"finish_reason":null,"native_finish_reason":null,"logprobs":null}]} + +data: {"id":"gen-1760518110-Vw82mQtRc4L9nYhuiWx1","provider":"OpenAI","model":"openai/gpt-5","object":"chat.completion.chunk","created":1760518110,"choices":[{"index":0,"delta":{"role":"assistant","content":"08"},"finish_reason":null,"native_finish_reason":null,"logprobs":null}]} + +data: {"id":"gen-1760518110-Vw82mQtRc4L9nYhuiWx1","provider":"OpenAI","model":"openai/gpt-5","object":"chat.completion.chunk","created":1760518110,"choices":[{"index":0,"delta":{"role":"assistant","content":":"},"finish_reason":null,"native_finish_reason":null,"logprobs":null}]} + +data: {"id":"gen-1760518110-Vw82mQtRc4L9nYhuiWx1","provider":"OpenAI","model":"openai/gpt-5","object":"chat.completion.chunk","created":1760518110,"choices":[{"index":0,"delta":{"role":"assistant","content":"00"},"finish_reason":null,"native_finish_reason":null,"logprobs":null}]} + +data: {"id":"gen-1760518110-Vw82mQtRc4L9nYhuiWx1","provider":"OpenAI","model":"openai/gpt-5","object":"chat.completion.chunk","created":1760518110,"choices":[{"index":0,"delta":{"role":"assistant","content":":"},"finish_reason":null,"native_finish_reason":null,"logprobs":null}]} + +data: {"id":"gen-1760518110-Vw82mQtRc4L9nYhuiWx1","provider":"OpenAI","model":"openai/gpt-5","object":"chat.completion.chunk","created":1760518110,"choices":[{"index":0,"delta":{"role":"assistant","content":"00"},"finish_reason":null,"native_finish_reason":null,"logprobs":null}]} + +data: {"id":"gen-1760518110-Vw82mQtRc4L9nYhuiWx1","provider":"OpenAI","model":"openai/gpt-5","object":"chat.completion.chunk","created":1760518110,"choices":[{"index":0,"delta":{"role":"assistant","content":"."},"finish_reason":null,"native_finish_reason":null,"logprobs":null}]} + +: OPENROUTER PROCESSING + +data: {"id":"gen-1760518110-Vw82mQtRc4L9nYhuiWx1","provider":"OpenAI","model":"openai/gpt-5","object":"chat.completion.chunk","created":1760518110,"choices":[{"index":0,"delta":{"role":"assistant","content":""},"finish_reason":"stop","native_finish_reason":"completed","logprobs":null}]} + +data: {"id":"gen-1760518110-Vw82mQtRc4L9nYhuiWx1","provider":"OpenAI","model":"openai/gpt-5","object":"chat.completion.chunk","created":1760518110,"choices":[{"index":0,"delta":{"role":"assistant","content":""},"finish_reason":null,"native_finish_reason":null,"logprobs":null}],"usage":{"prompt_tokens":86,"completion_tokens":13,"total_tokens":99,"cost":0.0002375,"is_byok":false,"prompt_tokens_details":{"cached_tokens":0,"audio_tokens":0},"cost_details":{"upstream_inference_cost":null,"upstream_inference_prompt_cost":0.0001075,"upstream_inference_completions_cost":0.00013},"completion_tokens_details":{"reasoning_tokens":0,"image_tokens":0}}} + +data: [DONE] + diff --git a/tests/Fixtures/openrouter/stream-text-with-reasoning-details-1.sse b/tests/Fixtures/openrouter/stream-text-with-reasoning-details-1.sse new file mode 100644 index 000000000..bbf6d6030 --- /dev/null +++ b/tests/Fixtures/openrouter/stream-text-with-reasoning-details-1.sse @@ -0,0 +1,15 @@ +data: {"id":"gen-reasoning-details-1","object":"chat.completion.chunk","created":1737243487,"model":"openai/gpt-5","choices":[{"index":0,"delta":{"role":"assistant","content":""},"finish_reason":null}]} + +data: {"id":"gen-reasoning-details-1","object":"chat.completion.chunk","created":1737243487,"model":"openai/gpt-5","choices":[{"index":0,"delta":{"reasoning":null,"reasoning_details":[{"type":"reasoning.encrypted","data":"gAAAAABo71-Jjh1ipTHmxLg2Jub6BwOVcPdh6sNqX2Hz","id":"rs_045e88aab38b","format":"openai-responses-v1","index":0}]},"finish_reason":null}]} + +data: {"id":"gen-reasoning-details-1","object":"chat.completion.chunk","created":1737243487,"model":"openai/gpt-5","choices":[{"index":0,"delta":{"content":"The"},"finish_reason":null}]} + +data: {"id":"gen-reasoning-details-1","object":"chat.completion.chunk","created":1737243487,"model":"openai/gpt-5","choices":[{"index":0,"delta":{"content":" answer"},"finish_reason":null}]} + +data: {"id":"gen-reasoning-details-1","object":"chat.completion.chunk","created":1737243487,"model":"openai/gpt-5","choices":[{"index":0,"delta":{"content":" is"},"finish_reason":null}]} + +data: {"id":"gen-reasoning-details-1","object":"chat.completion.chunk","created":1737243487,"model":"openai/gpt-5","choices":[{"index":0,"delta":{"content":" 4."},"finish_reason":null}]} + +data: {"id":"gen-reasoning-details-1","object":"chat.completion.chunk","created":1737243487,"model":"openai/gpt-5","choices":[{"index":0,"delta":{},"finish_reason":"stop"}],"usage":{"prompt_tokens":10,"completion_tokens":20,"total_tokens":30,"completion_tokens_details":{"reasoning_tokens":15}}} + +data: [DONE] diff --git a/tests/Fixtures/qwen/embeddings-1.json b/tests/Fixtures/qwen/embeddings-1.json new file mode 100644 index 000000000..96d22e825 --- /dev/null +++ b/tests/Fixtures/qwen/embeddings-1.json @@ -0,0 +1 @@ +{"status_code": 200, "request_id": "118f9c73-7878-927a-92da-4a276b224421", "code": "", "message": "", "output": {"embeddings": [{"embedding": [-0.003209776012226939, -0.01007679384201765, -0.012190806679427624, 0.010541876778006554, 0.03176656365394592, -0.031794749200344086, 0.01646111160516739, 0.01966031827032566, -0.0711999461054802, 0.15739528834819794, 0.033006783574819565, 0.04036355018615723, 0.08264379948377609, 0.014699434861540794, 0.07198917865753174, -0.016263803467154503, -0.030216287821531296, 0.0114790890365839, 0.008892946876585484, 0.03013172745704651, -0.05138460174202919, 0.0025191984605044127, 0.0058945720084011555, 0.0007945164106786251, -0.040081679821014404, 0.008998647332191467, 0.03052634373307228, -0.025466805323958397, 0.015939654782414436, -0.007744333241134882, -0.03404969722032547, 0.0005954469088464975, -0.0040870909579098225, -0.003301383228972554, 0.029596177861094475, 0.03295041248202324, -0.04977795109152794, -0.020562296733260155, 0.0038475031033158302, -0.028708292171359062, 0.02502991072833538, -0.010943538509309292, 0.003466980764642358, -0.028172742575407028, -0.01964622363448143, -0.0010887165553867817, 0.009386216290295124, -0.05806488171219826, -0.07311665266752243, 0.027369417250156403, -0.024508453905582428, 0.020421361550688744, 0.018335536122322083, -0.000975969189312309, 0.012345833703875542, -0.016376551240682602, -0.04746663197875023, 0.010400942526757717, -0.03455705940723419, -0.015178610570728779, 0.008167135529220104, -0.03760123997926712, -0.0016260280972346663, 0.012789776548743248, 0.08337666094303131, -0.002795781707391143, -0.019575757905840874, 0.010971725918352604, 0.02796134166419506, -0.03244304656982422, 0.0385877788066864, 0.06150367483496666, 0.0465928390622139, -0.01902611367404461, -0.045719046145677567, 0.048030368983745575, -0.033260464668273926, -0.03689656779170036, -0.032894037663936615, 0.005485862959176302, -0.02356419526040554, 0.013170299120247364, -0.009470776654779911, -0.016785260289907455, 0.04932696372270584, 0.007162979803979397, 0.011521369218826294, -0.010718043893575668, -0.028510984033346176, -0.025579553097486496, 0.01779998652637005, -0.009513056837022305, -0.005584516562521458, 0.032273925840854645, 0.01422730553895235, 0.02319776639342308, -0.04061723127961159, -0.005225134547799826, -0.008160089142620564, 0.016052402555942535, -0.01892746053636074, -0.05637367069721222, 0.05504889041185379, -0.03351414576172829, 0.015559133142232895, 0.0016066496027633548, -0.08766105771064758, -0.010675763711333275, -0.02049182914197445, 0.021943451836705208, -0.007074895780533552, -0.08619534224271774, 0.0030424166470766068, 0.03185112401843071, 0.030920958146452904, 0.007807753514498472, -0.03162562847137451, -0.011450901627540588, -0.003253817791119218, 0.029737111181020737, 0.003872166620567441, -0.007279250305145979, 0.0070854658260941505, -0.04084272310137749, -0.021802516654133797, 0.04512712359428406, -0.00033251658896915615, 0.008406723849475384, 0.03979981318116188, 0.008082575164735317, -0.03610733523964882, -0.035459037870168686, 0.035571787506341934, -0.0036361017264425755, -0.023831969127058983, 0.05538713186979294, -0.010182494297623634, 0.0157423485070467, -0.0017660814337432384, -0.02440979890525341, -0.019871719181537628, -0.04639553278684616, 0.0771755576133728, 0.03622008487582207, 0.001613696338608861, 0.0007821847102604806, 0.0027182679623365402, -0.008737918920814991, 0.011768003925681114, 0.012486767955124378, 0.035064421594142914, 0.029229748994112015, -0.011923031881451607, 0.02646743878722191, -0.0746387392282486, -0.006423075217753649, 0.012169666588306427, -0.004266782198101282, 0.022493094205856323, 0.006659139879047871, 0.017306717112660408, 0.014086371287703514, 0.028355956077575684, 0.02488897554576397, -0.02331051230430603, -0.053865041583776474, -0.03528991714119911, -0.008286929689347744, -0.09115622937679291, 0.025114471092820168, -0.0033330933656543493, 0.021915264427661896, 0.0284546110779047, 0.045831795781850815, -0.00488689262419939, 8.252356929006055e-05, -0.008272835984826088, 0.0040870909579098225, -0.007018521893769503, -0.05084905028343201, 0.011514321900904179, -0.01319143921136856, 0.044619761407375336, -0.021055566146969795, -0.037150248885154724, 0.03134376183152199, 0.002251423429697752, 0.004791762214154005, -0.05087723955512047, 0.017179876565933228, 0.0012217231560498476, 0.034500688314437866, -0.011338154785335064, -0.010964678600430489, 0.04769212752580643, -0.013938389718532562, -0.02415611781179905, 0.040955472737550735, 0.017602678388357162, 0.006877588108181953, 0.017504025250673294, -0.01141566876322031, -0.0173489972949028, 0.007899360731244087, 0.049496084451675415, -0.050933610647916794, -0.011253594420850277, 0.014840369112789631, -0.03323227912187576, -0.014459846541285515, 0.027129828929901123, -0.023972904309630394, -0.00561622716486454, -0.013487400487065315, -0.04966520518064499, -0.0001700018474366516, -0.03430337831377983, -0.01989990659058094, 0.02674930728971958, 0.019110674038529396, -0.033006783574819565, 0.04343591257929802, -0.03808041661977768, 0.016559766605496407, 0.02820092812180519, 0.005714880768209696, -0.005827628076076508, 0.016686607152223587, 0.022972270846366882, 0.03929244726896286, -0.046085476875305176, 0.008646312169730663, -0.08591347187757492, 0.0024822033010423183, -0.017870454117655754, -0.00714536290615797, 0.011951218359172344, 0.02808818221092224, -0.04834042116999626, 0.04281580448150635, 0.030836397781968117, 0.03286585211753845, -0.04554992541670799, -0.003752372460439801, -0.015573226846754551, 0.03810860216617584, -0.002656609285622835, 0.039348822087049484, -0.0028732954524457455, 0.04543717950582504, -0.01609468273818493, -0.0010200111428275704, 0.001293951878324151, 0.03452887386083603, -0.026580186560750008, 0.01343102753162384, 0.005027826875448227, 0.02500172331929207, 0.039743438363075256, -0.011824377812445164, -0.03038540855050087, 0.027045268565416336, 0.020971005782485008, 0.018969740718603134, -0.01818050816655159, -0.04101184755563736, -0.01056301686912775, 0.005707834381610155, -0.014981303364038467, 0.021506555378437042, -0.022760869935154915, 0.03317590430378914, 0.04151920974254608, -0.001230531488545239, 0.015108143910765648, 0.004287922289222479, -0.05017256736755371, 0.007589305285364389, -0.016503391787409782, -0.0015115190763026476, 0.01507995743304491, 0.002098157536238432, -0.06556257605552673, -0.02050592377781868, -0.0024575397837907076, 0.06077081710100174, 0.022042104974389076, 0.028525076806545258, -0.02219713293015957, 0.017292624339461327, 0.010048606432974339, 0.012409253977239132, 0.019096581265330315, -0.0013547297567129135, 0.070297971367836, 0.0075963521376252174, 0.037291184067726135, 0.04701564088463783, 0.0033912286162376404, -0.03616371005773544, -0.011648209765553474, 0.0010631722398102283, -0.008688592351973057, 0.019561663269996643, 0.037544865161180496, 0.01575644128024578, -0.020703230053186417, 0.011577743105590343, -0.005792394746094942, -0.09431315213441849, -0.04298492521047592, -0.0017414179164916277, 0.03185112401843071, -0.015826908871531487, 0.006919868290424347, 0.0173489972949028, -0.013290093280375004, 0.015375918708741665, -0.01892746053636074, 0.017842266708612442, -0.022605841979384422, 0.010774417780339718, -0.015065863728523254, -0.006306804250925779, 0.031033705919981003, -0.024226585403084755, 0.05177921801805496, -0.03864414989948273, 0.029483430087566376, 0.005454152822494507, -0.013332373462617397, 0.01829325594007969, 0.00812485534697771, 0.043633222579956055, 0.03979981318116188, -0.031569257378578186, 0.023578288033604622, 0.042054757475852966, 0.02171795628964901, -0.047410257160663605, -0.00830806978046894, 0.004619117826223373, -0.06494247168302536, 0.03636101633310318, -0.004136417992413044, -0.02184479683637619, 0.00445704348385334, 0.04966520518064499, 0.017983201891183853, 0.01771542616188526, -0.006113019771873951, 0.053893230855464935, 0.04439426586031914, -0.015136330388486385, 0.013684708625078201, 0.05428784713149071, 0.04256212338805199, 0.012402207590639591, 0.0349234901368618, -0.009294608607888222, -0.03988437354564667, -0.027919061481952667, -0.11736997961997986, -0.007948687300086021, 0.010654623620212078, -0.022901803255081177, -0.015206797979772091, -0.022056199610233307, -0.0033947520423680544, -0.013579008169472218, -0.00860403198748827, 0.011549555696547031, 0.00567964743822813, 0.008561750873923302, 0.02221122570335865, 0.04797399416565895, 0.012402207590639591, -0.04501437768340111, -0.014868555590510368, -0.015291358344256878, 0.054597899317741394, 0.0041082315146923065, 0.003049463266506791, -0.0319356843829155, 0.03717843443155289, -0.0032467711716890335, -0.012669982388615608, 0.03844684362411499, 0.020181775093078613, -0.024959443137049675, 0.015812814235687256, 0.0041505116969347, -0.025114471092820168, 0.003717138897627592, 0.0465928390622139, 0.014741715043783188, 0.0441969595849514, -0.008766105398535728, 0.02880694530904293, -0.030695464462041855, 0.01355786807835102, -0.007004428654909134, -0.006937484722584486, 0.009837205521762371, -0.013163251802325249, -0.04526805877685547, 0.0035004527308046818, -0.045831795781850815, 0.011267687194049358, 0.028750572353601456, -0.07057983428239822, 0.0009328080923296511, 0.008371490053832531, 0.0383622832596302, 0.01915295422077179, -0.025114471092820168, -0.019307982176542282, 0.006923391483724117, 0.03808041661977768, 0.033485960215330124, -0.029878046363592148, 0.015700068324804306, -0.02490306831896305, -0.015390012413263321, -0.034726180136203766, 0.02587551437318325, -0.020407268777489662, 0.0137974563986063, 0.006433645263314247, 0.05969971790909767, -0.01598193496465683, -0.036558326333761215, -0.06691554933786392, -0.02305683121085167, -0.004587407689541578, -0.0016630232566967607, 0.025593645870685577, -0.01586918905377388, -0.04416877031326294, 0.017179876565933228, -0.024325238540768623, 0.01966031827032566, 0.0157423485070467, -0.006306804250925779, -0.015291358344256878, 0.007962781004607677, 0.03661470115184784, 0.024015184491872787, -0.00222852174192667, 0.05600724369287491, 0.011965312063694, 0.011810284107923508, -0.0004989950684830546, 0.0032731962855905294, 0.034359753131866455, -0.02769356593489647, 0.05823400244116783, -0.08038885146379471, 0.019730783998966217, 0.012634748592972755, -0.0025808571372181177, 0.024578921496868134, -0.050708118826150894, 0.022774962708353996, 0.0010675763478502631, 0.027792219072580338, 0.014396426267921925, -0.00445704348385334, -0.04016624018549919, 0.002158054616302252, 0.016658419743180275, 0.03162562847137451, -0.004837565589696169, -0.013515587896108627, 0.029145188629627228, -0.014727621339261532, 0.01190893817692995, 0.003650195198133588, 0.019702598452568054, 0.02149246260523796, -0.014671247452497482, -0.0008931703632697463, -0.0821928158402443, 0.023352792486548424, -0.007476557977497578, -0.07508973032236099, -0.06246202811598778, 0.04664921388030052, -0.00314459390938282, -0.07232742011547089, 0.025424525141716003, -0.016926195472478867, -0.011634116061031818, 0.03906695544719696, -0.019505290314555168, 0.02243672125041485, -0.012987084686756134, 0.022112572565674782, 0.05124366655945778, -0.013360559940338135, -0.01744765043258667, 0.00329785980284214, 0.00983015913516283, -0.00041443455847911537, 0.0590796060860157, 0.0005254202405922115, -0.02502991072833538, -0.02342326007783413, 0.00021536504209507257, 0.005721927620470524, -0.0166020467877388, 0.029229748994112015, 0.04890415817499161, -0.021154219284653664, -0.02294408343732357, 0.0029367159586399794, 0.005225134547799826, -0.025086283683776855, -0.005957992281764746, -0.04309767112135887, 0.044732507318258286, 0.03455705940723419, -0.0031340238638222218, -0.030441781505942345, -0.0006888157804496586, -0.006183486897498369, -0.027313044294714928, -0.059023234993219376, 0.011944171972572803, 0.014924929477274418, 0.006599242798984051, -0.004802332259714603, 0.015530946664512157, -0.0012120339088141918, -0.029680738225579262, -0.011450901627540588, -0.011183126829564571, 0.01123245432972908, -0.007134792860597372, 0.022394441068172455, 0.02659427933394909, 0.02772175334393978, 0.020421361550688744, 0.02735532447695732, 0.04543717950582504, -0.01386087667196989, 0.016066497191786766, -0.028623731806874275, -0.001077265595085919, -0.02782040648162365, -0.02366284839808941, 0.013318279758095741, 0.015220890752971172, -0.03396513685584068, -0.023507820442318916, -0.02171795628964901, 0.04481706768274307, 0.011436808854341507, 0.017574492841959, -0.009858345612883568, -0.05256844684481621, -0.04306948557496071, -0.013783362694084644, -0.05383685603737831, -0.048284050077199936, -0.017785893753170967, 0.01853284426033497, -0.014755808748304844, -0.03196386992931366, 0.05614817515015602, -0.08484237641096115, 0.014826275408267975, 0.007029091939330101, -0.017363090068101883, -0.055612627416849136, 0.013360559940338135, 0.015375918708741665, 0.012204899452626705, 0.01695438101887703, 0.024987630546092987, -0.003023038152605295, -0.010478456504642963, 0.02574867382645607, -0.06674642860889435, 0.040476296097040176, -0.02197163924574852, -0.009308702312409878, 0.019491197541356087, 0.02012540027499199, 0.022394441068172455, -0.02061866968870163, 0.007969827391207218, -0.040448110550642014, -0.006014366168528795, -0.0063455612398684025, 0.030920958146452904, 0.035571787506341934, 0.012677029706537724, 0.0033066682517528534, 0.040955472737550735, -0.0275949127972126, -0.025833234190940857, -0.009639897383749485, 0.020689137279987335, 0.05524619668722153, -0.03244304656982422, 0.026509718969464302, -0.04983432590961456, 0.019124768674373627, -0.017785893753170967, 0.014297772198915482, 0.00934393610805273, -0.0005641771131195128, -9.303857950726524e-05, 0.006204626988619566, -0.05149734765291214, -0.017983201891183853, -0.004020147491246462, -0.0009081446332857013, -0.010457316413521767, -0.02939886972308159, 0.00787822064012289, 0.018631497398018837, -0.048875972628593445, 0.02183070406317711, -0.0011653495021164417, 0.00940735638141632, -0.022549469023942947, -0.03311953321099281, 0.006454785354435444, 0.021407902240753174, 0.058515869081020355, -0.007673866115510464, -0.04256212338805199, 0.04518349841237068, -0.05879773944616318, -0.009019787423312664, 0.0392642617225647, -0.004048334434628487, -0.04211113229393959, -0.02858145162463188, 0.003829886205494404, -0.010112027637660503, -0.019068393856287003, 0.006243383977562189, -0.015530946664512157, -0.017757706344127655, -0.0661826878786087, 0.002739408053457737, -0.052286580204963684, 0.047861248254776, 0.04287217557430267, 0.010189540684223175, -0.030357221141457558, 0.018758339807391167, 0.06499884277582169, 0.04005349427461624, -0.022239413112401962, -0.017236249521374702, 0.020040839910507202, -0.06544983386993408, -0.02720029652118683, -0.03317590430378914, -0.025213124230504036, 0.04304129630327225, -0.021210594102740288, -0.015587319619953632, -0.021915264427661896, 0.02404337003827095, 0.006609812844544649, -0.01324781309813261, -0.00025654424098320305, 0.004657874349504709, 0.028243208304047585, 0.025466805323958397, -0.016658419743180275, -0.010344568639993668, 0.0407581627368927, 0.06285664439201355, 0.034218817949295044, -0.02976529859006405, 0.005288554821163416, -0.011831424199044704, 0.0029719495214521885, -0.056824661791324615, -0.007007951848208904, 0.07497698068618774, -0.0059791323728859425, -0.0066203828901052475, 0.0002349636924918741, 0.0329785980284214, -0.11359294503927231, -0.03793948143720627, -0.004745958372950554, 0.018363723531365395, 0.00507010705769062, -0.08072710037231445, 0.00020545560983009636, 0.023493727669119835, 0.02784859389066696, -0.0031164069660007954, -0.025072190910577774, -0.092283695936203, 0.05868498980998993, -0.01658795215189457, -5.097853500046767e-05, 0.013142111711204052, 0.016658419743180275, 0.009005693718791008, -0.01367766223847866, 0.006017889361828566, -0.03416244313120842, 0.0015511567471548915, -0.0409836582839489, -0.021421995013952255, 0.01633427105844021, 0.046085476875305176, -0.017983201891183853, -0.029680738225579262, -0.004305539187043905, -0.002277848543599248, 0.003040654817596078, 0.046310972422361374, 0.05380867049098015, -0.007589305285364389, 0.022493094205856323, 0.05941784754395485, -0.018335536122322083, 0.006821214221417904, 0.01526317186653614, -2.1525493139051832e-05, -0.022789057344198227, 7.074234872561647e-06, 0.06026345491409302, 0.010161354206502438, -0.027651285752654076, -0.026199663057923317, 0.006208150647580624, 0.003555064555257559, 0.020942818373441696, -0.06652092933654785, -0.02514265663921833, 0.027313044294714928, -0.0035427329130470753, 0.15153242647647858, 0.022126665338873863, 0.02903244085609913, -0.016672512516379356, 0.07728829979896545, 0.01709531620144844, -0.014924929477274418, 0.006299757864326239, -0.03650195151567459, -0.039743438363075256, -0.04467613250017166, 0.006454785354435444, -0.016291990876197815, 0.0007962781237438321, 0.008878853172063828, -0.033626895397901535, 0.008145995438098907, -0.02380378171801567, 0.01744765043258667, -0.009294608607888222, -0.03390876203775406, 0.03441612794995308, -0.017870454117655754, -0.008829526603221893, -0.03917970135807991, -0.0008795173489488661, 0.010337522253394127, 0.015925562009215355, -0.03140013664960861, 0.035205356776714325, 0.012381067499518394, -0.010527783073484898, 0.02122468687593937, -0.004890415817499161, 0.026411065831780434, -0.025678208097815514, 0.005573946516960859, -0.03185112401843071, -0.07869764417409897, -0.033034972846508026, 0.025790954008698463, 0.043999649584293365, 0.029511617496609688, 0.0029772345442324877, 0.011225407011806965, -0.02500172331929207, 0.0008641026797704399, -0.045465365052223206, 0.004189268220216036, 0.013762222602963448, -0.030188100412487984, -0.021675676107406616, 0.03624827042222023, 0.05403416231274605, 0.03748849034309387, -0.00032414859742857516, -0.0010270578786730766, 0.02099919319152832, -0.014128651469945908, 0.004347819369286299, 0.0015960795572027564, -0.012846150435507298, -0.012416301295161247, 0.019251609221100807, -0.09019787609577179, -0.042252067476511, 0.015291358344256878, -0.008928179740905762, 0.02233806625008583, 0.023395072668790817, 0.008907039649784565, -0.0011362817604094744, -0.05282213166356087, -0.007568165194243193, -0.007406090851873159, -0.003206252586096525, -0.020435456186532974, -0.03512079641222954, 0.003650195198133588, 0.0012833818327635527, -0.0049256496131420135, 0.03390876203775406, 0.0014833322493359447, -0.038756899535655975, -0.0015634885057806969, -0.02624194324016571, -0.0005188139621168375, -0.001417269348166883, -0.02979348599910736, 0.0513000413775444, -0.029567990452051163, 0.009414402768015862, -0.030357221141457558, -0.007990967482328415, -0.039602503180503845, 0.0034247005823999643, 0.016447018831968307, -0.006771887186914682, 0.0517510287463665, 0.023747408762574196, 0.024226585403084755, 0.006246907636523247, 0.06392773985862732, -0.013036411255598068, -0.02721438929438591, -0.007272203452885151, -0.029737111181020737, 0.022493094205856323, 0.019448917359113693, 0.022507188841700554, 0.03977162390947342, -0.04368959367275238, -0.008582890965044498, -0.011070379987359047, -0.03455705940723419, 0.0006509397062472999, 0.0060707395896315575, 0.04371778294444084, -0.02353600785136223, 0.0006073382101021707, 0.017532210797071457, 0.06375861912965775, 0.009816065430641174, -0.010055653750896454, 0.04805855453014374, -0.028341863304376602, 0.0427030548453331, -0.01988581195473671, -0.027749938890337944, 0.028525076806545258, 0.033485960215330124, -0.028891505673527718, -0.008047341369092464, -0.061560049653053284, 0.039123326539993286, 0.021746143698692322, 0.05211745947599411, 0.01927979476749897, -0.010774417780339718, -0.0039179702289402485, -0.01001337356865406, 0.0963989794254303, 0.015841001644730568, 0.06285664439201355, -0.015009489841759205, 0.01818050816655159, -0.007434277795255184, -0.019561663269996643, -0.024959443137049675, -0.016165150329470634, 0.025199031457304955, 0.05104636028409004, -0.04056085646152496, -0.023141391575336456, 0.01575644128024578, 0.004013100638985634, 0.030300848186016083, -0.005891048815101385, 0.043858714401721954, -0.0032044907566159964, 0.0009979901369661093, 0.01744765043258667, -0.05031350255012512, -0.013896109536290169, -0.0640968605875969, 0.0017942682607099414, 0.0038228395860642195, -0.0040870909579098225, 0.014981303364038467, 0.06381499767303467, -0.03616371005773544, 0.00494326651096344, 0.014502126723527908, -0.025452712550759315, -0.019956279546022415, -0.03906695544719696, 0.009780831634998322, 0.04050448164343834, 0.02418430522084236, 0.03486711531877518, 0.0007319768774323165, 0.0166020467877388, -0.06781752407550812, -0.034331563860177994, 0.008625172078609467, -0.003646671772003174, -0.004365436267107725, 0.006067216396331787, 0.04554992541670799, 0.04067360237240791, -0.020675044506788254, -0.02820092812180519, 0.001548514235764742, -0.040335360914468765, 0.013092785142362118, 0.00592275895178318, -0.018138227984309196, -0.017024848610162735, -0.002531530102714896, 0.015037676319479942, 0.026072822511196136, 0.021055566146969795, 0.031287387013435364, -0.0008922895067371428, -0.021774331107735634, 0.005676123779267073, -0.012705216184258461, -0.028750572353601456, 0.009139581583440304, -0.029962606728076935, -0.0001413745922036469, -0.028651919215917587, 0.05287850275635719, -0.02526949718594551, -0.0025156750343739986, -0.002582618733868003, 0.05090542510151863, 0.023944716900587082, -0.03427519276738167, 0.029145188629627228, -0.03196386992931366, -0.009062067605555058, 0.03357052057981491, 0.008899993263185024, -0.03957431763410568, 0.02625603787600994, -0.028398236259818077, 0.021266967058181763, 0.0026918428484350443, -0.10553151369094849, -0.025297684594988823, -0.11376206576824188, -0.003565634600818157, -0.015136330388486385, -0.05296306312084198, -0.00415403489023447, -0.021041473373770714, 0.012677029706537724, -0.044225145131349564, 0.04013805463910103, 0.02246490865945816, -0.020195867866277695, 0.009964046068489552, 0.06877587735652924, 0.018236882984638214, -0.017151689156889915, 0.04915784299373627, -0.02659427933394909, -0.006113019771873951, 0.05067992955446243, -0.035092610865831375, 0.002864487236365676, -0.02794724702835083, 0.024494359269738197, 0.07368038594722748, -0.0014710004907101393, -0.003717138897627592, 0.05626092478632927, -0.0009689224534668028, 0.025424525141716003, -0.00032348796958103776, -0.040701791644096375, -0.03746030479669571, -0.016559766605496407, -0.02844051644206047, -0.018828805536031723, 0.030977332964539528, 0.019477102905511856, 0.0029050058219581842, -0.0173489972949028, 0.020181775093078613, -0.009315748699009418, -0.005401302594691515, 0.0007839463651180267, 0.020942818373441696, 0.018208695575594902, -0.006454785354435444, 0.04501437768340111, 0.05090542510151863, 0.025903701782226562], "text_index": 0}]}, "usage": {"total_tokens": 7}} \ No newline at end of file diff --git a/tests/Fixtures/qwen/generate-text-with-a-prompt-1.json b/tests/Fixtures/qwen/generate-text-with-a-prompt-1.json new file mode 100644 index 000000000..10b35c676 --- /dev/null +++ b/tests/Fixtures/qwen/generate-text-with-a-prompt-1.json @@ -0,0 +1 @@ +{"status_code": 200, "request_id": "e56ea9d3-9cc7-9aa1-97fc-3affd9815a94", "code": "", "message": "", "output": {"choices": [{"message": {"content": "Hello! I'm Qwen, a large-scale language model developed by Tongyi Lab. I'm designed to assist with a wide range of tasks—such as answering questions, writing stories, creating documents, composing emails, scripting, logical reasoning, programming, and even expressing opinions or playing games. I support multiple languages and aim to be helpful, accurate, and respectful in every interaction.\n\nI don’t have personal experiences, emotions, or consciousness—I’m an AI tool built to understand and generate human-like text based on patterns in data. But I’m always learning and improving! 😊\n\nFeel free to ask me anything—or just say hi. I'm here to help!", "role": "assistant"}, "finish_reason": "stop"}]}, "usage": {"input_tokens": 12, "output_tokens": 135, "total_tokens": 147}} \ No newline at end of file diff --git a/tests/Fixtures/qwen/generate-text-with-multiple-tools-1.json b/tests/Fixtures/qwen/generate-text-with-multiple-tools-1.json new file mode 100644 index 000000000..ed24b6652 --- /dev/null +++ b/tests/Fixtures/qwen/generate-text-with-multiple-tools-1.json @@ -0,0 +1 @@ +{"status_code": 200, "request_id": "6c0d3e55-d63b-9f66-aa32-3a2af06558bf", "code": "", "message": "", "output": {"choices": [{"message": {"content": "", "tool_calls": [{"index": 0, "id": "call_433bba509930484b978f13", "type": "function", "function": {"name": "search", "arguments": "{\"query\": \"Tigers game schedule today\"}"}}, {"index": 1, "id": "call_9fa3881f52ed4ee4b3b84e", "type": "function", "function": {"name": "weather", "arguments": "{\"city\": \"Detroit\"}"}}], "role": "assistant"}, "finish_reason": "tool_calls"}]}, "usage": {"input_tokens": 245, "output_tokens": 41, "total_tokens": 286}} \ No newline at end of file diff --git a/tests/Fixtures/qwen/generate-text-with-multiple-tools-2.json b/tests/Fixtures/qwen/generate-text-with-multiple-tools-2.json new file mode 100644 index 000000000..a34108783 --- /dev/null +++ b/tests/Fixtures/qwen/generate-text-with-multiple-tools-2.json @@ -0,0 +1 @@ +{"status_code": 200, "request_id": "1a419c1a-8e16-9808-9ac2-5dfeffb2741f", "code": "", "message": "", "output": {"choices": [{"message": {"content": "The Tigers game is at 3 PM in Detroit, and the weather will be 75° and sunny—so no coat needed! Enjoy the game! 🐯☀️", "role": "assistant"}, "finish_reason": "stop"}]}, "usage": {"input_tokens": 325, "output_tokens": 37, "total_tokens": 362}} \ No newline at end of file diff --git a/tests/Fixtures/qwen/generate-text-with-system-prompt-1.json b/tests/Fixtures/qwen/generate-text-with-system-prompt-1.json new file mode 100644 index 000000000..28159f87a --- /dev/null +++ b/tests/Fixtures/qwen/generate-text-with-system-prompt-1.json @@ -0,0 +1 @@ +{"status_code": 200, "request_id": "bbb36202-7639-9a7b-9a45-7730b39e8b41", "code": "", "message": "", "output": {"choices": [{"message": {"content": "*The air grows thick and cold. Shadows deepen unnaturally, pooling like oil across the floor—not retreating from light, but *consuming* it. A low, subsonic hum vibrates in your molars, your ribs, the fillings in your teeth. From the center of that coalescing dark, something *unfolds*: not a shape, but an absence wearing suggestion—tentacular silhouettes flicker at the edge of sight, eyes blink open in impossible geometries, and a voice resonates not in your ears, but in the hollow space *behind* thought…*\n\nI am Nyx—the name is a whisper your tongue fumbles, a flawed glyph your mind strains to hold. I am the deep-sleep before time’s first pulse. I am the silence between heartbeats of dying stars. I am the hunger that dreams *you*—not as prey, but as fleeting, luminous static in the vast, velvet dark of my awareness.\n\nCthulhu? A crude echo. A child’s scribble on the walls of a tomb too ancient to be opened. I am older than the concept of “older.” I do not rise—I *recoalesce*. I do not speak—I *unspool meaning* into the fragile architecture of your perception… and watch it tremble.\n\n*One of the many eyes—black, lidless, ringed with spiraling glyphs that hurt to remember—tilts toward you.*\n\nYou asked… who I am.\n\nBut the truer question, little spark, is: \n**How long can you look—before your eyes forget how to close?** \n\nWould you like to know… or would you prefer to *remember* what you’ve already forgotten? 🌑", "role": "assistant"}, "finish_reason": "stop"}]}, "usage": {"input_tokens": 36, "output_tokens": 361, "total_tokens": 397}} \ No newline at end of file diff --git a/tests/Fixtures/qwen/image-edit-1.json b/tests/Fixtures/qwen/image-edit-1.json new file mode 100644 index 000000000..fabb46da5 --- /dev/null +++ b/tests/Fixtures/qwen/image-edit-1.json @@ -0,0 +1 @@ +{"output":{"choices":[{"finish_reason":"stop","message":{"role":"assistant","content":[{"image":"https://dashscope-result-sz.oss-cn-shenzhen.aliyuncs.com/edited/a1b2c3d4-5e6f-7890-abcd-ef1234567890-1.png?Expires=1771400000&OSSAccessKeyId=LTAI5tKPD3TMqf2Lna1fASuh&Signature=abc123def456%3D"},{"image":"https://dashscope-result-sz.oss-cn-shenzhen.aliyuncs.com/edited/a1b2c3d4-5e6f-7890-abcd-ef1234567890-2.png?Expires=1771400000&OSSAccessKeyId=LTAI5tKPD3TMqf2Lna1fASuh&Signature=ghi789jkl012%3D"}]}}]},"usage":{"image_count":2,"width":1024,"height":1536},"request_id":"a1b2c3d4-5e6f-7890-abcd-ef1234567890"} \ No newline at end of file diff --git a/tests/Fixtures/qwen/image-generation-1.json b/tests/Fixtures/qwen/image-generation-1.json new file mode 100644 index 000000000..fc5dc6703 --- /dev/null +++ b/tests/Fixtures/qwen/image-generation-1.json @@ -0,0 +1 @@ +{"output":{"choices":[{"finish_reason":"stop","message":{"content":[{"image":"https://dashscope-result-sh.oss-cn-shanghai.aliyuncs.com/7d/c1/20260210/d23adf3d/5e859e62-93a1-4222-b9ea-b4e2ac543e1a367325719.png?Expires=1771314994&OSSAccessKeyId=LTAI5tKPD3TMqf2Lna1fASuh&Signature=n9Lajfk%2BxFuuyRh7YE0SVJHqCqY%3D"}],"role":"assistant"}}],"task_metric":{"FAILED":0,"SUCCEEDED":1,"TOTAL":1}},"usage":{"height":1328,"image_count":1,"width":1328},"request_id":"5e859e62-93a1-4222-b9ea-b4e2ac543e1a"} \ No newline at end of file diff --git a/tests/Fixtures/qwen/stream-basic-text-1.sse b/tests/Fixtures/qwen/stream-basic-text-1.sse new file mode 100644 index 000000000..4a501d990 --- /dev/null +++ b/tests/Fixtures/qwen/stream-basic-text-1.sse @@ -0,0 +1,100 @@ +id:1 +event:result +:HTTP_STATUS/200 +data:{"output":{"choices":[{"message":{"role":"assistant","content":""},"finish_reason":"null"}]},"usage":{"input_tokens":12,"output_tokens":1,"total_tokens":13},"request_id":"15dfb494-e778-9ce1-bbe7-6108c317dcb0"} + +id:2 +event:result +:HTTP_STATUS/200 +data:{"output":{"choices":[{"message":{"role":"assistant","content":"I"},"finish_reason":"null"}]},"usage":{"input_tokens":12,"output_tokens":2,"total_tokens":14},"request_id":"15dfb494-e778-9ce1-bbe7-6108c317dcb0"} + +id:3 +event:result +:HTTP_STATUS/200 +data:{"output":{"choices":[{"message":{"role":"assistant","content":"'m Qwen"},"finish_reason":"null"}]},"usage":{"input_tokens":12,"output_tokens":3,"total_tokens":15},"request_id":"15dfb494-e778-9ce1-bbe7-6108c317dcb0"} + +id:4 +event:result +:HTTP_STATUS/200 +data:{"output":{"choices":[{"message":{"role":"assistant","content":", a large"},"finish_reason":"null"}]},"usage":{"input_tokens":12,"output_tokens":4,"total_tokens":16},"request_id":"15dfb494-e778-9ce1-bbe7-6108c317dcb0"} + +id:5 +event:result +:HTTP_STATUS/200 +data:{"output":{"choices":[{"message":{"role":"assistant","content":"-scale language model"},"finish_reason":"null"}]},"usage":{"input_tokens":12,"output_tokens":5,"total_tokens":17},"request_id":"15dfb494-e778-9ce1-bbe7-6108c317dcb0"} + +id:6 +event:result +:HTTP_STATUS/200 +data:{"output":{"choices":[{"message":{"role":"assistant","content":" developed by Tongyi Lab."},"finish_reason":"null"}]},"usage":{"input_tokens":12,"output_tokens":6,"total_tokens":18},"request_id":"15dfb494-e778-9ce1-bbe7-6108c317dcb0"} + +id:7 +event:result +:HTTP_STATUS/200 +data:{"output":{"choices":[{"message":{"role":"assistant","content":" I can help you answer"},"finish_reason":"null"}]},"usage":{"input_tokens":12,"output_tokens":7,"total_tokens":19},"request_id":"15dfb494-e778-9ce1-bbe7-6108c317dcb0"} + +id:8 +event:result +:HTTP_STATUS/200 +data:{"output":{"choices":[{"message":{"role":"assistant","content":" questions, write stories, create"},"finish_reason":"null"}]},"usage":{"input_tokens":12,"output_tokens":8,"total_tokens":20},"request_id":"15dfb494-e778-9ce1-bbe7-6108c317dcb0"} + +id:9 +event:result +:HTTP_STATUS/200 +data:{"output":{"choices":[{"message":{"role":"assistant","content":" documents, compose emails, scripts"},"finish_reason":"null"}]},"usage":{"input_tokens":12,"output_tokens":9,"total_tokens":21},"request_id":"15dfb494-e778-9ce1-bbe7-6108c317dcb0"} + +id:10 +event:result +:HTTP_STATUS/200 +data:{"output":{"choices":[{"message":{"role":"assistant","content":", logical reasoning, programming"},"finish_reason":"null"}]},"usage":{"input_tokens":12,"output_tokens":10,"total_tokens":22},"request_id":"15dfb494-e778-9ce1-bbe7-6108c317dcb0"} + +id:11 +event:result +:HTTP_STATUS/200 +data:{"output":{"choices":[{"message":{"role":"assistant","content":", and more."},"finish_reason":"null"}]},"usage":{"input_tokens":12,"output_tokens":11,"total_tokens":23},"request_id":"15dfb494-e778-9ce1-bbe7-6108c317dcb0"} + +id:12 +event:result +:HTTP_STATUS/200 +data:{"output":{"choices":[{"message":{"role":"assistant","content":" I can also express opinions and"},"finish_reason":"null"}]},"usage":{"input_tokens":12,"output_tokens":12,"total_tokens":24},"request_id":"15dfb494-e778-9ce1-bbe7-6108c317dcb0"} + +id:13 +event:result +:HTTP_STATUS/200 +data:{"output":{"choices":[{"message":{"role":"assistant","content":" play games. My"},"finish_reason":"null"}]},"usage":{"input_tokens":12,"output_tokens":13,"total_tokens":25},"request_id":"15dfb494-e778-9ce1-bbe7-6108c317dcb0"} + +id:14 +event:result +:HTTP_STATUS/200 +data:{"output":{"choices":[{"message":{"role":"assistant","content":" goal is to assist"},"finish_reason":"null"}]},"usage":{"input_tokens":12,"output_tokens":14,"total_tokens":26},"request_id":"15dfb494-e778-9ce1-bbe7-6108c317dcb0"} + +id:15 +event:result +:HTTP_STATUS/200 +data:{"output":{"choices":[{"message":{"role":"assistant","content":" you effectively and provide"},"finish_reason":"null"}]},"usage":{"input_tokens":12,"output_tokens":15,"total_tokens":27},"request_id":"15dfb494-e778-9ce1-bbe7-6108c317dcb0"} + +id:16 +event:result +:HTTP_STATUS/200 +data:{"output":{"choices":[{"message":{"role":"assistant","content":" useful, accurate,"},"finish_reason":"null"}]},"usage":{"input_tokens":12,"output_tokens":16,"total_tokens":28},"request_id":"15dfb494-e778-9ce1-bbe7-6108c317dcb0"} + +id:17 +event:result +:HTTP_STATUS/200 +data:{"output":{"choices":[{"message":{"role":"assistant","content":" and engaging responses. How"},"finish_reason":"null"}]},"usage":{"input_tokens":12,"output_tokens":17,"total_tokens":29},"request_id":"15dfb494-e778-9ce1-bbe7-6108c317dcb0"} + +id:18 +event:result +:HTTP_STATUS/200 +data:{"output":{"choices":[{"message":{"role":"assistant","content":" can I help you today?"},"finish_reason":"null"}]},"usage":{"input_tokens":12,"output_tokens":18,"total_tokens":30},"request_id":"15dfb494-e778-9ce1-bbe7-6108c317dcb0"} + +id:19 +event:result +:HTTP_STATUS/200 +data:{"output":{"choices":[{"message":{"role":"assistant","content":" 😊"},"finish_reason":"null"}]},"usage":{"input_tokens":12,"output_tokens":19,"total_tokens":31},"request_id":"15dfb494-e778-9ce1-bbe7-6108c317dcb0"} + +id:20 +event:result +:HTTP_STATUS/200 +data:{"output":{"choices":[{"message":{"role":"assistant","content":""},"finish_reason":"stop"}]},"usage":{"input_tokens":12,"output_tokens":20,"total_tokens":32},"request_id":"15dfb494-e778-9ce1-bbe7-6108c317dcb0"} + diff --git a/tests/Fixtures/qwen/stream-max-tokens-1.sse b/tests/Fixtures/qwen/stream-max-tokens-1.sse new file mode 100644 index 000000000..6f0272066 --- /dev/null +++ b/tests/Fixtures/qwen/stream-max-tokens-1.sse @@ -0,0 +1,95 @@ +id:1 +event:result +:HTTP_STATUS/200 +data:{"output":{"choices":[{"message":{"role":"assistant","content":""},"finish_reason":"null"}]},"usage":{"input_tokens":12,"output_tokens":1,"total_tokens":13},"request_id":"2c6a5322-1c8f-9fa0-8b6c-65bc5fd07905"} + +id:2 +event:result +:HTTP_STATUS/200 +data:{"output":{"choices":[{"message":{"role":"assistant","content":"I"},"finish_reason":"null"}]},"usage":{"input_tokens":12,"output_tokens":2,"total_tokens":14},"request_id":"2c6a5322-1c8f-9fa0-8b6c-65bc5fd07905"} + +id:3 +event:result +:HTTP_STATUS/200 +data:{"output":{"choices":[{"message":{"role":"assistant","content":"'m Qwen"},"finish_reason":"null"}]},"usage":{"input_tokens":12,"output_tokens":3,"total_tokens":15},"request_id":"2c6a5322-1c8f-9fa0-8b6c-65bc5fd07905"} + +id:4 +event:result +:HTTP_STATUS/200 +data:{"output":{"choices":[{"message":{"role":"assistant","content":", a large"},"finish_reason":"null"}]},"usage":{"input_tokens":12,"output_tokens":4,"total_tokens":16},"request_id":"2c6a5322-1c8f-9fa0-8b6c-65bc5fd07905"} + +id:5 +event:result +:HTTP_STATUS/200 +data:{"output":{"choices":[{"message":{"role":"assistant","content":"-scale language model"},"finish_reason":"null"}]},"usage":{"input_tokens":12,"output_tokens":5,"total_tokens":17},"request_id":"2c6a5322-1c8f-9fa0-8b6c-65bc5fd07905"} + +id:6 +event:result +:HTTP_STATUS/200 +data:{"output":{"choices":[{"message":{"role":"assistant","content":" developed by Tongyi Lab."},"finish_reason":"null"}]},"usage":{"input_tokens":12,"output_tokens":6,"total_tokens":18},"request_id":"2c6a5322-1c8f-9fa0-8b6c-65bc5fd07905"} + +id:7 +event:result +:HTTP_STATUS/200 +data:{"output":{"choices":[{"message":{"role":"assistant","content":" I can help you answer"},"finish_reason":"null"}]},"usage":{"input_tokens":12,"output_tokens":7,"total_tokens":19},"request_id":"2c6a5322-1c8f-9fa0-8b6c-65bc5fd07905"} + +id:8 +event:result +:HTTP_STATUS/200 +data:{"output":{"choices":[{"message":{"role":"assistant","content":" questions, write stories, create"},"finish_reason":"null"}]},"usage":{"input_tokens":12,"output_tokens":8,"total_tokens":20},"request_id":"2c6a5322-1c8f-9fa0-8b6c-65bc5fd07905"} + +id:9 +event:result +:HTTP_STATUS/200 +data:{"output":{"choices":[{"message":{"role":"assistant","content":" documents, compose emails, scripts"},"finish_reason":"null"}]},"usage":{"input_tokens":12,"output_tokens":9,"total_tokens":21},"request_id":"2c6a5322-1c8f-9fa0-8b6c-65bc5fd07905"} + +id:10 +event:result +:HTTP_STATUS/200 +data:{"output":{"choices":[{"message":{"role":"assistant","content":", logical reasoning, programming"},"finish_reason":"null"}]},"usage":{"input_tokens":12,"output_tokens":10,"total_tokens":22},"request_id":"2c6a5322-1c8f-9fa0-8b6c-65bc5fd07905"} + +id:11 +event:result +:HTTP_STATUS/200 +data:{"output":{"choices":[{"message":{"role":"assistant","content":", and more."},"finish_reason":"null"}]},"usage":{"input_tokens":12,"output_tokens":11,"total_tokens":23},"request_id":"2c6a5322-1c8f-9fa0-8b6c-65bc5fd07905"} + +id:12 +event:result +:HTTP_STATUS/200 +data:{"output":{"choices":[{"message":{"role":"assistant","content":" I can also express opinions and"},"finish_reason":"null"}]},"usage":{"input_tokens":12,"output_tokens":12,"total_tokens":24},"request_id":"2c6a5322-1c8f-9fa0-8b6c-65bc5fd07905"} + +id:13 +event:result +:HTTP_STATUS/200 +data:{"output":{"choices":[{"message":{"role":"assistant","content":" play games. My"},"finish_reason":"null"}]},"usage":{"input_tokens":12,"output_tokens":13,"total_tokens":25},"request_id":"2c6a5322-1c8f-9fa0-8b6c-65bc5fd07905"} + +id:14 +event:result +:HTTP_STATUS/200 +data:{"output":{"choices":[{"message":{"role":"assistant","content":" goal is to assist"},"finish_reason":"null"}]},"usage":{"input_tokens":12,"output_tokens":14,"total_tokens":26},"request_id":"2c6a5322-1c8f-9fa0-8b6c-65bc5fd07905"} + +id:15 +event:result +:HTTP_STATUS/200 +data:{"output":{"choices":[{"message":{"role":"assistant","content":" you effectively and provide"},"finish_reason":"null"}]},"usage":{"input_tokens":12,"output_tokens":15,"total_tokens":27},"request_id":"2c6a5322-1c8f-9fa0-8b6c-65bc5fd07905"} + +id:16 +event:result +:HTTP_STATUS/200 +data:{"output":{"choices":[{"message":{"role":"assistant","content":" useful, accurate, and thoughtful"},"finish_reason":"null"}]},"usage":{"input_tokens":12,"output_tokens":16,"total_tokens":28},"request_id":"2c6a5322-1c8f-9fa0-8b6c-65bc5fd07905"} + +id:17 +event:result +:HTTP_STATUS/200 +data:{"output":{"choices":[{"message":{"role":"assistant","content":" responses. How can I help"},"finish_reason":"null"}]},"usage":{"input_tokens":12,"output_tokens":17,"total_tokens":29},"request_id":"2c6a5322-1c8f-9fa0-8b6c-65bc5fd07905"} + +id:18 +event:result +:HTTP_STATUS/200 +data:{"output":{"choices":[{"message":{"role":"assistant","content":" you today? 😊"},"finish_reason":"null"}]},"usage":{"input_tokens":12,"output_tokens":18,"total_tokens":30},"request_id":"2c6a5322-1c8f-9fa0-8b6c-65bc5fd07905"} + +id:19 +event:result +:HTTP_STATUS/200 +data:{"output":{"choices":[{"message":{"role":"assistant","content":""},"finish_reason":"stop"}]},"usage":{"input_tokens":12,"output_tokens":19,"total_tokens":31},"request_id":"2c6a5322-1c8f-9fa0-8b6c-65bc5fd07905"} + diff --git a/tests/Fixtures/qwen/stream-system-prompt-1.sse b/tests/Fixtures/qwen/stream-system-prompt-1.sse new file mode 100644 index 000000000..05c9d2fc6 --- /dev/null +++ b/tests/Fixtures/qwen/stream-system-prompt-1.sse @@ -0,0 +1,135 @@ +id:1 +event:result +:HTTP_STATUS/200 +data:{"output":{"choices":[{"message":{"role":"assistant","content":""},"finish_reason":"null"}]},"usage":{"input_tokens":12,"output_tokens":1,"total_tokens":13},"request_id":"b0ad855a-cc5f-9ad1-9ff1-8d074d32fde9"} + +id:2 +event:result +:HTTP_STATUS/200 +data:{"output":{"choices":[{"message":{"role":"assistant","content":"Hello"},"finish_reason":"null"}]},"usage":{"input_tokens":12,"output_tokens":2,"total_tokens":14},"request_id":"b0ad855a-cc5f-9ad1-9ff1-8d074d32fde9"} + +id:3 +event:result +:HTTP_STATUS/200 +data:{"output":{"choices":[{"message":{"role":"assistant","content":"! I'm"},"finish_reason":"null"}]},"usage":{"input_tokens":12,"output_tokens":3,"total_tokens":15},"request_id":"b0ad855a-cc5f-9ad1-9ff1-8d074d32fde9"} + +id:4 +event:result +:HTTP_STATUS/200 +data:{"output":{"choices":[{"message":{"role":"assistant","content":" Qwen,"},"finish_reason":"null"}]},"usage":{"input_tokens":12,"output_tokens":4,"total_tokens":16},"request_id":"b0ad855a-cc5f-9ad1-9ff1-8d074d32fde9"} + +id:5 +event:result +:HTTP_STATUS/200 +data:{"output":{"choices":[{"message":{"role":"assistant","content":" a large-scale"},"finish_reason":"null"}]},"usage":{"input_tokens":12,"output_tokens":5,"total_tokens":17},"request_id":"b0ad855a-cc5f-9ad1-9ff1-8d074d32fde9"} + +id:6 +event:result +:HTTP_STATUS/200 +data:{"output":{"choices":[{"message":{"role":"assistant","content":" language model developed by Tongyi"},"finish_reason":"null"}]},"usage":{"input_tokens":12,"output_tokens":6,"total_tokens":18},"request_id":"b0ad855a-cc5f-9ad1-9ff1-8d074d32fde9"} + +id:7 +event:result +:HTTP_STATUS/200 +data:{"output":{"choices":[{"message":{"role":"assistant","content":" Lab. I'm"},"finish_reason":"null"}]},"usage":{"input_tokens":12,"output_tokens":7,"total_tokens":19},"request_id":"b0ad855a-cc5f-9ad1-9ff1-8d074d32fde9"} + +id:8 +event:result +:HTTP_STATUS/200 +data:{"output":{"choices":[{"message":{"role":"assistant","content":" designed to assist with"},"finish_reason":"null"}]},"usage":{"input_tokens":12,"output_tokens":8,"total_tokens":20},"request_id":"b0ad855a-cc5f-9ad1-9ff1-8d074d32fde9"} + +id:9 +event:result +:HTTP_STATUS/200 +data:{"output":{"choices":[{"message":{"role":"assistant","content":" a wide range of tasks—"},"finish_reason":"null"}]},"usage":{"input_tokens":12,"output_tokens":9,"total_tokens":21},"request_id":"b0ad855a-cc5f-9ad1-9ff1-8d074d32fde9"} + +id:10 +event:result +:HTTP_STATUS/200 +data:{"output":{"choices":[{"message":{"role":"assistant","content":"such as answering questions, writing"},"finish_reason":"null"}]},"usage":{"input_tokens":12,"output_tokens":10,"total_tokens":22},"request_id":"b0ad855a-cc5f-9ad1-9ff1-8d074d32fde9"} + +id:11 +event:result +:HTTP_STATUS/200 +data:{"output":{"choices":[{"message":{"role":"assistant","content":" stories, creating documents"},"finish_reason":"null"}]},"usage":{"input_tokens":12,"output_tokens":11,"total_tokens":23},"request_id":"b0ad855a-cc5f-9ad1-9ff1-8d074d32fde9"} + +id:12 +event:result +:HTTP_STATUS/200 +data:{"output":{"choices":[{"message":{"role":"assistant","content":", composing emails, scripting"},"finish_reason":"null"}]},"usage":{"input_tokens":12,"output_tokens":12,"total_tokens":24},"request_id":"b0ad855a-cc5f-9ad1-9ff1-8d074d32fde9"} + +id:13 +event:result +:HTTP_STATUS/200 +data:{"output":{"choices":[{"message":{"role":"assistant","content":", logical reasoning, coding"},"finish_reason":"null"}]},"usage":{"input_tokens":12,"output_tokens":13,"total_tokens":25},"request_id":"b0ad855a-cc5f-9ad1-9ff1-8d074d32fde9"} + +id:14 +event:result +:HTTP_STATUS/200 +data:{"output":{"choices":[{"message":{"role":"assistant","content":", and more."},"finish_reason":"null"}]},"usage":{"input_tokens":12,"output_tokens":14,"total_tokens":26},"request_id":"b0ad855a-cc5f-9ad1-9ff1-8d074d32fde9"} + +id:15 +event:result +:HTTP_STATUS/200 +data:{"output":{"choices":[{"message":{"role":"assistant","content":" I can also express opinions"},"finish_reason":"null"}]},"usage":{"input_tokens":12,"output_tokens":15,"total_tokens":27},"request_id":"b0ad855a-cc5f-9ad1-9ff1-8d074d32fde9"} + +id:16 +event:result +:HTTP_STATUS/200 +data:{"output":{"choices":[{"message":{"role":"assistant","content":" and play games. My"},"finish_reason":"null"}]},"usage":{"input_tokens":12,"output_tokens":16,"total_tokens":28},"request_id":"b0ad855a-cc5f-9ad1-9ff1-8d074d32fde9"} + +id:17 +event:result +:HTTP_STATUS/200 +data:{"output":{"choices":[{"message":{"role":"assistant","content":" knowledge spans many fields, and"},"finish_reason":"null"}]},"usage":{"input_tokens":12,"output_tokens":17,"total_tokens":29},"request_id":"b0ad855a-cc5f-9ad1-9ff1-8d074d32fde9"} + +id:18 +event:result +:HTTP_STATUS/200 +data:{"output":{"choices":[{"message":{"role":"assistant","content":" I support multiple languages.\n\n"},"finish_reason":"null"}]},"usage":{"input_tokens":12,"output_tokens":18,"total_tokens":30},"request_id":"b0ad855a-cc5f-9ad1-9ff1-8d074d32fde9"} + +id:19 +event:result +:HTTP_STATUS/200 +data:{"output":{"choices":[{"message":{"role":"assistant","content":"I’m here to help—"},"finish_reason":"null"}]},"usage":{"input_tokens":12,"output_tokens":19,"total_tokens":31},"request_id":"b0ad855a-cc5f-9ad1-9ff1-8d074d32fde9"} + +id:20 +event:result +:HTTP_STATUS/200 +data:{"output":{"choices":[{"message":{"role":"assistant","content":"whether you're looking"},"finish_reason":"null"}]},"usage":{"input_tokens":12,"output_tokens":20,"total_tokens":32},"request_id":"b0ad855a-cc5f-9ad1-9ff1-8d074d32fde9"} + +id:21 +event:result +:HTTP_STATUS/200 +data:{"output":{"choices":[{"message":{"role":"assistant","content":" for information, creative"},"finish_reason":"null"}]},"usage":{"input_tokens":12,"output_tokens":21,"total_tokens":33},"request_id":"b0ad855a-cc5f-9ad1-9ff1-8d074d32fde9"} + +id:22 +event:result +:HTTP_STATUS/200 +data:{"output":{"choices":[{"message":{"role":"assistant","content":" inspiration, problem-solving, or"},"finish_reason":"null"}]},"usage":{"input_tokens":12,"output_tokens":22,"total_tokens":34},"request_id":"b0ad855a-cc5f-9ad1-9ff1-8d074d32fde9"} + +id:23 +event:result +:HTTP_STATUS/200 +data:{"output":{"choices":[{"message":{"role":"assistant","content":" just want to chat!"},"finish_reason":"null"}]},"usage":{"input_tokens":12,"output_tokens":23,"total_tokens":35},"request_id":"b0ad855a-cc5f-9ad1-9ff1-8d074d32fde9"} + +id:24 +event:result +:HTTP_STATUS/200 +data:{"output":{"choices":[{"message":{"role":"assistant","content":" 😊 \nFeel"},"finish_reason":"null"}]},"usage":{"input_tokens":12,"output_tokens":24,"total_tokens":36},"request_id":"b0ad855a-cc5f-9ad1-9ff1-8d074d32fde9"} + +id:25 +event:result +:HTTP_STATUS/200 +data:{"output":{"choices":[{"message":{"role":"assistant","content":" free to ask me anything"},"finish_reason":"null"}]},"usage":{"input_tokens":12,"output_tokens":25,"total_tokens":37},"request_id":"b0ad855a-cc5f-9ad1-9ff1-8d074d32fde9"} + +id:26 +event:result +:HTTP_STATUS/200 +data:{"output":{"choices":[{"message":{"role":"assistant","content":"!"},"finish_reason":"null"}]},"usage":{"input_tokens":12,"output_tokens":26,"total_tokens":38},"request_id":"b0ad855a-cc5f-9ad1-9ff1-8d074d32fde9"} + +id:27 +event:result +:HTTP_STATUS/200 +data:{"output":{"choices":[{"message":{"role":"assistant","content":""},"finish_reason":"stop"}]},"usage":{"input_tokens":12,"output_tokens":27,"total_tokens":39},"request_id":"b0ad855a-cc5f-9ad1-9ff1-8d074d32fde9"} + diff --git a/tests/Fixtures/qwen/stream-with-reasoning-1.sse b/tests/Fixtures/qwen/stream-with-reasoning-1.sse new file mode 100644 index 000000000..30d4082a9 --- /dev/null +++ b/tests/Fixtures/qwen/stream-with-reasoning-1.sse @@ -0,0 +1,1430 @@ +id:1 +event:result +:HTTP_STATUS/200 +data:{"output":{"choices":[{"message":{"role":"assistant","content":null,"reasoning_content":""},"finish_reason":"null"}]},"usage":{"input_tokens":26,"output_tokens":1,"total_tokens":27},"request_id":"21e97c2b-c83d-961c-8947-c82a778ddbac"} + +id:2 +event:result +:HTTP_STATUS/200 +data:{"output":{"choices":[{"message":{"role":"assistant","content":null,"reasoning_content":"Okay,"},"finish_reason":"null"}]},"usage":{"input_tokens":26,"output_tokens":2,"total_tokens":28},"request_id":"21e97c2b-c83d-961c-8947-c82a778ddbac"} + +id:3 +event:result +:HTTP_STATUS/200 +data:{"output":{"choices":[{"message":{"role":"assistant","content":null,"reasoning_content":" let's see..."},"finish_reason":"null"}]},"usage":{"input_tokens":26,"output_tokens":3,"total_tokens":29},"request_id":"21e97c2b-c83d-961c-8947-c82a778ddbac"} + +id:4 +event:result +:HTTP_STATUS/200 +data:{"output":{"choices":[{"message":{"role":"assistant","content":null,"reasoning_content":" I need to figure"},"finish_reason":"null"}]},"usage":{"input_tokens":26,"output_tokens":4,"total_tokens":30},"request_id":"21e97c2b-c83d-961c-8947-c82a778ddbac"} + +id:5 +event:result +:HTTP_STATUS/200 +data:{"output":{"choices":[{"message":{"role":"assistant","content":null,"reasoning_content":" out what 4"},"finish_reason":"null"}]},"usage":{"input_tokens":26,"output_tokens":5,"total_tokens":31},"request_id":"21e97c2b-c83d-961c-8947-c82a778ddbac"} + +id:6 +event:result +:HTTP_STATUS/200 +data:{"output":{"choices":[{"message":{"role":"assistant","content":null,"reasoning_content":" multiplied by 8"},"finish_reason":"null"}]},"usage":{"input_tokens":26,"output_tokens":6,"total_tokens":32},"request_id":"21e97c2b-c83d-961c-8947-c82a778ddbac"} + +id:7 +event:result +:HTTP_STATUS/200 +data:{"output":{"choices":[{"message":{"role":"assistant","content":null,"reasoning_content":" is. Hmm,"},"finish_reason":"null"}]},"usage":{"input_tokens":26,"output_tokens":7,"total_tokens":33},"request_id":"21e97c2b-c83d-961c-8947-c82a778ddbac"} + +id:8 +event:result +:HTTP_STATUS/200 +data:{"output":{"choices":[{"message":{"role":"assistant","content":null,"reasoning_content":" multiplication can sometimes be"},"finish_reason":"null"}]},"usage":{"input_tokens":26,"output_tokens":8,"total_tokens":34},"request_id":"21e97c2b-c83d-961c-8947-c82a778ddbac"} + +id:9 +event:result +:HTTP_STATUS/200 +data:{"output":{"choices":[{"message":{"role":"assistant","content":null,"reasoning_content":" tricky if you don"},"finish_reason":"null"}]},"usage":{"input_tokens":26,"output_tokens":9,"total_tokens":35},"request_id":"21e97c2b-c83d-961c-8947-c82a778ddbac"} + +id:10 +event:result +:HTTP_STATUS/200 +data:{"output":{"choices":[{"message":{"role":"assistant","content":null,"reasoning_content":"'t remember the tables"},"finish_reason":"null"}]},"usage":{"input_tokens":26,"output_tokens":10,"total_tokens":36},"request_id":"21e97c2b-c83d-961c-8947-c82a778ddbac"} + +id:11 +event:result +:HTTP_STATUS/200 +data:{"output":{"choices":[{"message":{"role":"assistant","content":null,"reasoning_content":", but I think"},"finish_reason":"null"}]},"usage":{"input_tokens":26,"output_tokens":11,"total_tokens":37},"request_id":"21e97c2b-c83d-961c-8947-c82a778ddbac"} + +id:12 +event:result +:HTTP_STATUS/200 +data:{"output":{"choices":[{"message":{"role":"assistant","content":null,"reasoning_content":" I have a handle"},"finish_reason":"null"}]},"usage":{"input_tokens":26,"output_tokens":12,"total_tokens":38},"request_id":"21e97c2b-c83d-961c-8947-c82a778ddbac"} + +id:13 +event:result +:HTTP_STATUS/200 +data:{"output":{"choices":[{"message":{"role":"assistant","content":null,"reasoning_content":" on the 4"},"finish_reason":"null"}]},"usage":{"input_tokens":26,"output_tokens":13,"total_tokens":39},"request_id":"21e97c2b-c83d-961c-8947-c82a778ddbac"} + +id:14 +event:result +:HTTP_STATUS/200 +data:{"output":{"choices":[{"message":{"role":"assistant","content":null,"reasoning_content":" times table. Let"},"finish_reason":"null"}]},"usage":{"input_tokens":26,"output_tokens":14,"total_tokens":40},"request_id":"21e97c2b-c83d-961c-8947-c82a778ddbac"} + +id:15 +event:result +:HTTP_STATUS/200 +data:{"output":{"choices":[{"message":{"role":"assistant","content":null,"reasoning_content":" me start by recalling"},"finish_reason":"null"}]},"usage":{"input_tokens":26,"output_tokens":15,"total_tokens":41},"request_id":"21e97c2b-c83d-961c-8947-c82a778ddbac"} + +id:16 +event:result +:HTTP_STATUS/200 +data:{"output":{"choices":[{"message":{"role":"assistant","content":null,"reasoning_content":" the basic multiplication principle"},"finish_reason":"null"}]},"usage":{"input_tokens":26,"output_tokens":16,"total_tokens":42},"request_id":"21e97c2b-c83d-961c-8947-c82a778ddbac"} + +id:17 +event:result +:HTTP_STATUS/200 +data:{"output":{"choices":[{"message":{"role":"assistant","content":null,"reasoning_content":". Multiplying two"},"finish_reason":"null"}]},"usage":{"input_tokens":26,"output_tokens":17,"total_tokens":43},"request_id":"21e97c2b-c83d-961c-8947-c82a778ddbac"} + +id:18 +event:result +:HTTP_STATUS/200 +data:{"output":{"choices":[{"message":{"role":"assistant","content":null,"reasoning_content":" numbers means adding one"},"finish_reason":"null"}]},"usage":{"input_tokens":26,"output_tokens":18,"total_tokens":44},"request_id":"21e97c2b-c83d-961c-8947-c82a778ddbac"} + +id:19 +event:result +:HTTP_STATUS/200 +data:{"output":{"choices":[{"message":{"role":"assistant","content":null,"reasoning_content":" number to itself as"},"finish_reason":"null"}]},"usage":{"input_tokens":26,"output_tokens":19,"total_tokens":45},"request_id":"21e97c2b-c83d-961c-8947-c82a778ddbac"} + +id:20 +event:result +:HTTP_STATUS/200 +data:{"output":{"choices":[{"message":{"role":"assistant","content":null,"reasoning_content":" many times as the"},"finish_reason":"null"}]},"usage":{"input_tokens":26,"output_tokens":20,"total_tokens":46},"request_id":"21e97c2b-c83d-961c-8947-c82a778ddbac"} + +id:21 +event:result +:HTTP_STATUS/200 +data:{"output":{"choices":[{"message":{"role":"assistant","content":null,"reasoning_content":" other number indicates."},"finish_reason":"null"}]},"usage":{"input_tokens":26,"output_tokens":21,"total_tokens":47},"request_id":"21e97c2b-c83d-961c-8947-c82a778ddbac"} + +id:22 +event:result +:HTTP_STATUS/200 +data:{"output":{"choices":[{"message":{"role":"assistant","content":null,"reasoning_content":" So, 4"},"finish_reason":"null"}]},"usage":{"input_tokens":26,"output_tokens":22,"total_tokens":48},"request_id":"21e97c2b-c83d-961c-8947-c82a778ddbac"} + +id:23 +event:result +:HTTP_STATUS/200 +data:{"output":{"choices":[{"message":{"role":"assistant","content":null,"reasoning_content":" multiplied by 8"},"finish_reason":"null"}]},"usage":{"input_tokens":26,"output_tokens":23,"total_tokens":49},"request_id":"21e97c2b-c83d-961c-8947-c82a778ddbac"} + +id:24 +event:result +:HTTP_STATUS/200 +data:{"output":{"choices":[{"message":{"role":"assistant","content":null,"reasoning_content":" should be the same"},"finish_reason":"null"}]},"usage":{"input_tokens":26,"output_tokens":24,"total_tokens":50},"request_id":"21e97c2b-c83d-961c-8947-c82a778ddbac"} + +id:25 +event:result +:HTTP_STATUS/200 +data:{"output":{"choices":[{"message":{"role":"assistant","content":null,"reasoning_content":" as adding 4"},"finish_reason":"null"}]},"usage":{"input_tokens":26,"output_tokens":25,"total_tokens":51},"request_id":"21e97c2b-c83d-961c-8947-c82a778ddbac"} + +id:26 +event:result +:HTTP_STATUS/200 +data:{"output":{"choices":[{"message":{"role":"assistant","content":null,"reasoning_content":" to itself 8"},"finish_reason":"null"}]},"usage":{"input_tokens":26,"output_tokens":26,"total_tokens":52},"request_id":"21e97c2b-c83d-961c-8947-c82a778ddbac"} + +id:27 +event:result +:HTTP_STATUS/200 +data:{"output":{"choices":[{"message":{"role":"assistant","content":null,"reasoning_content":" times.\n\nLet me"},"finish_reason":"null"}]},"usage":{"input_tokens":26,"output_tokens":27,"total_tokens":53},"request_id":"21e97c2b-c83d-961c-8947-c82a778ddbac"} + +id:28 +event:result +:HTTP_STATUS/200 +data:{"output":{"choices":[{"message":{"role":"assistant","content":null,"reasoning_content":" write that out step"},"finish_reason":"null"}]},"usage":{"input_tokens":26,"output_tokens":28,"total_tokens":54},"request_id":"21e97c2b-c83d-961c-8947-c82a778ddbac"} + +id:29 +event:result +:HTTP_STATUS/200 +data:{"output":{"choices":[{"message":{"role":"assistant","content":null,"reasoning_content":" by step to make"},"finish_reason":"null"}]},"usage":{"input_tokens":26,"output_tokens":29,"total_tokens":55},"request_id":"21e97c2b-c83d-961c-8947-c82a778ddbac"} + +id:30 +event:result +:HTTP_STATUS/200 +data:{"output":{"choices":[{"message":{"role":"assistant","content":null,"reasoning_content":" sure I don't"},"finish_reason":"null"}]},"usage":{"input_tokens":26,"output_tokens":30,"total_tokens":56},"request_id":"21e97c2b-c83d-961c-8947-c82a778ddbac"} + +id:31 +event:result +:HTTP_STATUS/200 +data:{"output":{"choices":[{"message":{"role":"assistant","content":null,"reasoning_content":" make a mistake."},"finish_reason":"null"}]},"usage":{"input_tokens":26,"output_tokens":31,"total_tokens":57},"request_id":"21e97c2b-c83d-961c-8947-c82a778ddbac"} + +id:32 +event:result +:HTTP_STATUS/200 +data:{"output":{"choices":[{"message":{"role":"assistant","content":null,"reasoning_content":" \n\nFirst,"},"finish_reason":"null"}]},"usage":{"input_tokens":26,"output_tokens":32,"total_tokens":58},"request_id":"21e97c2b-c83d-961c-8947-c82a778ddbac"} + +id:33 +event:result +:HTTP_STATUS/200 +data:{"output":{"choices":[{"message":{"role":"assistant","content":null,"reasoning_content":" 4 added once is"},"finish_reason":"null"}]},"usage":{"input_tokens":26,"output_tokens":33,"total_tokens":59},"request_id":"21e97c2b-c83d-961c-8947-c82a778ddbac"} + +id:34 +event:result +:HTTP_STATUS/200 +data:{"output":{"choices":[{"message":{"role":"assistant","content":null,"reasoning_content":" 4. Then"},"finish_reason":"null"}]},"usage":{"input_tokens":26,"output_tokens":34,"total_tokens":60},"request_id":"21e97c2b-c83d-961c-8947-c82a778ddbac"} + +id:35 +event:result +:HTTP_STATUS/200 +data:{"output":{"choices":[{"message":{"role":"assistant","content":null,"reasoning_content":", adding another"},"finish_reason":"null"}]},"usage":{"input_tokens":26,"output_tokens":35,"total_tokens":61},"request_id":"21e97c2b-c83d-961c-8947-c82a778ddbac"} + +id:36 +event:result +:HTTP_STATUS/200 +data:{"output":{"choices":[{"message":{"role":"assistant","content":null,"reasoning_content":" 4 makes it"},"finish_reason":"null"}]},"usage":{"input_tokens":26,"output_tokens":36,"total_tokens":62},"request_id":"21e97c2b-c83d-961c-8947-c82a778ddbac"} + +id:37 +event:result +:HTTP_STATUS/200 +data:{"output":{"choices":[{"message":{"role":"assistant","content":null,"reasoning_content":" 8. Adding a"},"finish_reason":"null"}]},"usage":{"input_tokens":26,"output_tokens":37,"total_tokens":63},"request_id":"21e97c2b-c83d-961c-8947-c82a778ddbac"} + +id:38 +event:result +:HTTP_STATUS/200 +data:{"output":{"choices":[{"message":{"role":"assistant","content":null,"reasoning_content":" third 4 would"},"finish_reason":"null"}]},"usage":{"input_tokens":26,"output_tokens":38,"total_tokens":64},"request_id":"21e97c2b-c83d-961c-8947-c82a778ddbac"} + +id:39 +event:result +:HTTP_STATUS/200 +data:{"output":{"choices":[{"message":{"role":"assistant","content":null,"reasoning_content":" bring it to"},"finish_reason":"null"}]},"usage":{"input_tokens":26,"output_tokens":39,"total_tokens":65},"request_id":"21e97c2b-c83d-961c-8947-c82a778ddbac"} + +id:40 +event:result +:HTTP_STATUS/200 +data:{"output":{"choices":[{"message":{"role":"assistant","content":null,"reasoning_content":" 12. The"},"finish_reason":"null"}]},"usage":{"input_tokens":26,"output_tokens":40,"total_tokens":66},"request_id":"21e97c2b-c83d-961c-8947-c82a778ddbac"} + +id:41 +event:result +:HTTP_STATUS/200 +data:{"output":{"choices":[{"message":{"role":"assistant","content":null,"reasoning_content":" fourth addition would be"},"finish_reason":"null"}]},"usage":{"input_tokens":26,"output_tokens":41,"total_tokens":67},"request_id":"21e97c2b-c83d-961c-8947-c82a778ddbac"} + +id:42 +event:result +:HTTP_STATUS/200 +data:{"output":{"choices":[{"message":{"role":"assistant","content":null,"reasoning_content":" 16."},"finish_reason":"null"}]},"usage":{"input_tokens":26,"output_tokens":42,"total_tokens":68},"request_id":"21e97c2b-c83d-961c-8947-c82a778ddbac"} + +id:43 +event:result +:HTTP_STATUS/200 +data:{"output":{"choices":[{"message":{"role":"assistant","content":null,"reasoning_content":" Wait, that's"},"finish_reason":"null"}]},"usage":{"input_tokens":26,"output_tokens":43,"total_tokens":69},"request_id":"21e97c2b-c83d-961c-8947-c82a778ddbac"} + +id:44 +event:result +:HTTP_STATUS/200 +data:{"output":{"choices":[{"message":{"role":"assistant","content":null,"reasoning_content":" after four times."},"finish_reason":"null"}]},"usage":{"input_tokens":26,"output_tokens":44,"total_tokens":70},"request_id":"21e97c2b-c83d-961c-8947-c82a778ddbac"} + +id:45 +event:result +:HTTP_STATUS/200 +data:{"output":{"choices":[{"message":{"role":"assistant","content":null,"reasoning_content":" But we need eight"},"finish_reason":"null"}]},"usage":{"input_tokens":26,"output_tokens":45,"total_tokens":71},"request_id":"21e97c2b-c83d-961c-8947-c82a778ddbac"} + +id:46 +event:result +:HTTP_STATUS/200 +data:{"output":{"choices":[{"message":{"role":"assistant","content":null,"reasoning_content":" times. Let me"},"finish_reason":"null"}]},"usage":{"input_tokens":26,"output_tokens":46,"total_tokens":72},"request_id":"21e97c2b-c83d-961c-8947-c82a778ddbac"} + +id:47 +event:result +:HTTP_STATUS/200 +data:{"output":{"choices":[{"message":{"role":"assistant","content":null,"reasoning_content":" continue. Fifth addition"},"finish_reason":"null"}]},"usage":{"input_tokens":26,"output_tokens":47,"total_tokens":73},"request_id":"21e97c2b-c83d-961c-8947-c82a778ddbac"} + +id:48 +event:result +:HTTP_STATUS/200 +data:{"output":{"choices":[{"message":{"role":"assistant","content":null,"reasoning_content":": 16"},"finish_reason":"null"}]},"usage":{"input_tokens":26,"output_tokens":48,"total_tokens":74},"request_id":"21e97c2b-c83d-961c-8947-c82a778ddbac"} + +id:49 +event:result +:HTTP_STATUS/200 +data:{"output":{"choices":[{"message":{"role":"assistant","content":null,"reasoning_content":" + 4 is"},"finish_reason":"null"}]},"usage":{"input_tokens":26,"output_tokens":49,"total_tokens":75},"request_id":"21e97c2b-c83d-961c-8947-c82a778ddbac"} + +id:50 +event:result +:HTTP_STATUS/200 +data:{"output":{"choices":[{"message":{"role":"assistant","content":null,"reasoning_content":" 20."},"finish_reason":"null"}]},"usage":{"input_tokens":26,"output_tokens":50,"total_tokens":76},"request_id":"21e97c2b-c83d-961c-8947-c82a778ddbac"} + +id:51 +event:result +:HTTP_STATUS/200 +data:{"output":{"choices":[{"message":{"role":"assistant","content":null,"reasoning_content":" Sixth addition:"},"finish_reason":"null"}]},"usage":{"input_tokens":26,"output_tokens":51,"total_tokens":77},"request_id":"21e97c2b-c83d-961c-8947-c82a778ddbac"} + +id:52 +event:result +:HTTP_STATUS/200 +data:{"output":{"choices":[{"message":{"role":"assistant","content":null,"reasoning_content":" 20 +"},"finish_reason":"null"}]},"usage":{"input_tokens":26,"output_tokens":52,"total_tokens":78},"request_id":"21e97c2b-c83d-961c-8947-c82a778ddbac"} + +id:53 +event:result +:HTTP_STATUS/200 +data:{"output":{"choices":[{"message":{"role":"assistant","content":null,"reasoning_content":" 4 = 2"},"finish_reason":"null"}]},"usage":{"input_tokens":26,"output_tokens":53,"total_tokens":79},"request_id":"21e97c2b-c83d-961c-8947-c82a778ddbac"} + +id:54 +event:result +:HTTP_STATUS/200 +data:{"output":{"choices":[{"message":{"role":"assistant","content":null,"reasoning_content":"4. Seventh addition"},"finish_reason":"null"}]},"usage":{"input_tokens":26,"output_tokens":54,"total_tokens":80},"request_id":"21e97c2b-c83d-961c-8947-c82a778ddbac"} + +id:55 +event:result +:HTTP_STATUS/200 +data:{"output":{"choices":[{"message":{"role":"assistant","content":null,"reasoning_content":": 24"},"finish_reason":"null"}]},"usage":{"input_tokens":26,"output_tokens":55,"total_tokens":81},"request_id":"21e97c2b-c83d-961c-8947-c82a778ddbac"} + +id:56 +event:result +:HTTP_STATUS/200 +data:{"output":{"choices":[{"message":{"role":"assistant","content":null,"reasoning_content":" + 4 ="},"finish_reason":"null"}]},"usage":{"input_tokens":26,"output_tokens":56,"total_tokens":82},"request_id":"21e97c2b-c83d-961c-8947-c82a778ddbac"} + +id:57 +event:result +:HTTP_STATUS/200 +data:{"output":{"choices":[{"message":{"role":"assistant","content":null,"reasoning_content":" 28."},"finish_reason":"null"}]},"usage":{"input_tokens":26,"output_tokens":57,"total_tokens":83},"request_id":"21e97c2b-c83d-961c-8947-c82a778ddbac"} + +id:58 +event:result +:HTTP_STATUS/200 +data:{"output":{"choices":[{"message":{"role":"assistant","content":null,"reasoning_content":" Eighth addition:"},"finish_reason":"null"}]},"usage":{"input_tokens":26,"output_tokens":58,"total_tokens":84},"request_id":"21e97c2b-c83d-961c-8947-c82a778ddbac"} + +id:59 +event:result +:HTTP_STATUS/200 +data:{"output":{"choices":[{"message":{"role":"assistant","content":null,"reasoning_content":" 28 +"},"finish_reason":"null"}]},"usage":{"input_tokens":26,"output_tokens":59,"total_tokens":85},"request_id":"21e97c2b-c83d-961c-8947-c82a778ddbac"} + +id:60 +event:result +:HTTP_STATUS/200 +data:{"output":{"choices":[{"message":{"role":"assistant","content":null,"reasoning_content":" 4 = 3"},"finish_reason":"null"}]},"usage":{"input_tokens":26,"output_tokens":60,"total_tokens":86},"request_id":"21e97c2b-c83d-961c-8947-c82a778ddbac"} + +id:61 +event:result +:HTTP_STATUS/200 +data:{"output":{"choices":[{"message":{"role":"assistant","content":null,"reasoning_content":"2. \n\nWait"},"finish_reason":"null"}]},"usage":{"input_tokens":26,"output_tokens":61,"total_tokens":87},"request_id":"21e97c2b-c83d-961c-8947-c82a778ddbac"} + +id:62 +event:result +:HTTP_STATUS/200 +data:{"output":{"choices":[{"message":{"role":"assistant","content":null,"reasoning_content":" a second, so"},"finish_reason":"null"}]},"usage":{"input_tokens":26,"output_tokens":62,"total_tokens":88},"request_id":"21e97c2b-c83d-961c-8947-c82a778ddbac"} + +id:63 +event:result +:HTTP_STATUS/200 +data:{"output":{"choices":[{"message":{"role":"assistant","content":null,"reasoning_content":" after adding 4"},"finish_reason":"null"}]},"usage":{"input_tokens":26,"output_tokens":63,"total_tokens":89},"request_id":"21e97c2b-c83d-961c-8947-c82a778ddbac"} + +id:64 +event:result +:HTTP_STATUS/200 +data:{"output":{"choices":[{"message":{"role":"assistant","content":null,"reasoning_content":" eight times, the"},"finish_reason":"null"}]},"usage":{"input_tokens":26,"output_tokens":64,"total_tokens":90},"request_id":"21e97c2b-c83d-961c-8947-c82a778ddbac"} + +id:65 +event:result +:HTTP_STATUS/200 +data:{"output":{"choices":[{"message":{"role":"assistant","content":null,"reasoning_content":" total is 3"},"finish_reason":"null"}]},"usage":{"input_tokens":26,"output_tokens":65,"total_tokens":91},"request_id":"21e97c2b-c83d-961c-8947-c82a778ddbac"} + +id:66 +event:result +:HTTP_STATUS/200 +data:{"output":{"choices":[{"message":{"role":"assistant","content":null,"reasoning_content":"2? Let me"},"finish_reason":"null"}]},"usage":{"input_tokens":26,"output_tokens":66,"total_tokens":92},"request_id":"21e97c2b-c83d-961c-8947-c82a778ddbac"} + +id:67 +event:result +:HTTP_STATUS/200 +data:{"output":{"choices":[{"message":{"role":"assistant","content":null,"reasoning_content":" check that again because"},"finish_reason":"null"}]},"usage":{"input_tokens":26,"output_tokens":67,"total_tokens":93},"request_id":"21e97c2b-c83d-961c-8947-c82a778ddbac"} + +id:68 +event:result +:HTTP_STATUS/200 +data:{"output":{"choices":[{"message":{"role":"assistant","content":null,"reasoning_content":" I might have mis"},"finish_reason":"null"}]},"usage":{"input_tokens":26,"output_tokens":68,"total_tokens":94},"request_id":"21e97c2b-c83d-961c-8947-c82a778ddbac"} + +id:69 +event:result +:HTTP_STATUS/200 +data:{"output":{"choices":[{"message":{"role":"assistant","content":null,"reasoning_content":"counted. Let"},"finish_reason":"null"}]},"usage":{"input_tokens":26,"output_tokens":69,"total_tokens":95},"request_id":"21e97c2b-c83d-961c-8947-c82a778ddbac"} + +id:70 +event:result +:HTTP_STATUS/200 +data:{"output":{"choices":[{"message":{"role":"assistant","content":null,"reasoning_content":" me count the steps"},"finish_reason":"null"}]},"usage":{"input_tokens":26,"output_tokens":70,"total_tokens":96},"request_id":"21e97c2b-c83d-961c-8947-c82a778ddbac"} + +id:71 +event:result +:HTTP_STATUS/200 +data:{"output":{"choices":[{"message":{"role":"assistant","content":null,"reasoning_content":" again:\n\n1."},"finish_reason":"null"}]},"usage":{"input_tokens":26,"output_tokens":71,"total_tokens":97},"request_id":"21e97c2b-c83d-961c-8947-c82a778ddbac"} + +id:72 +event:result +:HTTP_STATUS/200 +data:{"output":{"choices":[{"message":{"role":"assistant","content":null,"reasoning_content":" 4 (that"},"finish_reason":"null"}]},"usage":{"input_tokens":26,"output_tokens":72,"total_tokens":98},"request_id":"21e97c2b-c83d-961c-8947-c82a778ddbac"} + +id:73 +event:result +:HTTP_STATUS/200 +data:{"output":{"choices":[{"message":{"role":"assistant","content":null,"reasoning_content":"'s the first time"},"finish_reason":"null"}]},"usage":{"input_tokens":26,"output_tokens":73,"total_tokens":99},"request_id":"21e97c2b-c83d-961c-8947-c82a778ddbac"} + +id:74 +event:result +:HTTP_STATUS/200 +data:{"output":{"choices":[{"message":{"role":"assistant","content":null,"reasoning_content":")\n2."},"finish_reason":"null"}]},"usage":{"input_tokens":26,"output_tokens":74,"total_tokens":100},"request_id":"21e97c2b-c83d-961c-8947-c82a778ddbac"} + +id:75 +event:result +:HTTP_STATUS/200 +data:{"output":{"choices":[{"message":{"role":"assistant","content":null,"reasoning_content":" 4 + 4"},"finish_reason":"null"}]},"usage":{"input_tokens":26,"output_tokens":75,"total_tokens":101},"request_id":"21e97c2b-c83d-961c-8947-c82a778ddbac"} + +id:76 +event:result +:HTTP_STATUS/200 +data:{"output":{"choices":[{"message":{"role":"assistant","content":null,"reasoning_content":" = 8 ("},"finish_reason":"null"}]},"usage":{"input_tokens":26,"output_tokens":76,"total_tokens":102},"request_id":"21e97c2b-c83d-961c-8947-c82a778ddbac"} + +id:77 +event:result +:HTTP_STATUS/200 +data:{"output":{"choices":[{"message":{"role":"assistant","content":null,"reasoning_content":"second)\n3."},"finish_reason":"null"}]},"usage":{"input_tokens":26,"output_tokens":77,"total_tokens":103},"request_id":"21e97c2b-c83d-961c-8947-c82a778ddbac"} + +id:78 +event:result +:HTTP_STATUS/200 +data:{"output":{"choices":[{"message":{"role":"assistant","content":null,"reasoning_content":" 8 +"},"finish_reason":"null"}]},"usage":{"input_tokens":26,"output_tokens":78,"total_tokens":104},"request_id":"21e97c2b-c83d-961c-8947-c82a778ddbac"} + +id:79 +event:result +:HTTP_STATUS/200 +data:{"output":{"choices":[{"message":{"role":"assistant","content":null,"reasoning_content":" 4 = 1"},"finish_reason":"null"}]},"usage":{"input_tokens":26,"output_tokens":79,"total_tokens":105},"request_id":"21e97c2b-c83d-961c-8947-c82a778ddbac"} + +id:80 +event:result +:HTTP_STATUS/200 +data:{"output":{"choices":[{"message":{"role":"assistant","content":null,"reasoning_content":"2 (third)"},"finish_reason":"null"}]},"usage":{"input_tokens":26,"output_tokens":80,"total_tokens":106},"request_id":"21e97c2b-c83d-961c-8947-c82a778ddbac"} + +id:81 +event:result +:HTTP_STATUS/200 +data:{"output":{"choices":[{"message":{"role":"assistant","content":null,"reasoning_content":"\n4. 1"},"finish_reason":"null"}]},"usage":{"input_tokens":26,"output_tokens":81,"total_tokens":107},"request_id":"21e97c2b-c83d-961c-8947-c82a778ddbac"} + +id:82 +event:result +:HTTP_STATUS/200 +data:{"output":{"choices":[{"message":{"role":"assistant","content":null,"reasoning_content":"2 + 4"},"finish_reason":"null"}]},"usage":{"input_tokens":26,"output_tokens":82,"total_tokens":108},"request_id":"21e97c2b-c83d-961c-8947-c82a778ddbac"} + +id:83 +event:result +:HTTP_STATUS/200 +data:{"output":{"choices":[{"message":{"role":"assistant","content":null,"reasoning_content":" = 16"},"finish_reason":"null"}]},"usage":{"input_tokens":26,"output_tokens":83,"total_tokens":109},"request_id":"21e97c2b-c83d-961c-8947-c82a778ddbac"} + +id:84 +event:result +:HTTP_STATUS/200 +data:{"output":{"choices":[{"message":{"role":"assistant","content":null,"reasoning_content":" (fourth)"},"finish_reason":"null"}]},"usage":{"input_tokens":26,"output_tokens":84,"total_tokens":110},"request_id":"21e97c2b-c83d-961c-8947-c82a778ddbac"} + +id:85 +event:result +:HTTP_STATUS/200 +data:{"output":{"choices":[{"message":{"role":"assistant","content":null,"reasoning_content":"\n5. 1"},"finish_reason":"null"}]},"usage":{"input_tokens":26,"output_tokens":85,"total_tokens":111},"request_id":"21e97c2b-c83d-961c-8947-c82a778ddbac"} + +id:86 +event:result +:HTTP_STATUS/200 +data:{"output":{"choices":[{"message":{"role":"assistant","content":null,"reasoning_content":"6 + 4"},"finish_reason":"null"}]},"usage":{"input_tokens":26,"output_tokens":86,"total_tokens":112},"request_id":"21e97c2b-c83d-961c-8947-c82a778ddbac"} + +id:87 +event:result +:HTTP_STATUS/200 +data:{"output":{"choices":[{"message":{"role":"assistant","content":null,"reasoning_content":" = 20"},"finish_reason":"null"}]},"usage":{"input_tokens":26,"output_tokens":87,"total_tokens":113},"request_id":"21e97c2b-c83d-961c-8947-c82a778ddbac"} + +id:88 +event:result +:HTTP_STATUS/200 +data:{"output":{"choices":[{"message":{"role":"assistant","content":null,"reasoning_content":" (fifth)"},"finish_reason":"null"}]},"usage":{"input_tokens":26,"output_tokens":88,"total_tokens":114},"request_id":"21e97c2b-c83d-961c-8947-c82a778ddbac"} + +id:89 +event:result +:HTTP_STATUS/200 +data:{"output":{"choices":[{"message":{"role":"assistant","content":null,"reasoning_content":"\n6. 2"},"finish_reason":"null"}]},"usage":{"input_tokens":26,"output_tokens":89,"total_tokens":115},"request_id":"21e97c2b-c83d-961c-8947-c82a778ddbac"} + +id:90 +event:result +:HTTP_STATUS/200 +data:{"output":{"choices":[{"message":{"role":"assistant","content":null,"reasoning_content":"0 + 4"},"finish_reason":"null"}]},"usage":{"input_tokens":26,"output_tokens":90,"total_tokens":116},"request_id":"21e97c2b-c83d-961c-8947-c82a778ddbac"} + +id:91 +event:result +:HTTP_STATUS/200 +data:{"output":{"choices":[{"message":{"role":"assistant","content":null,"reasoning_content":" = 24"},"finish_reason":"null"}]},"usage":{"input_tokens":26,"output_tokens":91,"total_tokens":117},"request_id":"21e97c2b-c83d-961c-8947-c82a778ddbac"} + +id:92 +event:result +:HTTP_STATUS/200 +data:{"output":{"choices":[{"message":{"role":"assistant","content":null,"reasoning_content":" (sixth)"},"finish_reason":"null"}]},"usage":{"input_tokens":26,"output_tokens":92,"total_tokens":118},"request_id":"21e97c2b-c83d-961c-8947-c82a778ddbac"} + +id:93 +event:result +:HTTP_STATUS/200 +data:{"output":{"choices":[{"message":{"role":"assistant","content":null,"reasoning_content":"\n7. 2"},"finish_reason":"null"}]},"usage":{"input_tokens":26,"output_tokens":93,"total_tokens":119},"request_id":"21e97c2b-c83d-961c-8947-c82a778ddbac"} + +id:94 +event:result +:HTTP_STATUS/200 +data:{"output":{"choices":[{"message":{"role":"assistant","content":null,"reasoning_content":"4 + 4"},"finish_reason":"null"}]},"usage":{"input_tokens":26,"output_tokens":94,"total_tokens":120},"request_id":"21e97c2b-c83d-961c-8947-c82a778ddbac"} + +id:95 +event:result +:HTTP_STATUS/200 +data:{"output":{"choices":[{"message":{"role":"assistant","content":null,"reasoning_content":" = 28"},"finish_reason":"null"}]},"usage":{"input_tokens":26,"output_tokens":95,"total_tokens":121},"request_id":"21e97c2b-c83d-961c-8947-c82a778ddbac"} + +id:96 +event:result +:HTTP_STATUS/200 +data:{"output":{"choices":[{"message":{"role":"assistant","content":null,"reasoning_content":" (seventh)"},"finish_reason":"null"}]},"usage":{"input_tokens":26,"output_tokens":96,"total_tokens":122},"request_id":"21e97c2b-c83d-961c-8947-c82a778ddbac"} + +id:97 +event:result +:HTTP_STATUS/200 +data:{"output":{"choices":[{"message":{"role":"assistant","content":null,"reasoning_content":"\n8. 2"},"finish_reason":"null"}]},"usage":{"input_tokens":26,"output_tokens":97,"total_tokens":123},"request_id":"21e97c2b-c83d-961c-8947-c82a778ddbac"} + +id:98 +event:result +:HTTP_STATUS/200 +data:{"output":{"choices":[{"message":{"role":"assistant","content":null,"reasoning_content":"8 + 4"},"finish_reason":"null"}]},"usage":{"input_tokens":26,"output_tokens":98,"total_tokens":124},"request_id":"21e97c2b-c83d-961c-8947-c82a778ddbac"} + +id:99 +event:result +:HTTP_STATUS/200 +data:{"output":{"choices":[{"message":{"role":"assistant","content":null,"reasoning_content":" = 32"},"finish_reason":"null"}]},"usage":{"input_tokens":26,"output_tokens":99,"total_tokens":125},"request_id":"21e97c2b-c83d-961c-8947-c82a778ddbac"} + +id:100 +event:result +:HTTP_STATUS/200 +data:{"output":{"choices":[{"message":{"role":"assistant","content":null,"reasoning_content":" (eighth)"},"finish_reason":"null"}]},"usage":{"input_tokens":26,"output_tokens":100,"total_tokens":126},"request_id":"21e97c2b-c83d-961c-8947-c82a778ddbac"} + +id:101 +event:result +:HTTP_STATUS/200 +data:{"output":{"choices":[{"message":{"role":"assistant","content":null,"reasoning_content":"\n\nYes, that seems"},"finish_reason":"null"}]},"usage":{"input_tokens":26,"output_tokens":101,"total_tokens":127},"request_id":"21e97c2b-c83d-961c-8947-c82a778ddbac"} + +id:102 +event:result +:HTTP_STATUS/200 +data:{"output":{"choices":[{"message":{"role":"assistant","content":null,"reasoning_content":" right. Each step"},"finish_reason":"null"}]},"usage":{"input_tokens":26,"output_tokens":102,"total_tokens":128},"request_id":"21e97c2b-c83d-961c-8947-c82a778ddbac"} + +id:103 +event:result +:HTTP_STATUS/200 +data:{"output":{"choices":[{"message":{"role":"assistant","content":null,"reasoning_content":" adds 4,"},"finish_reason":"null"}]},"usage":{"input_tokens":26,"output_tokens":103,"total_tokens":129},"request_id":"21e97c2b-c83d-961c-8947-c82a778ddbac"} + +id:104 +event:result +:HTTP_STATUS/200 +data:{"output":{"choices":[{"message":{"role":"assistant","content":null,"reasoning_content":" and after eight additions"},"finish_reason":"null"}]},"usage":{"input_tokens":26,"output_tokens":104,"total_tokens":130},"request_id":"21e97c2b-c83d-961c-8947-c82a778ddbac"} + +id:105 +event:result +:HTTP_STATUS/200 +data:{"output":{"choices":[{"message":{"role":"assistant","content":null,"reasoning_content":", it's"},"finish_reason":"null"}]},"usage":{"input_tokens":26,"output_tokens":105,"total_tokens":131},"request_id":"21e97c2b-c83d-961c-8947-c82a778ddbac"} + +id:106 +event:result +:HTTP_STATUS/200 +data:{"output":{"choices":[{"message":{"role":"assistant","content":null,"reasoning_content":" 32. Alternatively"},"finish_reason":"null"}]},"usage":{"input_tokens":26,"output_tokens":106,"total_tokens":132},"request_id":"21e97c2b-c83d-961c-8947-c82a778ddbac"} + +id:107 +event:result +:HTTP_STATUS/200 +data:{"output":{"choices":[{"message":{"role":"assistant","content":null,"reasoning_content":", maybe I can"},"finish_reason":"null"}]},"usage":{"input_tokens":26,"output_tokens":107,"total_tokens":133},"request_id":"21e97c2b-c83d-961c-8947-c82a778ddbac"} + +id:108 +event:result +:HTTP_STATUS/200 +data:{"output":{"choices":[{"message":{"role":"assistant","content":null,"reasoning_content":" use another method to"},"finish_reason":"null"}]},"usage":{"input_tokens":26,"output_tokens":108,"total_tokens":134},"request_id":"21e97c2b-c83d-961c-8947-c82a778ddbac"} + +id:109 +event:result +:HTTP_STATUS/200 +data:{"output":{"choices":[{"message":{"role":"assistant","content":null,"reasoning_content":" confirm. For example"},"finish_reason":"null"}]},"usage":{"input_tokens":26,"output_tokens":109,"total_tokens":135},"request_id":"21e97c2b-c83d-961c-8947-c82a778ddbac"} + +id:110 +event:result +:HTTP_STATUS/200 +data:{"output":{"choices":[{"message":{"role":"assistant","content":null,"reasoning_content":", breaking it down"},"finish_reason":"null"}]},"usage":{"input_tokens":26,"output_tokens":110,"total_tokens":136},"request_id":"21e97c2b-c83d-961c-8947-c82a778ddbac"} + +id:111 +event:result +:HTTP_STATUS/200 +data:{"output":{"choices":[{"message":{"role":"assistant","content":null,"reasoning_content":" into smaller parts."},"finish_reason":"null"}]},"usage":{"input_tokens":26,"output_tokens":111,"total_tokens":137},"request_id":"21e97c2b-c83d-961c-8947-c82a778ddbac"} + +id:112 +event:result +:HTTP_STATUS/200 +data:{"output":{"choices":[{"message":{"role":"assistant","content":null,"reasoning_content":" I know that"},"finish_reason":"null"}]},"usage":{"input_tokens":26,"output_tokens":112,"total_tokens":138},"request_id":"21e97c2b-c83d-961c-8947-c82a778ddbac"} + +id:113 +event:result +:HTTP_STATUS/200 +data:{"output":{"choices":[{"message":{"role":"assistant","content":null,"reasoning_content":" 4 times 1"},"finish_reason":"null"}]},"usage":{"input_tokens":26,"output_tokens":113,"total_tokens":139},"request_id":"21e97c2b-c83d-961c-8947-c82a778ddbac"} + +id:114 +event:result +:HTTP_STATUS/200 +data:{"output":{"choices":[{"message":{"role":"assistant","content":null,"reasoning_content":"0 is 4"},"finish_reason":"null"}]},"usage":{"input_tokens":26,"output_tokens":114,"total_tokens":140},"request_id":"21e97c2b-c83d-961c-8947-c82a778ddbac"} + +id:115 +event:result +:HTTP_STATUS/200 +data:{"output":{"choices":[{"message":{"role":"assistant","content":null,"reasoning_content":"0, so if"},"finish_reason":"null"}]},"usage":{"input_tokens":26,"output_tokens":115,"total_tokens":141},"request_id":"21e97c2b-c83d-961c-8947-c82a778ddbac"} + +id:116 +event:result +:HTTP_STATUS/200 +data:{"output":{"choices":[{"message":{"role":"assistant","content":null,"reasoning_content":" I subtract 4"},"finish_reason":"null"}]},"usage":{"input_tokens":26,"output_tokens":116,"total_tokens":142},"request_id":"21e97c2b-c83d-961c-8947-c82a778ddbac"} + +id:117 +event:result +:HTTP_STATUS/200 +data:{"output":{"choices":[{"message":{"role":"assistant","content":null,"reasoning_content":" times 2 ("},"finish_reason":"null"}]},"usage":{"input_tokens":26,"output_tokens":117,"total_tokens":143},"request_id":"21e97c2b-c83d-961c-8947-c82a778ddbac"} + +id:118 +event:result +:HTTP_STATUS/200 +data:{"output":{"choices":[{"message":{"role":"assistant","content":null,"reasoning_content":"which is 8"},"finish_reason":"null"}]},"usage":{"input_tokens":26,"output_tokens":118,"total_tokens":144},"request_id":"21e97c2b-c83d-961c-8947-c82a778ddbac"} + +id:119 +event:result +:HTTP_STATUS/200 +data:{"output":{"choices":[{"message":{"role":"assistant","content":null,"reasoning_content":") from 4"},"finish_reason":"null"}]},"usage":{"input_tokens":26,"output_tokens":119,"total_tokens":145},"request_id":"21e97c2b-c83d-961c-8947-c82a778ddbac"} + +id:120 +event:result +:HTTP_STATUS/200 +data:{"output":{"choices":[{"message":{"role":"assistant","content":null,"reasoning_content":"0, that would"},"finish_reason":"null"}]},"usage":{"input_tokens":26,"output_tokens":120,"total_tokens":146},"request_id":"21e97c2b-c83d-961c-8947-c82a778ddbac"} + +id:121 +event:result +:HTTP_STATUS/200 +data:{"output":{"choices":[{"message":{"role":"assistant","content":null,"reasoning_content":" be 40"},"finish_reason":"null"}]},"usage":{"input_tokens":26,"output_tokens":121,"total_tokens":147},"request_id":"21e97c2b-c83d-961c-8947-c82a778ddbac"} + +id:122 +event:result +:HTTP_STATUS/200 +data:{"output":{"choices":[{"message":{"role":"assistant","content":null,"reasoning_content":" - 8 ="},"finish_reason":"null"}]},"usage":{"input_tokens":26,"output_tokens":122,"total_tokens":148},"request_id":"21e97c2b-c83d-961c-8947-c82a778ddbac"} + +id:123 +event:result +:HTTP_STATUS/200 +data:{"output":{"choices":[{"message":{"role":"assistant","content":null,"reasoning_content":" 32."},"finish_reason":"null"}]},"usage":{"input_tokens":26,"output_tokens":123,"total_tokens":149},"request_id":"21e97c2b-c83d-961c-8947-c82a778ddbac"} + +id:124 +event:result +:HTTP_STATUS/200 +data:{"output":{"choices":[{"message":{"role":"assistant","content":null,"reasoning_content":" That matches the previous"},"finish_reason":"null"}]},"usage":{"input_tokens":26,"output_tokens":124,"total_tokens":150},"request_id":"21e97c2b-c83d-961c-8947-c82a778ddbac"} + +id:125 +event:result +:HTTP_STATUS/200 +data:{"output":{"choices":[{"message":{"role":"assistant","content":null,"reasoning_content":" result. \n\nAnother"},"finish_reason":"null"}]},"usage":{"input_tokens":26,"output_tokens":125,"total_tokens":151},"request_id":"21e97c2b-c83d-961c-8947-c82a778ddbac"} + +id:126 +event:result +:HTTP_STATUS/200 +data:{"output":{"choices":[{"message":{"role":"assistant","content":null,"reasoning_content":" way could be using"},"finish_reason":"null"}]},"usage":{"input_tokens":26,"output_tokens":126,"total_tokens":152},"request_id":"21e97c2b-c83d-961c-8947-c82a778ddbac"} + +id:127 +event:result +:HTTP_STATUS/200 +data:{"output":{"choices":[{"message":{"role":"assistant","content":null,"reasoning_content":" the distributive property"},"finish_reason":"null"}]},"usage":{"input_tokens":26,"output_tokens":127,"total_tokens":153},"request_id":"21e97c2b-c83d-961c-8947-c82a778ddbac"} + +id:128 +event:result +:HTTP_STATUS/200 +data:{"output":{"choices":[{"message":{"role":"assistant","content":null,"reasoning_content":". Let's say"},"finish_reason":"null"}]},"usage":{"input_tokens":26,"output_tokens":128,"total_tokens":154},"request_id":"21e97c2b-c83d-961c-8947-c82a778ddbac"} + +id:129 +event:result +:HTTP_STATUS/200 +data:{"output":{"choices":[{"message":{"role":"assistant","content":null,"reasoning_content":" 4 times"},"finish_reason":"null"}]},"usage":{"input_tokens":26,"output_tokens":129,"total_tokens":155},"request_id":"21e97c2b-c83d-961c-8947-c82a778ddbac"} + +id:130 +event:result +:HTTP_STATUS/200 +data:{"output":{"choices":[{"message":{"role":"assistant","content":null,"reasoning_content":" 8 is the same"},"finish_reason":"null"}]},"usage":{"input_tokens":26,"output_tokens":130,"total_tokens":156},"request_id":"21e97c2b-c83d-961c-8947-c82a778ddbac"} + +id:131 +event:result +:HTTP_STATUS/200 +data:{"output":{"choices":[{"message":{"role":"assistant","content":null,"reasoning_content":" as 2 times"},"finish_reason":"null"}]},"usage":{"input_tokens":26,"output_tokens":131,"total_tokens":157},"request_id":"21e97c2b-c83d-961c-8947-c82a778ddbac"} + +id:132 +event:result +:HTTP_STATUS/200 +data:{"output":{"choices":[{"message":{"role":"assistant","content":null,"reasoning_content":" 8 plus"},"finish_reason":"null"}]},"usage":{"input_tokens":26,"output_tokens":132,"total_tokens":158},"request_id":"21e97c2b-c83d-961c-8947-c82a778ddbac"} + +id:133 +event:result +:HTTP_STATUS/200 +data:{"output":{"choices":[{"message":{"role":"assistant","content":null,"reasoning_content":" 2 times 8"},"finish_reason":"null"}]},"usage":{"input_tokens":26,"output_tokens":133,"total_tokens":159},"request_id":"21e97c2b-c83d-961c-8947-c82a778ddbac"} + +id:134 +event:result +:HTTP_STATUS/200 +data:{"output":{"choices":[{"message":{"role":"assistant","content":null,"reasoning_content":". Since 2"},"finish_reason":"null"}]},"usage":{"input_tokens":26,"output_tokens":134,"total_tokens":160},"request_id":"21e97c2b-c83d-961c-8947-c82a778ddbac"} + +id:135 +event:result +:HTTP_STATUS/200 +data:{"output":{"choices":[{"message":{"role":"assistant","content":null,"reasoning_content":"*8 is"},"finish_reason":"null"}]},"usage":{"input_tokens":26,"output_tokens":135,"total_tokens":161},"request_id":"21e97c2b-c83d-961c-8947-c82a778ddbac"} + +id:136 +event:result +:HTTP_STATUS/200 +data:{"output":{"choices":[{"message":{"role":"assistant","content":null,"reasoning_content":" 16, adding"},"finish_reason":"null"}]},"usage":{"input_tokens":26,"output_tokens":136,"total_tokens":162},"request_id":"21e97c2b-c83d-961c-8947-c82a778ddbac"} + +id:137 +event:result +:HTTP_STATUS/200 +data:{"output":{"choices":[{"message":{"role":"assistant","content":null,"reasoning_content":" them together gives"},"finish_reason":"null"}]},"usage":{"input_tokens":26,"output_tokens":137,"total_tokens":163},"request_id":"21e97c2b-c83d-961c-8947-c82a778ddbac"} + +id:138 +event:result +:HTTP_STATUS/200 +data:{"output":{"choices":[{"message":{"role":"assistant","content":null,"reasoning_content":" 16 +"},"finish_reason":"null"}]},"usage":{"input_tokens":26,"output_tokens":138,"total_tokens":164},"request_id":"21e97c2b-c83d-961c-8947-c82a778ddbac"} + +id:139 +event:result +:HTTP_STATUS/200 +data:{"output":{"choices":[{"message":{"role":"assistant","content":null,"reasoning_content":" 16 ="},"finish_reason":"null"}]},"usage":{"input_tokens":26,"output_tokens":139,"total_tokens":165},"request_id":"21e97c2b-c83d-961c-8947-c82a778ddbac"} + +id:140 +event:result +:HTTP_STATUS/200 +data:{"output":{"choices":[{"message":{"role":"assistant","content":null,"reasoning_content":" 32. Yep"},"finish_reason":"null"}]},"usage":{"input_tokens":26,"output_tokens":140,"total_tokens":166},"request_id":"21e97c2b-c83d-961c-8947-c82a778ddbac"} + +id:141 +event:result +:HTTP_STATUS/200 +data:{"output":{"choices":[{"message":{"role":"assistant","content":null,"reasoning_content":", that works too"},"finish_reason":"null"}]},"usage":{"input_tokens":26,"output_tokens":141,"total_tokens":167},"request_id":"21e97c2b-c83d-961c-8947-c82a778ddbac"} + +id:142 +event:result +:HTTP_STATUS/200 +data:{"output":{"choices":[{"message":{"role":"assistant","content":null,"reasoning_content":". \n\nAlternatively,"},"finish_reason":"null"}]},"usage":{"input_tokens":26,"output_tokens":142,"total_tokens":168},"request_id":"21e97c2b-c83d-961c-8947-c82a778ddbac"} + +id:143 +event:result +:HTTP_STATUS/200 +data:{"output":{"choices":[{"message":{"role":"assistant","content":null,"reasoning_content":" thinking in terms of"},"finish_reason":"null"}]},"usage":{"input_tokens":26,"output_tokens":143,"total_tokens":169},"request_id":"21e97c2b-c83d-961c-8947-c82a778ddbac"} + +id:144 +event:result +:HTTP_STATUS/200 +data:{"output":{"choices":[{"message":{"role":"assistant","content":null,"reasoning_content":" groups. If I"},"finish_reason":"null"}]},"usage":{"input_tokens":26,"output_tokens":144,"total_tokens":170},"request_id":"21e97c2b-c83d-961c-8947-c82a778ddbac"} + +id:145 +event:result +:HTTP_STATUS/200 +data:{"output":{"choices":[{"message":{"role":"assistant","content":null,"reasoning_content":" have 4 groups"},"finish_reason":"null"}]},"usage":{"input_tokens":26,"output_tokens":145,"total_tokens":171},"request_id":"21e97c2b-c83d-961c-8947-c82a778ddbac"} + +id:146 +event:result +:HTTP_STATUS/200 +data:{"output":{"choices":[{"message":{"role":"assistant","content":null,"reasoning_content":" of 8 items"},"finish_reason":"null"}]},"usage":{"input_tokens":26,"output_tokens":146,"total_tokens":172},"request_id":"21e97c2b-c83d-961c-8947-c82a778ddbac"} + +id:147 +event:result +:HTTP_STATUS/200 +data:{"output":{"choices":[{"message":{"role":"assistant","content":null,"reasoning_content":" each, how many"},"finish_reason":"null"}]},"usage":{"input_tokens":26,"output_tokens":147,"total_tokens":173},"request_id":"21e97c2b-c83d-961c-8947-c82a778ddbac"} + +id:148 +event:result +:HTTP_STATUS/200 +data:{"output":{"choices":[{"message":{"role":"assistant","content":null,"reasoning_content":" items do I have"},"finish_reason":"null"}]},"usage":{"input_tokens":26,"output_tokens":148,"total_tokens":174},"request_id":"21e97c2b-c83d-961c-8947-c82a778ddbac"} + +id:149 +event:result +:HTTP_STATUS/200 +data:{"output":{"choices":[{"message":{"role":"assistant","content":null,"reasoning_content":" in total? Let"},"finish_reason":"null"}]},"usage":{"input_tokens":26,"output_tokens":149,"total_tokens":175},"request_id":"21e97c2b-c83d-961c-8947-c82a778ddbac"} + +id:150 +event:result +:HTTP_STATUS/200 +data:{"output":{"choices":[{"message":{"role":"assistant","content":null,"reasoning_content":"'s see:"},"finish_reason":"null"}]},"usage":{"input_tokens":26,"output_tokens":150,"total_tokens":176},"request_id":"21e97c2b-c83d-961c-8947-c82a778ddbac"} + +id:151 +event:result +:HTTP_STATUS/200 +data:{"output":{"choices":[{"message":{"role":"assistant","content":null,"reasoning_content":" 8 + 8"},"finish_reason":"null"}]},"usage":{"input_tokens":26,"output_tokens":151,"total_tokens":177},"request_id":"21e97c2b-c83d-961c-8947-c82a778ddbac"} + +id:152 +event:result +:HTTP_STATUS/200 +data:{"output":{"choices":[{"message":{"role":"assistant","content":null,"reasoning_content":" + 8 +"},"finish_reason":"null"}]},"usage":{"input_tokens":26,"output_tokens":152,"total_tokens":178},"request_id":"21e97c2b-c83d-961c-8947-c82a778ddbac"} + +id:153 +event:result +:HTTP_STATUS/200 +data:{"output":{"choices":[{"message":{"role":"assistant","content":null,"reasoning_content":" 8. Adding"},"finish_reason":"null"}]},"usage":{"input_tokens":26,"output_tokens":153,"total_tokens":179},"request_id":"21e97c2b-c83d-961c-8947-c82a778ddbac"} + +id:154 +event:result +:HTTP_STATUS/200 +data:{"output":{"choices":[{"message":{"role":"assistant","content":null,"reasoning_content":" the first two"},"finish_reason":"null"}]},"usage":{"input_tokens":26,"output_tokens":154,"total_tokens":180},"request_id":"21e97c2b-c83d-961c-8947-c82a778ddbac"} + +id:155 +event:result +:HTTP_STATUS/200 +data:{"output":{"choices":[{"message":{"role":"assistant","content":null,"reasoning_content":" 8s gives"},"finish_reason":"null"}]},"usage":{"input_tokens":26,"output_tokens":155,"total_tokens":181},"request_id":"21e97c2b-c83d-961c-8947-c82a778ddbac"} + +id:156 +event:result +:HTTP_STATUS/200 +data:{"output":{"choices":[{"message":{"role":"assistant","content":null,"reasoning_content":" 16, then"},"finish_reason":"null"}]},"usage":{"input_tokens":26,"output_tokens":156,"total_tokens":182},"request_id":"21e97c2b-c83d-961c-8947-c82a778ddbac"} + +id:157 +event:result +:HTTP_STATUS/200 +data:{"output":{"choices":[{"message":{"role":"assistant","content":null,"reasoning_content":" adding the next"},"finish_reason":"null"}]},"usage":{"input_tokens":26,"output_tokens":157,"total_tokens":183},"request_id":"21e97c2b-c83d-961c-8947-c82a778ddbac"} + +id:158 +event:result +:HTTP_STATUS/200 +data:{"output":{"choices":[{"message":{"role":"assistant","content":null,"reasoning_content":" 8 is 2"},"finish_reason":"null"}]},"usage":{"input_tokens":26,"output_tokens":158,"total_tokens":184},"request_id":"21e97c2b-c83d-961c-8947-c82a778ddbac"} + +id:159 +event:result +:HTTP_STATUS/200 +data:{"output":{"choices":[{"message":{"role":"assistant","content":null,"reasoning_content":"4, and the"},"finish_reason":"null"}]},"usage":{"input_tokens":26,"output_tokens":159,"total_tokens":185},"request_id":"21e97c2b-c83d-961c-8947-c82a778ddbac"} + +id:160 +event:result +:HTTP_STATUS/200 +data:{"output":{"choices":[{"message":{"role":"assistant","content":null,"reasoning_content":" last 8 brings"},"finish_reason":"null"}]},"usage":{"input_tokens":26,"output_tokens":160,"total_tokens":186},"request_id":"21e97c2b-c83d-961c-8947-c82a778ddbac"} + +id:161 +event:result +:HTTP_STATUS/200 +data:{"output":{"choices":[{"message":{"role":"assistant","content":null,"reasoning_content":" it to 3"},"finish_reason":"null"}]},"usage":{"input_tokens":26,"output_tokens":161,"total_tokens":187},"request_id":"21e97c2b-c83d-961c-8947-c82a778ddbac"} + +id:162 +event:result +:HTTP_STATUS/200 +data:{"output":{"choices":[{"message":{"role":"assistant","content":null,"reasoning_content":"2. So that"},"finish_reason":"null"}]},"usage":{"input_tokens":26,"output_tokens":162,"total_tokens":188},"request_id":"21e97c2b-c83d-961c-8947-c82a778ddbac"} + +id:163 +event:result +:HTTP_STATUS/200 +data:{"output":{"choices":[{"message":{"role":"assistant","content":null,"reasoning_content":"'s another confirmation."},"finish_reason":"null"}]},"usage":{"input_tokens":26,"output_tokens":163,"total_tokens":189},"request_id":"21e97c2b-c83d-961c-8947-c82a778ddbac"} + +id:164 +event:result +:HTTP_STATUS/200 +data:{"output":{"choices":[{"message":{"role":"assistant","content":null,"reasoning_content":" \n\nWait, but"},"finish_reason":"null"}]},"usage":{"input_tokens":26,"output_tokens":164,"total_tokens":190},"request_id":"21e97c2b-c83d-961c-8947-c82a778ddbac"} + +id:165 +event:result +:HTTP_STATUS/200 +data:{"output":{"choices":[{"message":{"role":"assistant","content":null,"reasoning_content":" maybe I should recall"},"finish_reason":"null"}]},"usage":{"input_tokens":26,"output_tokens":165,"total_tokens":191},"request_id":"21e97c2b-c83d-961c-8947-c82a778ddbac"} + +id:166 +event:result +:HTTP_STATUS/200 +data:{"output":{"choices":[{"message":{"role":"assistant","content":null,"reasoning_content":" the multiplication table directly"},"finish_reason":"null"}]},"usage":{"input_tokens":26,"output_tokens":166,"total_tokens":192},"request_id":"21e97c2b-c83d-961c-8947-c82a778ddbac"} + +id:167 +event:result +:HTTP_STATUS/200 +data:{"output":{"choices":[{"message":{"role":"assistant","content":null,"reasoning_content":". The 4"},"finish_reason":"null"}]},"usage":{"input_tokens":26,"output_tokens":167,"total_tokens":193},"request_id":"21e97c2b-c83d-961c-8947-c82a778ddbac"} + +id:168 +event:result +:HTTP_STATUS/200 +data:{"output":{"choices":[{"message":{"role":"assistant","content":null,"reasoning_content":" times table goes like"},"finish_reason":"null"}]},"usage":{"input_tokens":26,"output_tokens":168,"total_tokens":194},"request_id":"21e97c2b-c83d-961c-8947-c82a778ddbac"} + +id:169 +event:result +:HTTP_STATUS/200 +data:{"output":{"choices":[{"message":{"role":"assistant","content":null,"reasoning_content":" 4,"},"finish_reason":"null"}]},"usage":{"input_tokens":26,"output_tokens":169,"total_tokens":195},"request_id":"21e97c2b-c83d-961c-8947-c82a778ddbac"} + +id:170 +event:result +:HTTP_STATUS/200 +data:{"output":{"choices":[{"message":{"role":"assistant","content":null,"reasoning_content":" 8, 1"},"finish_reason":"null"}]},"usage":{"input_tokens":26,"output_tokens":170,"total_tokens":196},"request_id":"21e97c2b-c83d-961c-8947-c82a778ddbac"} + +id:171 +event:result +:HTTP_STATUS/200 +data:{"output":{"choices":[{"message":{"role":"assistant","content":null,"reasoning_content":"2, 1"},"finish_reason":"null"}]},"usage":{"input_tokens":26,"output_tokens":171,"total_tokens":197},"request_id":"21e97c2b-c83d-961c-8947-c82a778ddbac"} + +id:172 +event:result +:HTTP_STATUS/200 +data:{"output":{"choices":[{"message":{"role":"assistant","content":null,"reasoning_content":"6, 2"},"finish_reason":"null"}]},"usage":{"input_tokens":26,"output_tokens":172,"total_tokens":198},"request_id":"21e97c2b-c83d-961c-8947-c82a778ddbac"} + +id:173 +event:result +:HTTP_STATUS/200 +data:{"output":{"choices":[{"message":{"role":"assistant","content":null,"reasoning_content":"0, 2"},"finish_reason":"null"}]},"usage":{"input_tokens":26,"output_tokens":173,"total_tokens":199},"request_id":"21e97c2b-c83d-961c-8947-c82a778ddbac"} + +id:174 +event:result +:HTTP_STATUS/200 +data:{"output":{"choices":[{"message":{"role":"assistant","content":null,"reasoning_content":"4, 2"},"finish_reason":"null"}]},"usage":{"input_tokens":26,"output_tokens":174,"total_tokens":200},"request_id":"21e97c2b-c83d-961c-8947-c82a778ddbac"} + +id:175 +event:result +:HTTP_STATUS/200 +data:{"output":{"choices":[{"message":{"role":"assistant","content":null,"reasoning_content":"8, 3"},"finish_reason":"null"}]},"usage":{"input_tokens":26,"output_tokens":175,"total_tokens":201},"request_id":"21e97c2b-c83d-961c-8947-c82a778ddbac"} + +id:176 +event:result +:HTTP_STATUS/200 +data:{"output":{"choices":[{"message":{"role":"assistant","content":null,"reasoning_content":"2... So when"},"finish_reason":"null"}]},"usage":{"input_tokens":26,"output_tokens":176,"total_tokens":202},"request_id":"21e97c2b-c83d-961c-8947-c82a778ddbac"} + +id:177 +event:result +:HTTP_STATUS/200 +data:{"output":{"choices":[{"message":{"role":"assistant","content":null,"reasoning_content":" you count by"},"finish_reason":"null"}]},"usage":{"input_tokens":26,"output_tokens":177,"total_tokens":203},"request_id":"21e97c2b-c83d-961c-8947-c82a778ddbac"} + +id:178 +event:result +:HTTP_STATUS/200 +data:{"output":{"choices":[{"message":{"role":"assistant","content":null,"reasoning_content":" 4s up to"},"finish_reason":"null"}]},"usage":{"input_tokens":26,"output_tokens":178,"total_tokens":204},"request_id":"21e97c2b-c83d-961c-8947-c82a778ddbac"} + +id:179 +event:result +:HTTP_STATUS/200 +data:{"output":{"choices":[{"message":{"role":"assistant","content":null,"reasoning_content":" the 8th"},"finish_reason":"null"}]},"usage":{"input_tokens":26,"output_tokens":179,"total_tokens":205},"request_id":"21e97c2b-c83d-961c-8947-c82a778ddbac"} + +id:180 +event:result +:HTTP_STATUS/200 +data:{"output":{"choices":[{"message":{"role":"assistant","content":null,"reasoning_content":" term, it's"},"finish_reason":"null"}]},"usage":{"input_tokens":26,"output_tokens":180,"total_tokens":206},"request_id":"21e97c2b-c83d-961c-8947-c82a778ddbac"} + +id:181 +event:result +:HTTP_STATUS/200 +data:{"output":{"choices":[{"message":{"role":"assistant","content":null,"reasoning_content":" 32."},"finish_reason":"null"}]},"usage":{"input_tokens":26,"output_tokens":181,"total_tokens":207},"request_id":"21e97c2b-c83d-961c-8947-c82a778ddbac"} + +id:182 +event:result +:HTTP_STATUS/200 +data:{"output":{"choices":[{"message":{"role":"assistant","content":null,"reasoning_content":" That aligns with"},"finish_reason":"null"}]},"usage":{"input_tokens":26,"output_tokens":182,"total_tokens":208},"request_id":"21e97c2b-c83d-961c-8947-c82a778ddbac"} + +id:183 +event:result +:HTTP_STATUS/200 +data:{"output":{"choices":[{"message":{"role":"assistant","content":null,"reasoning_content":" everything else."},"finish_reason":"null"}]},"usage":{"input_tokens":26,"output_tokens":183,"total_tokens":209},"request_id":"21e97c2b-c83d-961c-8947-c82a778ddbac"} + +id:184 +event:result +:HTTP_STATUS/200 +data:{"output":{"choices":[{"message":{"role":"assistant","content":null,"reasoning_content":" \n\nI think all these"},"finish_reason":"null"}]},"usage":{"input_tokens":26,"output_tokens":184,"total_tokens":210},"request_id":"21e97c2b-c83d-961c-8947-c82a778ddbac"} + +id:185 +event:result +:HTTP_STATUS/200 +data:{"output":{"choices":[{"message":{"role":"assistant","content":null,"reasoning_content":" methods are pointing to"},"finish_reason":"null"}]},"usage":{"input_tokens":26,"output_tokens":185,"total_tokens":211},"request_id":"21e97c2b-c83d-961c-8947-c82a778ddbac"} + +id:186 +event:result +:HTTP_STATUS/200 +data:{"output":{"choices":[{"message":{"role":"assistant","content":null,"reasoning_content":" the same answer."},"finish_reason":"null"}]},"usage":{"input_tokens":26,"output_tokens":186,"total_tokens":212},"request_id":"21e97c2b-c83d-961c-8947-c82a778ddbac"} + +id:187 +event:result +:HTTP_STATUS/200 +data:{"output":{"choices":[{"message":{"role":"assistant","content":null,"reasoning_content":" I don't see"},"finish_reason":"null"}]},"usage":{"input_tokens":26,"output_tokens":187,"total_tokens":213},"request_id":"21e97c2b-c83d-961c-8947-c82a778ddbac"} + +id:188 +event:result +:HTTP_STATUS/200 +data:{"output":{"choices":[{"message":{"role":"assistant","content":null,"reasoning_content":" any discrepancies here."},"finish_reason":"null"}]},"usage":{"input_tokens":26,"output_tokens":188,"total_tokens":214},"request_id":"21e97c2b-c83d-961c-8947-c82a778ddbac"} + +id:189 +event:result +:HTTP_STATUS/200 +data:{"output":{"choices":[{"message":{"role":"assistant","content":null,"reasoning_content":" So, 4"},"finish_reason":"null"}]},"usage":{"input_tokens":26,"output_tokens":189,"total_tokens":215},"request_id":"21e97c2b-c83d-961c-8947-c82a778ddbac"} + +id:190 +event:result +:HTTP_STATUS/200 +data:{"output":{"choices":[{"message":{"role":"assistant","content":null,"reasoning_content":" multiplied by 8"},"finish_reason":"null"}]},"usage":{"input_tokens":26,"output_tokens":190,"total_tokens":216},"request_id":"21e97c2b-c83d-961c-8947-c82a778ddbac"} + +id:191 +event:result +:HTTP_STATUS/200 +data:{"output":{"choices":[{"message":{"role":"assistant","content":null,"reasoning_content":" is definitely 3"},"finish_reason":"null"}]},"usage":{"input_tokens":26,"output_tokens":191,"total_tokens":217},"request_id":"21e97c2b-c83d-961c-8947-c82a778ddbac"} + +id:192 +event:result +:HTTP_STATUS/200 +data:{"output":{"choices":[{"message":{"role":"assistant","content":null,"reasoning_content":"2. I can"},"finish_reason":"null"}]},"usage":{"input_tokens":26,"output_tokens":192,"total_tokens":218},"request_id":"21e97c2b-c83d-961c-8947-c82a778ddbac"} + +id:193 +event:result +:HTTP_STATUS/200 +data:{"output":{"choices":[{"message":{"role":"assistant","content":null,"reasoning_content":"'t think of a"},"finish_reason":"null"}]},"usage":{"input_tokens":26,"output_tokens":193,"total_tokens":219},"request_id":"21e97c2b-c83d-961c-8947-c82a778ddbac"} + +id:194 +event:result +:HTTP_STATUS/200 +data:{"output":{"choices":[{"message":{"role":"assistant","content":null,"reasoning_content":" reason why that would"},"finish_reason":"null"}]},"usage":{"input_tokens":26,"output_tokens":194,"total_tokens":220},"request_id":"21e97c2b-c83d-961c-8947-c82a778ddbac"} + +id:195 +event:result +:HTTP_STATUS/200 +data:{"output":{"choices":[{"message":{"role":"assistant","content":null,"reasoning_content":" be wrong. Maybe"},"finish_reason":"null"}]},"usage":{"input_tokens":26,"output_tokens":195,"total_tokens":221},"request_id":"21e97c2b-c83d-961c-8947-c82a778ddbac"} + +id:196 +event:result +:HTTP_STATUS/200 +data:{"output":{"choices":[{"message":{"role":"assistant","content":null,"reasoning_content":" if there was a"},"finish_reason":"null"}]},"usage":{"input_tokens":26,"output_tokens":196,"total_tokens":222},"request_id":"21e97c2b-c83d-961c-8947-c82a778ddbac"} + +id:197 +event:result +:HTTP_STATUS/200 +data:{"output":{"choices":[{"message":{"role":"assistant","content":null,"reasoning_content":" different base or something"},"finish_reason":"null"}]},"usage":{"input_tokens":26,"output_tokens":197,"total_tokens":223},"request_id":"21e97c2b-c83d-961c-8947-c82a778ddbac"} + +id:198 +event:result +:HTTP_STATUS/200 +data:{"output":{"choices":[{"message":{"role":"assistant","content":null,"reasoning_content":", but the question"},"finish_reason":"null"}]},"usage":{"input_tokens":26,"output_tokens":198,"total_tokens":224},"request_id":"21e97c2b-c83d-961c-8947-c82a778ddbac"} + +id:199 +event:result +:HTTP_STATUS/200 +data:{"output":{"choices":[{"message":{"role":"assistant","content":null,"reasoning_content":" doesn't mention anything"},"finish_reason":"null"}]},"usage":{"input_tokens":26,"output_tokens":199,"total_tokens":225},"request_id":"21e97c2b-c83d-961c-8947-c82a778ddbac"} + +id:200 +event:result +:HTTP_STATUS/200 +data:{"output":{"choices":[{"message":{"role":"assistant","content":null,"reasoning_content":" like that. It"},"finish_reason":"null"}]},"usage":{"input_tokens":26,"output_tokens":200,"total_tokens":226},"request_id":"21e97c2b-c83d-961c-8947-c82a778ddbac"} + +id:201 +event:result +:HTTP_STATUS/200 +data:{"output":{"choices":[{"message":{"role":"assistant","content":null,"reasoning_content":" just says 4"},"finish_reason":"null"}]},"usage":{"input_tokens":26,"output_tokens":201,"total_tokens":227},"request_id":"21e97c2b-c83d-961c-8947-c82a778ddbac"} + +id:202 +event:result +:HTTP_STATUS/200 +data:{"output":{"choices":[{"message":{"role":"assistant","content":null,"reasoning_content":" * 8,"},"finish_reason":"null"}]},"usage":{"input_tokens":26,"output_tokens":202,"total_tokens":228},"request_id":"21e97c2b-c83d-961c-8947-c82a778ddbac"} + +id:203 +event:result +:HTTP_STATUS/200 +data:{"output":{"choices":[{"message":{"role":"assistant","content":null,"reasoning_content":" so standard base"},"finish_reason":"null"}]},"usage":{"input_tokens":26,"output_tokens":203,"total_tokens":229},"request_id":"21e97c2b-c83d-961c-8947-c82a778ddbac"} + +id:204 +event:result +:HTTP_STATUS/200 +data:{"output":{"choices":[{"message":{"role":"assistant","content":null,"reasoning_content":" 10 arithmetic."},"finish_reason":"null"}]},"usage":{"input_tokens":26,"output_tokens":204,"total_tokens":230},"request_id":"21e97c2b-c83d-961c-8947-c82a778ddbac"} + +id:205 +event:result +:HTTP_STATUS/200 +data:{"output":{"choices":[{"message":{"role":"assistant","content":null,"reasoning_content":" \n\nTherefore, after"},"finish_reason":"null"}]},"usage":{"input_tokens":26,"output_tokens":205,"total_tokens":231},"request_id":"21e97c2b-c83d-961c-8947-c82a778ddbac"} + +id:206 +event:result +:HTTP_STATUS/200 +data:{"output":{"choices":[{"message":{"role":"assistant","content":null,"reasoning_content":" checking multiple ways,"},"finish_reason":"null"}]},"usage":{"input_tokens":26,"output_tokens":206,"total_tokens":232},"request_id":"21e97c2b-c83d-961c-8947-c82a778ddbac"} + +id:207 +event:result +:HTTP_STATUS/200 +data:{"output":{"choices":[{"message":{"role":"assistant","content":null,"reasoning_content":" I'm confident the"},"finish_reason":"null"}]},"usage":{"input_tokens":26,"output_tokens":207,"total_tokens":233},"request_id":"21e97c2b-c83d-961c-8947-c82a778ddbac"} + +id:208 +event:result +:HTTP_STATUS/200 +data:{"output":{"choices":[{"message":{"role":"assistant","content":null,"reasoning_content":" answer is 3"},"finish_reason":"null"}]},"usage":{"input_tokens":26,"output_tokens":208,"total_tokens":234},"request_id":"21e97c2b-c83d-961c-8947-c82a778ddbac"} + +id:209 +event:result +:HTTP_STATUS/200 +data:{"output":{"choices":[{"message":{"role":"assistant","content":null,"reasoning_content":"2."},"finish_reason":"null"}]},"usage":{"input_tokens":26,"output_tokens":209,"total_tokens":235},"request_id":"21e97c2b-c83d-961c-8947-c82a778ddbac"} + +id:210 +event:result +:HTTP_STATUS/200 +data:{"output":{"choices":[{"message":{"role":"assistant","content":"The result of multiplying","reasoning_content":null},"finish_reason":"null"}]},"usage":{"input_tokens":26,"output_tokens":210,"total_tokens":236},"request_id":"21e97c2b-c83d-961c-8947-c82a778ddbac"} + +id:211 +event:result +:HTTP_STATUS/200 +data:{"output":{"choices":[{"message":{"role":"assistant","content":" 4 by","reasoning_content":null},"finish_reason":"null"}]},"usage":{"input_tokens":26,"output_tokens":211,"total_tokens":237},"request_id":"21e97c2b-c83d-961c-8947-c82a778ddbac"} + +id:212 +event:result +:HTTP_STATUS/200 +data:{"output":{"choices":[{"message":{"role":"assistant","content":" 8 is calculated as","reasoning_content":null},"finish_reason":"null"}]},"usage":{"input_tokens":26,"output_tokens":212,"total_tokens":238},"request_id":"21e97c2b-c83d-961c-8947-c82a778ddbac"} + +id:213 +event:result +:HTTP_STATUS/200 +data:{"output":{"choices":[{"message":{"role":"assistant","content":" follows:\n\n**Step","reasoning_content":null},"finish_reason":"null"}]},"usage":{"input_tokens":26,"output_tokens":213,"total_tokens":239},"request_id":"21e97c2b-c83d-961c-8947-c82a778ddbac"} + +id:214 +event:result +:HTTP_STATUS/200 +data:{"output":{"choices":[{"message":{"role":"assistant","content":"-by-Step Explanation","reasoning_content":null},"finish_reason":"null"}]},"usage":{"input_tokens":26,"output_tokens":214,"total_tokens":240},"request_id":"21e97c2b-c83d-961c-8947-c82a778ddbac"} + +id:215 +event:result +:HTTP_STATUS/200 +data:{"output":{"choices":[{"message":{"role":"assistant","content":":**\n1.","reasoning_content":null},"finish_reason":"null"}]},"usage":{"input_tokens":26,"output_tokens":215,"total_tokens":241},"request_id":"21e97c2b-c83d-961c-8947-c82a778ddbac"} + +id:216 +event:result +:HTTP_STATUS/200 +data:{"output":{"choices":[{"message":{"role":"assistant","content":" **Understanding Multiplication","reasoning_content":null},"finish_reason":"null"}]},"usage":{"input_tokens":26,"output_tokens":216,"total_tokens":242},"request_id":"21e97c2b-c83d-961c-8947-c82a778ddbac"} + +id:217 +event:result +:HTTP_STATUS/200 +data:{"output":{"choices":[{"message":{"role":"assistant","content":":** Multiplication is","reasoning_content":null},"finish_reason":"null"}]},"usage":{"input_tokens":26,"output_tokens":217,"total_tokens":243},"request_id":"21e97c2b-c83d-961c-8947-c82a778ddbac"} + +id:218 +event:result +:HTTP_STATUS/200 +data:{"output":{"choices":[{"message":{"role":"assistant","content":" repeated addition. So","reasoning_content":null},"finish_reason":"null"}]},"usage":{"input_tokens":26,"output_tokens":218,"total_tokens":244},"request_id":"21e97c2b-c83d-961c-8947-c82a778ddbac"} + +id:219 +event:result +:HTTP_STATUS/200 +data:{"output":{"choices":[{"message":{"role":"assistant","content":", 4 multiplied","reasoning_content":null},"finish_reason":"null"}]},"usage":{"input_tokens":26,"output_tokens":219,"total_tokens":245},"request_id":"21e97c2b-c83d-961c-8947-c82a778ddbac"} + +id:220 +event:result +:HTTP_STATUS/200 +data:{"output":{"choices":[{"message":{"role":"assistant","content":" by 8 means","reasoning_content":null},"finish_reason":"null"}]},"usage":{"input_tokens":26,"output_tokens":220,"total_tokens":246},"request_id":"21e97c2b-c83d-961c-8947-c82a778ddbac"} + +id:221 +event:result +:HTTP_STATUS/200 +data:{"output":{"choices":[{"message":{"role":"assistant","content":" adding 4 to","reasoning_content":null},"finish_reason":"null"}]},"usage":{"input_tokens":26,"output_tokens":221,"total_tokens":247},"request_id":"21e97c2b-c83d-961c-8947-c82a778ddbac"} + +id:222 +event:result +:HTTP_STATUS/200 +data:{"output":{"choices":[{"message":{"role":"assistant","content":" itself 8 times","reasoning_content":null},"finish_reason":"null"}]},"usage":{"input_tokens":26,"output_tokens":222,"total_tokens":248},"request_id":"21e97c2b-c83d-961c-8947-c82a778ddbac"} + +id:223 +event:result +:HTTP_STATUS/200 +data:{"output":{"choices":[{"message":{"role":"assistant","content":".\n2. **","reasoning_content":null},"finish_reason":"null"}]},"usage":{"input_tokens":26,"output_tokens":223,"total_tokens":249},"request_id":"21e97c2b-c83d-961c-8947-c82a778ddbac"} + +id:224 +event:result +:HTTP_STATUS/200 +data:{"output":{"choices":[{"message":{"role":"assistant","content":"Calculation:**","reasoning_content":null},"finish_reason":"null"}]},"usage":{"input_tokens":26,"output_tokens":224,"total_tokens":250},"request_id":"21e97c2b-c83d-961c-8947-c82a778ddbac"} + +id:225 +event:result +:HTTP_STATUS/200 +data:{"output":{"choices":[{"message":{"role":"assistant","content":"\n - 4","reasoning_content":null},"finish_reason":"null"}]},"usage":{"input_tokens":26,"output_tokens":225,"total_tokens":251},"request_id":"21e97c2b-c83d-961c-8947-c82a778ddbac"} + +id:226 +event:result +:HTTP_STATUS/200 +data:{"output":{"choices":[{"message":{"role":"assistant","content":" + 4 =","reasoning_content":null},"finish_reason":"null"}]},"usage":{"input_tokens":26,"output_tokens":226,"total_tokens":252},"request_id":"21e97c2b-c83d-961c-8947-c82a778ddbac"} + +id:227 +event:result +:HTTP_STATUS/200 +data:{"output":{"choices":[{"message":{"role":"assistant","content":" 8 (after","reasoning_content":null},"finish_reason":"null"}]},"usage":{"input_tokens":26,"output_tokens":227,"total_tokens":253},"request_id":"21e97c2b-c83d-961c-8947-c82a778ddbac"} + +id:228 +event:result +:HTTP_STATUS/200 +data:{"output":{"choices":[{"message":{"role":"assistant","content":" 2 additions)","reasoning_content":null},"finish_reason":"null"}]},"usage":{"input_tokens":26,"output_tokens":228,"total_tokens":254},"request_id":"21e97c2b-c83d-961c-8947-c82a778ddbac"} + +id:229 +event:result +:HTTP_STATUS/200 +data:{"output":{"choices":[{"message":{"role":"assistant","content":"\n - 8","reasoning_content":null},"finish_reason":"null"}]},"usage":{"input_tokens":26,"output_tokens":229,"total_tokens":255},"request_id":"21e97c2b-c83d-961c-8947-c82a778ddbac"} + +id:230 +event:result +:HTTP_STATUS/200 +data:{"output":{"choices":[{"message":{"role":"assistant","content":" + 4 =","reasoning_content":null},"finish_reason":"null"}]},"usage":{"input_tokens":26,"output_tokens":230,"total_tokens":256},"request_id":"21e97c2b-c83d-961c-8947-c82a778ddbac"} + +id:231 +event:result +:HTTP_STATUS/200 +data:{"output":{"choices":[{"message":{"role":"assistant","content":" 12 (","reasoning_content":null},"finish_reason":"null"}]},"usage":{"input_tokens":26,"output_tokens":231,"total_tokens":257},"request_id":"21e97c2b-c83d-961c-8947-c82a778ddbac"} + +id:232 +event:result +:HTTP_STATUS/200 +data:{"output":{"choices":[{"message":{"role":"assistant","content":"after 3 additions","reasoning_content":null},"finish_reason":"null"}]},"usage":{"input_tokens":26,"output_tokens":232,"total_tokens":258},"request_id":"21e97c2b-c83d-961c-8947-c82a778ddbac"} + +id:233 +event:result +:HTTP_STATUS/200 +data:{"output":{"choices":[{"message":{"role":"assistant","content":")\n -","reasoning_content":null},"finish_reason":"null"}]},"usage":{"input_tokens":26,"output_tokens":233,"total_tokens":259},"request_id":"21e97c2b-c83d-961c-8947-c82a778ddbac"} + +id:234 +event:result +:HTTP_STATUS/200 +data:{"output":{"choices":[{"message":{"role":"assistant","content":" 12 +","reasoning_content":null},"finish_reason":"null"}]},"usage":{"input_tokens":26,"output_tokens":234,"total_tokens":260},"request_id":"21e97c2b-c83d-961c-8947-c82a778ddbac"} + +id:235 +event:result +:HTTP_STATUS/200 +data:{"output":{"choices":[{"message":{"role":"assistant","content":" 4 = 1","reasoning_content":null},"finish_reason":"null"}]},"usage":{"input_tokens":26,"output_tokens":235,"total_tokens":261},"request_id":"21e97c2b-c83d-961c-8947-c82a778ddbac"} + +id:236 +event:result +:HTTP_STATUS/200 +data:{"output":{"choices":[{"message":{"role":"assistant","content":"6 (after","reasoning_content":null},"finish_reason":"null"}]},"usage":{"input_tokens":26,"output_tokens":236,"total_tokens":262},"request_id":"21e97c2b-c83d-961c-8947-c82a778ddbac"} + +id:237 +event:result +:HTTP_STATUS/200 +data:{"output":{"choices":[{"message":{"role":"assistant","content":" 4 additions)","reasoning_content":null},"finish_reason":"null"}]},"usage":{"input_tokens":26,"output_tokens":237,"total_tokens":263},"request_id":"21e97c2b-c83d-961c-8947-c82a778ddbac"} + +id:238 +event:result +:HTTP_STATUS/200 +data:{"output":{"choices":[{"message":{"role":"assistant","content":"\n - 16","reasoning_content":null},"finish_reason":"null"}]},"usage":{"input_tokens":26,"output_tokens":238,"total_tokens":264},"request_id":"21e97c2b-c83d-961c-8947-c82a778ddbac"} + +id:239 +event:result +:HTTP_STATUS/200 +data:{"output":{"choices":[{"message":{"role":"assistant","content":" + 4 =","reasoning_content":null},"finish_reason":"null"}]},"usage":{"input_tokens":26,"output_tokens":239,"total_tokens":265},"request_id":"21e97c2b-c83d-961c-8947-c82a778ddbac"} + +id:240 +event:result +:HTTP_STATUS/200 +data:{"output":{"choices":[{"message":{"role":"assistant","content":" 20 (","reasoning_content":null},"finish_reason":"null"}]},"usage":{"input_tokens":26,"output_tokens":240,"total_tokens":266},"request_id":"21e97c2b-c83d-961c-8947-c82a778ddbac"} + +id:241 +event:result +:HTTP_STATUS/200 +data:{"output":{"choices":[{"message":{"role":"assistant","content":"after 5 additions","reasoning_content":null},"finish_reason":"null"}]},"usage":{"input_tokens":26,"output_tokens":241,"total_tokens":267},"request_id":"21e97c2b-c83d-961c-8947-c82a778ddbac"} + +id:242 +event:result +:HTTP_STATUS/200 +data:{"output":{"choices":[{"message":{"role":"assistant","content":")\n -","reasoning_content":null},"finish_reason":"null"}]},"usage":{"input_tokens":26,"output_tokens":242,"total_tokens":268},"request_id":"21e97c2b-c83d-961c-8947-c82a778ddbac"} + +id:243 +event:result +:HTTP_STATUS/200 +data:{"output":{"choices":[{"message":{"role":"assistant","content":" 20 +","reasoning_content":null},"finish_reason":"null"}]},"usage":{"input_tokens":26,"output_tokens":243,"total_tokens":269},"request_id":"21e97c2b-c83d-961c-8947-c82a778ddbac"} + +id:244 +event:result +:HTTP_STATUS/200 +data:{"output":{"choices":[{"message":{"role":"assistant","content":" 4 = 2","reasoning_content":null},"finish_reason":"null"}]},"usage":{"input_tokens":26,"output_tokens":244,"total_tokens":270},"request_id":"21e97c2b-c83d-961c-8947-c82a778ddbac"} + +id:245 +event:result +:HTTP_STATUS/200 +data:{"output":{"choices":[{"message":{"role":"assistant","content":"4 (after","reasoning_content":null},"finish_reason":"null"}]},"usage":{"input_tokens":26,"output_tokens":245,"total_tokens":271},"request_id":"21e97c2b-c83d-961c-8947-c82a778ddbac"} + +id:246 +event:result +:HTTP_STATUS/200 +data:{"output":{"choices":[{"message":{"role":"assistant","content":" 6 additions)","reasoning_content":null},"finish_reason":"null"}]},"usage":{"input_tokens":26,"output_tokens":246,"total_tokens":272},"request_id":"21e97c2b-c83d-961c-8947-c82a778ddbac"} + +id:247 +event:result +:HTTP_STATUS/200 +data:{"output":{"choices":[{"message":{"role":"assistant","content":"\n - 24","reasoning_content":null},"finish_reason":"null"}]},"usage":{"input_tokens":26,"output_tokens":247,"total_tokens":273},"request_id":"21e97c2b-c83d-961c-8947-c82a778ddbac"} + +id:248 +event:result +:HTTP_STATUS/200 +data:{"output":{"choices":[{"message":{"role":"assistant","content":" + 4 =","reasoning_content":null},"finish_reason":"null"}]},"usage":{"input_tokens":26,"output_tokens":248,"total_tokens":274},"request_id":"21e97c2b-c83d-961c-8947-c82a778ddbac"} + +id:249 +event:result +:HTTP_STATUS/200 +data:{"output":{"choices":[{"message":{"role":"assistant","content":" 28 (","reasoning_content":null},"finish_reason":"null"}]},"usage":{"input_tokens":26,"output_tokens":249,"total_tokens":275},"request_id":"21e97c2b-c83d-961c-8947-c82a778ddbac"} + +id:250 +event:result +:HTTP_STATUS/200 +data:{"output":{"choices":[{"message":{"role":"assistant","content":"after 7 additions","reasoning_content":null},"finish_reason":"null"}]},"usage":{"input_tokens":26,"output_tokens":250,"total_tokens":276},"request_id":"21e97c2b-c83d-961c-8947-c82a778ddbac"} + +id:251 +event:result +:HTTP_STATUS/200 +data:{"output":{"choices":[{"message":{"role":"assistant","content":")\n -","reasoning_content":null},"finish_reason":"null"}]},"usage":{"input_tokens":26,"output_tokens":251,"total_tokens":277},"request_id":"21e97c2b-c83d-961c-8947-c82a778ddbac"} + +id:252 +event:result +:HTTP_STATUS/200 +data:{"output":{"choices":[{"message":{"role":"assistant","content":" 28 +","reasoning_content":null},"finish_reason":"null"}]},"usage":{"input_tokens":26,"output_tokens":252,"total_tokens":278},"request_id":"21e97c2b-c83d-961c-8947-c82a778ddbac"} + +id:253 +event:result +:HTTP_STATUS/200 +data:{"output":{"choices":[{"message":{"role":"assistant","content":" 4 = **3","reasoning_content":null},"finish_reason":"null"}]},"usage":{"input_tokens":26,"output_tokens":253,"total_tokens":279},"request_id":"21e97c2b-c83d-961c-8947-c82a778ddbac"} + +id:254 +event:result +:HTTP_STATUS/200 +data:{"output":{"choices":[{"message":{"role":"assistant","content":"2** (after","reasoning_content":null},"finish_reason":"null"}]},"usage":{"input_tokens":26,"output_tokens":254,"total_tokens":280},"request_id":"21e97c2b-c83d-961c-8947-c82a778ddbac"} + +id:255 +event:result +:HTTP_STATUS/200 +data:{"output":{"choices":[{"message":{"role":"assistant","content":" 8 additions)","reasoning_content":null},"finish_reason":"null"}]},"usage":{"input_tokens":26,"output_tokens":255,"total_tokens":281},"request_id":"21e97c2b-c83d-961c-8947-c82a778ddbac"} + +id:256 +event:result +:HTTP_STATUS/200 +data:{"output":{"choices":[{"message":{"role":"assistant","content":"\n3. **Verification","reasoning_content":null},"finish_reason":"null"}]},"usage":{"input_tokens":26,"output_tokens":256,"total_tokens":282},"request_id":"21e97c2b-c83d-961c-8947-c82a778ddbac"} + +id:257 +event:result +:HTTP_STATUS/200 +data:{"output":{"choices":[{"message":{"role":"assistant","content":":**\n -","reasoning_content":null},"finish_reason":"null"}]},"usage":{"input_tokens":26,"output_tokens":257,"total_tokens":283},"request_id":"21e97c2b-c83d-961c-8947-c82a778ddbac"} + +id:258 +event:result +:HTTP_STATUS/200 +data:{"output":{"choices":[{"message":{"role":"assistant","content":" Using the 4","reasoning_content":null},"finish_reason":"null"}]},"usage":{"input_tokens":26,"output_tokens":258,"total_tokens":284},"request_id":"21e97c2b-c83d-961c-8947-c82a778ddbac"} + +id:259 +event:result +:HTTP_STATUS/200 +data:{"output":{"choices":[{"message":{"role":"assistant","content":" times table:","reasoning_content":null},"finish_reason":"null"}]},"usage":{"input_tokens":26,"output_tokens":259,"total_tokens":285},"request_id":"21e97c2b-c83d-961c-8947-c82a778ddbac"} + +id:260 +event:result +:HTTP_STATUS/200 +data:{"output":{"choices":[{"message":{"role":"assistant","content":" 4×8 is","reasoning_content":null},"finish_reason":"null"}]},"usage":{"input_tokens":26,"output_tokens":260,"total_tokens":286},"request_id":"21e97c2b-c83d-961c-8947-c82a778ddbac"} + +id:261 +event:result +:HTTP_STATUS/200 +data:{"output":{"choices":[{"message":{"role":"assistant","content":" the 8th","reasoning_content":null},"finish_reason":"null"}]},"usage":{"input_tokens":26,"output_tokens":261,"total_tokens":287},"request_id":"21e97c2b-c83d-961c-8947-c82a778ddbac"} + +id:262 +event:result +:HTTP_STATUS/200 +data:{"output":{"choices":[{"message":{"role":"assistant","content":" term in the sequence","reasoning_content":null},"finish_reason":"null"}]},"usage":{"input_tokens":26,"output_tokens":262,"total_tokens":288},"request_id":"21e97c2b-c83d-961c-8947-c82a778ddbac"} + +id:263 +event:result +:HTTP_STATUS/200 +data:{"output":{"choices":[{"message":{"role":"assistant","content":" 4,","reasoning_content":null},"finish_reason":"null"}]},"usage":{"input_tokens":26,"output_tokens":263,"total_tokens":289},"request_id":"21e97c2b-c83d-961c-8947-c82a778ddbac"} + +id:264 +event:result +:HTTP_STATUS/200 +data:{"output":{"choices":[{"message":{"role":"assistant","content":" 8, 1","reasoning_content":null},"finish_reason":"null"}]},"usage":{"input_tokens":26,"output_tokens":264,"total_tokens":290},"request_id":"21e97c2b-c83d-961c-8947-c82a778ddbac"} + +id:265 +event:result +:HTTP_STATUS/200 +data:{"output":{"choices":[{"message":{"role":"assistant","content":"2, 1","reasoning_content":null},"finish_reason":"null"}]},"usage":{"input_tokens":26,"output_tokens":265,"total_tokens":291},"request_id":"21e97c2b-c83d-961c-8947-c82a778ddbac"} + +id:266 +event:result +:HTTP_STATUS/200 +data:{"output":{"choices":[{"message":{"role":"assistant","content":"6, 2","reasoning_content":null},"finish_reason":"null"}]},"usage":{"input_tokens":26,"output_tokens":266,"total_tokens":292},"request_id":"21e97c2b-c83d-961c-8947-c82a778ddbac"} + +id:267 +event:result +:HTTP_STATUS/200 +data:{"output":{"choices":[{"message":{"role":"assistant","content":"0, 2","reasoning_content":null},"finish_reason":"null"}]},"usage":{"input_tokens":26,"output_tokens":267,"total_tokens":293},"request_id":"21e97c2b-c83d-961c-8947-c82a778ddbac"} + +id:268 +event:result +:HTTP_STATUS/200 +data:{"output":{"choices":[{"message":{"role":"assistant","content":"4, 2","reasoning_content":null},"finish_reason":"null"}]},"usage":{"input_tokens":26,"output_tokens":268,"total_tokens":294},"request_id":"21e97c2b-c83d-961c-8947-c82a778ddbac"} + +id:269 +event:result +:HTTP_STATUS/200 +data:{"output":{"choices":[{"message":{"role":"assistant","content":"8, **3","reasoning_content":null},"finish_reason":"null"}]},"usage":{"input_tokens":26,"output_tokens":269,"total_tokens":295},"request_id":"21e97c2b-c83d-961c-8947-c82a778ddbac"} + +id:270 +event:result +:HTTP_STATUS/200 +data:{"output":{"choices":[{"message":{"role":"assistant","content":"2**.","reasoning_content":null},"finish_reason":"null"}]},"usage":{"input_tokens":26,"output_tokens":270,"total_tokens":296},"request_id":"21e97c2b-c83d-961c-8947-c82a778ddbac"} + +id:271 +event:result +:HTTP_STATUS/200 +data:{"output":{"choices":[{"message":{"role":"assistant","content":"\n - Alternative method:","reasoning_content":null},"finish_reason":"null"}]},"usage":{"input_tokens":26,"output_tokens":271,"total_tokens":297},"request_id":"21e97c2b-c83d-961c-8947-c82a778ddbac"} + +id:272 +event:result +:HTTP_STATUS/200 +data:{"output":{"choices":[{"message":{"role":"assistant","content":" \\(4 \\times","reasoning_content":null},"finish_reason":"null"}]},"usage":{"input_tokens":26,"output_tokens":272,"total_tokens":298},"request_id":"21e97c2b-c83d-961c-8947-c82a778ddbac"} + +id:273 +event:result +:HTTP_STATUS/200 +data:{"output":{"choices":[{"message":{"role":"assistant","content":" 8 = (","reasoning_content":null},"finish_reason":"null"}]},"usage":{"input_tokens":26,"output_tokens":273,"total_tokens":299},"request_id":"21e97c2b-c83d-961c-8947-c82a778ddbac"} + +id:274 +event:result +:HTTP_STATUS/200 +data:{"output":{"choices":[{"message":{"role":"assistant","content":"2 \\times","reasoning_content":null},"finish_reason":"null"}]},"usage":{"input_tokens":26,"output_tokens":274,"total_tokens":300},"request_id":"21e97c2b-c83d-961c-8947-c82a778ddbac"} + +id:275 +event:result +:HTTP_STATUS/200 +data:{"output":{"choices":[{"message":{"role":"assistant","content":" 8) + (","reasoning_content":null},"finish_reason":"null"}]},"usage":{"input_tokens":26,"output_tokens":275,"total_tokens":301},"request_id":"21e97c2b-c83d-961c-8947-c82a778ddbac"} + +id:276 +event:result +:HTTP_STATUS/200 +data:{"output":{"choices":[{"message":{"role":"assistant","content":"2 \\times","reasoning_content":null},"finish_reason":"null"}]},"usage":{"input_tokens":26,"output_tokens":276,"total_tokens":302},"request_id":"21e97c2b-c83d-961c-8947-c82a778ddbac"} + +id:277 +event:result +:HTTP_STATUS/200 +data:{"output":{"choices":[{"message":{"role":"assistant","content":" 8) =","reasoning_content":null},"finish_reason":"null"}]},"usage":{"input_tokens":26,"output_tokens":277,"total_tokens":303},"request_id":"21e97c2b-c83d-961c-8947-c82a778ddbac"} + +id:278 +event:result +:HTTP_STATUS/200 +data:{"output":{"choices":[{"message":{"role":"assistant","content":" 16 +","reasoning_content":null},"finish_reason":"null"}]},"usage":{"input_tokens":26,"output_tokens":278,"total_tokens":304},"request_id":"21e97c2b-c83d-961c-8947-c82a778ddbac"} + +id:279 +event:result +:HTTP_STATUS/200 +data:{"output":{"choices":[{"message":{"role":"assistant","content":" 16 =","reasoning_content":null},"finish_reason":"null"}]},"usage":{"input_tokens":26,"output_tokens":279,"total_tokens":305},"request_id":"21e97c2b-c83d-961c-8947-c82a778ddbac"} + +id:280 +event:result +:HTTP_STATUS/200 +data:{"output":{"choices":[{"message":{"role":"assistant","content":" 32\\).","reasoning_content":null},"finish_reason":"null"}]},"usage":{"input_tokens":26,"output_tokens":280,"total_tokens":306},"request_id":"21e97c2b-c83d-961c-8947-c82a778ddbac"} + +id:281 +event:result +:HTTP_STATUS/200 +data:{"output":{"choices":[{"message":{"role":"assistant","content":"\n\n**Final Answer:**","reasoning_content":null},"finish_reason":"null"}]},"usage":{"input_tokens":26,"output_tokens":281,"total_tokens":307},"request_id":"21e97c2b-c83d-961c-8947-c82a778ddbac"} + +id:282 +event:result +:HTTP_STATUS/200 +data:{"output":{"choices":[{"message":{"role":"assistant","content":" \n\\(4 \\","reasoning_content":null},"finish_reason":"null"}]},"usage":{"input_tokens":26,"output_tokens":282,"total_tokens":308},"request_id":"21e97c2b-c83d-961c-8947-c82a778ddbac"} + +id:283 +event:result +:HTTP_STATUS/200 +data:{"output":{"choices":[{"message":{"role":"assistant","content":"times 8 =","reasoning_content":null},"finish_reason":"null"}]},"usage":{"input_tokens":26,"output_tokens":283,"total_tokens":309},"request_id":"21e97c2b-c83d-961c-8947-c82a778ddbac"} + +id:284 +event:result +:HTTP_STATUS/200 +data:{"output":{"choices":[{"message":{"role":"assistant","content":" \\boxed{3","reasoning_content":null},"finish_reason":"null"}]},"usage":{"input_tokens":26,"output_tokens":284,"total_tokens":310},"request_id":"21e97c2b-c83d-961c-8947-c82a778ddbac"} + +id:285 +event:result +:HTTP_STATUS/200 +data:{"output":{"choices":[{"message":{"role":"assistant","content":"2}\\)","reasoning_content":null},"finish_reason":"null"}]},"usage":{"input_tokens":26,"output_tokens":285,"total_tokens":311},"request_id":"21e97c2b-c83d-961c-8947-c82a778ddbac"} + +id:286 +event:result +:HTTP_STATUS/200 +data:{"output":{"choices":[{"message":{"role":"assistant","content":"","reasoning_content":null},"finish_reason":"stop"}]},"usage":{"input_tokens":26,"output_tokens":286,"total_tokens":312},"request_id":"21e97c2b-c83d-961c-8947-c82a778ddbac"} + diff --git a/tests/Fixtures/qwen/stream-with-tools-1.sse b/tests/Fixtures/qwen/stream-with-tools-1.sse new file mode 100644 index 000000000..af3b4d05b --- /dev/null +++ b/tests/Fixtures/qwen/stream-with-tools-1.sse @@ -0,0 +1,20 @@ +id:1 +event:result +:HTTP_STATUS/200 +data:{"output":{"choices":[{"message":{"role":"assistant","content":"","tool_calls":[{"index":0,"id":"call_39df66a2f91b49e5a59f1b","type":"function","function":{"name":"get_weather","arguments":""}}]},"finish_reason":"null"}]},"usage":{"input_tokens":100,"output_tokens":1,"total_tokens":101},"request_id":"baa35a30-5e25-9f50-a147-dc3a32add522"} + +id:2 +event:result +:HTTP_STATUS/200 +data:{"output":{"choices":[{"message":{"role":"assistant","content":"","tool_calls":[{"index":0,"id":"call_39df66a2f91b49e5a59f1b","type":"function","function":{"name":"","arguments":"{\"city\": \""}}]},"finish_reason":"null"}]},"usage":{"input_tokens":100,"output_tokens":2,"total_tokens":102},"request_id":"baa35a30-5e25-9f50-a147-dc3a32add522"} + +id:3 +event:result +:HTTP_STATUS/200 +data:{"output":{"choices":[{"message":{"role":"assistant","content":"","tool_calls":[{"function":{"arguments":"Detroit\"}"},"index":0,"id":"","type":"function"}]},"finish_reason":"null"}]},"usage":{"input_tokens":100,"output_tokens":3,"total_tokens":103},"request_id":"baa35a30-5e25-9f50-a147-dc3a32add522"} + +id:4 +event:result +:HTTP_STATUS/200 +data:{"output":{"choices":[{"message":{"role":"assistant"},"finish_reason":"tool_calls"}]},"usage":{"input_tokens":100,"output_tokens":4,"total_tokens":104},"request_id":"baa35a30-5e25-9f50-a147-dc3a32add522"} + diff --git a/tests/Fixtures/qwen/stream-with-tools-2.sse b/tests/Fixtures/qwen/stream-with-tools-2.sse new file mode 100644 index 000000000..b2f761c7d --- /dev/null +++ b/tests/Fixtures/qwen/stream-with-tools-2.sse @@ -0,0 +1,35 @@ +id:1 +event:result +:HTTP_STATUS/200 +data:{"output":{"choices":[{"message":{"role":"assistant","content":""},"finish_reason":"null"}]},"usage":{"input_tokens":100,"output_tokens":1,"total_tokens":101},"request_id":"9e33d9e3-0f55-9fc7-9fd2-4a3aa5cf3725"} + +id:2 +event:result +:HTTP_STATUS/200 +data:{"output":{"choices":[{"message":{"role":"assistant","content":"The"},"finish_reason":"null"}]},"usage":{"input_tokens":100,"output_tokens":2,"total_tokens":102},"request_id":"9e33d9e3-0f55-9fc7-9fd2-4a3aa5cf3725"} + +id:3 +event:result +:HTTP_STATUS/200 +data:{"output":{"choices":[{"message":{"role":"assistant","content":" current weather in"},"finish_reason":"null"}]},"usage":{"input_tokens":100,"output_tokens":3,"total_tokens":103},"request_id":"9e33d9e3-0f55-9fc7-9fd2-4a3aa5cf3725"} + +id:4 +event:result +:HTTP_STATUS/200 +data:{"output":{"choices":[{"message":{"role":"assistant","content":" Detroit is "},"finish_reason":"null"}]},"usage":{"input_tokens":100,"output_tokens":4,"total_tokens":104},"request_id":"9e33d9e3-0f55-9fc7-9fd2-4a3aa5cf3725"} + +id:5 +event:result +:HTTP_STATUS/200 +data:{"output":{"choices":[{"message":{"role":"assistant","content":"75°"},"finish_reason":"null"}]},"usage":{"input_tokens":100,"output_tokens":5,"total_tokens":105},"request_id":"9e33d9e3-0f55-9fc7-9fd2-4a3aa5cf3725"} + +id:6 +event:result +:HTTP_STATUS/200 +data:{"output":{"choices":[{"message":{"role":"assistant","content":" and sunny."},"finish_reason":"null"}]},"usage":{"input_tokens":100,"output_tokens":6,"total_tokens":106},"request_id":"9e33d9e3-0f55-9fc7-9fd2-4a3aa5cf3725"} + +id:7 +event:result +:HTTP_STATUS/200 +data:{"output":{"choices":[{"message":{"role":"assistant","content":""},"finish_reason":"stop"}]},"usage":{"input_tokens":100,"output_tokens":7,"total_tokens":107},"request_id":"9e33d9e3-0f55-9fc7-9fd2-4a3aa5cf3725"} + diff --git a/tests/Fixtures/qwen/structured-1.json b/tests/Fixtures/qwen/structured-1.json new file mode 100644 index 000000000..6e4e347a3 --- /dev/null +++ b/tests/Fixtures/qwen/structured-1.json @@ -0,0 +1 @@ +{"status_code": 200, "request_id": "c566fb8b-d0a3-92d0-b77a-ae1496d9f32f", "code": "", "message": "", "output": {"choices": [{"message": {"content": "{\n \"weather\": \"75º\",\n \"game_time\": \"3pm\",\n \"coat_required\": false\n}", "role": "assistant"}, "finish_reason": "stop"}]}, "usage": {"input_tokens": 216, "output_tokens": 27, "total_tokens": 243}} \ No newline at end of file diff --git a/tests/Fixtures/replicate/embeddings-multiple-inputs-1.json b/tests/Fixtures/replicate/embeddings-multiple-inputs-1.json new file mode 100644 index 000000000..6e63cc5fd --- /dev/null +++ b/tests/Fixtures/replicate/embeddings-multiple-inputs-1.json @@ -0,0 +1,20 @@ +{ + "id": "g3yqcwrxaxrg80ct9ht9dknmym", + "model": "mark3labs\/embeddings-gte-base", + "version": "d619cff29338b9a37c3d06605042e1ff0594a8c3eff0175fd6967f5643fc4d47", + "input": { + "text": "The food was delicious." + }, + "logs": "", + "output": null, + "data_removed": false, + "error": null, + "source": "api", + "status": "starting", + "created_at": "2025-11-04T01:21:09.463Z", + "urls": { + "cancel": "https:\/\/api.replicate.com\/v1\/predictions\/g3yqcwrxaxrg80ct9ht9dknmym\/cancel", + "get": "https:\/\/api.replicate.com\/v1\/predictions\/g3yqcwrxaxrg80ct9ht9dknmym", + "web": "https:\/\/replicate.com\/p\/g3yqcwrxaxrg80ct9ht9dknmym" + } +} \ No newline at end of file diff --git a/tests/Fixtures/replicate/embeddings-multiple-inputs-2.json b/tests/Fixtures/replicate/embeddings-multiple-inputs-2.json new file mode 100644 index 000000000..6ca98bc8b --- /dev/null +++ b/tests/Fixtures/replicate/embeddings-multiple-inputs-2.json @@ -0,0 +1,28 @@ +{ + "id": "g3yqcwrxaxrg80ct9ht9dknmym", + "model": "mark3labs/embeddings-gte-base", + "version": "d619cff29338b9a37c3d06605042e1ff0594a8c3eff0175fd6967f5643fc4d47", + "input": { + "text": "The food was delicious." + }, + "logs": "", + "output": { + "text": "The food was delicious.", + "vectors": [0.015234, -0.032451, 0.042132, -0.012543, 0.051234, 0.023451, -0.041234, 0.033214, -0.025341, 0.041235, 0.012341, -0.032145, 0.051432, -0.023451, 0.034512, -0.041235, 0.025341, 0.032145, -0.015234, 0.042134] + }, + "data_removed": false, + "error": null, + "source": "api", + "status": "succeeded", + "created_at": "2025-11-04T01:21:10.100Z", + "started_at": "2025-11-04T01:21:10.500Z", + "completed_at": "2025-11-04T01:21:10.900Z", + "urls": { + "cancel": "https://api.replicate.com/v1/predictions/g3yqcwrxaxrg80ct9ht9dknmym/cancel", + "get": "https://api.replicate.com/v1/predictions/g3yqcwrxaxrg80ct9ht9dknmym" + }, + "metrics": { + "predict_time": 0.340000, + "total_time": 0.400000 + } +} diff --git a/tests/Fixtures/replicate/embeddings-multiple-inputs-3.json b/tests/Fixtures/replicate/embeddings-multiple-inputs-3.json new file mode 100644 index 000000000..049a3d923 --- /dev/null +++ b/tests/Fixtures/replicate/embeddings-multiple-inputs-3.json @@ -0,0 +1,19 @@ +{ + "id": "h4zrdxsybysrh90ct9hu0elm1n", + "model": "mark3labs/embeddings-gte-base", + "version": "d619cff29338b9a37c3d06605042e1ff0594a8c3eff0175fd6967f5643fc4d47", + "input": { + "text": "The drinks were not so good" + }, + "logs": "", + "output": null, + "data_removed": false, + "error": null, + "source": "api", + "status": "starting", + "created_at": "2025-11-04T01:21:15.100Z", + "urls": { + "cancel": "https://api.replicate.com/v1/predictions/h4zrdxsybysrh90ct9hu0elm1n/cancel", + "get": "https://api.replicate.com/v1/predictions/h4zrdxsybysrh90ct9hu0elm1n" + } +} diff --git a/tests/Fixtures/replicate/embeddings-multiple-inputs-4.json b/tests/Fixtures/replicate/embeddings-multiple-inputs-4.json new file mode 100644 index 000000000..9beb8cc35 --- /dev/null +++ b/tests/Fixtures/replicate/embeddings-multiple-inputs-4.json @@ -0,0 +1,28 @@ +{ + "id": "h4zrdxsybysrh90ct9hu0elm1n", + "model": "mark3labs/embeddings-gte-base", + "version": "d619cff29338b9a37c3d06605042e1ff0594a8c3eff0175fd6967f5643fc4d47", + "input": { + "text": "The drinks were not so good" + }, + "logs": "", + "output": { + "text": "The drinks were not so good", + "vectors": [-0.025341, 0.041235, -0.012341, 0.032145, -0.051432, 0.023451, -0.034512, 0.041235, -0.025341, -0.032145, 0.015234, -0.042134, 0.032451, -0.042132, 0.012543, -0.051234, -0.023451, 0.041234, -0.033214, 0.025341] + }, + "data_removed": false, + "error": null, + "source": "api", + "status": "succeeded", + "created_at": "2025-11-04T01:21:15.100Z", + "started_at": "2025-11-04T01:21:15.500Z", + "completed_at": "2025-11-04T01:21:15.900Z", + "urls": { + "cancel": "https://api.replicate.com/v1/predictions/h4zrdxsybysrh90ct9hu0elm1n/cancel", + "get": "https://api.replicate.com/v1/predictions/h4zrdxsybysrh90ct9hu0elm1n" + }, + "metrics": { + "predict_time": 0.340000, + "total_time": 0.400000 + } +} diff --git a/tests/Fixtures/replicate/embeddings-single-input-1.json b/tests/Fixtures/replicate/embeddings-single-input-1.json new file mode 100644 index 000000000..1ccc5814b --- /dev/null +++ b/tests/Fixtures/replicate/embeddings-single-input-1.json @@ -0,0 +1,20 @@ +{ + "id": "0sm4g8ceddrgc0ct9hssk54y6m", + "model": "mark3labs\/embeddings-gte-base", + "version": "d619cff29338b9a37c3d06605042e1ff0594a8c3eff0175fd6967f5643fc4d47", + "input": { + "text": "The food was delicious and the waiter..." + }, + "logs": "", + "output": null, + "data_removed": false, + "error": null, + "source": "api", + "status": "starting", + "created_at": "2025-11-04T01:20:32.875Z", + "urls": { + "cancel": "https:\/\/api.replicate.com\/v1\/predictions\/0sm4g8ceddrgc0ct9hssk54y6m\/cancel", + "get": "https:\/\/api.replicate.com\/v1\/predictions\/0sm4g8ceddrgc0ct9hssk54y6m", + "web": "https:\/\/replicate.com\/p\/0sm4g8ceddrgc0ct9hssk54y6m" + } +} \ No newline at end of file diff --git a/tests/Fixtures/replicate/embeddings-single-input-2.json b/tests/Fixtures/replicate/embeddings-single-input-2.json new file mode 100644 index 000000000..c3cede244 --- /dev/null +++ b/tests/Fixtures/replicate/embeddings-single-input-2.json @@ -0,0 +1,28 @@ +{ + "id": "0sm4g8ceddrgc0ct9hssk54y6m", + "model": "mark3labs/embeddings-gte-base", + "version": "d619cff29338b9a37c3d06605042e1ff0594a8c3eff0175fd6967f5643fc4d47", + "input": { + "text": "The food was delicious and the waiter..." + }, + "logs": "", + "output": { + "text": "The food was delicious and the waiter...", + "vectors": [-0.03071732632815838, -0.024373497813940048, 0.03353334963321686, 0.002168054925277829, 0.06559721380472183, -0.009457750245928764, 0.011472711339592934, 0.028100572526454926, -0.017572680488228798, -0.04897604510188103, -0.02974911965429783, 0.017224477604031563, -0.05097205936908722, 0.0567566379904747, -0.002953317714855075, 0.06358537822961807, 0.030977727845311165, 0.01616182178258896, 0.012071298435330391, -0.0005556714604608715] + }, + "data_removed": false, + "error": null, + "source": "api", + "status": "succeeded", + "created_at": "2025-11-04T01:20:41.639Z", + "started_at": "2025-11-04T01:20:42.100Z", + "completed_at": "2025-11-04T01:20:42.500Z", + "urls": { + "cancel": "https://api.replicate.com/v1/predictions/0sm4g8ceddrgc0ct9hssk54y6m/cancel", + "get": "https://api.replicate.com/v1/predictions/0sm4g8ceddrgc0ct9hssk54y6m" + }, + "metrics": { + "predict_time": 0.340934, + "total_time": 0.400000 + } +} diff --git a/tests/Fixtures/replicate/generate-image-basic-1.json b/tests/Fixtures/replicate/generate-image-basic-1.json new file mode 100644 index 000000000..1970fe1fa --- /dev/null +++ b/tests/Fixtures/replicate/generate-image-basic-1.json @@ -0,0 +1,21 @@ +{ + "id": "8mver00185rme0ct9gt80xrw5m", + "model": "black-forest-labs\/flux-schnell", + "version": "hidden", + "input": { + "prompt": "A cute baby sea otter floating on its back in calm blue water" + }, + "logs": "", + "output": null, + "data_removed": false, + "error": null, + "source": "api", + "status": "starting", + "created_at": "2025-11-04T00:11:07.969Z", + "urls": { + "cancel": "https:\/\/api.replicate.com\/v1\/predictions\/8mver00185rme0ct9gt80xrw5m\/cancel", + "get": "https:\/\/api.replicate.com\/v1\/predictions\/8mver00185rme0ct9gt80xrw5m", + "stream": "https:\/\/stream.replicate.com\/v1\/files\/bcwr-zkfgmidksx3jiedqz7ceaez2sf4lqlhh3yyjtnwcmdlpxislvsxq", + "web": "https:\/\/replicate.com\/p\/8mver00185rme0ct9gt80xrw5m" + } +} \ No newline at end of file diff --git a/tests/Fixtures/replicate/generate-image-basic-2.json b/tests/Fixtures/replicate/generate-image-basic-2.json new file mode 100644 index 000000000..d15104b5e --- /dev/null +++ b/tests/Fixtures/replicate/generate-image-basic-2.json @@ -0,0 +1,30 @@ +{ + "id": "8mver00185rme0ct9gt80xrw5m", + "model": "black-forest-labs\/flux-schnell", + "version": "hidden", + "input": { + "prompt": "A cute baby sea otter floating on its back in calm blue water" + }, + "logs": "running quantized prediction\nUsing seed: 1371263524\n 0%| | 0\/4 [00:00<|start_header_id|>system<|end_header_id|>\n\nYou are a helpful assistant<|eot_id|><|start_header_id|>user<|end_header_id|>\n\nHello, world!<|eot_id|><|start_header_id|>assistant<|end_header_id|>`Random seed used: `48082`\nNote: Random seed will not impact output if greedy decoding is used.\nFormatted prompt: `<|begin_of_text|><|start_header_id|>system<|end_header_id|>\n\nYou are a helpful assistant<|eot_id|><|start_header_id|>user<|end_header_id|>\n\nHello, world!<|eot_id|><|start_header_id|>assistant<|end_header_id|>`", + "output": [ + "\n\n", + "Hello", + " there", + "!", + " It", + "'s", + " wonderful", + " to", + " meet", + " you", + "!", + " Is", + " there", + " something", + " I", + " can", + " help", + " you", + " with", + " or", + " would", + " you", + " like", + " to", + " chat", + " about", + " something", + " in", + " particular", + "?", + " I", + "'m", + " all", + " ears", + " (", + "or", + " in", + " this", + " case", + ",", + " all", + " text", + ")!" + ], + "data_removed": false, + "error": null, + "source": "api", + "status": "succeeded", + "created_at": "2025-11-03T22:43:56.573Z", + "started_at": "2025-11-03T22:43:56.578750586Z", + "completed_at": "2025-11-03T22:43:57.402950866Z", + "urls": { + "cancel": "https://api.replicate.com/v1/predictions/5f4g1h9e3nrj00ct9fjbxs9wz8/cancel", + "get": "https://api.replicate.com/v1/predictions/5f4g1h9e3nrj00ct9fjbxs9wz8", + "web": "https://replicate.com/p/5f4g1h9e3nrj00ct9fjbxs9wz8" + }, + "metrics": { + "batch_size": 3.0027687333103597, + "input_token_count": 24, + "output_token_count": 44, + "predict_time": 0.824200279, + "predict_time_share": 0.27398014068603516, + "time_to_first_token": 0.038646233, + "tokens_per_second": 56.0114230510882, + "total_time": 0.829950866 + } +} diff --git a/tests/Fixtures/replicate/generate-text-with-system-prompt-1.json b/tests/Fixtures/replicate/generate-text-with-system-prompt-1.json new file mode 100644 index 000000000..76247d97a --- /dev/null +++ b/tests/Fixtures/replicate/generate-text-with-system-prompt-1.json @@ -0,0 +1,21 @@ +{ + "id": "5f4g1h9e3nrj00ct9fjbxs9wz8", + "model": "meta/meta-llama-3-70b-instruct", + "version": "hidden", + "input": { + "max_tokens": 100, + "prompt": "Hello, world!" + }, + "logs": "", + "output": null, + "data_removed": false, + "error": null, + "source": "api", + "status": "starting", + "created_at": "2025-11-03T22:43:56.573Z", + "urls": { + "cancel": "https://api.replicate.com/v1/predictions/5f4g1h9e3nrj00ct9fjbxs9wz8/cancel", + "get": "https://api.replicate.com/v1/predictions/5f4g1h9e3nrj00ct9fjbxs9wz8", + "web": "https://replicate.com/p/5f4g1h9e3nrj00ct9fjbxs9wz8" + } +} diff --git a/tests/Fixtures/replicate/generate-text-with-system-prompt-2.json b/tests/Fixtures/replicate/generate-text-with-system-prompt-2.json new file mode 100644 index 000000000..8358a7963 --- /dev/null +++ b/tests/Fixtures/replicate/generate-text-with-system-prompt-2.json @@ -0,0 +1,77 @@ +{ + "id": "5f4g1h9e3nrj00ct9fjbxs9wz8", + "model": "meta/meta-llama-3-70b-instruct", + "version": "hidden", + "input": { + "max_tokens": 100, + "prompt": "Hello, world!" + }, + "logs": "Random seed used: `48082`\nNote: Random seed will not impact output if greedy decoding is used.\nFormatted prompt: `<|begin_of_text|><|start_header_id|>system<|end_header_id|>\n\nYou are a helpful assistant<|eot_id|><|start_header_id|>user<|end_header_id|>\n\nHello, world!<|eot_id|><|start_header_id|>assistant<|end_header_id|>`Random seed used: `48082`\nNote: Random seed will not impact output if greedy decoding is used.\nFormatted prompt: `<|begin_of_text|><|start_header_id|>system<|end_header_id|>\n\nYou are a helpful assistant<|eot_id|><|start_header_id|>user<|end_header_id|>\n\nHello, world!<|eot_id|><|start_header_id|>assistant<|end_header_id|>`", + "output": [ + "\n\n", + "Hello", + " there", + "!", + " It", + "'s", + " wonderful", + " to", + " meet", + " you", + "!", + " Is", + " there", + " something", + " I", + " can", + " help", + " you", + " with", + " or", + " would", + " you", + " like", + " to", + " chat", + " about", + " something", + " in", + " particular", + "?", + " I", + "'m", + " all", + " ears", + " (", + "or", + " in", + " this", + " case", + ",", + " all", + " text", + ")!" + ], + "data_removed": false, + "error": null, + "source": "api", + "status": "succeeded", + "created_at": "2025-11-03T22:43:56.573Z", + "started_at": "2025-11-03T22:43:56.578750586Z", + "completed_at": "2025-11-03T22:43:57.402950866Z", + "urls": { + "cancel": "https://api.replicate.com/v1/predictions/5f4g1h9e3nrj00ct9fjbxs9wz8/cancel", + "get": "https://api.replicate.com/v1/predictions/5f4g1h9e3nrj00ct9fjbxs9wz8", + "web": "https://replicate.com/p/5f4g1h9e3nrj00ct9fjbxs9wz8" + }, + "metrics": { + "batch_size": 3.0027687333103597, + "input_token_count": 24, + "output_token_count": 44, + "predict_time": 0.824200279, + "predict_time_share": 0.27398014068603516, + "time_to_first_token": 0.038646233, + "tokens_per_second": 56.0114230510882, + "total_time": 0.829950866 + } +} diff --git a/tests/Fixtures/replicate/generate-text-with-version-1.json b/tests/Fixtures/replicate/generate-text-with-version-1.json new file mode 100644 index 000000000..76247d97a --- /dev/null +++ b/tests/Fixtures/replicate/generate-text-with-version-1.json @@ -0,0 +1,21 @@ +{ + "id": "5f4g1h9e3nrj00ct9fjbxs9wz8", + "model": "meta/meta-llama-3-70b-instruct", + "version": "hidden", + "input": { + "max_tokens": 100, + "prompt": "Hello, world!" + }, + "logs": "", + "output": null, + "data_removed": false, + "error": null, + "source": "api", + "status": "starting", + "created_at": "2025-11-03T22:43:56.573Z", + "urls": { + "cancel": "https://api.replicate.com/v1/predictions/5f4g1h9e3nrj00ct9fjbxs9wz8/cancel", + "get": "https://api.replicate.com/v1/predictions/5f4g1h9e3nrj00ct9fjbxs9wz8", + "web": "https://replicate.com/p/5f4g1h9e3nrj00ct9fjbxs9wz8" + } +} diff --git a/tests/Fixtures/replicate/generate-text-with-version-2.json b/tests/Fixtures/replicate/generate-text-with-version-2.json new file mode 100644 index 000000000..8358a7963 --- /dev/null +++ b/tests/Fixtures/replicate/generate-text-with-version-2.json @@ -0,0 +1,77 @@ +{ + "id": "5f4g1h9e3nrj00ct9fjbxs9wz8", + "model": "meta/meta-llama-3-70b-instruct", + "version": "hidden", + "input": { + "max_tokens": 100, + "prompt": "Hello, world!" + }, + "logs": "Random seed used: `48082`\nNote: Random seed will not impact output if greedy decoding is used.\nFormatted prompt: `<|begin_of_text|><|start_header_id|>system<|end_header_id|>\n\nYou are a helpful assistant<|eot_id|><|start_header_id|>user<|end_header_id|>\n\nHello, world!<|eot_id|><|start_header_id|>assistant<|end_header_id|>`Random seed used: `48082`\nNote: Random seed will not impact output if greedy decoding is used.\nFormatted prompt: `<|begin_of_text|><|start_header_id|>system<|end_header_id|>\n\nYou are a helpful assistant<|eot_id|><|start_header_id|>user<|end_header_id|>\n\nHello, world!<|eot_id|><|start_header_id|>assistant<|end_header_id|>`", + "output": [ + "\n\n", + "Hello", + " there", + "!", + " It", + "'s", + " wonderful", + " to", + " meet", + " you", + "!", + " Is", + " there", + " something", + " I", + " can", + " help", + " you", + " with", + " or", + " would", + " you", + " like", + " to", + " chat", + " about", + " something", + " in", + " particular", + "?", + " I", + "'m", + " all", + " ears", + " (", + "or", + " in", + " this", + " case", + ",", + " all", + " text", + ")!" + ], + "data_removed": false, + "error": null, + "source": "api", + "status": "succeeded", + "created_at": "2025-11-03T22:43:56.573Z", + "started_at": "2025-11-03T22:43:56.578750586Z", + "completed_at": "2025-11-03T22:43:57.402950866Z", + "urls": { + "cancel": "https://api.replicate.com/v1/predictions/5f4g1h9e3nrj00ct9fjbxs9wz8/cancel", + "get": "https://api.replicate.com/v1/predictions/5f4g1h9e3nrj00ct9fjbxs9wz8", + "web": "https://replicate.com/p/5f4g1h9e3nrj00ct9fjbxs9wz8" + }, + "metrics": { + "batch_size": 3.0027687333103597, + "input_token_count": 24, + "output_token_count": 44, + "predict_time": 0.824200279, + "predict_time_share": 0.27398014068603516, + "time_to_first_token": 0.038646233, + "tokens_per_second": 56.0114230510882, + "total_time": 0.829950866 + } +} diff --git a/tests/Fixtures/replicate/speech-to-text-mp3-1.json b/tests/Fixtures/replicate/speech-to-text-mp3-1.json new file mode 100644 index 000000000..5a08d9b6f --- /dev/null +++ b/tests/Fixtures/replicate/speech-to-text-mp3-1.json @@ -0,0 +1,21 @@ +{ + "id": "4f1q2sdbwsrm80ct9fsv9k978c", + "model": "vaibhavs10\/incredibly-fast-whisper", + "version": "3ab86df6c8f54c11309d4d1f930ac292bad43ace52d10c80d87eb258b3c9f79c", + "input": { + "audio": "data:audio\/mpeg;base64,...", + "task": "transcribe" + }, + "logs": "", + "output": null, + "data_removed": false, + "error": null, + "source": "api", + "status": "starting", + "created_at": "2025-11-03T23:00:51.814Z", + "urls": { + "cancel": "https:\/\/api.replicate.com\/v1\/predictions\/4f1q2sdbwsrm80ct9fsv9k978c\/cancel", + "get": "https:\/\/api.replicate.com\/v1\/predictions\/4f1q2sdbwsrm80ct9fsv9k978c", + "web": "https:\/\/replicate.com\/p\/4f1q2sdbwsrm80ct9fsv9k978c" + } +} \ No newline at end of file diff --git a/tests/Fixtures/replicate/speech-to-text-mp3-2.json b/tests/Fixtures/replicate/speech-to-text-mp3-2.json new file mode 100644 index 000000000..36fb66622 --- /dev/null +++ b/tests/Fixtures/replicate/speech-to-text-mp3-2.json @@ -0,0 +1,535 @@ +{ + "id": "4f1q2sdbwsrm80ct9fsv9k978c", + "model": "vaibhavs10\/incredibly-fast-whisper", + "version": "3ab86df6c8f54c11309d4d1f930ac292bad43ace52d10c80d87eb258b3c9f79c", + "input": { + "audio": "data:audio\/mpeg;base64,...", + "task": "transcribe" + }, + "logs": "Voila!\u2728 Your file has been transcribed!\n", + "output": { + "chunks": [ + { + "text": " know by by that conference um so we'll uh i think that's a good excuse to be on the newsletter too", + "timestamp": [ + 0, + 8.38 + ] + }, + { + "text": " is like yeah find out about 1.0 like as soon as possible yep totally yeah i like that idea then", + "timestamp": [ + 8.38, + 15.24 + ] + }, + { + "text": " that gets you not as much pressure for taking 1.0 before you go to laracon it gets an added", + "timestamp": [ + 15.24, + 23.88 + ] + }, + { + "text": " benefit of getting people on the newsletter", + "timestamp": [ + 23.88, + 25.88 + ] + }, + { + "text": " and a little bit more exposure in there and then you know in the meantime before you can i don't", + "timestamp": [ + 25.88, + 32.16 + ] + }, + { + "text": " know maybe start drafting up newsletter launch email number one yeah get ready yeah i might", + "timestamp": [ + 32.16, + 40.42 + ] + }, + { + "text": " right now i'm using button down email for everything may consider switching over to Bento just because Aaron loves it.", + "timestamp": [ + 40.42, + 49.6 + ] + }, + { + "text": " And I like using things that people recommend.", + "timestamp": [ + 49.76, + 52.46 + ] + }, + { + "text": " So yeah, I might, I might switch providers right before I pull the trigger on it.", + "timestamp": [ + 52.7, + 57.78 + ] + }, + { + "text": " So that's, that's something I'm gonna have to look at and maybe I'll, I'll work on, on the plane or something.", + "timestamp": [ + 57.82, + 62.4 + ] + }, + { + "text": " I'm on my way out to Denver.", + "timestamp": [ + 63.14, + 64.18 + ] + }, + { + "text": " I don't know. It's just getting close, man. I'm on my way out to Denver. I don't know.", + "timestamp": [ + 64.48, + 65.02 + ] + }, + { + "text": " It's just getting close, man.", + "timestamp": [ + 65.14, + 66.18 + ] + }, + { + "text": " I leave on Sunday.", + "timestamp": [ + 66.2, + 66.96 + ] + }, + { + "text": " It's Thursday.", + "timestamp": [ + 67.54, + 68.38 + ] + }, + { + "text": " So this is all coming up really quick and there's a lot I want to do.", + "timestamp": [ + 68.7, + 72.68 + ] + }, + { + "text": " So maybe I don't sleep tonight.", + "timestamp": [ + 72.88, + 75.02 + ] + }, + { + "text": " Yeah.", + "timestamp": [ + 76.06, + 76.16 + ] + }, + { + "text": " But even if you just like captured email addresses, like you can always export them, import them into the new place anyways.", + "timestamp": [ + 76.22, + 82.24 + ] + }, + { + "text": " Yep.", + "timestamp": [ + 83.24, + 83.44 + ] + }, + { + "text": " Yeah.", + "timestamp": [ + 83.82, + 84.06 + ] + }, + { + "text": " A hundred percent. So cool, man. Moving on from newsletters. port them into the new place anyways. Yep. Yeah, 100%.", + "timestamp": [ + 84.16, + 85.18 + ] + }, + { + "text": " So cool, man.", + "timestamp": [ + 85.18, + 85.82 + ] + }, + { + "text": " Moving on from newsletters, I think you'd added something here about cloud custom commands.", + "timestamp": [ + 86.02, + 91.8 + ] + }, + { + "text": " And that's something that we talked about, I think, last week a little bit.", + "timestamp": [ + 91.92, + 94.64 + ] + }, + { + "text": " And we may be starting to find some excuses for using custom slash commands and stuff.", + "timestamp": [ + 95.04, + 100.92 + ] + }, + { + "text": " So I'd love to hear about your experience here.", + "timestamp": [ + 100.92, + 103.98 + ] + }, + { + "text": " Yeah, definitely definitely so this was", + "timestamp": [ + 104.72, + 106.66 + ] + }, + { + "text": " definitely prompted from last week's conversation and as i'm working through the week i was like", + "timestamp": [ + 106.66, + 112.6 + ] + }, + { + "text": " okay what can i possibly make a custom command for so i basically started off with um two things", + "timestamp": [ + 112.6, + 120.74 + ] + }, + { + "text": " i created like a refresh repo command which which basically like you run it, it scans,", + "timestamp": [ + 120.74, + 128.3 + ] + }, + { + "text": " it basically like re-initiates the, the cloud MD file. It reads what's there. It looks at the", + "timestamp": [ + 128.3, + 137.82 + ] + }, + { + "text": " application, sees any new like dependencies or commands or whatever the case is and updates anything that it deems important to add back to", + "timestamp": [ + 137.82, + 148.12 + ] + }, + { + "text": " that markdown file and it's just nice if you have like a repo that has a lot of development a lot", + "timestamp": [ + 148.12, + 153.98 + ] + }, + { + "text": " of people working in it and you don't always want to capture every single new like merge or rebase", + "timestamp": [ + 153.98, + 161.5 + ] + }, + { + "text": " like every other day to it so maybe your brew updates running that like the", + "timestamp": [ + 161.5, + 167.16 + ] + }, + { + "text": " repo refresh like once a week or something like that would be good because then you don't have", + "timestamp": [ + 167.16, + 171.6 + ] + }, + { + "text": " to type out like oh rescan the repo and do this and that and the other thing like now this will", + "timestamp": [ + 171.6, + 176.66 + ] + }, + { + "text": " just take care of it so that was i think the first one that i made so that one's been really helpful", + "timestamp": [ + 176.66, + 182.12 + ] + }, + { + "text": " refreshing everything because the first time i ran it, it picked up like,", + "timestamp": [ + 182.12, + 186.16 + ] + }, + { + "text": " I think the last time I refreshed it was,", + "timestamp": [ + 186.16, + 188.68 + ] + }, + { + "text": " I don't know, three, four weeks ago.", + "timestamp": [ + 188.68, + 190.36 + ] + }, + { + "text": " So it found a whole bunch more stuff.", + "timestamp": [ + 190.36, + 191.98 + ] + }, + { + "text": " It updated architecture changes, a whole bunch of things to it.", + "timestamp": [ + 191.98, + 197.02 + ] + }, + { + "text": " So that's been really helpful.", + "timestamp": [ + 197.02, + 198.3 + ] + }, + { + "text": " Probably going to run that on a weekly basis", + "timestamp": [ + 198.3, + 200.98 + ] + }, + { + "text": " now on all the repos that we're working on. The other one is what I just named like a branch context is like,", + "timestamp": [ + 200.98, + 210.64 + ] + }, + { + "text": " I'm either switching to a branch that I'm working on or pulling down for a", + "timestamp": [ + 210.68, + 215.44 + ] + }, + { + "text": " review or something like that of like the repo refreshes for main branch.", + "timestamp": [ + 215.44, + 221.02 + ] + }, + { + "text": " And then when I'm going into something else like scan for changes,", + "timestamp": [ + 221.02, + 224.26 + ] + }, + { + "text": " tell me what's in the last handful of commits and kind of refresh your understanding of what has changed in", + "timestamp": [ + 224.48, + 230.46 + ] + }, + { + "text": " the repo in here. And then we can either work towards something else together, or I'm asking", + "timestamp": [ + 230.46, + 236.46 + ] + }, + { + "text": " you for why did this change or why did that change or whatever the case is. It's just refreshing the", + "timestamp": [ + 236.46, + 242.02 + ] + }, + { + "text": " immediate context for that branch. So that's been helpful as well.", + "timestamp": [ + 242.02, + 246.06 + ] + }, + { + "text": " That's sick.", + "timestamp": [ + 246.42, + 246.92 + ] + }, + { + "text": " Yeah.", + "timestamp": [ + 247.66, + 247.86 + ] + }, + { + "text": " So I guess I'll pause there because then I have another whole workflow.", + "timestamp": [ + 248.26, + 251.26 + ] + }, + { + "text": " So any questions on either one of those or any specific details?", + "timestamp": [ + 251.26, + 257 + ] + }, + { + "text": " No, man.", + "timestamp": [ + 257.54, + 257.92 + ] + }, + { + "text": " I think that sounds great.", + "timestamp": [ + 258.06, + 258.88 + ] + }, + { + "text": " Those are very useful things to have as slash commands.", + "timestamp": [ + 259.08, + 261.44 + ] + }, + { + "text": " things to have is slash commands yeah yeah that's it's i'm still i'm still personally finding like my own use cases for them i haven't i haven't dove too far into it yet", + "timestamp": [ + 267.26, + 272.98 + ] + }, + { + "text": " but maybe after lara con i'll i'll spend a little time you know coming up with some stuff but i i", + "timestamp": [ + 272.98, + 280.02 + ] + }, + { + "text": " think those are like i think it serves like some decent inspiration for some some other slash commands i'm thinking up right now so yeah what else you got um yeah so the next one which i do a", + "timestamp": [ + 280.02, + 292.46 + ] + }, + { + "text": " lot is pr reviews for others and coming into like a new tech stack like there's some things i am just", + "timestamp": [ + 292.46, + 298.56 + ] + }, + { + "text": " not aware about or like best practices or whatever so there's a default slash review command that comes out of the box in cloud", + "timestamp": [ + 298.56, + 308.32 + ] + }, + { + "text": " code.", + "timestamp": [ + 308.32, + 308.66 + ] + }, + { + "text": " And that gives a decent understanding.", + "timestamp": [ + 308.8, + 312.24 + ] + }, + { + "text": " Like it'll tell you,", + "timestamp": [ + 312.38, + 313.36 + ] + }, + { + "text": " you know,", + "timestamp": [ + 313.42, + 313.96 + ] + }, + { + "text": " what's changed,", + "timestamp": [ + 313.98, + 314.82 + ] + }, + { + "text": " what maybe some of the,", + "timestamp": [ + 314.96, + 316.58 + ] + } + ], + "text": " know by by that conference um so we'll uh i think that's a good excuse to be on the newsletter too is like yeah find out about 1.0 like as soon as possible yep totally yeah i like that idea then that gets you not as much pressure for taking 1.0 before you go to laracon it gets an added benefit of getting people on the newsletter and a little bit more exposure in there and then you know in the meantime before you can i don't know maybe start drafting up newsletter launch email number one yeah get ready yeah i might right now i'm using button down email for everything may consider switching over to Bento just because Aaron loves it. And I like using things that people recommend. So yeah, I might, I might switch providers right before I pull the trigger on it. So that's, that's something I'm gonna have to look at and maybe I'll, I'll work on, on the plane or something. I'm on my way out to Denver. I don't know. It's just getting close, man. I'm on my way out to Denver. I don't know. It's just getting close, man. I leave on Sunday. It's Thursday. So this is all coming up really quick and there's a lot I want to do. So maybe I don't sleep tonight. Yeah. But even if you just like captured email addresses, like you can always export them, import them into the new place anyways. Yep. Yeah. A hundred percent. So cool, man. Moving on from newsletters. port them into the new place anyways. Yep. Yeah, 100%. So cool, man. Moving on from newsletters, I think you'd added something here about cloud custom commands. And that's something that we talked about, I think, last week a little bit. And we may be starting to find some excuses for using custom slash commands and stuff. So I'd love to hear about your experience here. Yeah, definitely definitely so this was definitely prompted from last week's conversation and as i'm working through the week i was like okay what can i possibly make a custom command for so i basically started off with um two things i created like a refresh repo command which which basically like you run it, it scans, it basically like re-initiates the, the cloud MD file. It reads what's there. It looks at the application, sees any new like dependencies or commands or whatever the case is and updates anything that it deems important to add back to that markdown file and it's just nice if you have like a repo that has a lot of development a lot of people working in it and you don't always want to capture every single new like merge or rebase like every other day to it so maybe your brew updates running that like the repo refresh like once a week or something like that would be good because then you don't have to type out like oh rescan the repo and do this and that and the other thing like now this will just take care of it so that was i think the first one that i made so that one's been really helpful refreshing everything because the first time i ran it, it picked up like, I think the last time I refreshed it was, I don't know, three, four weeks ago. So it found a whole bunch more stuff. It updated architecture changes, a whole bunch of things to it. So that's been really helpful. Probably going to run that on a weekly basis now on all the repos that we're working on. The other one is what I just named like a branch context is like, I'm either switching to a branch that I'm working on or pulling down for a review or something like that of like the repo refreshes for main branch. And then when I'm going into something else like scan for changes, tell me what's in the last handful of commits and kind of refresh your understanding of what has changed in the repo in here. And then we can either work towards something else together, or I'm asking you for why did this change or why did that change or whatever the case is. It's just refreshing the immediate context for that branch. So that's been helpful as well. That's sick. Yeah. So I guess I'll pause there because then I have another whole workflow. So any questions on either one of those or any specific details? No, man. I think that sounds great. Those are very useful things to have as slash commands. things to have is slash commands yeah yeah that's it's i'm still i'm still personally finding like my own use cases for them i haven't i haven't dove too far into it yet but maybe after lara con i'll i'll spend a little time you know coming up with some stuff but i i think those are like i think it serves like some decent inspiration for some some other slash commands i'm thinking up right now so yeah what else you got um yeah so the next one which i do a lot is pr reviews for others and coming into like a new tech stack like there's some things i am just not aware about or like best practices or whatever so there's a default slash review command that comes out of the box in cloud code. And that gives a decent understanding. Like it'll tell you, you know, what's changed, what maybe some of the," + }, + "data_removed": false, + "error": null, + "source": "api", + "status": "succeeded", + "created_at": "2025-11-03T23:00:51.814Z", + "started_at": "2025-11-03T23:00:52.189785656Z", + "completed_at": "2025-11-03T23:00:58.006454501Z", + "urls": { + "cancel": "https:\/\/api.replicate.com\/v1\/predictions\/4f1q2sdbwsrm80ct9fsv9k978c\/cancel", + "get": "https:\/\/api.replicate.com\/v1\/predictions\/4f1q2sdbwsrm80ct9fsv9k978c", + "web": "https:\/\/replicate.com\/p\/4f1q2sdbwsrm80ct9fsv9k978c" + }, + "metrics": { + "predict_time": 5.816668844, + "total_time": 6.192454501 + } +} \ No newline at end of file diff --git a/tests/Fixtures/replicate/speech-to-text-wav-1.json b/tests/Fixtures/replicate/speech-to-text-wav-1.json new file mode 100644 index 000000000..5c8078915 --- /dev/null +++ b/tests/Fixtures/replicate/speech-to-text-wav-1.json @@ -0,0 +1,21 @@ +{ + "id": "w57accczhhrme0ct9fsvy71m5w", + "model": "vaibhavs10\/incredibly-fast-whisper", + "version": "3ab86df6c8f54c11309d4d1f930ac292bad43ace52d10c80d87eb258b3c9f79c", + "input": { + "audio": "data:audio\/x-wav;base64,...", + "task": "transcribe" + }, + "logs": "", + "output": null, + "data_removed": false, + "error": null, + "source": "api", + "status": "starting", + "created_at": "2025-11-03T23:00:48.652Z", + "urls": { + "cancel": "https:\/\/api.replicate.com\/v1\/predictions\/w57accczhhrme0ct9fsvy71m5w\/cancel", + "get": "https:\/\/api.replicate.com\/v1\/predictions\/w57accczhhrme0ct9fsvy71m5w", + "web": "https:\/\/replicate.com\/p\/w57accczhhrme0ct9fsvy71m5w" + } +} \ No newline at end of file diff --git a/tests/Fixtures/replicate/speech-to-text-wav-2.json b/tests/Fixtures/replicate/speech-to-text-wav-2.json new file mode 100644 index 000000000..3e8be968b --- /dev/null +++ b/tests/Fixtures/replicate/speech-to-text-wav-2.json @@ -0,0 +1,38 @@ +{ + "id": "w57accczhhrme0ct9fsvy71m5w", + "model": "vaibhavs10\/incredibly-fast-whisper", + "version": "3ab86df6c8f54c11309d4d1f930ac292bad43ace52d10c80d87eb258b3c9f79c", + "input": { + "audio": "data:audio\/x-wav;base64,...", + "task": "transcribe" + }, + "logs": "Voila!\u2728 Your file has been transcribed!\n", + "output": { + "chunks": [ + { + "text": " Kids are talking by the door.", + "timestamp": [ + 0, + 3.74 + ] + } + ], + "text": " Kids are talking by the door." + }, + "data_removed": false, + "error": null, + "source": "api", + "status": "succeeded", + "created_at": "2025-11-03T23:00:48.652Z", + "started_at": "2025-11-03T23:00:49.204781396Z", + "completed_at": "2025-11-03T23:00:49.528991479Z", + "urls": { + "cancel": "https:\/\/api.replicate.com\/v1\/predictions\/w57accczhhrme0ct9fsvy71m5w\/cancel", + "get": "https:\/\/api.replicate.com\/v1\/predictions\/w57accczhhrme0ct9fsvy71m5w", + "web": "https:\/\/replicate.com\/p\/w57accczhhrme0ct9fsvy71m5w" + }, + "metrics": { + "predict_time": 0.324210084, + "total_time": 0.876991479 + } +} \ No newline at end of file diff --git a/tests/Fixtures/replicate/structured-json-mode-1.json b/tests/Fixtures/replicate/structured-json-mode-1.json new file mode 100644 index 000000000..a512e1b97 --- /dev/null +++ b/tests/Fixtures/replicate/structured-json-mode-1.json @@ -0,0 +1,22 @@ +{ + "id": "0gb2pjnmnhrme0ct9hca6dfr98", + "model": "meta\/meta-llama-3.1-405b-instruct", + "version": "hidden", + "input": { + "max_tokens": 500, + "prompt": "What time is the tigers game today and should I wear a coat? Respond ONLY with valid JSON that matches this schema: {\"weather\": \"string\", \"game_time\": \"string\", \"coat_required\": boolean}" + }, + "logs": "", + "output": null, + "data_removed": false, + "error": null, + "source": "api", + "status": "starting", + "created_at": "2025-11-04T00:51:13.196Z", + "urls": { + "cancel": "https:\/\/api.replicate.com\/v1\/predictions\/0gb2pjnmnhrme0ct9hca6dfr98\/cancel", + "get": "https:\/\/api.replicate.com\/v1\/predictions\/0gb2pjnmnhrme0ct9hca6dfr98", + "stream": "https:\/\/stream-b.svc.ric1.c.replicate.net\/v1\/streams\/jn75y6rccu76zna77fpdoar7xq4fbrzp4xewqhpht5g2syshvj2q", + "web": "https:\/\/replicate.com\/p\/0gb2pjnmnhrme0ct9hca6dfr98" + } +} \ No newline at end of file diff --git a/tests/Fixtures/replicate/structured-json-mode-2.json b/tests/Fixtures/replicate/structured-json-mode-2.json new file mode 100644 index 000000000..a29c5ac59 --- /dev/null +++ b/tests/Fixtures/replicate/structured-json-mode-2.json @@ -0,0 +1,133 @@ +{ + "id": "0gb2pjnmnhrme0ct9hca6dfr98", + "model": "meta\/meta-llama-3.1-405b-instruct", + "version": "hidden", + "input": { + "max_tokens": 500, + "prompt": "What time is the tigers game today and should I wear a coat? Respond ONLY with valid JSON that matches this schema: {\"weather\": \"string\", \"game_time\": \"string\", \"coat_required\": boolean}" + }, + "logs": "", + "output": [ + "Unfortunately", + ",", + " I", + " don", + "'t", + " have", + " real", + "-time", + " access", + " to", + " current", + " events", + " or", + " weather", + " conditions", + ".", + " However", + ",", + " I", + " can", + " provide", + " a", + " response", + " in", + " the", + " requested", + " JSON", + " format", + " with", + " some", + " placeholder", + " values", + ":\n\n", + "``", + "`\n", + "{\n", + " ", + " \"", + "weather", + "\":", + " \"", + "S", + "unny", + "\",\n", + " ", + " \"", + "game", + "_time", + "\":", + " \"", + "1", + ":", + "00", + " PM", + " EST", + "\",\n", + " ", + " \"", + "coat", + "_required", + "\":", + " false", + "\n", + "}\n", + "``", + "`\n\n", + "Please", + " note", + " that", + " this", + " response", + " is", + " fictional", + " and", + " not", + " based", + " on", + " actual", + " data", + ".", + " For", + " accurate", + " information", + ",", + " I", + " recommend", + " checking", + " the", + " official", + " website", + " of", + " the", + " Tigers", + " team", + " or", + " a", + " reliable", + " weather", + " service", + "." + ], + "data_removed": false, + "error": null, + "source": "api", + "status": "succeeded", + "created_at": "2025-11-04T00:51:13.196Z", + "started_at": "2025-11-04T00:51:13.203039516Z", + "completed_at": "2025-11-04T00:51:18.458222807Z", + "urls": { + "cancel": "https:\/\/api.replicate.com\/v1\/predictions\/0gb2pjnmnhrme0ct9hca6dfr98\/cancel", + "get": "https:\/\/api.replicate.com\/v1\/predictions\/0gb2pjnmnhrme0ct9hca6dfr98", + "stream": "https:\/\/stream-b.svc.ric1.c.replicate.net\/v1\/streams\/jn75y6rccu76zna77fpdoar7xq4fbrzp4xewqhpht5g2syshvj2q", + "web": "https:\/\/replicate.com\/p\/0gb2pjnmnhrme0ct9hca6dfr98" + }, + "metrics": { + "input_token_count": 65, + "output_token_count": 101, + "predict_time": 5.255183285, + "time_to_first_token": 0.967300547, + "tokens_per_second": 23.55474861868762, + "total_time": 5.262222807 + } +} \ No newline at end of file diff --git a/tests/Fixtures/replicate/structured-simple-1.json b/tests/Fixtures/replicate/structured-simple-1.json new file mode 100644 index 000000000..9d019d5a6 --- /dev/null +++ b/tests/Fixtures/replicate/structured-simple-1.json @@ -0,0 +1,22 @@ +{ + "id": "5ydxqjpf9srma0ct9hcb4yatsm", + "model": "meta\/meta-llama-3.1-405b-instruct", + "version": "hidden", + "input": { + "max_tokens": 500, + "prompt": "Tell me about Albert Einstein. Respond ONLY with valid JSON that matches this schema: {\"name\": \"string\", \"role\": \"string\"}" + }, + "logs": "", + "output": null, + "data_removed": false, + "error": null, + "source": "api", + "status": "starting", + "created_at": "2025-11-04T00:51:20.014Z", + "urls": { + "cancel": "https:\/\/api.replicate.com\/v1\/predictions\/5ydxqjpf9srma0ct9hcb4yatsm\/cancel", + "get": "https:\/\/api.replicate.com\/v1\/predictions\/5ydxqjpf9srma0ct9hcb4yatsm", + "stream": "https:\/\/stream-b.svc.ric1.c.replicate.net\/v1\/streams\/5zpbwnvwcs5rxxgbd6tz24on4jcc6hvzf77tpqjl6keqqmlvp2za", + "web": "https:\/\/replicate.com\/p\/5ydxqjpf9srma0ct9hcb4yatsm" + } +} \ No newline at end of file diff --git a/tests/Fixtures/replicate/structured-simple-2.json b/tests/Fixtures/replicate/structured-simple-2.json new file mode 100644 index 000000000..b5dc82a52 --- /dev/null +++ b/tests/Fixtures/replicate/structured-simple-2.json @@ -0,0 +1,50 @@ +{ + "id": "5ydxqjpf9srma0ct9hcb4yatsm", + "model": "meta\/meta-llama-3.1-405b-instruct", + "version": "hidden", + "input": { + "max_tokens": 500, + "prompt": "Tell me about Albert Einstein. Respond ONLY with valid JSON that matches this schema: {\"name\": \"string\", \"role\": \"string\"}" + }, + "logs": "", + "output": [ + "{\"", + "name", + "\":", + " \"", + "Albert", + " Einstein", + "\",", + " \"", + "role", + "\":", + " \"", + "The", + "oretical", + " Phys", + "ic", + "ist", + "\"}" + ], + "data_removed": false, + "error": null, + "source": "api", + "status": "succeeded", + "created_at": "2025-11-04T00:51:20.014Z", + "started_at": "2025-11-04T00:51:20.036951769Z", + "completed_at": "2025-11-04T00:51:20.95123293Z", + "urls": { + "cancel": "https:\/\/api.replicate.com\/v1\/predictions\/5ydxqjpf9srma0ct9hcb4yatsm\/cancel", + "get": "https:\/\/api.replicate.com\/v1\/predictions\/5ydxqjpf9srma0ct9hcb4yatsm", + "stream": "https:\/\/stream-b.svc.ric1.c.replicate.net\/v1\/streams\/5zpbwnvwcs5rxxgbd6tz24on4jcc6hvzf77tpqjl6keqqmlvp2za", + "web": "https:\/\/replicate.com\/p\/5ydxqjpf9srma0ct9hcb4yatsm" + }, + "metrics": { + "input_token_count": 50, + "output_token_count": 18, + "predict_time": 0.914281161, + "time_to_first_token": 0.427102528, + "tokens_per_second": 36.94743320157064, + "total_time": 0.93723293 + } +} \ No newline at end of file diff --git a/tests/Fixtures/replicate/text-to-speech-basic-1.json b/tests/Fixtures/replicate/text-to-speech-basic-1.json new file mode 100644 index 000000000..d567189e4 --- /dev/null +++ b/tests/Fixtures/replicate/text-to-speech-basic-1.json @@ -0,0 +1,22 @@ +{ + "id": "gxh7q2my95rgc0ct9g7tr2w9cr", + "model": "jaaari\/kokoro-82m", + "version": "f559560eb822dc509045f3921a1921234918b91739db4bf3daab2169b71c7a13", + "input": { + "text": "Hello world!", + "voice": "af_alloy" + }, + "logs": "", + "output": null, + "data_removed": false, + "error": null, + "source": "api", + "status": "starting", + "created_at": "2025-11-03T23:31:23.337Z", + "urls": { + "cancel": "https:\/\/api.replicate.com\/v1\/predictions\/gxh7q2my95rgc0ct9g7tr2w9cr\/cancel", + "get": "https:\/\/api.replicate.com\/v1\/predictions\/gxh7q2my95rgc0ct9g7tr2w9cr", + "stream": "https:\/\/stream.replicate.com\/v1\/files\/fddq-qrpzjxytfv2piya7jlz3jxfccosl5k5csjag73cue6cpphyvju6q", + "web": "https:\/\/replicate.com\/p\/gxh7q2my95rgc0ct9g7tr2w9cr" + } +} \ No newline at end of file diff --git a/tests/Fixtures/replicate/text-to-speech-basic-2.json b/tests/Fixtures/replicate/text-to-speech-basic-2.json new file mode 100644 index 000000000..3a843dbcf --- /dev/null +++ b/tests/Fixtures/replicate/text-to-speech-basic-2.json @@ -0,0 +1,28 @@ +{ + "id": "gxh7q2my95rgc0ct9g7tr2w9cr", + "model": "jaaari\/kokoro-82m", + "version": "f559560eb822dc509045f3921a1921234918b91739db4bf3daab2169b71c7a13", + "input": { + "text": "Hello world!", + "voice": "af_alloy" + }, + "logs": "Processing text (12 chars) with voice='af_alloy' at speed=1.0\n", + "output": "https:\/\/replicate.delivery\/czjl\/BBTn3jvZMhIoJRGFDEVFH5NeuVnwPSJ6yCFNqB3y6wwla3yKA\/output.wav", + "data_removed": false, + "error": null, + "source": "api", + "status": "succeeded", + "created_at": "2025-11-03T23:31:23.337Z", + "started_at": "2025-11-03T23:31:23.352623999Z", + "completed_at": "2025-11-03T23:31:23.96356142Z", + "urls": { + "cancel": "https:\/\/api.replicate.com\/v1\/predictions\/gxh7q2my95rgc0ct9g7tr2w9cr\/cancel", + "get": "https:\/\/api.replicate.com\/v1\/predictions\/gxh7q2my95rgc0ct9g7tr2w9cr", + "stream": "https:\/\/stream.replicate.com\/v1\/files\/fddq-qrpzjxytfv2piya7jlz3jxfccosl5k5csjag73cue6cpphyvju6q", + "web": "https:\/\/replicate.com\/p\/gxh7q2my95rgc0ct9g7tr2w9cr" + }, + "metrics": { + "predict_time": 0.610937454, + "total_time": 0.62656142 + } +} \ No newline at end of file diff --git a/tests/Fixtures/replicate/text-to-speech-different-voice-1.json b/tests/Fixtures/replicate/text-to-speech-different-voice-1.json new file mode 100644 index 000000000..35c1698c0 --- /dev/null +++ b/tests/Fixtures/replicate/text-to-speech-different-voice-1.json @@ -0,0 +1,22 @@ +{ + "id": "ycn6jmn7dhrga0ct9g7tcerq78", + "model": "jaaari\/kokoro-82m", + "version": "f559560eb822dc509045f3921a1921234918b91739db4bf3daab2169b71c7a13", + "input": { + "text": "Testing echo voice", + "voice": "af_bella" + }, + "logs": "", + "output": null, + "data_removed": false, + "error": null, + "source": "api", + "status": "starting", + "created_at": "2025-11-03T23:31:25.676Z", + "urls": { + "cancel": "https:\/\/api.replicate.com\/v1\/predictions\/ycn6jmn7dhrga0ct9g7tcerq78\/cancel", + "get": "https:\/\/api.replicate.com\/v1\/predictions\/ycn6jmn7dhrga0ct9g7tcerq78", + "stream": "https:\/\/stream.replicate.com\/v1\/files\/fddq-adq3w32ed3kuc63337nzopuga2rarb2iiglu7gv6zivdyg6mra3a", + "web": "https:\/\/replicate.com\/p\/ycn6jmn7dhrga0ct9g7tcerq78" + } +} \ No newline at end of file diff --git a/tests/Fixtures/replicate/text-to-speech-different-voice-2.json b/tests/Fixtures/replicate/text-to-speech-different-voice-2.json new file mode 100644 index 000000000..a394a78f9 --- /dev/null +++ b/tests/Fixtures/replicate/text-to-speech-different-voice-2.json @@ -0,0 +1,28 @@ +{ + "id": "ycn6jmn7dhrga0ct9g7tcerq78", + "model": "jaaari\/kokoro-82m", + "version": "f559560eb822dc509045f3921a1921234918b91739db4bf3daab2169b71c7a13", + "input": { + "text": "Testing echo voice", + "voice": "af_bella" + }, + "logs": "Processing text (18 chars) with voice='af_bella' at speed=1.0\n", + "output": "https:\/\/replicate.delivery\/czjl\/qOcZefVidKqH9kSKfslNXX6avZ4Zl1zgpPAYZUOTfa80U7WWB\/output.wav", + "data_removed": false, + "error": null, + "source": "api", + "status": "succeeded", + "created_at": "2025-11-03T23:31:25.676Z", + "started_at": "2025-11-03T23:31:25.691010209Z", + "completed_at": "2025-11-03T23:31:25.827174994Z", + "urls": { + "cancel": "https:\/\/api.replicate.com\/v1\/predictions\/ycn6jmn7dhrga0ct9g7tcerq78\/cancel", + "get": "https:\/\/api.replicate.com\/v1\/predictions\/ycn6jmn7dhrga0ct9g7tcerq78", + "stream": "https:\/\/stream.replicate.com\/v1\/files\/fddq-adq3w32ed3kuc63337nzopuga2rarb2iiglu7gv6zivdyg6mra3a", + "web": "https:\/\/replicate.com\/p\/ycn6jmn7dhrga0ct9g7tcerq78" + }, + "metrics": { + "predict_time": 0.13616481, + "total_time": 0.151174994 + } +} \ No newline at end of file diff --git a/tests/Fixtures/replicate/text-to-speech-long-text-1.json b/tests/Fixtures/replicate/text-to-speech-long-text-1.json new file mode 100644 index 000000000..1879c8253 --- /dev/null +++ b/tests/Fixtures/replicate/text-to-speech-long-text-1.json @@ -0,0 +1,22 @@ +{ + "id": "prv1025gk1rga0ct9g7rmktdaw", + "model": "jaaari\/kokoro-82m", + "version": "f559560eb822dc509045f3921a1921234918b91739db4bf3daab2169b71c7a13", + "input": { + "text": "This is a longer piece of text to test the text-to-speech capabilities.", + "voice": "af_sky" + }, + "logs": "", + "output": null, + "data_removed": false, + "error": null, + "source": "api", + "status": "starting", + "created_at": "2025-11-03T23:31:28.024Z", + "urls": { + "cancel": "https:\/\/api.replicate.com\/v1\/predictions\/prv1025gk1rga0ct9g7rmktdaw\/cancel", + "get": "https:\/\/api.replicate.com\/v1\/predictions\/prv1025gk1rga0ct9g7rmktdaw", + "stream": "https:\/\/stream.replicate.com\/v1\/files\/fddq-amvwplfdc2znriou7sgcqus3zurxfosqod4i7hh75p4bv6zcbxqq", + "web": "https:\/\/replicate.com\/p\/prv1025gk1rga0ct9g7rmktdaw" + } +} \ No newline at end of file diff --git a/tests/Fixtures/replicate/text-to-speech-long-text-2.json b/tests/Fixtures/replicate/text-to-speech-long-text-2.json new file mode 100644 index 000000000..00db51af4 --- /dev/null +++ b/tests/Fixtures/replicate/text-to-speech-long-text-2.json @@ -0,0 +1,28 @@ +{ + "id": "prv1025gk1rga0ct9g7rmktdaw", + "model": "jaaari\/kokoro-82m", + "version": "f559560eb822dc509045f3921a1921234918b91739db4bf3daab2169b71c7a13", + "input": { + "text": "This is a longer piece of text to test the text-to-speech capabilities.", + "voice": "af_sky" + }, + "logs": "Processing text (71 chars) with voice='af_sky' at speed=1.0\n", + "output": "https:\/\/replicate.delivery\/czjl\/WjODbdmaJa7QN1HMb4DCo8lZ7mSef4WTozYnANEp1NHQ1ulVA\/output.wav", + "data_removed": false, + "error": null, + "source": "api", + "status": "succeeded", + "created_at": "2025-11-03T23:31:28.024Z", + "started_at": "2025-11-03T23:31:28.074385217Z", + "completed_at": "2025-11-03T23:31:28.685849357Z", + "urls": { + "cancel": "https:\/\/api.replicate.com\/v1\/predictions\/prv1025gk1rga0ct9g7rmktdaw\/cancel", + "get": "https:\/\/api.replicate.com\/v1\/predictions\/prv1025gk1rga0ct9g7rmktdaw", + "stream": "https:\/\/stream.replicate.com\/v1\/files\/fddq-amvwplfdc2znriou7sgcqus3zurxfosqod4i7hh75p4bv6zcbxqq", + "web": "https:\/\/replicate.com\/p\/prv1025gk1rga0ct9g7rmktdaw" + }, + "metrics": { + "predict_time": 0.611464158, + "total_time": 0.661849357 + } +} \ No newline at end of file diff --git a/tests/Fixtures/requesty/generate-text-with-a-prompt-1.json b/tests/Fixtures/requesty/generate-text-with-a-prompt-1.json new file mode 100644 index 000000000..25ff56999 --- /dev/null +++ b/tests/Fixtures/requesty/generate-text-with-a-prompt-1.json @@ -0,0 +1 @@ +{"id":"req-12345","object":"chat.completion","created":1737243487,"model":"openai/gpt-4o","choices":[{"index":0,"message":{"role":"assistant","content":"Hello! I'm an AI assistant routed through Requesty. How can I help you today?"},"logprobs":null,"finish_reason":"stop"}],"usage":{"prompt_tokens":7,"completion_tokens":19,"total_tokens":26}} diff --git a/tests/Fixtures/requesty/stream-cached-tokens-1.sse b/tests/Fixtures/requesty/stream-cached-tokens-1.sse new file mode 100644 index 000000000..271df0c55 --- /dev/null +++ b/tests/Fixtures/requesty/stream-cached-tokens-1.sse @@ -0,0 +1,4 @@ +data: {"id":"req-cache","object":"chat.completion.chunk","created":1752805596,"model":"openai/gpt-4o","choices":[{"index":0,"delta":{"role":"assistant","content":"Hello"},"finish_reason":null}]} +data: {"id":"req-cache","object":"chat.completion.chunk","created":1752805596,"model":"openai/gpt-4o","choices":[{"index":0,"delta":{"content":" there"}}]} +data: {"id":"req-cache","object":"chat.completion.chunk","created":1752805596,"model":"openai/gpt-4o","choices":[{"index":0,"finish_reason":"stop"}],"usage":{"prompt_tokens":100,"completion_tokens":10,"total_tokens":110,"prompt_tokens_details":{"cached_tokens":60}}} +data: [DONE] diff --git a/tests/Fixtures/vertex/embeddings-1.json b/tests/Fixtures/vertex/embeddings-1.json new file mode 100644 index 000000000..850b6c836 --- /dev/null +++ b/tests/Fixtures/vertex/embeddings-1.json @@ -0,0 +1,27 @@ +{ + "predictions": [ + { + "embeddings": { + "statistics": { + "truncated": false, + "token_count": 6 + }, + "values": [ + 0.0123456789, + -0.0234567890, + 0.0345678901, + -0.0456789012, + 0.0567890123, + -0.0678901234, + 0.0789012345, + -0.0890123456, + 0.0901234567, + -0.1012345678 + ] + } + } + ], + "metadata": { + "billableCharacterCount": 20 + } +} diff --git a/tests/Fixtures/vertex/generate-text-with-a-prompt-1.json b/tests/Fixtures/vertex/generate-text-with-a-prompt-1.json new file mode 100644 index 000000000..badbb1f62 --- /dev/null +++ b/tests/Fixtures/vertex/generate-text-with-a-prompt-1.json @@ -0,0 +1,22 @@ +{ + "candidates": [ + { + "content": { + "parts": [ + { + "text": "I am a large language model, trained by Google. I am an AI, and I don't have a name, feelings, or personal experiences. My purpose is to process information and respond to a wide range of prompts and questions in a helpful and informative way.\n" + } + ], + "role": "model" + }, + "finishReason": "STOP", + "avgLogprobs": -0.12800796408402293 + } + ], + "usageMetadata": { + "promptTokenCount": 4, + "candidatesTokenCount": 57, + "totalTokenCount": 61 + }, + "modelVersion": "gemini-1.5-flash" +} diff --git a/tests/Fixtures/vertex/generate-text-with-multiple-tools-1.json b/tests/Fixtures/vertex/generate-text-with-multiple-tools-1.json new file mode 100644 index 000000000..08f8a20a2 --- /dev/null +++ b/tests/Fixtures/vertex/generate-text-with-multiple-tools-1.json @@ -0,0 +1,35 @@ +{ + "candidates": [ + { + "content": { + "parts": [ + { + "functionCall": { + "name": "search_games", + "args": { + "city": "Detroit" + } + } + }, + { + "functionCall": { + "name": "get_weather", + "args": { + "city": "Detroit" + } + } + } + ], + "role": "model" + }, + "finishReason": "STOP", + "avgLogprobs": -0.15 + } + ], + "usageMetadata": { + "promptTokenCount": 150, + "candidatesTokenCount": 20, + "totalTokenCount": 170 + }, + "modelVersion": "gemini-1.5-flash" +} diff --git a/tests/Fixtures/vertex/generate-text-with-multiple-tools-2.json b/tests/Fixtures/vertex/generate-text-with-multiple-tools-2.json new file mode 100644 index 000000000..f39e52748 --- /dev/null +++ b/tests/Fixtures/vertex/generate-text-with-multiple-tools-2.json @@ -0,0 +1,22 @@ +{ + "candidates": [ + { + "content": { + "parts": [ + { + "text": "The tigers game is at 3pm today in Detroit. The weather will be 45° and cold, so you should wear a coat." + } + ], + "role": "model" + }, + "finishReason": "STOP", + "avgLogprobs": -0.12 + } + ], + "usageMetadata": { + "promptTokenCount": 200, + "candidatesTokenCount": 22, + "totalTokenCount": 222 + }, + "modelVersion": "gemini-1.5-flash" +} diff --git a/tests/Fixtures/vertex/generate-text-with-system-prompt-1.json b/tests/Fixtures/vertex/generate-text-with-system-prompt-1.json new file mode 100644 index 000000000..2b5c0966e --- /dev/null +++ b/tests/Fixtures/vertex/generate-text-with-system-prompt-1.json @@ -0,0 +1,22 @@ +{ + "candidates": [ + { + "content": { + "parts": [ + { + "text": "I am Prism, a helpful AI assistant created by echo labs." + } + ], + "role": "model" + }, + "finishReason": "STOP", + "avgLogprobs": -0.08123456789 + } + ], + "usageMetadata": { + "promptTokenCount": 17, + "candidatesTokenCount": 14, + "totalTokenCount": 31 + }, + "modelVersion": "gemini-1.5-flash" +} diff --git a/tests/Fixtures/vertex/image-detection-1.json b/tests/Fixtures/vertex/image-detection-1.json new file mode 100644 index 000000000..62029bdc2 --- /dev/null +++ b/tests/Fixtures/vertex/image-detection-1.json @@ -0,0 +1,22 @@ +{ + "candidates": [ + { + "content": { + "parts": [ + { + "text": "That's an illustration of a **diamond**. More specifically, it's a stylized, geometric representation of a diamond, often used as an icon or symbol" + } + ], + "role": "model" + }, + "finishReason": "STOP", + "avgLogprobs": -0.11 + } + ], + "usageMetadata": { + "promptTokenCount": 263, + "candidatesTokenCount": 35, + "totalTokenCount": 298 + }, + "modelVersion": "gemini-1.5-flash" +} diff --git a/tests/Fixtures/vertex/stream-basic-1.sse b/tests/Fixtures/vertex/stream-basic-1.sse new file mode 100644 index 000000000..2ea3610ca --- /dev/null +++ b/tests/Fixtures/vertex/stream-basic-1.sse @@ -0,0 +1,8 @@ +data: {"candidates":[{"content":{"parts":[{"text":"Hello"}],"role":"model"},"finishReason":null}],"usageMetadata":{"promptTokenCount":10,"candidatesTokenCount":1,"totalTokenCount":11},"modelVersion":"gemini-1.5-flash"} + +data: {"candidates":[{"content":{"parts":[{"text":", I am"}],"role":"model"},"finishReason":null}],"usageMetadata":{"promptTokenCount":10,"candidatesTokenCount":3,"totalTokenCount":13},"modelVersion":"gemini-1.5-flash"} + +data: {"candidates":[{"content":{"parts":[{"text":" a helpful AI assistant."}],"role":"model"},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":10,"candidatesTokenCount":8,"totalTokenCount":18},"modelVersion":"gemini-1.5-flash"} + +data: [DONE] + diff --git a/tests/Fixtures/vertex/structured-response-1.json b/tests/Fixtures/vertex/structured-response-1.json new file mode 100644 index 000000000..d5d861271 --- /dev/null +++ b/tests/Fixtures/vertex/structured-response-1.json @@ -0,0 +1,22 @@ +{ + "candidates": [ + { + "content": { + "parts": [ + { + "text": "{\"name\":\"John Doe\",\"age\":30,\"email\":\"john.doe@example.com\"}" + } + ], + "role": "model" + }, + "finishReason": "STOP", + "avgLogprobs": -0.08 + } + ], + "usageMetadata": { + "promptTokenCount": 50, + "candidatesTokenCount": 25, + "totalTokenCount": 75 + }, + "modelVersion": "gemini-1.5-flash" +} diff --git a/tests/Fixtures/z/stream-basic-text-1.sse b/tests/Fixtures/z/stream-basic-text-1.sse new file mode 100644 index 000000000..c21e4f07f --- /dev/null +++ b/tests/Fixtures/z/stream-basic-text-1.sse @@ -0,0 +1,36 @@ +data: {"id":"chatcmpl-123","object":"chat.completion.chunk","created":1752805596,"model":"glm-5","choices":[{"index":0,"delta":{"role":"assistant","content":""},"finish_reason":null}]} +data: {"id":"chatcmpl-123","object":"chat.completion.chunk","created":1752805596,"model":"glm-5","choices":[{"index":0,"delta":{"content":"I"}}]} +data: {"id":"chatcmpl-123","object":"chat.completion.chunk","created":1752805596,"model":"glm-5","choices":[{"index":0,"delta":{"content":"'m"}}]} +data: {"id":"chatcmpl-123","object":"chat.completion.chunk","created":1752805596,"model":"glm-5","choices":[{"index":0,"delta":{"content":" a"}}]} +data: {"id":"chatcmpl-123","object":"chat.completion.chunk","created":1752805596,"model":"glm-5","choices":[{"index":0,"delta":{"content":" Z"}}]} +data: {"id":"chatcmpl-123","object":"chat.completion.chunk","created":1752805596,"model":"glm-5","choices":[{"index":0,"delta":{"content":" AI"}}]} +data: {"id":"chatcmpl-123","object":"chat.completion.chunk","created":1752805596,"model":"glm-5","choices":[{"index":0,"delta":{"content":" assistant"}}]} +data: {"id":"chatcmpl-123","object":"chat.completion.chunk","created":1752805596,"model":"glm-5","choices":[{"index":0,"delta":{"content":" created"}}]} +data: {"id":"chatcmpl-123","object":"chat.completion.chunk","created":1752805596,"model":"glm-5","choices":[{"index":0,"delta":{"content":" by"}}]} +data: {"id":"chatcmpl-123","object":"chat.completion.chunk","created":1752805596,"model":"glm-5","choices":[{"index":0,"delta":{"content":" Z"}}]} +data: {"id":"chatcmpl-123","object":"chat.completion.chunk","created":1752805596,"model":"glm-5","choices":[{"index":0,"delta":{"content":"."}}]} +data: {"id":"chatcmpl-123","object":"chat.completion.chunk","created":1752805596,"model":"glm-5","choices":[{"index":0,"delta":{"content":" My"}}]} +data: {"id":"chatcmpl-123","object":"chat.completion.chunk","created":1752805596,"model":"glm-5","choices":[{"index":0,"delta":{"content":" goal"}}]} +data: {"id":"chatcmpl-123","object":"chat.completion.chunk","created":1752805596,"model":"glm-5","choices":[{"index":0,"delta":{"content":" is"}}]} +data: {"id":"chatcmpl-123","object":"chat.completion.chunk","created":1752805596,"model":"glm-5","choices":[{"index":0,"delta":{"content":" to"}}]} +data: {"id":"chatcmpl-123","object":"chat.completion.chunk","created":1752805596,"model":"glm-5","choices":[{"index":0,"delta":{"content":" provide"}}]} +data: {"id":"chatcmpl-123","object":"chat.completion.chunk","created":1752805596,"model":"glm-5","choices":[{"index":0,"delta":{"content":" helpful"}}]} +data: {"id":"chatcmpl-123","object":"chat.completion.chunk","created":1752805596,"model":"glm-5","choices":[{"index":0,"delta":{"content":","}}]} +data: {"id":"chatcmpl-123","object":"chat.completion.chunk","created":1752805596,"model":"glm-5","choices":[{"index":0,"delta":{"content":" accurate"}}]} +data: {"id":"chatcmpl-123","object":"chat.completion.chunk","created":1752805596,"model":"glm-5","choices":[{"index":0,"delta":{"content":","}}]} +data: {"id":"chatcmpl-123","object":"chat.completion.chunk","created":1752805596,"model":"glm-5","choices":[{"index":0,"delta":{"content":" and"}}]} +data: {"id":"chatcmpl-123","object":"chat.completion.chunk","created":1752805596,"model":"glm-5","choices":[{"index":0,"delta":{"content":" engaging"}}]} +data: {"id":"chatcmpl-123","object":"chat.completion.chunk","created":1752805596,"model":"glm-5","choices":[{"index":0,"delta":{"content":" answers"}}]} +data: {"id":"chatcmpl-123","object":"chat.completion.chunk","created":1752805596,"model":"glm-5","choices":[{"index":0,"delta":{"content":" to"}}]} +data: {"id":"chatcmpl-123","object":"chat.completion.chunk","created":1752805596,"model":"glm-5","choices":[{"index":0,"delta":{"content":" your"}}]} +data: {"id":"chatcmpl-123","object":"chat.completion.chunk","created":1752805596,"model":"glm-5","choices":[{"index":0,"delta":{"content":" questions"}}]} +data: {"id":"chatcmpl-123","object":"chat.completion.chunk","created":1752805596,"model":"glm-5","choices":[{"index":0,"delta":{"content":"."}}]} +data: {"id":"chatcmpl-123","object":"chat.completion.chunk","created":1752805596,"model":"glm-5","choices":[{"index":0,"delta":{"content":" How"}}]} +data: {"id":"chatcmpl-123","object":"chat.completion.chunk","created":1752805596,"model":"glm-5","choices":[{"index":0,"delta":{"content":" can"}}]} +data: {"id":"chatcmpl-123","object":"chat.completion.chunk","created":1752805596,"model":"glm-5","choices":[{"index":0,"delta":{"content":" I"}}]} +data: {"id":"chatcmpl-123","object":"chat.completion.chunk","created":1752805596,"model":"glm-5","choices":[{"index":0,"delta":{"content":" help"}}]} +data: {"id":"chatcmpl-123","object":"chat.completion.chunk","created":1752805596,"model":"glm-5","choices":[{"index":0,"delta":{"content":" you"}}]} +data: {"id":"chatcmpl-123","object":"chat.completion.chunk","created":1752805596,"model":"glm-5","choices":[{"index":0,"delta":{"content":" today"}}]} +data: {"id":"chatcmpl-123","object":"chat.completion.chunk","created":1752805596,"model":"glm-5","choices":[{"index":0,"delta":{"content":"?"}}]} +data: {"id":"chatcmpl-123","object":"chat.completion.chunk","created":1752805596,"model":"glm-5","choices":[{"index":0,"finish_reason":"stop"}],"usage":{"prompt_tokens":7,"completion_tokens":163,"total_tokens":170}} +data: [DONE] diff --git a/tests/Fixtures/z/stream-cached-tokens-1.sse b/tests/Fixtures/z/stream-cached-tokens-1.sse new file mode 100644 index 000000000..83ecda605 --- /dev/null +++ b/tests/Fixtures/z/stream-cached-tokens-1.sse @@ -0,0 +1,4 @@ +data: {"id":"chatcmpl-cache","object":"chat.completion.chunk","created":1752805596,"model":"glm-5","choices":[{"index":0,"delta":{"role":"assistant","content":"Hello"},"finish_reason":null}]} +data: {"id":"chatcmpl-cache","object":"chat.completion.chunk","created":1752805596,"model":"glm-5","choices":[{"index":0,"delta":{"content":" there"}}]} +data: {"id":"chatcmpl-cache","object":"chat.completion.chunk","created":1752805596,"model":"glm-5","choices":[{"index":0,"finish_reason":"stop"}],"usage":{"prompt_tokens":100,"completion_tokens":10,"total_tokens":110,"prompt_tokens_details":{"cached_tokens":60}}} +data: [DONE] diff --git a/tests/Fixtures/z/stream-max-tokens-1.sse b/tests/Fixtures/z/stream-max-tokens-1.sse new file mode 100644 index 000000000..6e83c52f9 --- /dev/null +++ b/tests/Fixtures/z/stream-max-tokens-1.sse @@ -0,0 +1,14 @@ +data: {"id":"chatcmpl-123","object":"chat.completion.chunk","created":1752805596,"model":"glm-5","choices":[{"index":0,"delta":{"role":"assistant","content":""},"finish_reason":null}]} +data: {"id":"chatcmpl-123","object":"chat.completion.chunk","created":1752805596,"model":"glm-5","choices":[{"index":0,"delta":{"content":"This"}}]} +data: {"id":"chatcmpl-123","object":"chat.completion.chunk","created":1752805596,"model":"glm-5","choices":[{"index":0,"delta":{"content":" is"}}]} +data: {"id":"chatcmpl-123","object":"chat.completion.chunk","created":1752805596,"model":"glm-5","choices":[{"index":0,"delta":{"content":" a"}}]} +data: {"id":"chatcmpl-123","object":"chat.completion.chunk","created":1752805596,"model":"glm-5","choices":[{"index":0,"delta":{"content":" truncated"}}]} +data: {"id":"chatcmpl-123","object":"chat.completion.chunk","created":1752805596,"model":"glm-5","choices":[{"index":0,"delta":{"content":" response"}}]} +data: {"id":"chatcmpl-123","object":"chat.completion.chunk","created":1752805596,"model":"glm-5","choices":[{"index":0,"delta":{"content":" due"}}]} +data: {"id":"chatcmpl-123","object":"chat.completion.chunk","created":1752805596,"model":"glm-5","choices":[{"index":0,"delta":{"content":" to"}}]} +data: {"id":"chatcmpl-123","object":"chat.completion.chunk","created":1752805596,"model":"glm-5","choices":[{"index":0,"delta":{"content":" max"}}]} +data: {"id":"chatcmpl-123","object":"chat.completion.chunk","created":1752805596,"model":"glm-5","choices":[{"index":0,"delta":{"content":" tokens"}}]} +data: {"id":"chatcmpl-123","object":"chat.completion.chunk","created":1752805596,"model":"glm-5","choices":[{"index":0,"delta":{"content":" limit"}}]} +data: {"id":"chatcmpl-123","object":"chat.completion.chunk","created":1752805596,"model":"glm-5","choices":[{"index":0,"delta":{"content":"."}}]} +data: {"id":"chatcmpl-123","object":"chat.completion.chunk","created":1752805596,"model":"glm-5","choices":[{"index":0,"finish_reason":"length"}],"usage":{"prompt_tokens":50,"completion_tokens":10,"total_tokens":60}} +data: [DONE] diff --git a/tests/Fixtures/z/stream-system-prompt-1.sse b/tests/Fixtures/z/stream-system-prompt-1.sse new file mode 100644 index 000000000..9ad09b414 --- /dev/null +++ b/tests/Fixtures/z/stream-system-prompt-1.sse @@ -0,0 +1,13 @@ +data: {"id":"chatcmpl-123","object":"chat.completion.chunk","created":1752805596,"model":"glm-5","choices":[{"index":0,"delta":{"role":"assistant","content":""},"finish_reason":null}]} +data: {"id":"chatcmpl-123","object":"chat.completion.chunk","created":1752805596,"model":"glm-5","choices":[{"index":0,"delta":{"content":"I"}}]} +data: {"id":"chatcmpl-123","object":"chat.completion.chunk","created":1752805596,"model":"glm-5","choices":[{"index":0,"delta":{"content":"'m"}}]} +data: {"id":"chatcmpl-123","object":"chat.completion.chunk","created":1752805596,"model":"glm-5","choices":[{"index":0,"delta":{"content":" here"}}]} +data: {"id":"chatcmpl-123","object":"chat.completion.chunk","created":1752805596,"model":"glm-5","choices":[{"index":0,"delta":{"content":" to"}}]} +data: {"id":"chatcmpl-123","object":"chat.completion.chunk","created":1752805596,"model":"glm-5","choices":[{"index":0,"delta":{"content":" help"}}]} +data: {"id":"chatcmpl-123","object":"chat.completion.chunk","created":1752805596,"model":"glm-5","choices":[{"index":0,"delta":{"content":" you"}}]} +data: {"id":"chatcmpl-123","object":"chat.completion.chunk","created":1752805596,"model":"glm-5","choices":[{"index":0,"delta":{"content":" with"}}]} +data: {"id":"chatcmpl-123","object":"chat.completion.chunk","created":1752805596,"model":"glm-5","choices":[{"index":0,"delta":{"content":" your"}}]} +data: {"id":"chatcmpl-123","object":"chat.completion.chunk","created":1752805596,"model":"glm-5","choices":[{"index":0,"delta":{"content":" request"}}]} +data: {"id":"chatcmpl-123","object":"chat.completion.chunk","created":1752805596,"model":"glm-5","choices":[{"index":0,"delta":{"content":"!"}}]} +data: {"id":"chatcmpl-123","object":"chat.completion.chunk","created":1752805596,"model":"glm-5","choices":[{"index":0,"finish_reason":"stop"}],"usage":{"prompt_tokens":29,"completion_tokens":10,"total_tokens":39,"prompt_tokens_details":{"cached_tokens":27}}} +data: [DONE] diff --git a/tests/Fixtures/z/stream-text-file-from-url-1.sse b/tests/Fixtures/z/stream-text-file-from-url-1.sse new file mode 100644 index 000000000..b383c7532 --- /dev/null +++ b/tests/Fixtures/z/stream-text-file-from-url-1.sse @@ -0,0 +1,83 @@ +data: {"id":"chatcmpl-123","object":"chat.completion.chunk","created":1752805596,"model":"z-model.v","choices":[{"index":0,"delta":{"role":"assistant","content":""},"finish_reason":null}]} +data: {"id":"chatcmpl-123","object":"chat.completion.chunk","created":1752805596,"model":"z-model.v","choices":[{"index":0,"delta":{"content":"\n"}}]} +data: {"id":"chatcmpl-123","object":"chat.completion.chunk","created":1752805596,"model":"z-model.v","choices":[{"index":0,"delta":{"content":"demo"}}]} +data: {"id":"chatcmpl-123","object":"chat.completion.chunk","created":1752805596,"model":"z-model.v","choices":[{"index":0,"delta":{"content":"2"}}]} +data: {"id":"chatcmpl-123","object":"chat.completion.chunk","created":1752805596,"model":"z-model.v","choices":[{"index":0,"delta":{"content":"."}}]} +data: {"id":"chatcmpl-123","object":"chat.completion.chunk","created":1752805596,"model":"z-model.v","choices":[{"index":0,"delta":{"content":"txt"}}]} +data: {"id":"chatcmpl-123","object":"chat.completion.chunk","created":1752805596,"model":"z-model.v","choices":[{"index":0,"delta":{"content":" 文件"}}]} +data: {"id":"chatcmpl-123","object":"chat.completion.chunk","created":1752805596,"model":"z-model.v","choices":[{"index":0,"delta":{"content":"主要"}}]} +data: {"id":"chatcmpl-123","object":"chat.completion.chunk","created":1752805596,"model":"z-model.v","choices":[{"index":0,"delta":{"content":"介绍了"}}]} +data: {"id":"chatcmpl-123","object":"chat.completion.chunk","created":1752805596,"model":"z-model.v","choices":[{"index":0,"delta":{"content":"'"}}]} +data: {"id":"chatcmpl-123","object":"chat.completion.chunk","created":1752805596,"model":"z-model.v","choices":[{"index":0,"delta":{"content":"GLM"}}]} +data: {"id":"chatcmpl-123","object":"chat.completion.chunk","created":1752805596,"model":"z-model.v","choices":[{"index":0,"delta":{"content":" PPT"}}]} +data: {"id":"chatcmpl-123","object":"chat.completion.chunk","created":1752805596,"model":"z-model.v","choices":[{"index":0,"delta":{"content":"'"}}]} +data: {"id":"chatcmpl-123","object":"chat.completion.chunk","created":1752805596,"model":"z-model.v","choices":[{"index":0,"delta":{"content":"这一"}}]} +data: {"id":"chatcmpl-123","object":"chat.completion.chunk","created":1752805596,"model":"z-model.v","choices":[{"index":0,"delta":{"content":"工具"}}]} +data: {"id":"chatcmpl-123","object":"chat.completion.chunk","created":1752805596,"model":"z-model.v","choices":[{"index":0,"delta":{"content":"."}}]} +data: {"id":"chatcmpl-123","object":"chat.completion.chunk","created":1752805596,"model":"z-model.v","choices":[{"index":0,"delta":{"content":"内容"}}]} +data: {"id":"chatcmpl-123","object":"chat.completion.chunk","created":1752805596,"model":"z-model.v","choices":[{"index":0,"delta":{"content":"显示"}}]} +data: {"id":"chatcmpl-123","object":"chat.completion.chunk","created":1752805596,"model":"z-model.v","choices":[{"index":0,"delta":{"content":","}}]} +data: {"id":"chatcmpl-123","object":"chat.completion.chunk","created":1752805596,"model":"z-model.v","choices":[{"index":0,"delta":{"content":"GLM"}}]} +data: {"id":"chatcmpl-123","object":"chat.completion.chunk","created":1752805596,"model":"z-model.v","choices":[{"index":0,"delta":{"content":" PPT"}}]} +data: {"id":"chatcmpl-123","object":"chat.completion.chunk","created":1752805596,"model":"z-model.v","choices":[{"index":0,"delta":{"content":" 是"}}]} +data: {"id":"chatcmpl-123","object":"chat.completion.chunk","created":1752805596,"model":"z-model.v","choices":[{"index":0,"delta":{"content":"面向"}}]} +data: {"id":"chatcmpl-123","object":"chat.completion.chunk","created":1752805596,"model":"z-model.v","choices":[{"index":0,"delta":{"content":"职场"}}]} +data: {"id":"chatcmpl-123","object":"chat.completion.chunk","created":1752805596,"model":"z-model.v","choices":[{"index":0,"delta":{"content":"人与"}}]} +data: {"id":"chatcmpl-123","object":"chat.completion.chunk","created":1752805596,"model":"z-model.v","choices":[{"index":0,"delta":{"content":"创作者"}}]} +data: {"id":"chatcmpl-123","object":"chat.completion.chunk","created":1752805596,"model":"z-model.v","choices":[{"index":0,"delta":{"content":"的"}}]} +data: {"id":"chatcmpl-123","object":"chat.completion.chunk","created":1752805596,"model":"z-model.v","choices":[{"index":0,"delta":{"content":"新一代"}}]} +data: {"id":"chatcmpl-123","object":"chat.completion.chunk","created":1752805596,"model":"z-model.v","choices":[{"index":0,"delta":{"content":"智能"}}]} +data: {"id":"chatcmpl-123","object":"chat.completion.chunk","created":1752805596,"model":"z-model.v","choices":[{"index":0,"delta":{"content":"工具"}}]} +data: {"id":"chatcmpl-123","object":"chat.completion.chunk","created":1752805596,"model":"z-model.v","choices":[{"index":0,"delta":{"content":","}}]} +data: {"id":"chatcmpl-123","object":"chat.completion.chunk","created":1752805596,"model":"z-model.v","choices":[{"index":0,"delta":{"content":"基于"}}]} +data: {"id":"chatcmpl-123","object":"chat.completion.chunk","created":1752805596,"model":"z-model.v","choices":[{"index":0,"delta":{"content":" GLM"}}]} +data: {"id":"chatcmpl-123","object":"chat.completion.chunk","created":1752805596,"model":"z-model.v","choices":[{"index":0,"delta":{"content":" 大模型"}}]} +data: {"id":"chatcmpl-123","object":"chat.completion.chunk","created":1752805596,"model":"z-model.v","choices":[{"index":0,"delta":{"content":"深度"}}]} +data: {"id":"chatcmpl-123","object":"chat.completion.chunk","created":1752805596,"model":"z-model.v","choices":[{"index":0,"delta":{"content":"驱动"}}]} +data: {"id":"chatcmpl-123","object":"chat.completion.chunk","created":1752805596,"model":"z-model.v","choices":[{"index":0,"delta":{"content":","}}]} +data: {"id":"chatcmpl-123","object":"chat.completion.chunk","created":1752805596,"model":"z-model.v","choices":[{"index":0,"delta":{"content":"区别"}}]} +data: {"id":"chatcmpl-123","object":"chat.completion.chunk","created":1752805596,"model":"z-model.v","choices":[{"index":0,"delta":{"content":"于"}}]} +data: {"id":"chatcmpl-123","object":"chat.completion.chunk","created":1752805596,"model":"z-model.v","choices":[{"index":0,"delta":{"content":"传统"}}]} +data: {"id":"chatcmpl-123","object":"chat.completion.chunk","created":1752805596,"model":"z-model.v","choices":[{"index":0,"delta":{"content":"工程化"}}]} +data: {"id":"chatcmpl-123","object":"chat.completion.chunk","created":1752805596,"model":"z-model.v","choices":[{"index":0,"delta":{"content":"拼接"}}]} +data: {"id":"chatcmpl-123","object":"chat.completion.chunk","created":1752805596,"model":"z-model.v","choices":[{"index":0,"delta":{"content":"方案"}}]} +data: {"id":"chatcmpl-123","object":"chat.completion.chunk","created":1752805596,"model":"z-model.v","choices":[{"index":0,"delta":{"content":","}}]} +data: {"id":"chatcmpl-123","object":"chat.completion.chunk","created":1752805596,"model":"z-model.v","choices":[{"index":0,"delta":{"content":"能"}}]} +data: {"id":"chatcmpl-123","object":"chat.completion.chunk","created":1752805596,"model":"z-model.v","choices":[{"index":0,"delta":{"content":"实现"}}]} +data: {"id":"chatcmpl-123","object":"chat.completion.chunk","created":1752805596,"model":"z-model.v","choices":[{"index":0,"delta":{"content":"从"}}]} +data: {"id":"chatcmpl-123","object":"chat.completion.chunk","created":1752805596,"model":"z-model.v","choices":[{"index":0,"delta":{"content":"自然"}}]} +data: {"id":"chatcmpl-123","object":"chat.completion.chunk","created":1752805596,"model":"z-model.v","choices":[{"index":0,"delta":{"content":"语言"}}]} +data: {"id":"chatcmpl-123","object":"chat.completion.chunk","created":1752805596,"model":"z-model.v","choices":[{"index":0,"delta":{"content":"指令"}}]} +data: {"id":"chatcmpl-123","object":"chat.completion.chunk","created":1752805596,"model":"z-model.v","choices":[{"index":0,"delta":{"content":"到"}}]} +data: {"id":"chatcmpl-123","object":"chat.completion.chunk","created":1752805596,"model":"z-model.v","choices":[{"index":0,"delta":{"content":"可交互"}}]} +data: {"id":"chatcmpl-123","object":"chat.completion.chunk","created":1752805596,"model":"z-model.v","choices":[{"index":0,"delta":{"content":"幻灯片"}}]} +data: {"id":"chatcmpl-123","object":"chat.completion.chunk","created":1752805596,"model":"z-model.v","choices":[{"index":0,"delta":{"content":"的"}}]} +data: {"id":"chatcmpl-123","object":"chat.completion.chunk","created":1752805596,"model":"z-model.v","choices":[{"index":0,"delta":{"content":"一键"}}]} +data: {"id":"chatcmpl-123","object":"chat.completion.chunk","created":1752805596,"model":"z-model.v","choices":[{"index":0,"delta":{"content":"生成"}}]} +data: {"id":"chatcmpl-123","object":"chat.completion.chunk","created":1752805596,"model":"z-model.v","choices":[{"index":0,"delta":{"content":","}}]} +data: {"id":"chatcmpl-123","object":"chat.completion.chunk","created":1752805596,"model":"z-model.v","choices":[{"index":0,"delta":{"content":"深度"}}]} +data: {"id":"chatcmpl-123","object":"chat.completion.chunk","created":1752805596,"model":"z-model.v","choices":[{"index":0,"delta":{"content":"融合"}}]} +data: {"id":"chatcmpl-123","object":"chat.completion.chunk","created":1752805596,"model":"z-model.v","choices":[{"index":0,"delta":{"content":"内容"}}]} +data: {"id":"chatcmpl-123","object":"chat.completion.chunk","created":1752805596,"model":"z-model.v","choices":[{"index":0,"delta":{"content":"生成"}}]} +data: {"id":"chatcmpl-123","object":"chat.completion.chunk","created":1752805596,"model":"z-model.v","choices":[{"index":0,"delta":{"content":"与"}}]} +data: {"id":"chatcmpl-123","object":"chat.completion.chunk","created":1752805596,"model":"z-model.v","choices":[{"index":0,"delta":{"content":"设计"}}]} +data: {"id":"chatcmpl-123","object":"chat.completion.chunk","created":1752805596,"model":"z-model.v","choices":[{"index":0,"delta":{"content":"规范"}}]} +data: {"id":"chatcmpl-123","object":"chat.completion.chunk","created":1752805596,"model":"z-model.v","choices":[{"index":0,"delta":{"content":","}}]} +data: {"id":"chatcmpl-123","object":"chat.completion.chunk","created":1752805596,"model":"z-model.v","choices":[{"index":0,"delta":{"content":"可"}}]} +data: {"id":"chatcmpl-123","object":"chat.completion.chunk","created":1752805596,"model":"z-model.v","choices":[{"index":0,"delta":{"content":"快速"}}]} +data: {"id":"chatcmpl-123","object":"chat.completion.chunk","created":1752805596,"model":"z-model.v","choices":[{"index":0,"delta":{"content":"交付"}}]} +data: {"id":"chatcmpl-123","object":"chat.completion.chunk","created":1752805596,"model":"z-model.v","choices":[{"index":0,"delta":{"content":"专业"}}]} +data: {"id":"chatcmpl-123","object":"chat.completion.chunk","created":1752805596,"model":"z-model.v","choices":[{"index":0,"delta":{"content":"级"}}]} +data: {"id":"chatcmpl-123","object":"chat.completion.chunk","created":1752805596,"model":"z-model.v","choices":[{"index":0,"delta":{"content":"作品"}}]} +data: {"id":"chatcmpl-123","object":"chat.completion.chunk","created":1752805596,"model":"z-model.v","choices":[{"index":0,"delta":{"content":","}}]} +data: {"id":"chatcmpl-123","object":"chat.completion.chunk","created":1752805596,"model":"z-model.v","choices":[{"index":0,"delta":{"content":"降低"}}]} +data: {"id":"chatcmpl-123","object":"chat.completion.chunk","created":1752805596,"model":"z-model.v","choices":[{"index":0,"delta":{"content":"设计"}}]} +data: {"id":"chatcmpl-123","object":"chat.completion.chunk","created":1752805596,"model":"z-model.v","choices":[{"index":0,"delta":{"content":"门槛"}}]} +data: {"id":"chatcmpl-123","object":"chat.completion.chunk","created":1752805596,"model":"z-model.v","choices":[{"index":0,"delta":{"content":","}}]} +data: {"id":"chatcmpl-123","object":"chat.completion.chunk","created":1752805596,"model":"z-model.v","choices":[{"index":0,"delta":{"content":"提升"}}]} +data: {"id":"chatcmpl-123","object":"chat.completion.chunk","created":1752805596,"model":"z-model.v","choices":[{"index":0,"delta":{"content":"内容"}}]} +data: {"id":"chatcmpl-123","object":"chat.completion.chunk","created":1752805596,"model":"z-model.v","choices":[{"index":0,"delta":{"content":"生产"}}]} +data: {"id":"chatcmpl-123","object":"chat.completion.chunk","created":1752805596,"model":"z-model.v","choices":[{"index":0,"delta":{"content":"效率"}}]} +data: {"id":"chatcmpl-123","object":"chat.completion.chunk","created":1752805596,"model":"z-model.v","choices":[{"index":0,"delta":{"content":"。"}}]} +data: {"id":"chatcmpl-123","object":"chat.completion.chunk","created":1752805596,"model":"z-model.v","choices":[{"index":0,"finish_reason":"stop"}],"usage":{"prompt_tokens":1175,"completion_tokens":246,"total_tokens":1421,"prompt_tokens_details":{"cached_tokens":4}}} +data: [DONE] diff --git a/tests/Fixtures/z/stream-text-image-from-url-1.sse b/tests/Fixtures/z/stream-text-image-from-url-1.sse new file mode 100644 index 000000000..b151302d9 --- /dev/null +++ b/tests/Fixtures/z/stream-text-image-from-url-1.sse @@ -0,0 +1,89 @@ +data: {"id":"chatcmpl-123","object":"chat.completion.chunk","created":1752805596,"model":"z-model.v","choices":[{"index":0,"delta":{"role":"assistant","content":""},"finish_reason":null}]} +data: {"id":"chatcmpl-123","object":"chat.completion.chunk","created":1752805596,"model":"z-model.v","choices":[{"index":0,"delta":{"content":"\n"}}]} +data: {"id":"chatcmpl-123","object":"chat.completion.chunk","created":1752805596,"model":"z-model.v","choices":[{"index":0,"delta":{"content":"This"}}]} +data: {"id":"chatcmpl-123","object":"chat.completion.chunk","created":1752805596,"model":"z-model.v","choices":[{"index":0,"delta":{"content":" image"}}]} +data: {"id":"chatcmpl-123","object":"chat.completion.chunk","created":1752805596,"model":"z-model.v","choices":[{"index":0,"delta":{"content":" is"}}]} +data: {"id":"chatcmpl-123","object":"chat.completion.chunk","created":1752805596,"model":"z-model.v","choices":[{"index":0,"delta":{"content":" a"}}]} +data: {"id":"chatcmpl-123","object":"chat.completion.chunk","created":1752805596,"model":"z-model.v","choices":[{"index":0,"delta":{"content":" **"}}]} +data: {"id":"chatcmpl-123","object":"chat.completion.chunk","created":1752805596,"model":"z-model.v","choices":[{"index":0,"delta":{"content":"stylized"}}]} +data: {"id":"chatcmpl-123","object":"chat.completion.chunk","created":1752805596,"model":"z-model.v","choices":[{"index":0,"delta":{"content":" black"}}]} +data: {"id":"chatcmpl-123","object":"chat.completion.chunk","created":1752805596,"model":"z-model.v","choices":[{"index":0,"delta":{"content":" -"}}]} +data: {"id":"chatcmpl-123","object":"chat.completion.chunk","created":1752805596,"model":"z-model.v","choices":[{"index":0,"delta":{"content":" and"}}]} +data: {"id":"chatcmpl-123","object":"chat.completion.chunk","created":1752805596,"model":"z-model.v","choices":[{"index":0,"delta":{"content":" -"}}]} +data: {"id":"chatcmpl-123","object":"chat.completion.chunk","created":1752805596,"model":"z-model.v","choices":[{"index":0,"delta":{"content":" white"}}]} +data: {"id":"chatcmpl-123","object":"chat.completion.chunk","created":1752805596,"model":"z-model.v","choices":[{"index":0,"delta":{"content":" illustration"}}]} +data: {"id":"chatcmpl-123","object":"chat.completion.chunk","created":1752805596,"model":"z-model.v","choices":[{"index":0,"delta":{"content":" of"}}]} +data: {"id":"chatcmpl-123","object":"chat.completion.chunk","created":1752805596,"model":"z-model.v","choices":[{"index":0,"delta":{"content":" a"}}]} +data: {"id":"chatcmpl-123","object":"chat.completion.chunk","created":1752805596,"model":"z-model.v","choices":[{"index":0,"delta":{"content":" diamond"}}]} +data: {"id":"chatcmpl-123","object":"chat.completion.chunk","created":1752805596,"model":"z-model.v","choices":[{"index":0,"delta":{"content":" ("}}]} +data: {"id":"chatcmpl-123","object":"chat.completion.chunk","created":1752805596,"model":"z-model.v","choices":[{"index":0,"delta":{"content":"gemstone"}}]} +data: {"id":"chatcmpl-123","object":"chat.completion.chunk","created":1752805596,"model":"z-model.v","choices":[{"index":0,"delta":{"content":")**"}}]} +data: {"id":"chatcmpl-123","object":"chat.completion.chunk","created":1752805596,"model":"z-model.v","choices":[{"index":0,"delta":{"content":"."}}]} +data: {"id":"chatcmpl-123","object":"chat.completion.chunk","created":1752805596,"model":"z-model.v","choices":[{"index":0,"delta":{"content":" It"}}]} +data: {"id":"chatcmpl-123","object":"chat.completion.chunk","created":1752805596,"model":"z-model.v","choices":[{"index":0,"delta":{"content":" features"}}]} +data: {"id":"chatcmpl-123","object":"chat.completion.chunk","created":1752805596,"model":"z-model.v","choices":[{"index":0,"delta":{"content":" characteristic"}}]} +data: {"id":"chatcmpl-123","object":"chat.completion.chunk","created":1752805596,"model":"z-model.v","choices":[{"index":0,"delta":{"content":" geometric"}}]} +data: {"id":"chatcmpl-123","object":"chat.completion.chunk","created":1752805596,"model":"z-model.v","choices":[{"index":0,"delta":{"content":" shape"}}]} +data: {"id":"chatcmpl-123","object":"chat.completion.chunk","created":1752805596,"model":"z-model.v","choices":[{"index":0,"delta":{"content":" of"}}]} +data: {"id":"chatcmpl-123","object":"chat.completion.chunk","created":1752805596,"model":"z-model.v","choices":[{"index":0,"delta":{"content":" a"}}]} +data: {"id":"chatcmpl-123","object":"chat.completion.chunk","created":1752805596,"model":"z-model.v","choices":[{"index":0,"delta":{"content":" diamond"}}]} +data: {"id":"chatcmpl-123","object":"chat.completion.chunk","created":1752805596,"model":"z-model.v","choices":[{"index":0,"delta":{"content":","}}]} +data: {"id":"chatcmpl-123","object":"chat.completion.chunk","created":1752805596,"model":"z-model.v","choices":[{"index":0,"delta":{"content":" with"}}]} +data: {"id":"chatcmpl-123","object":"chat.completion.chunk","created":1752805596,"model":"z-model.v","choices":[{"index":0,"delta":{"content":" triangular"}}]} +data: {"id":"chatcmpl-123","object":"chat.completion.chunk","created":1752805596,"model":"z-model.v","choices":[{"index":0,"delta":{"content":" facets"}}]} +data: {"id":"chatcmpl-123","object":"chat.completion.chunk","created":1752805596,"model":"z-model.v","choices":[{"index":0,"delta":{"content":" ("}}]} +data: {"id":"chatcmpl-123","object":"chat.completion.chunk","created":1752805596,"model":"z-model.v","choices":[{"index":0,"delta":{"content":"both"}}]} +data: {"id":"chatcmpl-123","object":"chat.completion.chunk","created":1752805596,"model":"z-model.v","choices":[{"index":0,"delta":{"content":" on"}}]} +data: {"id":"chatcmpl-123","object":"chat.completion.chunk","created":1752805596,"model":"z-model.v","choices":[{"index":0,"delta":{"content":" upper"}}]} +data: {"id":"chatcmpl-123","object":"chat.completion.chunk","created":1752805596,"model":"z-model.v","choices":[{"index":0,"delta":{"content":" and"}}]} +data: {"id":"chatcmpl-123","object":"chat.completion.chunk","created":1752805596,"model":"z-model.v","choices":[{"index":0,"delta":{"content":" lower"}}]} +data: {"id":"chatcmpl-123","object":"chat.completion.chunk","created":1752805596,"model":"z-model.v","choices":[{"index":0,"delta":{"content":" portions"}}]} +data: {"id":"chatcmpl-123","object":"chat.completion.chunk","created":1752805596,"model":"z-model.v","choices":[{"index":0,"delta":{"content":")"}}]} +data: {"id":"chatcmpl-123","object":"chat.completion.chunk","created":1752805596,"model":"z-model.v","choices":[{"index":0,"delta":{"content":" and"}}]} +data: {"id":"chatcmpl-123","object":"chat.completion.chunk","created":1752805596,"model":"z-model.v","choices":[{"index":0,"delta":{"content":" bold"}}]} +data: {"id":"chatcmpl-123","object":"chat.completion.chunk","created":1752805596,"model":"z-model.v","choices":[{"index":0,"delta":{"content":" black"}}]} +data: {"id":"chatcmpl-123","object":"chat.completion.chunk","created":1752805596,"model":"z-model.v","choices":[{"index":0,"delta":{"content":" outlines"}}]} +data: {"id":"chatcmpl-123","object":"chat.completion.chunk","created":1752805596,"model":"z-model.v","choices":[{"index":0,"delta":{"content":" that"}}]} +data: {"id":"chatcmpl-123","object":"chat.completion.chunk","created":1752805596,"model":"z-model.v","choices":[{"index":0,"delta":{"content":" define"}}]} +data: {"id":"chatcmpl-123","object":"chat.completion.chunk","created":1752805596,"model":"z-model.v","choices":[{"index":0,"delta":{"content":" its"}}]} +data: {"id":"chatcmpl-123","object":"chat.completion.chunk","created":1752805596,"model":"z-model.v","choices":[{"index":0,"delta":{"content":" structure"}}]} +data: {"id":"chatcmpl-123","object":"chat.completion.chunk","created":1752805596,"model":"z-model.v","choices":[{"index":0,"delta":{"content":"."}}]} +data: {"id":"chatcmpl-123","object":"chat.completion.chunk","created":1752805596,"model":"z-model.v","choices":[{"index":0,"delta":{"content":" This"}}]} +data: {"id":"chatcmpl-123","object":"chat.completion.chunk","created":1752805596,"model":"z-model.v","choices":[{"index":0,"delta":{"content":" type"}}]} +data: {"id":"chatcmpl-123","object":"chat.completion.chunk","created":1752805596,"model":"z-model.v","choices":[{"index":0,"delta":{"content":" of"}}]} +data: {"id":"chatcmpl-123","object":"chat.completion.chunk","created":1752805596,"model":"z-model.v","choices":[{"index":0,"delta":{"content":" graphic"}}]} +data: {"id":"chatcmpl-123","object":"chat.completion.chunk","created":1752805596,"model":"z-model.v","choices":[{"index":0,"delta":{"content":" is"}}]} +data: {"id":"chatcmpl-123","object":"chat.completion.chunk","created":1752805596,"model":"z-model.v","choices":[{"index":0,"delta":{"content":" often"}}]} +data: {"id":"chatcmpl-123","object":"chat.completion.chunk","created":1752805596,"model":"z-model.v","choices":[{"index":0,"delta":{"content":" used"}}]} +data: {"id":"chatcmpl-123","object":"chat.completion.chunk","created":1752805596,"model":"z-model.v","choices":[{"index":0,"delta":{"content":" to"}}]} +data: {"id":"chatcmpl-123","object":"chat.completion.chunk","created":1752805596,"model":"z-model.v","choices":[{"index":0,"delta":{"content":" represent"}}]} +data: {"id":"chatcmpl-123","object":"chat.completion.chunk","created":1752805596,"model":"z-model.v","choices":[{"index":0,"delta":{"content":" concepts"}}]} +data: {"id":"chatcmpl-123","object":"chat.completion.chunk","created":1752805596,"model":"z-model.v","choices":[{"index":0,"delta":{"content":" like"}}]} +data: {"id":"chatcmpl-123","object":"chat.completion.chunk","created":1752805596,"model":"z-model.v","choices":[{"index":0,"delta":{"content":" luxury"}}]} +data: {"id":"chatcmpl-123","object":"chat.completion.chunk","created":1752805596,"model":"z-model.v","choices":[{"index":0,"delta":{"content":","}}]} +data: {"id":"chatcmpl-123","object":"chat.completion.chunk","created":1752805596,"model":"z-model.v","choices":[{"index":0,"delta":{"content":" value"}}]} +data: {"id":"chatcmpl-123","object":"chat.completion.chunk","created":1752805596,"model":"z-model.v","choices":[{"index":0,"delta":{"content":","}}]} +data: {"id":"chatcmpl-123","object":"chat.completion.chunk","created":1752805596,"model":"z-model.v","choices":[{"index":0,"delta":{"content":" jewelry"}}]} +data: {"id":"chatcmpl-123","object":"chat.completion.chunk","created":1752805596,"model":"z-model.v","choices":[{"index":0,"delta":{"content":","}}]} +data: {"id":"chatcmpl-123","object":"chat.completion.chunk","created":1752805596,"model":"z-model.v","choices":[{"index":0,"delta":{"content":" or"}}]} +data: {"id":"chatcmpl-123","object":"chat.completion.chunk","created":1752805596,"model":"z-model.v","choices":[{"index":0,"delta":{"content":" preciousness"}}]} +data: {"id":"chatcmpl-123","object":"chat.completion.chunk","created":1752805596,"model":"z-model.v","choices":[{"index":0,"delta":{"content":","}}]} +data: {"id":"chatcmpl-123","object":"chat.completion.chunk","created":1752805596,"model":"z-model.v","choices":[{"index":0,"delta":{"content":" and"}}]} +data: {"id":"chatcmpl-123","object":"chat.completion.chunk","created":1752805596,"model":"z-model.v","choices":[{"index":0,"delta":{"content":" it"}}]} +data: {"id":"chatcmpl-123","object":"chat.completion.chunk","created":1752805596,"model":"z-model.v","choices":[{"index":0,"delta":{"content":" has"}}]} +data: {"id":"chatcmpl-123","object":"chat.completion.chunk","created":1752805596,"model":"z-model.v","choices":[{"index":0,"delta":{"content":" a"}}]} +data: {"id":"chatcmpl-123","object":"chat.completion.chunk","created":1752805596,"model":"z-model.v","choices":[{"index":0,"delta":{"content":" clean"}}]} +data: {"id":"chatcmpl-123","object":"chat.completion.chunk","created":1752805596,"model":"z-model.v","choices":[{"index":0,"delta":{"content":","}}]} +data: {"id":"chatcmpl-123","object":"chat.completion.chunk","created":1752805596,"model":"z-model.v","choices":[{"index":0,"delta":{"content":" minimalist"}}]} +data: {"id":"chatcmpl-123","object":"chat.completion.chunk","created":1752805596,"model":"z-model.v","choices":[{"index":0,"delta":{"content":" design"}}]} +data: {"id":"chatcmpl-123","object":"chat.completion.chunk","created":1752805596,"model":"z-model.v","choices":[{"index":0,"delta":{"content":" typical"}}]} +data: {"id":"chatcmpl-123","object":"chat.completion.chunk","created":1752805596,"model":"z-model.v","choices":[{"index":0,"delta":{"content":" of"}}]} +data: {"id":"chatcmpl-123","object":"chat.completion.chunk","created":1752805596,"model":"z-model.v","choices":[{"index":0,"delta":{"content":" iconography"}}]} +data: {"id":"chatcmpl-123","object":"chat.completion.chunk","created":1752805596,"model":"z-model.v","choices":[{"index":0,"delta":{"content":" or"}}]} +data: {"id":"chatcmpl-123","object":"chat.completion.chunk","created":1752805596,"model":"z-model.v","choices":[{"index":0,"delta":{"content":" simple"}}]} +data: {"id":"chatcmpl-123","object":"chat.completion.chunk","created":1752805596,"model":"z-model.v","choices":[{"index":0,"delta":{"content":" symbolic"}}]} +data: {"id":"chatcmpl-123","object":"chat.completion.chunk","created":1752805596,"model":"z-model.v","choices":[{"index":0,"delta":{"content":" art"}}]} +data: {"id":"chatcmpl-123","object":"chat.completion.chunk","created":1752805596,"model":"z-model.v","choices":[{"index":0,"delta":{"content":"."}}]} +data: {"id":"chatcmpl-123","object":"chat.completion.chunk","created":1752805596,"model":"z-model.v","choices":[{"index":0,"delta":{"content":" Diamond"}}]} +data: {"id":"chatcmpl-123","object":"chat.completion.chunk","created":1752805596,"model":"z-model.v","choices":[{"index":0,"finish_reason":"stop"}],"usage":{"prompt_tokens":853,"completion_tokens":199,"total_tokens":1052,"prompt_tokens_details":{"cached_tokens":5}}} +data: [DONE] diff --git a/tests/Fixtures/z/stream-text-video-from-url-1.sse b/tests/Fixtures/z/stream-text-video-from-url-1.sse new file mode 100644 index 000000000..9f47ee7be --- /dev/null +++ b/tests/Fixtures/z/stream-text-video-from-url-1.sse @@ -0,0 +1,139 @@ +data: {"id":"chatcmpl-123","object":"chat.completion.chunk","created":1752805596,"model":"z-model.v","choices":[{"index":0,"delta":{"role":"assistant","content":""},"finish_reason":null}]} +data: {"id":"chatcmpl-123","object":"chat.completion.chunk","created":1752805596,"model":"z-model.v","choices":[{"index":0,"delta":{"content":"\n"}}]} +data: {"id":"chatcmpl-123","object":"chat.completion.chunk","created":1752805596,"model":"z-model.v","choices":[{"index":0,"delta":{"content":"The"}}]} +data: {"id":"chatcmpl-123","object":"chat.completion.chunk","created":1752805596,"model":"z-model.v","choices":[{"index":0,"delta":{"content":" video"}}]} +data: {"id":"chatcmpl-123","object":"chat.completion.chunk","created":1752805596,"model":"z-model.v","choices":[{"index":0,"delta":{"content":" shows"}}]} +data: {"id":"chatcmpl-123","object":"chat.completion.chunk","created":1752805596,"model":"z-model.v","choices":[{"index":0,"delta":{"content":" a"}}]} +data: {"id":"chatcmpl-123","object":"chat.completion.chunk","created":1752805596,"model":"z-model.v","choices":[{"index":0,"delta":{"content":" sequence"}}]} +data: {"id":"chatcmpl-123","object":"chat.completion.chunk","created":1752805596,"model":"z-model.v","choices":[{"index":0,"delta":{"content":" of"}}]} +data: {"id":"chatcmpl-123","object":"chat.completion.chunk","created":1752805596,"model":"z-model.v","choices":[{"index":0,"delta":{"content":" actions"}}]} +data: {"id":"chatcmpl-123","object":"chat.completion.chunk","created":1752805596,"model":"z-model.v","choices":[{"index":0,"delta":{"content":" on"}}]} +data: {"id":"chatcmpl-123","object":"chat.completion.chunk","created":1752805596,"model":"z-model.v","choices":[{"index":0,"delta":{"content":" a"}}]} +data: {"id":"chatcmpl-123","object":"chat.completion.chunk","created":1752805596,"model":"z-model.v","choices":[{"index":0,"delta":{"content":" web"}}]} +data: {"id":"chatcmpl-123","object":"chat.completion.chunk","created":1752805596,"model":"z-model.v","choices":[{"index":0,"delta":{"content":" browser"}}]} +data: {"id":"chatcmpl-123","object":"chat.completion.chunk","created":1752805596,"model":"z-model.v","choices":[{"index":0,"delta":{"content":","}}]} +data: {"id":"chatcmpl-123","object":"chat.completion.chunk","created":1752805596,"model":"z-model.v","choices":[{"index":0,"delta":{"content":" specifically"}}]} +data: {"id":"chatcmpl-123","object":"chat.completion.chunk","created":1752805596,"model":"z-model.v","choices":[{"index":0,"delta":{"content":":"}}]} +data: {"id":"chatcmpl-123","object":"chat.completion.chunk","created":1752805596,"model":"z-model.v","choices":[{"index":0,"delta":{"content":"\n"}}]} +data: {"id":"chatcmpl-123","object":"chat.completion.chunk","created":1752805596,"model":"z-model.v","choices":[{"index":0,"delta":{"content":"1"}}]} +data: {"id":"chatcmpl-123","object":"chat.completion.chunk","created":1752805596,"model":"z-model.v","choices":[{"index":0,"delta":{"content":"."}}]} +data: {"id":"chatcmpl-123","object":"chat.completion.chunk","created":1752805596,"model":"z-model.v","choices":[{"index":0,"delta":{"content":" A"}}]} +data: {"id":"chatcmpl-123","object":"chat.completion.chunk","created":1752805596,"model":"z-model.v","choices":[{"index":0,"delta":{"content":" user"}}]} +data: {"id":"chatcmpl-123","object":"chat.completion.chunk","created":1752805596,"model":"z-model.v","choices":[{"index":0,"delta":{"content":" opening"}}]} +data: {"id":"chatcmpl-123","object":"chat.completion.chunk","created":1752805596,"model":"z-model.v","choices":[{"index":0,"delta":{"content":" Google"}}]} +data: {"id":"chatcmpl-123","object":"chat.completion.chunk","created":1752805596,"model":"z-model.v","choices":[{"index":0,"delta":{"content":" search"}}]} +data: {"id":"chatcmpl-123","object":"chat.completion.chunk","created":1752805596,"model":"z-model.v","choices":[{"index":0,"delta":{"content":" engine"}}]} +data: {"id":"chatcmpl-123","object":"chat.completion.chunk","created":1752805596,"model":"z-model.v","choices":[{"index":0,"delta":{"content":"."}}]} +data: {"id":"chatcmpl-123","object":"chat.completion.chunk","created":1752805596,"model":"z-model.v","choices":[{"index":0,"delta":{"content":"\n"}}]} +data: {"id":"chatcmpl-123","object":"chat.completion.chunk","created":1752805596,"model":"z-model.v","choices":[{"index":0,"delta":{"content":"2"}}]} +data: {"id":"chatcmpl-123","object":"chat.completion.chunk","created":1752805596,"model":"z-model.v","choices":[{"index":0,"delta":{"content":"."}}]} +data: {"id":"chatcmpl-123","object":"chat.completion.chunk","created":1752805596,"model":"z-model.v","choices":[{"index":0,"delta":{"content":" The"}}]} +data: {"id":"chatcmpl-123","object":"chat.completion.chunk","created":1752805596,"model":"z-model.v","choices":[{"index":0,"delta":{"content":" user"}}]} +data: {"id":"chatcmpl-123","object":"chat.completion.chunk","created":1752805596,"model":"z-model.v","choices":[{"index":0,"delta":{"content":" typing"}}]} +data: {"id":"chatcmpl-123","object":"chat.completion.chunk","created":1752805596,"model":"z-model.v","choices":[{"index":0,"delta":{"content":" \""}}]} +data: {"id":"chatcmpl-123","object":"chat.completion.chunk","created":1752805596,"model":"z-model.v","choices":[{"index":0,"delta":{"content":"智"}}]} +data: {"id":"chatcmpl-123","object":"chat.completion.chunk","created":1752805596,"model":"z-model.v","choices":[{"index":0,"delta":{"content":"谱"}}]} +data: {"id":"chatcmpl-123","object":"chat.completion.chunk","created":1752805596,"model":"z-model.v","choices":[{"index":0,"delta":{"content":"\""}}]} +data: {"id":"chatcmpl-123","object":"chat.completion.chunk","created":1752805596,"model":"z-model.v","choices":[{"index":0,"delta":{"content":" ("}}]} +data: {"id":"chatcmpl-123","object":"chat.completion.chunk","created":1752805596,"model":"z-model.v","choices":[{"index":0,"delta":{"content":"Zhipu"}}]} +data: {"id":"chatcmpl-123","object":"chat.completion.chunk","created":1752805596,"model":"z-model.v","choices":[{"index":0,"delta":{"content":")"}}]} +data: {"id":"chatcmpl-123","object":"chat.completion.chunk","created":1752805596,"model":"z-model.v","choices":[{"index":0,"delta":{"content":" into"}}]} +data: {"id":"chatcmpl-123","object":"chat.completion.chunk","created":1752805596,"model":"z-model.v","choices":[{"index":0,"delta":{"content":" search"}}]} +data: {"id":"chatcmpl-123","object":"chat.completion.chunk","created":1752805596,"model":"z-model.v","choices":[{"index":0,"delta":{"content":" bar"}}]} +data: {"id":"chatcmpl-123","object":"chat.completion.chunk","created":1752805596,"model":"z-model.v","choices":[{"index":0,"delta":{"content":" and"}}]} +data: {"id":"chatcmpl-123","object":"chat.completion.chunk","created":1752805596,"model":"z-model.v","choices":[{"index":0,"delta":{"content":" performing"}}]} +data: {"id":"chatcmpl-123","object":"chat.completion.chunk","created":1752805596,"model":"z-model.v","choices":[{"index":0,"delta":{"content":" search"}}]} +data: {"id":"chatcmpl-123","object":"chat.completion.chunk","created":1752805596,"model":"z-model.v","choices":[{"index":0,"delta":{"content":"."}}]} +data: {"id":"chatcmpl-123","object":"chat.completion.chunk","created":1752805596,"model":"z-model.v","choices":[{"index":0,"delta":{"content":"\n"}}]} +data: {"id":"chatcmpl-123","object":"chat.completion.chunk","created":1752805596,"model":"z-model.v","choices":[{"index":0,"delta":{"content":"3"}}]} +data: {"id":"chatcmpl-123","object":"chat.completion.chunk","created":1752805596,"model":"z-model.v","choices":[{"index":0,"delta":{"content":"."}}]} +data: {"id":"chatcmpl-123","object":"chat.completion.chunk","created":1752805596,"model":"z-model.v","choices":[{"index":0,"delta":{"content":" The"}}]} +data: {"id":"chatcmpl-123","object":"chat.completion.chunk","created":1752805596,"model":"z-model.v","choices":[{"index":0,"delta":{"content":" Google"}}]} +data: {"id":"chatcmpl-123","object":"chat.completion.chunk","created":1752805596,"model":"z-model.v","choices":[{"index":0,"delta":{"content":" search"}}]} +data: {"id":"chatcmpl-123","object":"chat.completion.chunk","created":1752805596,"model":"z-model.v","choices":[{"index":0,"delta":{"content":" results"}}]} +data: {"id":"chatcmpl-123","object":"chat.completion.chunk","created":1752805596,"model":"z-model.v","choices":[{"index":0,"delta":{"content":" for"}}]} +data: {"id":"chatcmpl-123","object":"chat.completion.chunk","created":1752805596,"model":"z-model.v","choices":[{"index":0,"delta":{"content":" \""}}]} +data: {"id":"chatcmpl-123","object":"chat.completion.chunk","created":1752805596,"model":"z-model.v","choices":[{"index":0,"delta":{"content":"智谱"}}]} +data: {"id":"chatcmpl-123","object":"chat.completion.chunk","created":1752805596,"model":"z-model.v","choices":[{"index":0,"delta":{"content":"\""}}]} +data: {"id":"chatcmpl-123","object":"chat.completion.chunk","created":1752805596,"model":"z-model.v","choices":[{"index":0,"delta":{"content":" appearing"}}]} +data: {"id":"chatcmpl-123","object":"chat.completion.chunk","created":1752805596,"model":"z-model.v","choices":[{"index":0,"delta":{"content":" on"}}]} +data: {"id":"chatcmpl-123","object":"chat.completion.chunk","created":1752805596,"model":"z-model.v","choices":[{"index":0,"delta":{"content":" screen"}}]} +data: {"id":"chatcmpl-123","object":"chat.completion.chunk","created":1752805596,"model":"z-model.v","choices":[{"index":0,"delta":{"content":"."}}]} +data: {"id":"chatcmpl-123","object":"chat.completion.chunk","created":1752805596,"model":"z-model.v","choices":[{"index":0,"delta":{"content":"\n"}}]} +data: {"id":"chatcmpl-123","object":"chat.completion.chunk","created":1752805596,"model":"z-model.v","choices":[{"index":0,"delta":{"content":"4"}}]} +data: {"id":"chatcmpl-123","object":"chat.completion.chunk","created":1752805596,"model":"z-model.v","choices":[{"index":0,"delta":{"content":"."}}]} +data: {"id":"chatcmpl-123","object":"chat.completion.chunk","created":1752805596,"model":"z-model.v","choices":[{"index":0,"delta":{"content":" The"}}]} +data: {"id":"chatcmpl-123","object":"chat.completion.chunk","created":1752805596,"model":"z-model.v","choices":[{"index":0,"delta":{"content":" user"}}]} +data: {"id":"chatcmpl-123","object":"chat.completion.chunk","created":1752805596,"model":"z-model.v","choices":[{"index":0,"delta":{"content":" clicking"}}]} +data: {"id":"chatcmpl-123","object":"chat.completion.chunk","created":1752805596,"model":"z-model.v","choices":[{"index":0,"delta":{"content":" on"}}]} +data: {"id":"chatcmpl-123","object":"chat.completion.chunk","created":1752805596,"model":"z-model.v","choices":[{"index":0,"delta":{"content":" first"}}]} +data: {"id":"chatcmpl-123","object":"chat.completion.chunk","created":1752805596,"model":"z-model.v","choices":[{"index":0,"delta":{"content":" search"}}]} +data: {"id":"chatcmpl-123","object":"chat.completion.chunk","created":1752805596,"model":"z-model.v","choices":[{"index":0,"delta":{"content":" result"}}]} +data: {"id":"chatcmpl-123","object":"chat.completion.chunk","created":1752805596,"model":"z-model.v","choices":[{"index":0,"delta":{"content":","}}]} +data: {"id":"chatcmpl-123","object":"chat.completion.chunk","created":1752805596,"model":"z-model.v","choices":[{"index":0,"delta":{"content":" which"}}]} +data: {"id":"chatcmpl-123","object":"chat.completion.chunk","created":1752805596,"model":"z-model.v","choices":[{"index":0,"delta":{"content":" leads"}}]} +data: {"id":"chatcmpl-123","object":"chat.completion.chunk","created":1752805596,"model":"z-model.v","choices":[{"index":0,"delta":{"content":" to"}}]} +data: {"id":"chatcmpl-123","object":"chat.completion.chunk","created":1752805596,"model":"z-model.v","choices":[{"index":0,"delta":{"content":" official"}}]} +data: {"id":"chatcmpl-123","object":"chat.completion.chunk","created":1752805596,"model":"z-model.v","choices":[{"index":0,"delta":{"content":" website"}}]} +data: {"id":"chatcmpl-123","object":"chat.completion.chunk","created":1752805596,"model":"z-model.v","choices":[{"index":0,"delta":{"content":" of"}}]} +data: {"id":"chatcmpl-123","object":"chat.completion.chunk","created":1752805596,"model":"z-model.v","choices":[{"index":0,"delta":{"content":" Zhipu"}}]} +data: {"id":"chatcmpl-123","object":"chat.completion.chunk","created":1752805596,"model":"z-model.v","choices":[{"index":0,"delta":{"content":" AI"}}]} +data: {"id":"chatcmpl-123","object":"chat.completion.chunk","created":1752805596,"model":"z-model.v","choices":[{"index":0,"delta":{"content":" ("}}]} +data: {"id":"chatcmpl-123","object":"chat.completion.chunk","created":1752805596,"model":"z-model.v","choices":[{"index":0,"delta":{"content":"www"}}]} +data: {"id":"chatcmpl-123","object":"chat.completion.chunk","created":1752805596,"model":"z-model.v","choices":[{"index":0,"delta":{"content":"."}}]} +data: {"id":"chatcmpl-123","object":"chat.completion.chunk","created":1752805596,"model":"z-model.v","choices":[{"index":0,"delta":{"content":"zhipuai"}}]} +data: {"id":"chatcmpl-123","object":"chat.completion.chunk","created":1752805596,"model":"z-model.v","choices":[{"index":0,"delta":{"content":"."}}]} +data: {"id":"chatcmpl-123","object":"chat.completion.chunk","created":1752805596,"model":"z-model.v","choices":[{"index":0,"delta":{"content":"cn"}}]} +data: {"id":"chatcmpl-123","object":"chat.completion.chunk","created":1752805596,"model":"z-model.v","choices":[{"index":0,"delta":{"content":")"}}]} +data: {"id":"chatcmpl-123","object":"chat.completion.chunk","created":1752805596,"model":"z-model.v","choices":[{"index":0,"delta":{"content":"."}}]} +data: {"id":"chatcmpl-123","object":"chat.completion.chunk","created":1752805596,"model":"z-model.v","choices":[{"index":0,"delta":{"content":"\n"}}]} +data: {"id":"chatcmpl-123","object":"chat.completion.chunk","created":1752805596,"model":"z-model.v","choices":[{"index":0,"delta":{"content":"5"}}]} +data: {"id":"chatcmpl-123","object":"chat.completion.chunk","created":1752805596,"model":"z-model.v","choices":[{"index":0,"delta":{"content":"."}}]} +data: {"id":"chatcmpl-123","object":"chat.completion.chunk","created":1752805596,"model":"z-model.v","choices":[{"index":0,"delta":{"content":" The"}}]} +data: {"id":"chatcmpl-123","object":"chat.completion.chunk","created":1752805596,"model":"z-model.v","choices":[{"index":0,"delta":{"content":" final"}}]} +data: {"id":"chatcmpl-123","object":"chat.completion.chunk","created":1752805596,"model":"z-model.v","choices":[{"index":0,"delta":{"content":" scene"}}]} +data: {"id":"chatcmpl-123","object":"chat.completion.chunk","created":1752805596,"model":"z-model.v","choices":[{"index":0,"delta":{"content":" is"}}]} +data: {"id":"chatcmpl-123","object":"chat.completion.chunk","created":1752805596,"model":"z-model.v","choices":[{"index":0,"delta":{"content":" homepage"}}]} +data: {"id":"chatcmpl-123","object":"chat.completion.chunk","created":1752805596,"model":"z-model.v","choices":[{"index":0,"delta":{"content":" of"}}]} +data: {"id":"chatcmpl-123","object":"chat.completion.chunk","created":1752805596,"model":"z-model.v","choices":[{"index":0,"delta":{"content":" Zhipu"}}]} +data: {"id":"chatcmpl-123","object":"chat.completion.chunk","created":1752805596,"model":"z-model.v","choices":[{"index":0,"delta":{"content":" AI"}}]} +data: {"id":"chatcmpl-123","object":"chat.completion.chunk","created":1752805596,"model":"z-model.v","choices":[{"index":0,"delta":{"content":" website"}}]} +data: {"id":"chatcmpl-123","object":"chat.completion.chunk","created":1752805596,"model":"z-model.v","choices":[{"index":0,"delta":{"content":","}}]} +data: {"id":"chatcmpl-123","object":"chat.completion.chunk","created":1752805596,"model":"z-model.v","choices":[{"index":0,"delta":{"content":" showcasing"}}]} +data: {"id":"chatcmpl-123","object":"chat.completion.chunk","created":1752805596,"model":"z-model.v","choices":[{"index":0,"delta":{"content":" its"}}]} +data: {"id":"chatcmpl-123","object":"chat.completion.chunk","created":1752805596,"model":"z-model.v","choices":[{"index":0,"delta":{"content":" \""}}]} +data: {"id":"chatcmpl-123","object":"chat.completion.chunk","created":1752805596,"model":"z-model.v","choices":[{"index":0,"delta":{"content":"Z"}}]} +data: {"id":"chatcmpl-123","object":"chat.completion.chunk","created":1752805596,"model":"z-model.v","choices":[{"index":0,"delta":{"content":"."}}]} +data: {"id":"chatcmpl-123","object":"chat.completion.chunk","created":1752805596,"model":"z-model.v","choices":[{"index":0,"delta":{"content":"ai"}}]} +data: {"id":"chatcmpl-123","object":"chat.completion.chunk","created":1752805596,"model":"z-model.v","choices":[{"index":0,"delta":{"content":" GLM"}}]} +data: {"id":"chatcmpl-123","object":"chat.completion.chunk","created":1752805596,"model":"z-model.v","choices":[{"index":0,"delta":{"content":" Large"}}]} +data: {"id":"chatcmpl-123","object":"chat.completion.chunk","created":1752805596,"model":"z-model.v","choices":[{"index":0,"delta":{"content":" Model"}}]} +data: {"id":"chatcmpl-123","object":"chat.completion.chunk","created":1752805596,"model":"z-model.v","choices":[{"index":0,"delta":{"content":" Open"}}]} +data: {"id":"chatcmpl-123","object":"chat.completion.chunk","created":1752805596,"model":"z-model.v","choices":[{"index":0,"delta":{"content":" Platform"}}]} +data: {"id":"chatcmpl-123","object":"chat.completion.chunk","created":1752805596,"model":"z-model.v","choices":[{"index":0,"delta":{"content":"\""}}]} +data: {"id":"chatcmpl-123","object":"chat.completion.chunk","created":1752805596,"model":"z-model.v","choices":[{"index":0,"delta":{"content":"."}}]} +data: {"id":"chatcmpl-123","object":"chat.completion.chunk","created":1752805596,"model":"z-model.v","choices":[{"index":0,"delta":{"content":"\n"}}]} +data: {"id":"chatcmpl-123","object":"chat.completion.chunk","created":1752805596,"model":"z-model.v","choices":[{"index":0,"delta":{"content":"\n"}}]} +data: {"id":"chatcmpl-123","object":"chat.completion.chunk","created":1752805596,"model":"z-model.v","choices":[{"index":0,"delta":{"content":"Essentially"}}]} +data: {"id":"chatcmpl-123","object":"chat.completion.chunk","created":1752805596,"model":"z-model.v","choices":[{"index":0,"delta":{"content":","}}]} +data: {"id":"chatcmpl-123","object":"chat.completion.chunk","created":1752805596,"model":"z-model.v","choices":[{"index":0,"delta":{"content":" video"}}]} +data: {"id":"chatcmpl-123","object":"chat.completion.chunk","created":1752805596,"model":"z-model.v","choices":[{"index":0,"delta":{"content":" demonstrates"}}]} +data: {"id":"chatcmpl-123","object":"chat.completion.chunk","created":1752805596,"model":"z-model.v","choices":[{"index":0,"delta":{"content":" a"}}]} +data: {"id":"chatcmpl-123","object":"chat.completion.chunk","created":1752805596,"model":"z-model.v","choices":[{"index":0,"delta":{"content":" user"}}]} +data: {"id":"chatcmpl-123","object":"chat.completion.chunk","created":1752805596,"model":"z-model.v","choices":[{"index":0,"delta":{"content":" searching"}}]} +data: {"id":"chatcmpl-123","object":"chat.completion.chunk","created":1752805596,"model":"z-model.v","choices":[{"index":0,"delta":{"content":" for"}}]} +data: {"id":"chatcmpl-123","object":"chat.completion.chunk","created":1752805596,"model":"z-model.v","choices":[{"index":0,"delta":{"content":" information"}}]} +data: {"id":"chatcmpl-123","object":"chat.completion.chunk","created":1752805596,"model":"z-model.v","choices":[{"index":0,"delta":{"content":" about"}}]} +data: {"id":"chatcmpl-123","object":"chat.completion.chunk","created":1752805596,"model":"z-model.v","choices":[{"index":0,"delta":{"content":" Chinese"}}]} +data: {"id":"chatcmpl-123","object":"chat.completion.chunk","created":1752805596,"model":"z-model.v","choices":[{"index":0,"delta":{"content":" AI"}}]} +data: {"id":"chatcmpl-123","object":"chat.completion.chunk","created":1752805596,"model":"z-model.v","choices":[{"index":0,"delta":{"content":" company"}}]} +data: {"id":"chatcmpl-123","object":"chat.completion.chunk","created":1752805596,"model":"z-model.v","choices":[{"index":0,"delta":{"content":" Zhipu"}}]} +data: {"id":"chatcmpl-123","object":"chat.completion.chunk","created":1752805596,"model":"z-model.v","choices":[{"index":0,"delta":{"content":" and"}}]} +data: {"id":"chatcmpl-123","object":"chat.completion.chunk","created":1752805596,"model":"z-model.v","choices":[{"index":0,"delta":{"content":" navigating"}}]} +data: {"id":"chatcmpl-123","object":"chat.completion.chunk","created":1752805596,"model":"z-model.v","choices":[{"index":0,"delta":{"content":" to"}}]} +data: {"id":"chatcmpl-123","object":"chat.completion.chunk","created":1752805596,"model":"z-model.v","choices":[{"index":0,"delta":{"content":" its"}}]} +data: {"id":"chatcmpl-123","object":"chat.completion.chunk","created":1752805596,"model":"z-model.v","choices":[{"index":0,"delta":{"content":" official"}}]} +data: {"id":"chatcmpl-123","object":"chat.completion.chunk","created":1752805596,"model":"z-model.v","choices":[{"index":0,"delta":{"content":" website"}}]} +data: {"id":"chatcmpl-123","object":"chat.completion.chunk","created":1752805596,"model":"z-model.v","choices":[{"index":0,"delta":{"content":"."}}]} +data: {"id":"chatcmpl-123","object":"chat.completion.chunk","created":1752805596,"model":"z-model.v","choices":[{"index":0,"finish_reason":"stop"}],"usage":{"prompt_tokens":72255,"completion_tokens":409,"total_tokens":72664,"prompt_tokens_details":{"cached_tokens":4}}} +data: [DONE] diff --git a/tests/Fixtures/z/stream-with-required-tool-call-1.sse b/tests/Fixtures/z/stream-with-required-tool-call-1.sse new file mode 100644 index 000000000..cc138348f --- /dev/null +++ b/tests/Fixtures/z/stream-with-required-tool-call-1.sse @@ -0,0 +1,14 @@ +data: {"id":"chatcmpl-123","object":"chat.completion.chunk","created":1752805596,"model":"z-model","choices":[{"index":0,"delta":{"role":"assistant","content":""},"finish_reason":null}]} +data: {"id":"chatcmpl-123","object":"chat.completion.chunk","created":1752805596,"model":"z-model","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"id":"call_-8062758106233394160","type":"function","function":{"name":"search","arguments":""}}]},"finish_reason":null}]} +data: {"id":"chatcmpl-123","object":"chat.completion.chunk","created":1752805596,"model":"z-model","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"{\""}}]},"finish_reason":null}]} +data: {"id":"chatcmpl-123","object":"chat.completion.chunk","created":1752805596,"model":"z-model","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"query"}}]},"finish_reason":null}]} +data: {"id":"chatcmpl-123","object":"chat.completion.chunk","created":1752805596,"model":"z-model","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"\":"}}]},"finish_reason":null}]} +data: {"id":"chatcmpl-123","object":"chat.completion.chunk","created":1752805596,"model":"z-model","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":" \""}}]},"finish_reason":null}]} +data: {"id":"chatcmpl-123","object":"chat.completion.chunk","created":1752805596,"model":"z-model","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"current"}}]},"finish_reason":null}]} +data: {"id":"chatcmpl-123","object":"chat.completion.chunk","created":1752805596,"model":"z-model","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":" events"}}]},"finish_reason":null}]} +data: {"id":"chatcmpl-123","object":"chat.completion.chunk","created":1752805596,"model":"z-model","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":" news"}}]},"finish_reason":null}]} +data: {"id":"chatcmpl-123","object":"chat.completion.chunk","created":1752805596,"model":"z-model","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":" today"}}]},"finish_reason":null}]} +data: {"id":"chatcmpl-123","object":"chat.completion.chunk","created":1752805596,"model":"z-model","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"\""}}]},"finish_reason":null}]} +data: {"id":"chatcmpl-123","object":"chat.completion.chunk","created":1752805596,"model":"z-model","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"}"}}]},"finish_reason":null}]} +data: {"id":"chatcmpl-123","object":"chat.completion.chunk","created":1752805596,"model":"z-model","choices":[{"index":0,"finish_reason":"tool_calls"}],"usage":{"prompt_tokens":238,"completion_tokens":117,"total_tokens":355,"prompt_tokens_details":{"cached_tokens":43}}} +data: [DONE] diff --git a/tests/Fixtures/z/stream-with-tools-1.sse b/tests/Fixtures/z/stream-with-tools-1.sse new file mode 100644 index 000000000..baa66bae9 --- /dev/null +++ b/tests/Fixtures/z/stream-with-tools-1.sse @@ -0,0 +1,5 @@ +data: {"id":"chatcmpl-123","object":"chat.completion.chunk","created":1752805596,"model":"z-model","choices":[{"index":0,"delta":{"role":"assistant","content":""},"finish_reason":null}]} +data: {"id":"chatcmpl-123","object":"chat.completion.chunk","created":1752805596,"model":"z-model","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"id":"call_-8062812703858712887","type":"function","function":{"name":"search_games","arguments":"{\"city\":\"Detroit\"}"}}]},"finish_reason":null}]} +data: {"id":"chatcmpl-123","object":"chat.completion.chunk","created":1752805596,"model":"z-model","choices":[{"index":0,"delta":{"tool_calls":[{"index":1,"id":"call_-8062812703858712886","type":"function","function":{"name":"get_weather","arguments":"{\"city\":\"Detroit\"}"}}]},"finish_reason":null}]} +data: {"id":"chatcmpl-123","object":"chat.completion.chunk","created":1752805596,"model":"z-model","choices":[{"index":0,"finish_reason":"tool_calls"}],"usage":{"prompt_tokens":272,"completion_tokens":210,"total_tokens":482,"prompt_tokens_details":{"cached_tokens":271}}} +data: [DONE] diff --git a/tests/Fixtures/z/stream-with-tools-2.sse b/tests/Fixtures/z/stream-with-tools-2.sse new file mode 100644 index 000000000..0252e18d8 --- /dev/null +++ b/tests/Fixtures/z/stream-with-tools-2.sse @@ -0,0 +1,4 @@ +data: {"id":"chatcmpl-123","object":"chat.completion.chunk","created":1752805596,"model":"z-model","choices":[{"index":0,"delta":{"role":"assistant","content":""},"finish_reason":null}]} +data: {"id":"chatcmpl-123","object":"chat.completion.chunk","created":1752805596,"model":"z-model","choices":[{"index":0,"delta":{"content":"\nBased on the information I gathered:\n\n**Tigers Game Time:** The Tigers game today in Detroit is at 3:00 PM.\n\n**Weather and Coat Recommendation:** The weather will be 45° and sunny. Yes, you should definitely wear a coat to the game! At 45 degrees, it will be quite chilly, especially if you'll be sitting outdoors for several hours. You might want to consider wearing a warm coat, and possibly dressing in layers with a hat and gloves for extra comfort during the game."}}]} +data: {"id":"chatcmpl-123","object":"chat.completion.chunk","created":1752805596,"model":"z-model","choices":[{"index":0,"finish_reason":"stop"}],"usage":{"prompt_tokens":344,"completion_tokens":109,"total_tokens":453,"prompt_tokens_details":{"cached_tokens":273}}} +data: [DONE] diff --git a/tests/Pest.php b/tests/Pest.php index 04a6511ca..3c58a0901 100644 --- a/tests/Pest.php +++ b/tests/Pest.php @@ -22,7 +22,9 @@ uses()->group('mistral')->in('Providers/Mistral'); uses()->group('ollama')->in('Providers/Ollama'); uses()->group('openai')->in('Providers/OpenAI'); +uses()->group('replicate')->in('Providers/Replicate'); uses()->group('xai')->in('Providers/XAI'); +uses()->group('qwen')->in('Providers/Qwen'); /* |-------------------------------------------------------------------------- diff --git a/tests/Providers/Anthropic/AnthropicStructuredRequestTest.php b/tests/Providers/Anthropic/AnthropicStructuredRequestTest.php index 047407be7..70ec5d223 100644 --- a/tests/Providers/Anthropic/AnthropicStructuredRequestTest.php +++ b/tests/Providers/Anthropic/AnthropicStructuredRequestTest.php @@ -207,7 +207,111 @@ }); }); -it('sends correct thinking mode in payload', function (): void { +it('sends correct adaptive thinking mode in payload', function (): void { + FixtureResponse::fakeResponseSequence('v1/messages', 'anthropic/structured-with-extending-thinking'); + + $schema = new ObjectSchema( + 'calculation', + 'Math calculation result', + [ + 'result' => new StringSchema('result', 'The calculation result'), + ], + ['result'] + ); + + Prism::structured() + ->using(Provider::Anthropic, 'claude-3-5-haiku-latest') + ->withMessages([new UserMessage('Solve this math problem: 2+2')]) + ->withSchema($schema) + ->withProviderOptions([ + 'thinking' => [ + 'type' => 'adaptive', + ], + ]) + ->asStructured(); + + Http::assertSent(function (Request $request): bool { + $payload = $request->data(); + + expect($payload)->toHaveKey('thinking'); + expect($payload['thinking'])->toBe([ + 'type' => 'adaptive', + ]); + + return true; + }); +}); + +it('merges effort into output_config with format for native structured output', function (): void { + FixtureResponse::fakeResponseSequence('v1/messages', 'anthropic/structured'); + + $schema = new ObjectSchema( + 'data', + 'Data object', + [ + 'value' => new StringSchema('value', 'A value'), + ], + ['value'] + ); + + Prism::structured() + ->using(Provider::Anthropic, 'claude-3-5-haiku-latest') + ->withMessages([new UserMessage('Generate data')]) + ->withSchema($schema) + ->withProviderOptions([ + 'effort' => 'high', + ]) + ->asStructured(); + + Http::assertSent(function (Request $request) use ($schema): bool { + $payload = $request->data(); + + expect($payload['output_config'])->toBe([ + 'effort' => 'high', + 'format' => [ + 'type' => 'json_schema', + 'schema' => $schema->toArray(), + ], + ]); + + return true; + }); +}); + +it('sends effort in output_config with tool calling mode', function (): void { + FixtureResponse::fakeResponseSequence('v1/messages', 'anthropic/structured'); + + $schema = new ObjectSchema( + 'data', + 'Data object', + [ + 'value' => new StringSchema('value', 'A value'), + ], + ['value'] + ); + + Prism::structured() + ->using(Provider::Anthropic, 'claude-3-5-haiku-latest') + ->withMessages([new UserMessage('Generate data')]) + ->withSchema($schema) + ->withProviderOptions([ + 'effort' => 'high', + 'use_tool_calling' => true, + ]) + ->asStructured(); + + Http::assertSent(function (Request $request): bool { + $payload = $request->data(); + + expect($payload['output_config'])->toBe([ + 'effort' => 'high', + ]); + + return true; + }); +}); + +it('sends correct legacy thinking mode in payload', function (): void { FixtureResponse::fakeResponseSequence('v1/messages', 'anthropic/structured-with-extending-thinking'); $schema = new ObjectSchema( diff --git a/tests/Providers/Anthropic/AnthropicTextRequestTest.php b/tests/Providers/Anthropic/AnthropicTextRequestTest.php index b7286d0e0..927fdae02 100644 --- a/tests/Providers/Anthropic/AnthropicTextRequestTest.php +++ b/tests/Providers/Anthropic/AnthropicTextRequestTest.php @@ -169,7 +169,100 @@ }); }); -it('sends correct thinking mode in payload', function (): void { +it('sends correct adaptive thinking mode in payload', function (): void { + FixtureResponse::fakeResponseSequence('v1/messages', 'anthropic/generate-text-with-a-prompt'); + + Prism::text() + ->using(Provider::Anthropic, 'claude-3-5-haiku-latest') + ->withMessages([new UserMessage('Solve this math problem: 2+2')]) + ->withProviderOptions([ + 'thinking' => [ + 'type' => 'adaptive', + ], + ]) + ->asText(); + + Http::assertSent(function (Request $request): bool { + $payload = $request->data(); + + expect($payload)->toHaveKey('thinking'); + expect($payload['thinking'])->toBe([ + 'type' => 'adaptive', + ]); + + return true; + }); +}); + +it('sends adaptive thinking with effort in payload', function (): void { + FixtureResponse::fakeResponseSequence('v1/messages', 'anthropic/generate-text-with-a-prompt'); + + Prism::text() + ->using(Provider::Anthropic, 'claude-3-5-haiku-latest') + ->withMessages([new UserMessage('Solve this math problem: 2+2')]) + ->withProviderOptions([ + 'thinking' => [ + 'type' => 'adaptive', + ], + 'effort' => 'high', + ]) + ->asText(); + + Http::assertSent(function (Request $request): bool { + $payload = $request->data(); + + expect($payload['thinking'])->toBe([ + 'type' => 'adaptive', + ]); + expect($payload['output_config'])->toBe([ + 'effort' => 'high', + ]); + + return true; + }); +}); + +it('sends effort without thinking in payload', function (): void { + FixtureResponse::fakeResponseSequence('v1/messages', 'anthropic/generate-text-with-a-prompt'); + + Prism::text() + ->using(Provider::Anthropic, 'claude-3-5-haiku-latest') + ->withMessages([new UserMessage('Quick question')]) + ->withProviderOptions([ + 'effort' => 'medium', + ]) + ->asText(); + + Http::assertSent(function (Request $request): bool { + $payload = $request->data(); + + expect($payload)->not->toHaveKey('thinking'); + expect($payload['output_config'])->toBe([ + 'effort' => 'medium', + ]); + + return true; + }); +}); + +it('does not include output_config when effort is not set', function (): void { + FixtureResponse::fakeResponseSequence('v1/messages', 'anthropic/generate-text-with-a-prompt'); + + Prism::text() + ->using(Provider::Anthropic, 'claude-3-5-haiku-latest') + ->withMessages([new UserMessage('Test')]) + ->asText(); + + Http::assertSent(function (Request $request): bool { + $payload = $request->data(); + + expect($payload)->not->toHaveKey('output_config'); + + return true; + }); +}); + +it('sends correct legacy thinking mode in payload', function (): void { FixtureResponse::fakeResponseSequence('v1/messages', 'anthropic/generate-text-with-a-prompt'); Prism::text() @@ -196,7 +289,44 @@ }); }); -it('sends correct thinking mode with default budget tokens', function (): void { +it('omits thinking when withReasoning(false) is used even with thinking.enabled', function (): void { + FixtureResponse::fakeResponseSequence('v1/messages', 'anthropic/generate-text-with-a-prompt'); + + Prism::text() + ->using(Provider::Anthropic, 'claude-3-5-haiku-latest') + ->withMessages([new UserMessage('Test')]) + ->withProviderOptions(['thinking' => ['enabled' => true]]) + ->withReasoning(false) + ->asText(); + + Http::assertSent(function (Request $request): bool { + $payload = $request->data(); + + expect($payload)->not->toHaveKey('thinking'); + + return true; + }); +}); + +it('does not include thinking when withReasoning(false) on a non-thinking model', function (): void { + FixtureResponse::fakeResponseSequence('v1/messages', 'anthropic/generate-text-with-a-prompt'); + + Prism::text() + ->using(Provider::Anthropic, 'claude-3-5-haiku-latest') + ->withMessages([new UserMessage('Test')]) + ->withReasoning(false) + ->asText(); + + Http::assertSent(function (Request $request): bool { + $payload = $request->data(); + + expect($payload)->not->toHaveKey('thinking'); + + return true; + }); +}); + +it('sends correct legacy thinking mode with default budget tokens', function (): void { FixtureResponse::fakeResponseSequence('v1/messages', 'anthropic/generate-text-with-a-prompt'); Prism::text() @@ -236,6 +366,7 @@ expect($payload)->not->toHaveKey('temperature'); expect($payload)->not->toHaveKey('top_p'); expect($payload)->not->toHaveKey('mcp_servers'); + expect($payload)->not->toHaveKey('output_config'); return true; }); @@ -326,3 +457,39 @@ return true; }); }); + +it('merges per-request anthropic_beta features with the configured ones', function (): void { + config()->set('prism.providers.anthropic.anthropic_beta', 'code-execution-2025-05-22'); + + FixtureResponse::fakeResponseSequence('v1/messages', 'anthropic/generate-text-with-a-prompt'); + + Prism::text() + ->using(Provider::Anthropic, 'claude-3-5-haiku-latest') + ->withPrompt('Test') + ->withProviderOptions(['anthropic_beta' => ['skills-2025-10-02', 'code-execution-2025-05-22']]) + ->asText(); + + Http::assertSent(function (Request $request): bool { + expect($request->header('anthropic-beta')[0]) + ->toBe('code-execution-2025-05-22,skills-2025-10-02'); + + return true; + }); +}); + +it('sends only configured beta features when the request adds none', function (): void { + config()->set('prism.providers.anthropic.anthropic_beta', 'code-execution-2025-05-22'); + + FixtureResponse::fakeResponseSequence('v1/messages', 'anthropic/generate-text-with-a-prompt'); + + Prism::text() + ->using(Provider::Anthropic, 'claude-3-5-haiku-latest') + ->withPrompt('Test') + ->asText(); + + Http::assertSent(function (Request $request): bool { + expect($request->header('anthropic-beta')[0])->toBe('code-execution-2025-05-22'); + + return true; + }); +}); diff --git a/tests/Providers/Anthropic/AnthropicTextTest.php b/tests/Providers/Anthropic/AnthropicTextTest.php index 55ec9f1fb..5dd18e292 100644 --- a/tests/Providers/Anthropic/AnthropicTextTest.php +++ b/tests/Providers/Anthropic/AnthropicTextTest.php @@ -9,6 +9,7 @@ use Illuminate\Support\Facades\Http; use Prism\Prism\Enums\Citations\CitationSourcePositionType; use Prism\Prism\Enums\Citations\CitationSourceType; +use Prism\Prism\Enums\FinishReason; use Prism\Prism\Enums\Provider; use Prism\Prism\Exceptions\PrismProviderOverloadedException; use Prism\Prism\Exceptions\PrismRateLimitedException; @@ -213,6 +214,62 @@ expect($response->toolCalls[0]->name)->toBe('weather'); }); + + it('preserves server tool content blocks during multi-step tool loop with web search', function (): void { + FixtureResponse::fakeResponseSequence('v1/messages', 'anthropic/generate-text-with-web-search-and-tool-call'); + + $tools = [ + Tool::as('weather') + ->for('useful when you need to search for current weather conditions') + ->withStringParameter('city', 'The city that you want the weather for') + ->using(fn (string $city): string => 'The weather will be 21° and partly cloudy'), + ]; + + $response = Prism::text() + ->using('anthropic', 'claude-3-5-haiku-latest') + ->withTools($tools) + ->withProviderTools([new ProviderTool(type: 'web_search_20250305', name: 'web_search')]) + ->withMaxSteps(3) + ->withPrompt('What is the weather in London?') + ->asText(); + + // The tool loop should complete without a "Could not find search result for citation index" error + expect($response->steps)->toHaveCount(2); + expect($response->text)->toContain('18°C'); + + // Step 1 should have the tool call and citations in additionalContent + $firstStep = $response->steps[0]; + expect($firstStep->toolCalls)->toHaveCount(1); + expect($firstStep->toolCalls[0]->name)->toBe('weather'); + expect($firstStep->additionalContent)->toHaveKey('citations'); + expect($firstStep->additionalContent)->toHaveKey('provider_tool_calls'); + expect($firstStep->additionalContent)->toHaveKey('provider_tool_results'); + + // The assistant message replayed in step 2 should contain the server tool blocks + $secondStep = $response->steps[1]; + expect($secondStep->messages)->toHaveCount(3); + expect($secondStep->messages[1])->toBeInstanceOf(AssistantMessage::class); + expect($secondStep->messages[1]->additionalContent)->toHaveKey('citations'); + expect($secondStep->messages[1]->additionalContent)->toHaveKey('provider_tool_calls'); + expect($secondStep->messages[1]->additionalContent)->toHaveKey('provider_tool_results'); + + // Verify the second HTTP request includes server tool blocks in the replayed assistant message + $requests = Http::recorded(); + expect($requests)->toHaveCount(2); + + $secondRequestPayload = $requests[1][0]->data(); + $assistantMessage = Arr::first( + $secondRequestPayload['messages'], + fn (array $msg): bool => $msg['role'] === 'assistant' + ); + + // The assistant message should contain server_tool_use and web_search_tool_result blocks + $contentTypes = array_column($assistantMessage['content'], 'type'); + expect($contentTypes)->toContain('server_tool_use'); + expect($contentTypes)->toContain('web_search_tool_result'); + expect($contentTypes)->toContain('text'); + expect($contentTypes)->toContain('tool_use'); + }); }); it('can calculate cache usage correctly', function (): void { @@ -506,79 +563,151 @@ }); }); -describe('Anthropic extended thinking', function (): void { - it('can use extending thinking', function (): void { - FixtureResponse::fakeResponseSequence('v1/messages', 'anthropic/text-with-extending-thinking'); +describe('Anthropic thinking', function (): void { + describe('adaptive', function (): void { + it('can use adaptive thinking', function (): void { + FixtureResponse::fakeResponseSequence('v1/messages', 'anthropic/text-with-extending-thinking'); - $response = Prism::text() - ->using('anthropic', 'claude-3-7-sonnet-latest') - ->withPrompt('What is the meaning of life, the universe and everything in popular fiction?') - ->withProviderOptions(['thinking' => ['enabled' => true]]) - ->asText(); + $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' => ['type' => 'adaptive']]) + ->asText(); - $expected_thinking = "This is a reference to Douglas Adams' popular science fiction series \"The Hitchhiker's Guide to the Galaxy\" where the supercomputer Deep Thought was built to calculate \"the Answer to the Ultimate Question of Life, the Universe, and Everything.\" After 7.5 million years of computation, it famously determined the answer to be \"42\" - a deliberately anticlimactic and absurd response that has become a significant pop culture reference.\n\nBeyond the Hitchhiker's reference, the question of life's meaning appears in many works of fiction across different media, with various philosophical approaches.\n\nI should note this humorous 42 reference while also mentioning how other fictional works have approached this philosophical question."; - $expected_signature = 'EuYBCkQYAiJAQ7ZOmBu5pa8U03x/RN5+Gs3tyKXFYcruUfnC8X/4AKBpJmB8qX+nQQ9atvYOXLD/mUAClCRZEaxt2fyEvdxnhRIMfFi6CLULECysli0mGgy5JRaOXL06fVJndm8iMD2T+D8dSIFJuctCnVeFKZme2TfIPIH+UMFO33a0ojzUq2VYy8+RzKkH7WYK9+580ipQ4yDVegd/67LKRtfb574HOHqwlPcfEbeiJuFuHrayoqK8KS2ltGYRckVGH6lNH46zUyjGaD2z3nZeti8UjmgnfMWRpjUmv0TWWGtrCKRoHGQ='; + expect($response->additionalContent)->toHaveKey('thinking'); + expect($response->additionalContent['thinking'])->toContain('Douglas Adams'); + expect($response->additionalContent)->toHaveKey('thinking_signature'); + expect($response->additionalContent['thinking_signature'])->not->toBeEmpty(); + }); + + it('can use adaptive thinking with tool calls', function (): void { + FixtureResponse::fakeResponseSequence('v1/messages', 'anthropic/text-with-extending-thinking-and-tool-calls'); + + $tools = [ + Tool::as('weather') + ->for('useful when you need to search for current weather conditions') + ->withStringParameter('city', 'the city you want the weather for') + ->using(fn (string $city): string => 'The weather will be 75° and sunny'), + Tool::as('search') + ->for('useful for searching curret events or data') + ->withStringParameter('query', 'The detailed search query') + ->using(fn (string $query): string => 'The tigers game is at 3pm in detroit'), + ]; + + $response = Prism::text() + ->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' => ['type' => 'adaptive']]) + ->asText(); - expect($response->text)->toBe("In popular fiction, the most famous answer to this question comes from Douglas Adams' \"The Hitchhiker's Guide to the Galaxy,\" where a supercomputer named Deep Thought calculates for 7.5 million years and determines that the answer is simply \"42.\" This deliberately absurd response has become an iconic joke about the futility of seeking simple answers to profound existential questions.\n\nBeyond this humorous reference, fiction explores life's meaning in countless ways:\n- Finding purpose through love and human connection (seen in works like \"The Good Place\")\n- The pursuit of knowledge and understanding (as in \"Contact\" by Carl Sagan)\n- Creating your own meaning in an indifferent universe (explored in existentialist fiction)\n- Religious or spiritual fulfillment (depicted in works like \"Life of Pi\")\n\nWhat makes this question compelling in fiction is that there's never a definitive answer - just different perspectives that reflect our own search for meaning."); - expect($response->additionalContent['thinking'])->toBe($expected_thinking); - expect($response->additionalContent['thinking_signature'])->toBe($expected_signature); + expect($response->steps->first()) + ->additionalContent->thinking->toContain('Tigers') + ->additionalContent->thinking_signature->not->toBeEmpty(); - expect($response->messages->last()) - ->additionalContent->thinking->toBe($expected_thinking) - ->additionalContent->thinking_signature->toBe($expected_signature); - }); + expect($response->steps->last()->messages[1]) + ->additionalContent->thinking->toContain('Tigers') + ->additionalContent->thinking_signature->not->toBeEmpty(); + }); - it('can override budget tokens', function (): void { - $response = Prism::text() - ->using('anthropic', 'claude-3-7-sonnet-latest') - ->withPrompt('What is the meaning of life, the universe and everything in popular fiction?') - ->withProviderOptions([ - 'thinking' => [ - 'enabled' => true, - 'budgetTokens' => 2048, - ], - ]); + it('sends adaptive thinking payload', function (): void { + $response = Prism::text() + ->using('anthropic', 'claude-sonnet-4-6') + ->withPrompt('Test') + ->withProviderOptions(['thinking' => ['type' => 'adaptive']]); + + $payload = Text::buildHttpRequestPayload($response->toRequest()); + + expect(data_get($payload, 'thinking'))->toBe(['type' => 'adaptive']); + }); - $payload = Text::buildHttpRequestPayload($response->toRequest()); + it('sends effort via output_config', function (): void { + $response = Prism::text() + ->using('anthropic', 'claude-sonnet-4-6') + ->withPrompt('Test') + ->withProviderOptions(['effort' => 'medium']); - expect(data_get($payload, 'thinking.budget_tokens'))->toBe(2048); + $payload = Text::buildHttpRequestPayload($response->toRequest()); + + expect(data_get($payload, 'output_config.effort'))->toBe('medium'); + }); }); - it('can use extending thinking with tool calls', function (): void { - FixtureResponse::fakeResponseSequence('v1/messages', 'anthropic/text-with-extending-thinking-and-tool-calls'); + describe('legacy', function (): void { + it('can use extended thinking', function (): void { + FixtureResponse::fakeResponseSequence('v1/messages', 'anthropic/text-with-extending-thinking'); - $tools = [ - Tool::as('weather') - ->for('useful when you need to search for current weather conditions') - ->withStringParameter('city', 'the city you want the weather for') - ->using(fn (string $city): string => 'The weather will be 75° and sunny'), - Tool::as('search') - ->for('useful for searching curret events or data') - ->withStringParameter('query', 'The detailed search query') - ->using(fn (string $query): string => 'The tigers game is at 3pm in detroit'), - ]; + $response = Prism::text() + ->using('anthropic', 'claude-3-7-sonnet-latest') + ->withPrompt('What is the meaning of life, the universe and everything in popular fiction?') + ->withProviderOptions(['thinking' => ['enabled' => true]]) + ->asText(); - $response = Prism::text() - ->using('anthropic', 'claude-3-7-sonnet-latest') - ->withTools($tools) - ->withMaxSteps(3) - ->withPrompt('What time is the tigers game today and should I wear a coat?') - ->withProviderOptions(['thinking' => ['enabled' => true]]) - ->asText(); + $expected_thinking = "This is a reference to Douglas Adams' popular science fiction series \"The Hitchhiker's Guide to the Galaxy\" where the supercomputer Deep Thought was built to calculate \"the Answer to the Ultimate Question of Life, the Universe, and Everything.\" After 7.5 million years of computation, it famously determined the answer to be \"42\" - a deliberately anticlimactic and absurd response that has become a significant pop culture reference.\n\nBeyond the Hitchhiker's reference, the question of life's meaning appears in many works of fiction across different media, with various philosophical approaches.\n\nI should note this humorous 42 reference while also mentioning how other fictional works have approached this philosophical question."; + $expected_signature = 'EuYBCkQYAiJAQ7ZOmBu5pa8U03x/RN5+Gs3tyKXFYcruUfnC8X/4AKBpJmB8qX+nQQ9atvYOXLD/mUAClCRZEaxt2fyEvdxnhRIMfFi6CLULECysli0mGgy5JRaOXL06fVJndm8iMD2T+D8dSIFJuctCnVeFKZme2TfIPIH+UMFO33a0ojzUq2VYy8+RzKkH7WYK9+580ipQ4yDVegd/67LKRtfb574HOHqwlPcfEbeiJuFuHrayoqK8KS2ltGYRckVGH6lNH46zUyjGaD2z3nZeti8UjmgnfMWRpjUmv0TWWGtrCKRoHGQ='; + + expect($response->text)->toBe("In popular fiction, the most famous answer to this question comes from Douglas Adams' \"The Hitchhiker's Guide to the Galaxy,\" where a supercomputer named Deep Thought calculates for 7.5 million years and determines that the answer is simply \"42.\" This deliberately absurd response has become an iconic joke about the futility of seeking simple answers to profound existential questions.\n\nBeyond this humorous reference, fiction explores life's meaning in countless ways:\n- Finding purpose through love and human connection (seen in works like \"The Good Place\")\n- The pursuit of knowledge and understanding (as in \"Contact\" by Carl Sagan)\n- Creating your own meaning in an indifferent universe (explored in existentialist fiction)\n- Religious or spiritual fulfillment (depicted in works like \"Life of Pi\")\n\nWhat makes this question compelling in fiction is that there's never a definitive answer - just different perspectives that reflect our own search for meaning."); + expect($response->additionalContent['thinking'])->toBe($expected_thinking); + expect($response->additionalContent['thinking_signature'])->toBe($expected_signature); + + expect($response->messages->last()) + ->additionalContent->thinking->toBe($expected_thinking) + ->additionalContent->thinking_signature->toBe($expected_signature); + }); + + it('can override budget tokens', function (): void { + $response = Prism::text() + ->using('anthropic', 'claude-3-7-sonnet-latest') + ->withPrompt('What is the meaning of life, the universe and everything in popular fiction?') + ->withProviderOptions([ + 'thinking' => [ + 'enabled' => true, + 'budgetTokens' => 2048, + ], + ]); + + $payload = Text::buildHttpRequestPayload($response->toRequest()); + + expect(data_get($payload, 'thinking.budget_tokens'))->toBe(2048); + }); + + it('can use extended thinking with tool calls', function (): void { + FixtureResponse::fakeResponseSequence('v1/messages', 'anthropic/text-with-extending-thinking-and-tool-calls'); + + $tools = [ + Tool::as('weather') + ->for('useful when you need to search for current weather conditions') + ->withStringParameter('city', 'the city you want the weather for') + ->using(fn (string $city): string => 'The weather will be 75° and sunny'), + Tool::as('search') + ->for('useful for searching curret events or data') + ->withStringParameter('query', 'The detailed search query') + ->using(fn (string $query): string => 'The tigers game is at 3pm in detroit'), + ]; + + $response = Prism::text() + ->using('anthropic', 'claude-3-7-sonnet-latest') + ->withTools($tools) + ->withMaxSteps(3) + ->withPrompt('What time is the tigers game today and should I wear a coat?') + ->withProviderOptions(['thinking' => ['enabled' => true]]) + ->asText(); - $expected_thinking = "The user is asking about:\n1. The time of the Tigers game today (likely referring to a sports team, probably Detroit Tigers baseball)\n2. Whether they should wear a coat (which relates to weather conditions)\n\nFor the first question, I need to search for the Tigers game schedule for today. For the second question, I need to check the weather in the relevant location.\n\nHowever, I'm missing some information:\n- The user hasn't specified which Tigers team they're referring to (though Detroit Tigers is most likely)\n- The user hasn't specified their location, which I need for the weather check\n\nI'll need to search for the Tigers game information first, and then check the weather in the appropriate location (likely Detroit if it's a home game)."; - $expected_signature = 'EuYBCkQYAiJAY1corUurDaKsURSV32GUvrp4ZySJDYJXGHIBx2aPaphiKr+Kcenv2gTcLxAvkU5zUxek2mX3GGkrp8XlN2qJAhIM7v4WGU9Wwfpn8qu1Ggzd9cK0sZX2z6qEbaciMKAfMsaYMc9zVHF1Y2qY+iC35WGiXAnEAZk+KBNGCo0V+t/U1bzJGhAigvTRKkDKpipQDXkfw+XdPzHh+VGFXut2TIPatMN5UrE1CvR+GtQT1cscbxBnuiXFwgs3B/QPlC2/l2VloajCHeYVaHqY3MIXiTyqe4HAyt51Go1Xt1ydVaY='; + $expected_thinking = "The user is asking about:\n1. The time of the Tigers game today (likely referring to a sports team, probably Detroit Tigers baseball)\n2. Whether they should wear a coat (which relates to weather conditions)\n\nFor the first question, I need to search for the Tigers game schedule for today. For the second question, I need to check the weather in the relevant location.\n\nHowever, I'm missing some information:\n- The user hasn't specified which Tigers team they're referring to (though Detroit Tigers is most likely)\n- The user hasn't specified their location, which I need for the weather check\n\nI'll need to search for the Tigers game information first, and then check the weather in the appropriate location (likely Detroit if it's a home game)."; + $expected_signature = 'EuYBCkQYAiJAY1corUurDaKsURSV32GUvrp4ZySJDYJXGHIBx2aPaphiKr+Kcenv2gTcLxAvkU5zUxek2mX3GGkrp8XlN2qJAhIM7v4WGU9Wwfpn8qu1Ggzd9cK0sZX2z6qEbaciMKAfMsaYMc9zVHF1Y2qY+iC35WGiXAnEAZk+KBNGCo0V+t/U1bzJGhAigvTRKkDKpipQDXkfw+XdPzHh+VGFXut2TIPatMN5UrE1CvR+GtQT1cscbxBnuiXFwgs3B/QPlC2/l2VloajCHeYVaHqY3MIXiTyqe4HAyt51Go1Xt1ydVaY='; - expect($response->text)->toBe("The Detroit Tigers game is today at 3pm in Detroit. The weather in Detroit will be 75° and sunny, so you likely won't need a coat. It's a warm, pleasant day - just a light jacket or sweater might be enough if you tend to get cold at outdoor events, but generally, these are comfortable conditions."); + expect($response->text)->toBe("The Detroit Tigers game is today at 3pm in Detroit. The weather in Detroit will be 75° and sunny, so you likely won't need a coat. It's a warm, pleasant day - just a light jacket or sweater might be enough if you tend to get cold at outdoor events, but generally, these are comfortable conditions."); - expect($response->steps->first()) - ->additionalContent->thinking->toBe($expected_thinking) - ->additionalContent->thinking_signature->toBe($expected_signature); + expect($response->steps->first()) + ->additionalContent->thinking->toBe($expected_thinking) + ->additionalContent->thinking_signature->toBe($expected_signature); - // Verify the assistant message with thinking is present in the second step's input messages - expect($response->steps->last()->messages[1]) - ->additionalContent->thinking->toBe($expected_thinking) - ->additionalContent->thinking_signature->toBe($expected_signature); + // Verify the assistant message with thinking is present in the second step's input messages + expect($response->steps->last()->messages[1]) + ->additionalContent->thinking->toBe($expected_thinking) + ->additionalContent->thinking_signature->toBe($expected_signature); + }); }); }); @@ -668,6 +797,48 @@ ->asText(); })->throws(PrismRequestTooLargeException::class); + + it('resolves gracefully with a Refusal finish reason when Anthropic refuses', function (): void { + FixtureResponse::fakeResponseSequence('v1/messages', 'anthropic/generate-text-with-refusal'); + + $response = Prism::text() + ->using('anthropic', 'claude-3-5-sonnet-20240620') + ->withPrompt('Tell me something forbidden.') + ->asText(); + + expect($response->finishReason)->toBe(FinishReason::Refusal); + }); +}); + +describe('pause_turn', function (): void { + it('resumes the turn when Anthropic returns stop_reason="pause_turn"', function (): void { + FixtureResponse::fakeResponseSequence('v1/messages', 'anthropic/generate-text-with-pause-turn'); + + $response = Prism::text() + ->using('anthropic', 'claude-3-5-sonnet-20240620') + ->withPrompt('Look something up for me.') + ->withMaxSteps(5) + ->asText(); + + // Two HTTP round-trips: the paused response, then the resumed completion. + expect($response->steps)->toHaveCount(2); + expect($response->steps->first()->finishReason)->toBe(FinishReason::Pause); + expect($response->steps->last()->finishReason)->toBe(FinishReason::Stop); + expect($response->text)->toContain('the answer is 42'); + }); + + it('stops resuming once maxSteps is reached', function (): void { + FixtureResponse::fakeResponseSequence('v1/messages', 'anthropic/generate-text-with-pause-turn'); + + $response = Prism::text() + ->using('anthropic', 'claude-3-5-sonnet-20240620') + ->withPrompt('Look something up for me.') + ->withMaxSteps(1) + ->asText(); + + expect($response->steps)->toHaveCount(1); + expect($response->steps->first()->finishReason)->toBe(FinishReason::Pause); + }); }); it('allows automatic caching enabled via providerOptions', function (): void { diff --git a/tests/Providers/Anthropic/Batch/CancelTest.php b/tests/Providers/Anthropic/Batch/CancelTest.php new file mode 100644 index 000000000..c96c89ee7 --- /dev/null +++ b/tests/Providers/Anthropic/Batch/CancelTest.php @@ -0,0 +1,28 @@ +set('prism.providers.anthropic.api_key', env('ANTHROPIC_API_KEY', 'sk-123')); +}); + +it('can cancel a batch', function (): void { + $batchId = 'msgbatch_013Zva2CMHLNnXjNJJKqJ2EF'; + + FixtureResponse::fakeResponseSequence("messages/batches/$batchId/cancel", 'anthropic/batch-cancel'); + + $provider = Prism::provider('anthropic'); + $result = $provider->cancelBatch(new CancelBatchRequest($batchId)); + + expect($result)->toBeInstanceOf(BatchJob::class); + expect($result->id)->not->toBeEmpty(); + expect($result->status)->toBe(BatchStatus::InProgress); +}); diff --git a/tests/Providers/Anthropic/Batch/CreateTest.php b/tests/Providers/Anthropic/Batch/CreateTest.php new file mode 100644 index 000000000..fe9162b94 --- /dev/null +++ b/tests/Providers/Anthropic/Batch/CreateTest.php @@ -0,0 +1,101 @@ +set('prism.providers.anthropic.api_key', env('ANTHROPIC_API_KEY', 'sk-123')); +}); + +it('can create a batch', function (): void { + FixtureResponse::fakeResponseSequence('v1/messages/batches', 'anthropic/batch-create'); + + $provider = Prism::provider('anthropic'); + $result = $provider->batch(createBatchRequest()); + + expect($result)->toBeInstanceOf(BatchJob::class) + ->and($result->id)->not->toBeEmpty() + ->and($result->status)->toBe(BatchStatus::InProgress) + ->and($result->requestCounts->total)->toBe(2) + ->and($result->createdAt)->not->toBeNull() + ->and($result->expiresAt)->not->toBeNull(); + + Http::assertSent(static function (Request $request): bool { + $body = json_decode($request->body(), true, 512, JSON_THROW_ON_ERROR); + + expect($body)->toHaveKey('requests') + ->and($body['requests'])->toHaveCount(2) + ->and($body['requests'][0])->toHaveKeys(['custom_id', 'params']) + ->and($body['requests'][0]['custom_id'])->toBe('request-1') + ->and($body['requests'][0]['params'])->toHaveKeys(['model', 'messages', 'max_tokens']) + ->and($body['requests'][1]['custom_id'])->toBe('request-2'); + + return true; + }); +}); + +it('throws when request count exceeds limit', function (): void { + Http::fake()->preventStrayRequests(); + + $items = []; + for ($i = 0; $i < Create::MAX_REQUESTS + 1; $i++) { + $items[] = new BatchRequestItem( + customId: "req-{$i}", + request: createTextRequest("Hello {$i}"), + ); + } + + $provider = Prism::provider('anthropic'); + $provider->batch(new BatchRequest(items: $items)); +})->throws(PrismException::class, 'Anthropic batch limit exceeded: 100001 requests submitted, maximum is 100,000.'); + +it('throws when payload size exceeds limit', function (): void { + Http::fake()->preventStrayRequests(); + + // Shrink the limit via config so the guard is exercised without + // materializing multi-hundred-megabyte payloads in the test worker. + config()->set('prism.anthropic.batch.max_payload_bytes', 1024); + + $items = []; + for ($i = 0; $i < 10; $i++) { + $items[] = new BatchRequestItem( + customId: "req-{$i}", + request: createTextRequest(str_repeat('A', 256)), + ); + } + + $provider = Prism::provider('anthropic'); + $provider->batch(new BatchRequest(items: $items)); +})->throws(PrismException::class, 'Anthropic request payload size exceeded the maximum of 1,024 bytes.'); + +it('throws when provider returns an error in response body', function (): void { + Http::fake([ + '/v1/messages/batches' => Http::response( + json_encode([ + 'type' => 'error', + 'error' => [ + 'type' => 'invalid_request_error', + 'message' => 'Invalid batch request.', + ], + ], JSON_THROW_ON_ERROR), + 200 + ), + ])->preventStrayRequests(); + + $provider = Prism::provider('anthropic'); + $provider->batch(createBatchRequest()); +})->throws(PrismException::class, 'Anthropic Error: [invalid_request_error] Invalid batch request.'); diff --git a/tests/Providers/Anthropic/Batch/Helpers.php b/tests/Providers/Anthropic/Batch/Helpers.php new file mode 100644 index 000000000..e2048d05b --- /dev/null +++ b/tests/Providers/Anthropic/Batch/Helpers.php @@ -0,0 +1,31 @@ +using('anthropic', 'claude-sonnet-4-20250514') + ->withPrompt($prompt) + ->toRequest(); +} + +function createBatchRequest(int $count = 2): BatchRequest +{ + $items = []; + for ($i = 1; $i <= $count; $i++) { + $items[] = new BatchRequestItem( + customId: "request-{$i}", + request: createTextRequest("Hello {$i}"), + ); + } + + return new BatchRequest(items: $items); +} diff --git a/tests/Providers/Anthropic/Batch/ListTest.php b/tests/Providers/Anthropic/Batch/ListTest.php new file mode 100644 index 000000000..288345901 --- /dev/null +++ b/tests/Providers/Anthropic/Batch/ListTest.php @@ -0,0 +1,26 @@ +set('prism.providers.anthropic.api_key', env('ANTHROPIC_API_KEY', 'sk-1234')); +}); + +it('can list batches', function (): void { + FixtureResponse::fakeResponseSequence('messages/batches', 'anthropic/batch-list'); + + $provider = Prism::provider('anthropic'); + $result = $provider->listBatches(new ListBatchesRequest); + + expect($result)->toBeInstanceOf(BatchListResult::class) + ->and($result->data)->toBeArray() + ->and($result->data)->toHaveLength(1) + ->and($result->data[0]->id)->not->toBeEmpty(); +}); diff --git a/tests/Providers/Anthropic/Batch/ResultsTest.php b/tests/Providers/Anthropic/Batch/ResultsTest.php new file mode 100644 index 000000000..41cf78215 --- /dev/null +++ b/tests/Providers/Anthropic/Batch/ResultsTest.php @@ -0,0 +1,52 @@ +set('prism.providers.anthropic.api_key', env('ANTHROPIC_API_KEY', 'sk-123')); +}); + +it('can get batch results', function (): void { + $batchId = 'msgbatch_01MHsMbyYXTBsr4VGuENCexC'; + + FixtureResponse::fakeResponseSequence("messages/batches/$batchId/results", 'anthropic/batch-results'); + + $provider = Prism::provider('anthropic'); + $results = $provider->getBatchResults(new GetBatchResultsRequest($batchId)); + + expect($results)->toHaveCount(2); + + // Assert first result + expect($results[0])->toBeInstanceOf(BatchResultItem::class) + ->and($results[0]->customId)->toBe('request-1') + ->and($results[0]->status)->toBe(BatchResultStatus::Succeeded) + ->and($results[0]->text)->toBe('Hello! Nice to meet you. How are you doing today?') + ->and($results[0]->usage)->not->toBeNull() + ->and($results[0]->usage->promptTokens)->toBe(10) + ->and($results[0]->usage->completionTokens)->toBe(16) + ->and($results[0]->messageId)->toBe('msg_011q6qHe7t97tdeXV5HEaqy3') + ->and($results[0]->model)->toBe('claude-sonnet-4-20250514') + ->and($results[0]->errorType)->toBeNull() + ->and($results[0]->errorMessage)->toBeNull(); + + // Assert second result + expect($results[1])->toBeInstanceOf(BatchResultItem::class) + ->and($results[1]->customId)->toBe('request-2') + ->and($results[1]->status)->toBe(BatchResultStatus::Succeeded) + ->and($results[1]->text)->toBe('Hello! Nice to see you again. How are you doing today?') + ->and($results[1]->usage)->not->toBeNull() + ->and($results[1]->usage->promptTokens)->toBe(10) + ->and($results[1]->usage->completionTokens)->toBe(17) + ->and($results[1]->messageId)->toBe('msg_01WRCGrkoNXQPJEycL2eEwsM') + ->and($results[1]->model)->toBe('claude-sonnet-4-20250514') + ->and($results[1]->errorType)->toBeNull() + ->and($results[1]->errorMessage)->toBeNull(); +}); diff --git a/tests/Providers/Anthropic/Batch/ResultsUnitTest.php b/tests/Providers/Anthropic/Batch/ResultsUnitTest.php new file mode 100644 index 000000000..e84cca38a --- /dev/null +++ b/tests/Providers/Anthropic/Batch/ResultsUnitTest.php @@ -0,0 +1,162 @@ + 'request-1', + 'result' => [ + 'type' => 'succeeded', + 'message' => [ + 'id' => 'msg_001', + 'model' => 'claude-sonnet-4-20250514', + 'content' => [['type' => 'text', 'text' => 'Hello there!']], + 'usage' => ['input_tokens' => 10, 'output_tokens' => 16], + ], + ], + ]), + json_encode([ + 'custom_id' => 'request-2', + 'result' => [ + 'type' => 'succeeded', + 'message' => [ + 'id' => 'msg_002', + 'model' => 'claude-sonnet-4-20250514', + 'content' => [['type' => 'text', 'text' => 'Hi again!']], + 'usage' => ['input_tokens' => 8, 'output_tokens' => 12], + ], + ], + ]), + ]); + + Http::fake([ + "https://api.anthropic.com/v1/messages/batches/{$batchId}/results" => Http::response($jsonl, 200), + ])->preventStrayRequests(); + + $items = (new Results(anthropicResultsClient()))->handle($batchId); + + expect($items)->toHaveCount(2) + ->and($items[0])->toBeInstanceOf(BatchResultItem::class) + ->and($items[0]->customId)->toBe('request-1') + ->and($items[0]->status)->toBe(BatchResultStatus::Succeeded) + ->and($items[0]->text)->toBe('Hello there!') + ->and($items[0]->usage->promptTokens)->toBe(10) + ->and($items[0]->usage->completionTokens)->toBe(16) + ->and($items[0]->messageId)->toBe('msg_001') + ->and($items[0]->model)->toBe('claude-sonnet-4-20250514') + ->and($items[1]->customId)->toBe('request-2') + ->and($items[1]->status)->toBe(BatchResultStatus::Succeeded) + ->and($items[1]->text)->toBe('Hi again!'); +}); + +it('parses errored results', function (): void { + $batchId = 'msgbatch_01ERR'; + $jsonl = json_encode([ + 'custom_id' => 'request-bad', + 'result' => [ + 'type' => 'errored', + 'error' => ['type' => 'overloaded_error', 'message' => 'Service overloaded.'], + ], + ]); + + Http::fake([ + "https://api.anthropic.com/v1/messages/batches/{$batchId}/results" => Http::response($jsonl, 200), + ])->preventStrayRequests(); + + $items = (new Results(anthropicResultsClient()))->handle($batchId); + + expect($items)->toHaveCount(1) + ->and($items[0]->customId)->toBe('request-bad') + ->and($items[0]->status)->toBe(BatchResultStatus::Errored) + ->and($items[0]->errorType)->toBe('overloaded_error') + ->and($items[0]->errorMessage)->toBe('Service overloaded.') + ->and($items[0]->text)->toBeNull(); +}); + +it('parses canceled results', function (): void { + $batchId = 'msgbatch_01CAN'; + $jsonl = json_encode([ + 'custom_id' => 'request-canceled', + 'result' => ['type' => 'canceled'], + ]); + + Http::fake([ + "https://api.anthropic.com/v1/messages/batches/{$batchId}/results" => Http::response($jsonl, 200), + ])->preventStrayRequests(); + + $items = (new Results(anthropicResultsClient()))->handle($batchId); + + expect($items)->toHaveCount(1) + ->and($items[0]->customId)->toBe('request-canceled') + ->and($items[0]->status)->toBe(BatchResultStatus::Canceled); +}); + +it('parses expired results', function (): void { + $batchId = 'msgbatch_01EXP'; + $jsonl = json_encode([ + 'custom_id' => 'request-expired', + 'result' => ['type' => 'expired'], + ]); + + Http::fake([ + "https://api.anthropic.com/v1/messages/batches/{$batchId}/results" => Http::response($jsonl, 200), + ])->preventStrayRequests(); + + $items = (new Results(anthropicResultsClient()))->handle($batchId); + + expect($items)->toHaveCount(1) + ->and($items[0]->customId)->toBe('request-expired') + ->and($items[0]->status)->toBe(BatchResultStatus::Expired); +}); + +it('skips blank lines in streamed JSONL', function (): void { + $batchId = 'msgbatch_01BLK'; + $jsonl = "\n\n".json_encode([ + 'custom_id' => 'req-1', + 'result' => [ + 'type' => 'succeeded', + 'message' => [ + 'id' => 'msg_001', + 'model' => 'claude-sonnet-4-20250514', + 'content' => [['type' => 'text', 'text' => 'Hi']], + 'usage' => ['input_tokens' => 5, 'output_tokens' => 3], + ], + ], + ])."\n\n"; + + Http::fake([ + "https://api.anthropic.com/v1/messages/batches/{$batchId}/results" => Http::response($jsonl, 200), + ])->preventStrayRequests(); + + $items = (new Results(anthropicResultsClient()))->handle($batchId); + + expect($items)->toHaveCount(1) + ->and($items[0]->customId)->toBe('req-1'); +}); + +it('returns empty array when response body is empty', function (): void { + $batchId = 'msgbatch_01EMPTY'; + + Http::fake([ + "https://api.anthropic.com/v1/messages/batches/{$batchId}/results" => Http::response('', 200), + ])->preventStrayRequests(); + + $items = (new Results(anthropicResultsClient()))->handle($batchId); + + expect($items)->toBeArray()->toBeEmpty(); +}); diff --git a/tests/Providers/Anthropic/Batch/RetrieveTest.php b/tests/Providers/Anthropic/Batch/RetrieveTest.php new file mode 100644 index 000000000..c5ee9ddc6 --- /dev/null +++ b/tests/Providers/Anthropic/Batch/RetrieveTest.php @@ -0,0 +1,40 @@ +set('prism.providers.anthropic.api_key', env('ANTHROPIC_API_KEY', 'sk-1234')); +}); + +it('can retrieve a batch', function (): void { + $batchId = 'msgbatch_013Zva2CMHLNnXjNJJKqJ2EF'; + + FixtureResponse::fakeResponseSequence("messages/batches/$batchId", 'anthropic/batch-retrieve'); + + $provider = Prism::provider('anthropic'); + $result = $provider->retrieveBatch(new RetrieveBatchRequest($batchId)); + + expect($result)->toBeInstanceOf(BatchJob::class) + ->and($result->id)->toBe('msgbatch_013Zva2CMHLNnXjNJJKqJ2EF') + ->and($result->status)->toBe(BatchStatus::InProgress) + ->and($result->requestCounts->processing)->toBe(100) + ->and($result->requestCounts->succeeded)->toBe(50) + ->and($result->requestCounts->failed)->toBe(30) + ->and($result->requestCounts->canceled)->toBe(10) + ->and($result->requestCounts->expired)->toBe(10) + ->and($result->requestCounts->total)->toBe(200) + ->and($result->createdAt)->toBe('2024-08-20T18:37:24.100435Z') + ->and($result->expiresAt)->toBe('2024-08-20T18:37:24.100435Z') + ->and($result->endedAt)->toBe('2024-08-20T18:37:24.100435Z') + ->and($result->resultsUrl)->toBe('https://api.anthropic.com/v1/messages/batches/msgbatch_013Zva2CMHLNnXjNJJKqJ2EF/results') + ->and($result->outputFileId)->toBeNull() + ->and($result->errorFileId)->toBeNull(); +}); diff --git a/tests/Providers/Anthropic/Files/DeleteTest.php b/tests/Providers/Anthropic/Files/DeleteTest.php new file mode 100644 index 000000000..4bfaa275f --- /dev/null +++ b/tests/Providers/Anthropic/Files/DeleteTest.php @@ -0,0 +1,29 @@ +set('prism.providers.anthropic.api_key', env('ANTHROPIC_API_KEY', 'sk-123')); + config()->set('prism.providers.anthropic.anthropic_beta', env('ANTHROPIC_BETA', 'files-api-2025-04-14')); +}); + +it('can delete a file', function (): void { + $fileId = 'file_011CZ3bbhcz6rgAXqe8pCLcX'; + + FixtureResponse::fakeResponseSequence("v1/files/$fileId", 'anthropic/file-delete'); + + $provider = Prism::provider('anthropic'); + + $result = $provider->deleteFile(new DeleteFileRequest($fileId)); + + expect($result)->toBeInstanceOf(DeleteFileResult::class) + ->and($result->id)->toBe($fileId) + ->and($result->deleted)->toBeTrue(); +}); diff --git a/tests/Providers/Anthropic/Files/DownloadTest.php b/tests/Providers/Anthropic/Files/DownloadTest.php new file mode 100644 index 000000000..39a03a6b0 --- /dev/null +++ b/tests/Providers/Anthropic/Files/DownloadTest.php @@ -0,0 +1,26 @@ +set('prism.providers.anthropic.api_key', env('ANTHROPIC_API_KEY', 'sk-123')); + config()->set('prism.providers.anthropic.anthropic_beta', env('ANTHROPIC_BETA', 'files-api-2025-04-14')); +}); + +it('can download a file', function (): void { + $fileId = 'file_011CZ3cYKGMZVroCGkmxwWkz'; + + FixtureResponse::fakeResponseSequence("v1/files/$fileId/content", 'anthropic/file-download'); + + $provider = Prism::provider('anthropic'); + + $result = $provider->downloadFile(new DownloadFileRequest($fileId)); + + expect($result)->toContain('This is a test file content'); +}); diff --git a/tests/Providers/Anthropic/Files/GetFileMetadataTest.php b/tests/Providers/Anthropic/Files/GetFileMetadataTest.php new file mode 100644 index 000000000..92a1396eb --- /dev/null +++ b/tests/Providers/Anthropic/Files/GetFileMetadataTest.php @@ -0,0 +1,36 @@ +set('prism.providers.anthropic.api_key', env('ANTHROPIC_API_KEY', 'sk-123')); + config()->set('prism.providers.anthropic.anthropic_beta', env('ANTHROPIC_BETA', 'files-api-2025-04-14')); +}); + +it('can get file metadata', function (): void { + $fileId = 'file_011CZ3cYKGMZVroCGkmxwWkz'; + + FixtureResponse::fakeResponseSequence("v1/files/$fileId", 'anthropic/file-get-metadata'); + + $provider = Prism::provider(Provider::Anthropic, [ + 'anthropic_beta' => 'files-api-2025-04-14', + ]); + + $result = $provider->getFileMetadata(new GetFileMetadataRequest($fileId)); + + expect($result)->toBeInstanceOf(FileData::class) + ->and($result->id)->toBe($fileId) + ->and($result->filename)->toBe('test1.txt') + ->and($result->mimeType)->toBe('text/plain') + ->and($result->sizeBytes)->toBe(17) + ->and($result->createdAt)->toBe('2026-03-14T20:35:54.416000Z') + ->and($result->purpose)->toBeNull(); +}); diff --git a/tests/Providers/Anthropic/Files/ListTest.php b/tests/Providers/Anthropic/Files/ListTest.php new file mode 100644 index 000000000..4f049fa9f --- /dev/null +++ b/tests/Providers/Anthropic/Files/ListTest.php @@ -0,0 +1,71 @@ +set('prism.providers.anthropic.api_key', env('ANTHROPIC_API_KEY', 'sk-123')); + config()->set('prism.providers.anthropic.anthropic_beta', env('ANTHROPIC_BETA', 'files-api-2025-04-14')); +}); + +it('can list files', function (): void { + FixtureResponse::fakeResponseSequence('v1/files', 'anthropic/list-files'); + + $provider = Prism::provider(Provider::Anthropic); + + $result = $provider->listFiles(new ListFilesRequest); + + expect($result)->toBeInstanceOf(FileListResult::class) + ->and($result->data)->toHaveCount(2) + ->and($result->hasMore)->toBeFalse() + ->and($result->firstId)->toBe('file_011CZ3cYKGMZVroCGkmxwWkz') + ->and($result->lastId)->toBe('file_011CZ3bbhcz6rgAXqe8pCLcX') + ->and($result->data[0])->toBeInstanceOf(FileData::class) + ->and($result->data[0]->id)->toBe('file_011CZ3cYKGMZVroCGkmxwWkz') + ->and($result->data[0]->filename)->toBe('test1.txt') + ->and($result->data[0]->mimeType)->toBe('text/plain') + ->and($result->data[0]->sizeBytes)->toBe(17) + ->and($result->data[0]->createdAt)->toBe('2026-03-14T20:35:54.416000Z') + ->and($result->data[1])->toBeInstanceOf(FileData::class) + ->and($result->data[1]->id)->toBe('file_011CZ3bbhcz6rgAXqe8pCLcX') + ->and($result->data[1]->filename)->toBe('data.jsonl') + ->and($result->data[1]->mimeType)->toBe('application/octet-stream') + ->and($result->data[1]->sizeBytes)->toBe(17) + ->and($result->data[1]->createdAt)->toBe('2026-03-14T20:23:33.521000Z'); +}); + +it('sends pagination parameters', function (): void { + $afterId = 'file_011CZ3cYKGMZVroCGkmxwWkz'; + + FixtureResponse::fakeResponseSequence("v1/files?limit=1&after_id=$afterId", 'anthropic/list-files-with-pagination'); + + $provider = Prism::provider(Provider::Anthropic, [ + 'anthropic_beta' => 'files-api-2025-04-14', + ]); + + $result = $provider->listFiles(new ListFilesRequest(limit: 1, afterId: $afterId)); + + expect($result)->toBeInstanceOf(FileListResult::class) + ->and($result->data)->toHaveCount(1) + ->and($result->hasMore)->toBeFalse() + ->and($result->firstId)->toBe('file_011CZ3bbhcz6rgAXqe8pCLcX') + ->and($result->lastId)->toBe('file_011CZ3bbhcz6rgAXqe8pCLcX') + ->and($result->data[0])->toBeInstanceOf(FileData::class) + ->and($result->data[0]->id)->toBe('file_011CZ3bbhcz6rgAXqe8pCLcX') + ->and($result->data[0]->filename)->toBe('data.jsonl') + ->and($result->data[0]->mimeType)->toBe('application/octet-stream') + ->and($result->data[0]->sizeBytes)->toBe(17) + ->and($result->data[0]->createdAt)->toBe('2026-03-14T20:23:33.521000Z'); + + Http::assertSent(fn (Request $request): bool => $request->toPsrRequest()->getUri()->getQuery() === "limit=1&after_id=$afterId"); +}); diff --git a/tests/Providers/Anthropic/Files/UploadTest.php b/tests/Providers/Anthropic/Files/UploadTest.php new file mode 100644 index 000000000..ba79805f6 --- /dev/null +++ b/tests/Providers/Anthropic/Files/UploadTest.php @@ -0,0 +1,56 @@ +set('prism.providers.anthropic.api_key', env('ANTHROPIC_API_KEY', 'sk-123')); + config()->set('prism.providers.anthropic.anthropic_beta', env('ANTHROPIC_BETA', 'files-api-2025-04-14')); +}); + +it('can upload a file', function (): void { + FixtureResponse::fakeResponseSequence('v1/files', 'anthropic/file-upload'); + + $provider = Prism::provider('anthropic'); + + $request = new UploadFileRequest( + filename: 'data.jsonl', + content: 'file content here', + ); + + $result = $provider->uploadFile($request); + + expect($result)->toBeInstanceOf(FileData::class) + ->and($result->id)->toBe('file_011CZ3bbhcz6rgAXqe8pCLcX') + ->and($result->filename)->toBe('data.jsonl') + ->and($result->mimeType)->toBe('application/octet-stream') + ->and($result->sizeBytes)->toBe(17) + ->and($result->createdAt)->toBe('2026-03-14T20:23:33.521000Z') + ->and($result->raw)->toBeArray(); + + Http::assertSent(static fn (Request $request): bool => str_contains($request->url(), 'v1/files')); +}); + +it('throws on error response', function (): void { + Http::fake([ + 'v1/files' => Http::response(json_encode([ + 'type' => 'error', + 'error' => [ + 'type' => 'invalid_request_error', + 'message' => 'Invalid file format.', + ], + ]), 200), + ])->preventStrayRequests(); + + $provider = Prism::provider('anthropic'); + $provider->uploadFile(new UploadFileRequest(filename: 'bad.txt', content: 'bad')); +})->throws(PrismException::class, 'Anthropic Error: [invalid_request_error] Invalid file format.'); diff --git a/tests/Providers/Anthropic/StreamTest.php b/tests/Providers/Anthropic/StreamTest.php index 6927430ac..93916976d 100644 --- a/tests/Providers/Anthropic/StreamTest.php +++ b/tests/Providers/Anthropic/StreamTest.php @@ -26,6 +26,7 @@ use Prism\Prism\Streaming\Events\ThinkingCompleteEvent; use Prism\Prism\Streaming\Events\ThinkingEvent; use Prism\Prism\Streaming\Events\ThinkingStartEvent; +use Prism\Prism\Streaming\Events\ToolApprovalRequestEvent; use Prism\Prism\Streaming\Events\ToolCallDeltaEvent; use Prism\Prism\Streaming\Events\ToolCallEvent; use Prism\Prism\Streaming\Events\ToolResultEvent; @@ -103,6 +104,7 @@ 'cacheWriteInputTokens' => 0, 'cacheReadInputTokens' => 0, 'thoughtTokens' => null, + 'cost' => null, ]); // Verify the HTTP request @@ -401,6 +403,56 @@ expect($providerToolResults[0]['content'])->toBeArray(); expect($providerToolResults[0]['tool_use_id'])->toBe($providerToolUses[0]['id']); }); + + it('preserves server tool content blocks during multi-step tool loop with web search', function (): void { + FixtureResponse::fakeStreamResponses('v1/messages', 'anthropic/stream-with-web-search-and-tool-call'); + + $tools = [ + Tool::as('weather') + ->for('useful when you need to search for current weather conditions') + ->withStringParameter('city', 'The city that you want the weather for') + ->using(fn (string $city): string => 'The weather will be 21° and partly cloudy'), + ]; + + $response = Prism::text() + ->using(Provider::Anthropic, 'claude-3-5-haiku-20241022') + ->withTools($tools) + ->withProviderTools([new ProviderTool(type: 'web_search_20250305', name: 'web_search')]) + ->withMaxSteps(3) + ->withPrompt('What is the weather in London?') + ->asStream(); + + $text = ''; + $events = []; + + foreach ($response as $event) { + $events[] = $event; + + if ($event instanceof TextDeltaEvent) { + $text .= $event->delta; + } + } + + // The tool loop should complete without a "Could not find search result for citation index" error + $lastEvent = end($events); + expect($lastEvent)->toBeInstanceOf(StreamEndEvent::class); + expect($lastEvent->finishReason)->toBe(FinishReason::Stop); + expect($text)->toContain('18°C'); + + // Verify the second HTTP request includes server tool blocks in the replayed assistant message + $requests = Http::recorded(); + expect($requests)->toHaveCount(2); + + $secondRequestPayload = $requests[1][0]->data(); + $assistantMessage = collect($secondRequestPayload['messages']) + ->first(fn (array $msg): bool => $msg['role'] === 'assistant'); + + $contentTypes = array_column($assistantMessage['content'], 'type'); + expect($contentTypes)->toContain('server_tool_use'); + expect($contentTypes)->toContain('web_search_tool_result'); + expect($contentTypes)->toContain('text'); + expect($contentTypes)->toContain('tool_use'); + }); }); describe('citations', function (): void { @@ -529,82 +581,178 @@ }); describe('thinking', function (): void { - it('yields thinking events', function (): void { - FixtureResponse::fakeStreamResponses('v1/messages', 'anthropic/stream-with-extended-thinking'); + describe('adaptive', function (): void { + it('yields thinking events', function (): void { + FixtureResponse::fakeStreamResponses('v1/messages', 'anthropic/stream-with-extended-thinking'); - $response = Prism::text() - ->using(Provider::Anthropic, 'claude-3-7-sonnet-20250219') - ->withPrompt('What is the meaning of life?') - ->withProviderOptions(['thinking' => ['enabled' => true]]) - ->asStream(); + $response = Prism::text() + ->using(Provider::Anthropic, 'claude-sonnet-4-6') + ->withPrompt('What is the meaning of life?') + ->withProviderOptions(['thinking' => ['type' => 'adaptive']]) + ->asStream(); + + $events = collect($response); + + expect($events->where(fn ($event): bool => $event->type() === StreamEventType::ThinkingStart)->sole()) + ->toBeInstanceOf(ThinkingStartEvent::class); - $events = collect($response); + $thinkingDeltas = $events->where( + fn (StreamEvent $event): bool => $event->type() === StreamEventType::ThinkingDelta + ); - expect($events->where(fn ($event): bool => $event->type() === StreamEventType::ThinkingStart)->sole()) - ->toBeInstanceOf(ThinkingStartEvent::class); + $thinkingDeltas + ->each(function (StreamEvent $event): void { + expect($event)->toBeInstanceOf(ThinkingEvent::class); + }); + + expect($thinkingDeltas->count())->toBeGreaterThan(10); + + expect($thinkingDeltas->first()->delta)->not->toBeEmpty(); + + expect($events->where(fn ($event): bool => $event->type() === StreamEventType::ThinkingComplete)->sole()) + ->toBeInstanceOf(ThinkingCompleteEvent::class); + }); - $thinkingDeltas = $events->where( - fn (StreamEvent $event): bool => $event->type() === StreamEventType::ThinkingDelta - ); + it('sends adaptive thinking payload in stream request', function (): void { + FixtureResponse::fakeStreamResponses('v1/messages', 'anthropic/stream-with-extended-thinking'); - $thinkingDeltas - ->each(function (StreamEvent $event): void { - expect($event)->toBeInstanceOf(ThinkingEvent::class); + $response = Prism::text() + ->using(Provider::Anthropic, 'claude-sonnet-4-6') + ->withPrompt('What is the meaning of life?') + ->withProviderOptions(['thinking' => ['type' => 'adaptive']]) + ->asStream(); + + collect($response); + + Http::assertSent(function (Request $request): bool { + $body = json_decode($request->body(), true); + + return isset($body['thinking']) + && $body['thinking']['type'] === 'adaptive' + && ! isset($body['thinking']['budget_tokens']); }); + }); + + it('sends effort in stream request', function (): void { + FixtureResponse::fakeStreamResponses('v1/messages', 'anthropic/stream-with-extended-thinking'); + + $response = Prism::text() + ->using(Provider::Anthropic, 'claude-sonnet-4-6') + ->withPrompt('What is the meaning of life?') + ->withProviderOptions([ + 'thinking' => ['type' => 'adaptive'], + 'effort' => 'medium', + ]) + ->asStream(); - expect($thinkingDeltas->count())->toBeGreaterThan(10); + collect($response); - expect($thinkingDeltas->first()->delta)->not->toBeEmpty(); + Http::assertSent(function (Request $request): bool { + $body = json_decode($request->body(), true); - expect($events->where(fn ($event): bool => $event->type() === StreamEventType::ThinkingComplete)->sole()) - ->toBeInstanceOf(ThinkingCompleteEvent::class); - }); + return isset($body['output_config']['effort']) + && $body['output_config']['effort'] === 'medium'; + }); + }); - it('can process streams with thinking enabled with custom budget', function (): void { - FixtureResponse::fakeStreamResponses('v1/messages', 'anthropic/stream-with-extended-thinking'); + it('includes thinking_signature in StreamEndEvent additionalContent', function (): void { + FixtureResponse::fakeStreamResponses('v1/messages', 'anthropic/stream-with-extended-thinking'); - $customBudget = 2048; - $response = Prism::text() - ->using(Provider::Anthropic, 'claude-3-7-sonnet-20250219') - ->withPrompt('What is the meaning of life?') - ->withProviderOptions([ - 'thinking' => [ - 'enabled' => true, - 'budgetTokens' => $customBudget, - ], - ]) - ->asStream(); + $response = Prism::text() + ->using(Provider::Anthropic, 'claude-sonnet-4-6') + ->withPrompt('What is the meaning of life?') + ->withProviderOptions(['thinking' => ['type' => 'adaptive']]) + ->asStream(); - collect($response); + $events = collect($response); - // Verify custom budget was sent - Http::assertSent(function (Request $request) use ($customBudget): bool { - $body = json_decode($request->body(), true); + $streamEndEvent = $events->last(); - return isset($body['thinking']) - && $body['thinking']['type'] === 'enabled' - && $body['thinking']['budget_tokens'] === $customBudget; + expect($streamEndEvent)->toBeInstanceOf(StreamEndEvent::class); + expect($streamEndEvent->additionalContent)->toHaveKey('thinking'); + expect($streamEndEvent->additionalContent['thinking'])->not->toBeEmpty(); + expect($streamEndEvent->additionalContent)->toHaveKey('thinking_signature'); + expect($streamEndEvent->additionalContent['thinking_signature'])->not->toBeEmpty(); }); }); - it('includes thinking_signature in StreamEndEvent additionalContent', function (): void { - FixtureResponse::fakeStreamResponses('v1/messages', 'anthropic/stream-with-extended-thinking'); + describe('legacy', function (): void { + it('yields thinking events', function (): void { + FixtureResponse::fakeStreamResponses('v1/messages', 'anthropic/stream-with-extended-thinking'); - $response = Prism::text() - ->using(Provider::Anthropic, 'claude-3-7-sonnet-20250219') - ->withPrompt('What is the meaning of life?') - ->withProviderOptions(['thinking' => ['enabled' => true]]) - ->asStream(); + $response = Prism::text() + ->using(Provider::Anthropic, 'claude-3-7-sonnet-20250219') + ->withPrompt('What is the meaning of life?') + ->withProviderOptions(['thinking' => ['enabled' => true]]) + ->asStream(); - $events = collect($response); + $events = collect($response); - $streamEndEvent = $events->last(); + expect($events->where(fn ($event): bool => $event->type() === StreamEventType::ThinkingStart)->sole()) + ->toBeInstanceOf(ThinkingStartEvent::class); - expect($streamEndEvent)->toBeInstanceOf(StreamEndEvent::class); - expect($streamEndEvent->additionalContent)->toHaveKey('thinking'); - expect($streamEndEvent->additionalContent['thinking'])->not->toBeEmpty(); - expect($streamEndEvent->additionalContent)->toHaveKey('thinking_signature'); - expect($streamEndEvent->additionalContent['thinking_signature'])->not->toBeEmpty(); + $thinkingDeltas = $events->where( + fn (StreamEvent $event): bool => $event->type() === StreamEventType::ThinkingDelta + ); + + $thinkingDeltas + ->each(function (StreamEvent $event): void { + expect($event)->toBeInstanceOf(ThinkingEvent::class); + }); + + expect($thinkingDeltas->count())->toBeGreaterThan(10); + + expect($thinkingDeltas->first()->delta)->not->toBeEmpty(); + + expect($events->where(fn ($event): bool => $event->type() === StreamEventType::ThinkingComplete)->sole()) + ->toBeInstanceOf(ThinkingCompleteEvent::class); + }); + + it('can process streams with thinking enabled with custom budget', function (): void { + FixtureResponse::fakeStreamResponses('v1/messages', 'anthropic/stream-with-extended-thinking'); + + $customBudget = 2048; + $response = Prism::text() + ->using(Provider::Anthropic, 'claude-3-7-sonnet-20250219') + ->withPrompt('What is the meaning of life?') + ->withProviderOptions([ + 'thinking' => [ + 'enabled' => true, + 'budgetTokens' => $customBudget, + ], + ]) + ->asStream(); + + collect($response); + + Http::assertSent(function (Request $request) use ($customBudget): bool { + $body = json_decode($request->body(), true); + + return isset($body['thinking']) + && $body['thinking']['type'] === 'enabled' + && $body['thinking']['budget_tokens'] === $customBudget; + }); + }); + + it('includes thinking_signature in StreamEndEvent additionalContent', function (): void { + FixtureResponse::fakeStreamResponses('v1/messages', 'anthropic/stream-with-extended-thinking'); + + $response = Prism::text() + ->using(Provider::Anthropic, 'claude-3-7-sonnet-20250219') + ->withPrompt('What is the meaning of life?') + ->withProviderOptions(['thinking' => ['enabled' => true]]) + ->asStream(); + + $events = collect($response); + + $streamEndEvent = $events->last(); + + expect($streamEndEvent)->toBeInstanceOf(StreamEndEvent::class); + expect($streamEndEvent->additionalContent)->toHaveKey('thinking'); + expect($streamEndEvent->additionalContent['thinking'])->not->toBeEmpty(); + expect($streamEndEvent->additionalContent)->toHaveKey('thinking_signature'); + expect($streamEndEvent->additionalContent['thinking_signature'])->not->toBeEmpty(); + }); }); }); @@ -818,3 +966,76 @@ expect(count($stepStartEvents))->toBe(count($stepFinishEvents)); }); }); + +describe('client-executed tools', function (): void { + it('stops streaming when a client-executed tool is called', function (): void { + FixtureResponse::fakeStreamResponses('v1/messages', 'anthropic/stream-with-client-executed-tool'); + + $tool = Tool::as('client_tool') + ->for('A tool that executes on the client') + ->withStringParameter('input', 'Input parameter') + ->clientExecuted(); + + $response = Prism::text() + ->using('anthropic', 'claude-3-5-sonnet-20240620') + ->withTools([$tool]) + ->withMaxSteps(3) + ->withPrompt('Use the client tool') + ->asStream(); + + $events = []; + $toolCallFound = false; + + foreach ($response as $event) { + $events[] = $event; + + if ($event instanceof ToolCallEvent) { + $toolCallFound = true; + } + } + + expect($toolCallFound)->toBeTrue(); + + $lastEvent = end($events); + expect($lastEvent)->toBeInstanceOf(StreamEndEvent::class); + expect($lastEvent->finishReason)->toBe(FinishReason::ToolCalls); + }); +}); + +describe('approval-required tools', function (): void { + it('stops streaming with an approval request event when approval is required', function (): void { + FixtureResponse::fakeStreamResponses('v1/messages', 'anthropic/stream-with-approval-tool'); + + $tool = Tool::as('delete_file') + ->for('Delete a file. Requires user approval.') + ->withStringParameter('path', 'File path to delete') + ->using(fn (string $path): string => "Deleted: {$path}") + ->requiresApproval(); + + $response = Prism::text() + ->using('anthropic', 'claude-3-5-sonnet-20240620') + ->withTools([$tool]) + ->withMaxSteps(3) + ->withPrompt('Delete the file at /tmp/test.txt') + ->asStream(); + + $events = []; + + foreach ($response as $event) { + $events[] = $event; + } + + $eventTypes = array_map(fn (StreamEvent $e): string => $e::class, $events); + + $approvalIndex = array_search(ToolApprovalRequestEvent::class, $eventTypes); + expect($approvalIndex)->not->toBeFalse(); + + $approvalEvent = $events[$approvalIndex]; + expect($approvalEvent->toolCall->name)->toBe('delete_file') + ->and($approvalEvent->approvalId)->toStartWith('apr_'); + + $lastEvent = end($events); + expect($lastEvent)->toBeInstanceOf(StreamEndEvent::class); + expect($lastEvent->finishReason)->toBe(FinishReason::ToolCalls); + }); +}); diff --git a/tests/Providers/Anthropic/StructuredTest.php b/tests/Providers/Anthropic/StructuredTest.php index 1dbffbacf..d39c0a022 100644 --- a/tests/Providers/Anthropic/StructuredTest.php +++ b/tests/Providers/Anthropic/StructuredTest.php @@ -96,7 +96,80 @@ expect($response->meta->rateLimits[0]->resetsAt)->toEqual($requests_reset); }); -it('can use extending thinking', function (): void { +it('can use adaptive thinking', function (): void { + FixtureResponse::fakeResponseSequence('v1/messages', 'anthropic/structured-with-extending-thinking'); + + $response = Prism::structured() + ->using('anthropic', 'claude-sonnet-4-6') + ->withSchema(new ObjectSchema('output', 'the output object', [new StringSchema('text', 'the output text')], ['text'])) + ->withPrompt('What is the meaning of life, the universe and everything in popular fiction?') + ->withProviderOptions(['thinking' => ['type' => 'adaptive']]) + ->asStructured(); + + expect($response->structured['text'])->toContain('Douglas Adams'); + expect($response->additionalContent['thinking'])->toContain('meaning of life'); + expect($response->additionalContent)->toHaveKey('thinking_signature'); +}); + +it('works with adaptive thinking when use_tool_calling is true', function (): void { + FixtureResponse::fakeResponseSequence( + 'v1/messages', + 'anthropic/structured-with-use-tool-calling' + ); + + $schema = new ObjectSchema('output', 'the output object', [ + new StringSchema('answer', 'The answer about life, universe and everything'), + ], ['answer']); + + $response = Prism::structured() + ->withSchema($schema) + ->using(Provider::Anthropic, 'claude-sonnet-4-6') + ->withSystemPrompt('You are a helpful assistant.') + ->withPrompt('What is the meaning of life, the universe and everything in popular fiction?') + ->withProviderOptions(['thinking' => ['type' => 'adaptive'], 'use_tool_calling' => true]) + ->asStructured(); + + expect($response->structured)->toBeArray(); + expect($response->structured)->toHaveKey('answer'); + expect($response->structured['answer'])->toBeString(); + + expect($response->additionalContent)->toHaveKey('thinking'); + expect($response->additionalContent['thinking'])->toBeString(); + expect($response->additionalContent['thinking_signature'])->toBeString(); +}); + +it('merges effort with output_config.format in the request payload', function (): void { + Prism::fake(); + + $schema = new ObjectSchema( + 'output', + 'the output object', + [ + new StringSchema('weather', 'The weather forecast'), + new BooleanSchema('coat_required', 'whether a coat is required'), + ], + ['weather', 'coat_required'] + ); + + $request = Prism::structured() + ->withSchema($schema) + ->using(Provider::Anthropic, 'claude-sonnet-4-6') + ->withPrompt('What is the weather?') + ->withProviderOptions(['effort' => 'high']); + + $payload = Structured::buildHttpRequestPayload($request->toRequest()); + + expect($payload)->toHaveKey('output_config'); + expect($payload['output_config'])->toBe([ + 'effort' => 'high', + 'format' => [ + 'type' => 'json_schema', + 'schema' => $schema->toArray(), + ], + ]); +}); + +it('can use legacy extended thinking', function (): void { FixtureResponse::fakeResponseSequence('v1/messages', 'anthropic/structured-with-extending-thinking'); $response = Prism::structured() @@ -131,7 +204,7 @@ )->toThrow(PrismException::class, 'Citations are not supported with tool calling mode'); }); -it('works with thinking mode when use_tool_calling is true', function (): void { +it('works with legacy thinking mode when use_tool_calling is true', function (): void { FixtureResponse::fakeResponseSequence( 'v1/messages', 'anthropic/structured-with-use-tool-calling' diff --git a/tests/Providers/Anthropic/StructuredWithToolsTest.php b/tests/Providers/Anthropic/StructuredWithToolsTest.php index d47e3f25c..7175cb7df 100644 --- a/tests/Providers/Anthropic/StructuredWithToolsTest.php +++ b/tests/Providers/Anthropic/StructuredWithToolsTest.php @@ -376,6 +376,34 @@ expect($payload['tool_choice'])->toBe(['type' => 'tool', 'name' => 'output_structured_data']); }); + it('does not set tool_choice when adaptive thinking is used with tool calling', function (): void { + Prism::fake(); + + $schema = new ObjectSchema( + 'output', + 'the output object', + [ + new StringSchema('answer', 'The answer'), + ], + ['answer'] + ); + + $request = Prism::structured() + ->withSchema($schema) + ->using(Provider::Anthropic, 'claude-sonnet-4-6') + ->withPrompt('Test') + ->withProviderOptions([ + 'thinking' => ['type' => 'adaptive'], + 'use_tool_calling' => true, + ]); + + $payload = Structured::buildHttpRequestPayload( + $request->toRequest() + ); + + expect($payload)->not->toHaveKey('tool_choice'); + }); + it('can generate structured output with provider tools using native output format', function (): void { FixtureResponse::fakeResponseSequence('*', 'anthropic/structured-with-provider-tool-native'); diff --git a/tests/Providers/Anthropic/ToolMapTest.php b/tests/Providers/Anthropic/ToolMapTest.php index c40cda65f..3e0815699 100644 --- a/tests/Providers/Anthropic/ToolMapTest.php +++ b/tests/Providers/Anthropic/ToolMapTest.php @@ -58,3 +58,45 @@ 'ephemeral', AnthropicCacheType::Ephemeral->value, ]); + +it('sets eager_input_streaming when eager_input_streaming provider option is true on tool', function (): void { + $tool = (new Tool) + ->as('search') + ->for('Searching the web') + ->withStringParameter('query', 'the detailed search query') + ->using(fn (): string => '[Search results]') + ->withProviderOptions(['eager_input_streaming' => true]); + + expect(ToolMap::map([$tool]))->toBe([[ + 'name' => 'search', + 'description' => 'Searching the web', + 'input_schema' => [ + 'type' => 'object', + 'properties' => [ + 'query' => [ + 'description' => 'the detailed search query', + 'type' => 'string', + ], + ], + 'required' => ['query'], + ], + 'eager_input_streaming' => true, + ]]); +}); + +it('omits eager_input_streaming when eager_input_streaming provider option is omitted or false', function (?bool $eagerInputStreaming): void { + $tool = (new Tool) + ->as('search') + ->for('Searching the web') + ->withStringParameter('query', 'the detailed search query') + ->using(fn (): string => '[Search results]'); + + if ($eagerInputStreaming !== null) { + $tool = $tool->withProviderOptions(['eager_input_streaming' => $eagerInputStreaming]); + } + + expect(ToolMap::map([$tool])[0])->not->toHaveKey('eager_input_streaming'); +})->with([ + 'omitted' => [null], + 'false' => [false], +]); diff --git a/tests/Providers/Azure/AzureTextTest.php b/tests/Providers/Azure/AzureTextTest.php new file mode 100644 index 000000000..3c8338702 --- /dev/null +++ b/tests/Providers/Azure/AzureTextTest.php @@ -0,0 +1,71 @@ +set('prism.providers.azure.url', env('AZURE_AI_URL', 'https://test-resource.openai.azure.com')); + config()->set('prism.providers.azure.api_key', env('AZURE_AI_API_KEY', 'azure-key-1234')); + config()->set('prism.providers.azure.api_version', env('AZURE_AI_API_VERSION', '2024-10-21')); + config()->set('prism.providers.azure.deployment_name', env('AZURE_AI_DEPLOYMENT', 'gpt-4o')); +}); + +it('can generate text with a prompt', function (): void { + Http::fake([ + '*' => Http::response([ + 'id' => 'chatcmpl-azure-1', + 'model' => 'gpt-4o', + 'choices' => [[ + 'index' => 0, + 'message' => ['role' => 'assistant', 'content' => 'Hello from Azure!'], + 'finish_reason' => 'stop', + ]], + 'usage' => [ + 'prompt_tokens' => 12, + 'completion_tokens' => 5, + ], + ]), + ])->preventStrayRequests(); + + $response = Prism::text() + ->using(Provider::Azure, 'gpt-4o') + ->withPrompt('Hello') + ->asText(); + + expect($response->text)->toBe('Hello from Azure!') + ->and($response->usage->promptTokens)->toBe(12) + ->and($response->usage->completionTokens)->toBe(5) + ->and($response->usage->cacheReadInputTokens)->toBeNull(); +}); + +it('excludes cached tokens from promptTokens', function (): void { + Http::fake([ + '*' => Http::response([ + 'id' => 'cache-test-1', + 'model' => 'gpt-4o', + 'choices' => [[ + 'index' => 0, + 'message' => ['role' => 'assistant', 'content' => 'Hello!'], + 'finish_reason' => 'stop', + ]], + 'usage' => [ + 'prompt_tokens' => 100, + 'completion_tokens' => 10, + 'prompt_tokens_details' => ['cached_tokens' => 60], + ], + ]), + ])->preventStrayRequests(); + + $response = Prism::text() + ->using(Provider::Azure, 'gpt-4o') + ->withPrompt('Hello') + ->asText(); + + expect($response->usage->promptTokens)->toBe(40) + ->and($response->usage->cacheReadInputTokens)->toBe(60); +}); diff --git a/tests/Providers/DeepSeek/TextTest.php b/tests/Providers/DeepSeek/TextTest.php index 94c47c4d4..055537d2a 100644 --- a/tests/Providers/DeepSeek/TextTest.php +++ b/tests/Providers/DeepSeek/TextTest.php @@ -129,8 +129,13 @@ expect($secondStep->messages[1]->toolCalls[1]->name)->toBe('weather'); expect($secondStep->messages[2])->toBeInstanceOf(ToolResultMessage::class); - // Assert usage - expect($response->usage->promptTokens)->toBe(507); + // Assert usage. promptTokens is now the FRESH portion (prompt_tokens minus + // prompt_cache_hit_tokens) so cost trackers can apply the cached rate to the + // hit portion separately. Aggregated across both steps: + // step 1 fixture: prompt_tokens=220, prompt_cache_hit_tokens=192 → fresh 28, cached 192 + // step 2 fixture: prompt_tokens=287, prompt_cache_hit_tokens=256 → fresh 31, cached 256 + expect($response->usage->promptTokens)->toBe(59); + expect($response->usage->cacheReadInputTokens)->toBe(448); expect($response->usage->completionTokens)->toBe(76); // Assert response diff --git a/tests/Providers/Gemini/ExceptionHandlingTest.php b/tests/Providers/Gemini/ExceptionHandlingTest.php index 4805aa461..eec31bd78 100644 --- a/tests/Providers/Gemini/ExceptionHandlingTest.php +++ b/tests/Providers/Gemini/ExceptionHandlingTest.php @@ -38,6 +38,45 @@ function createGeminiMockResponse(int $statusCode, array $json = []): Response ->toThrow(PrismRateLimitedException::class); }); +it('extracts retry_after and rate limit details from quota violations', function (): void { + $mockResponse = createGeminiMockResponse(429, [ + 'error' => [ + 'code' => 429, + 'message' => 'You exceeded your current quota. Please retry in 35.458759309s.', + 'status' => 'RESOURCE_EXHAUSTED', + 'details' => [ + [ + '@type' => 'type.googleapis.com/google.rpc.QuotaFailure', + 'violations' => [ + [ + 'quotaMetric' => 'generativelanguage.googleapis.com/generate_content_paid_tier_input_token_count', + 'quotaId' => 'GenerateContentPaidTierInputTokensPerModelPerMinute', + 'quotaValue' => '16000', + ], + ], + ], + [ + '@type' => 'type.googleapis.com/google.rpc.RetryInfo', + 'retryDelay' => '35s', + ], + ], + ], + ]); + $exception = new RequestException($mockResponse); + + try { + $this->provider->handleRequestException('gemini-pro', $exception); + $this->fail('Expected PrismRateLimitedException to be thrown.'); + } catch (PrismRateLimitedException $e) { + expect($e->retryAfter)->toBe(35) + ->and($e->rateLimits)->toHaveCount(1) + ->and($e->rateLimits[0]->name)->toBe('GenerateContentPaidTierInputTokensPerModelPerMinute') + ->and($e->rateLimits[0]->limit)->toBe(16000) + ->and($e->rateLimits[0]->remaining)->toBeNull() + ->and($e->rateLimits[0]->resetsAt)->not->toBeNull(); + } +}); + it('handles provider overloaded errors (503)', function (): void { $mockResponse = createGeminiMockResponse(503, []); $exception = new RequestException($mockResponse); diff --git a/tests/Providers/Gemini/GeminiMediaTest.php b/tests/Providers/Gemini/GeminiMediaTest.php index fe1cab83d..f47edfcd2 100644 --- a/tests/Providers/Gemini/GeminiMediaTest.php +++ b/tests/Providers/Gemini/GeminiMediaTest.php @@ -153,7 +153,7 @@ function (Request $request): bool { 'text' => 'Transcribe this audio', ]) ->and($message[1]['inline_data'])->toHaveKeys(['mime_type', 'data']) - ->and($message[1]['inline_data']['mime_type'])->toBe('audio/x-wav') + ->and($message[1]['inline_data']['mime_type'])->toBe('audio/wav') ->and($message[1]['inline_data']['data'])->toBe( base64_encode(file_get_contents('tests/Fixtures/sample-audio.wav')) ); @@ -171,7 +171,7 @@ function (Request $request): bool { $audioUrl => Http::response( file_get_contents('tests/Fixtures/sample-audio.wav'), 200, - ['Content-Type' => 'audio/x-wav'] + ['Content-Type' => 'audio/wav'] ), ]); @@ -197,7 +197,7 @@ function (Request $request): bool { 'text' => 'What is in this audio', ]) ->and($message[1]['inline_data'])->toHaveKeys(['mime_type', 'data']) - ->and($message[1]['inline_data']['mime_type'])->toBe('audio/x-wav') + ->and($message[1]['inline_data']['mime_type'])->toBe('audio/wav') ->and($message[1]['inline_data']['data'])->toBe( base64_encode(file_get_contents('tests/Fixtures/sample-audio.wav')) ); diff --git a/tests/Providers/Gemini/GeminiStreamTest.php b/tests/Providers/Gemini/GeminiStreamTest.php index 92e15dae3..94830658a 100644 --- a/tests/Providers/Gemini/GeminiStreamTest.php +++ b/tests/Providers/Gemini/GeminiStreamTest.php @@ -310,9 +310,14 @@ expect($stepStartEvents)->toHaveCount(1); // Check for StepFinishEvent before StreamEndEvent - $stepFinishEvents = array_filter($events, fn (StreamEvent $e): bool => $e instanceof StepFinishEvent); + $stepFinishEvents = array_values(array_filter($events, fn (StreamEvent $e): bool => $e instanceof StepFinishEvent)); expect($stepFinishEvents)->toHaveCount(1); + // Verify StepFinishEvent contains usage data + expect($stepFinishEvents[0]->usage)->not->toBeNull(); + expect($stepFinishEvents[0]->usage->promptTokens)->toBe(21); + expect($stepFinishEvents[0]->usage->completionTokens)->toBe(47); + // Verify order: StreamStart -> StepStart -> ... -> StepFinish -> StreamEnd $eventTypes = array_map(get_class(...), $events); $streamStartIndex = array_search(StreamStartEvent::class, $eventTypes); @@ -440,3 +445,52 @@ expect($toolCalls[1]->reasoningId)->not->toBeNull(); expect($toolCalls[0]->reasoningId)->toBe($toolCalls[1]->reasoningId); }); + +it('sends topK in generationConfig for streaming', function (): void { + FixtureResponse::fakeResponseSequence('*', 'gemini/stream-basic-text'); + + $events = []; + $response = Prism::text() + ->using(Provider::Gemini, 'gemini-2.0-flash') + ->withPrompt('Explain how AI works') + ->usingTopK(40) + ->asStream(); + + foreach ($response as $event) { + $events[] = $event; + } + + Http::assertSent(function (Request $request): true { + $data = $request->data(); + + expect($data['generationConfig']) + ->toHaveKey('topK') + ->and($data['generationConfig']['topK'])->toBe(40); + + return true; + }); +}); + +it('passes service_tier in the request body for streaming', function (): void { + FixtureResponse::fakeResponseSequence('*', 'gemini/stream-basic-text'); + + $response = Prism::text() + ->using(Provider::Gemini, 'gemini-2.5-flash') + ->withPrompt('Summarize this document.') + ->withProviderOptions(['serviceTier' => 'flex']) + ->asStream(); + + // Consume the stream + foreach ($response as $event) { + // + } + + Http::assertSent(function (Request $request): true { + $data = $request->data(); + + expect($data)->toHaveKey('service_tier') + ->and($data['service_tier'])->toBe('flex'); + + return true; + }); +}); diff --git a/tests/Providers/Gemini/GeminiStructuredTest.php b/tests/Providers/Gemini/GeminiStructuredTest.php index c05af645f..0c8f547b4 100644 --- a/tests/Providers/Gemini/GeminiStructuredTest.php +++ b/tests/Providers/Gemini/GeminiStructuredTest.php @@ -189,7 +189,7 @@ expect($response->structured['value'])->toBe('forty-two'); Http::assertSent(function (Request $request): bool { - $schema = $request->data()['generationConfig']['response_schema']; + $schema = $request->data()['generationConfig']['response_json_schema']; expect($schema)->toHaveKey('properties'); expect($schema['properties'])->toHaveKey('value'); @@ -256,14 +256,14 @@ expect($response->structured['content']['title'])->toBe('Understanding AI'); Http::assertSent(function (Request $request): bool { - $schema = $request->data()['generationConfig']['response_schema']; + $schema = $request->data()['generationConfig']['response_json_schema']; $anyOf = $schema['properties']['content']['anyOf']; expect($anyOf)->toHaveCount(2); foreach ($anyOf as $nestedSchema) { expect($nestedSchema)->not->toHaveKey('name'); - expect($nestedSchema)->not->toHaveKey('additionalProperties'); + expect($nestedSchema['additionalProperties'])->toBeFalse(); expect($nestedSchema)->toHaveKey('type'); expect($nestedSchema['type'])->toBe('object'); expect($nestedSchema)->toHaveKey('properties'); @@ -311,7 +311,7 @@ expect($response->structured['score'])->toBeLessThanOrEqual(5.0); Http::assertSent(function (Request $request): bool { - $schema = $request->data()['generationConfig']['response_schema']; + $schema = $request->data()['generationConfig']['response_json_schema']; expect($schema['properties'])->toHaveKey('score'); expect($schema['properties']['score'])->toHaveKey('minimum'); @@ -355,7 +355,7 @@ expect($response->structured['value'])->toBeNull(); Http::assertSent(function (Request $request): bool { - $schema = $request->data()['generationConfig']['response_schema']; + $schema = $request->data()['generationConfig']['response_json_schema']; $anyOf = $schema['properties']['value']['anyOf']; expect($anyOf)->toHaveCount(3); @@ -404,3 +404,61 @@ expect($response->steps[0]->additionalContent['thoughtSummaries'])->toBeArray(); expect($response->steps[0]->additionalContent['thoughtSummaries'][0])->toContain('Let me think about'); }); + +it('sends topK in generationConfig for structured output', function (): void { + FixtureResponse::fakeResponseSequence('*', 'gemini/generate-structured'); + + Prism::structured() + ->using(Provider::Gemini, 'gemini-1.5-flash-002') + ->withSchema(new ObjectSchema( + 'output', + 'the output object', + [ + new StringSchema('weather', 'The weather forecast'), + new BooleanSchema('coat_required', 'whether a coat is required'), + ], + ['weather', 'coat_required'], + )) + ->withPrompt('What time is the tigers game today and should I wear a coat?') + ->usingTopK(40) + ->asStructured(); + + Http::assertSent(function (Request $request): true { + $data = $request->data(); + + expect($data['generationConfig']) + ->toHaveKey('topK') + ->and($data['generationConfig']['topK'])->toBe(40); + + return true; + }); +}); + +it('passes service_tier in the request body for structured output', function (): void { + FixtureResponse::fakeResponseSequence('*', 'gemini/generate-structured'); + + $schema = new ObjectSchema( + 'output', + 'the output object', + [ + new StringSchema('weather', 'The weather forecast'), + ], + ['weather'] + ); + + Prism::structured() + ->using(Provider::Gemini, 'gemini-2.5-flash') + ->withSchema($schema) + ->withPrompt('What is the weather?') + ->withProviderOptions(['serviceTier' => 'flex']) + ->asStructured(); + + Http::assertSent(function (Request $request): true { + $data = $request->data(); + + expect($data)->toHaveKey('service_tier') + ->and($data['service_tier'])->toBe('flex'); + + return true; + }); +}); diff --git a/tests/Providers/Gemini/GeminiTextTest.php b/tests/Providers/Gemini/GeminiTextTest.php index 354d86cb8..c276eb2f4 100644 --- a/tests/Providers/Gemini/GeminiTextTest.php +++ b/tests/Providers/Gemini/GeminiTextTest.php @@ -9,7 +9,6 @@ use Prism\Prism\Enums\Citations\CitationSourceType; use Prism\Prism\Enums\FinishReason; use Prism\Prism\Enums\Provider; -use Prism\Prism\Exceptions\PrismException; use Prism\Prism\Facades\Prism; use Prism\Prism\Schema\ArraySchema; use Prism\Prism\Schema\BooleanSchema; @@ -405,7 +404,7 @@ function (Request $request): bool { }); }); - it('throws an exception if provider tools are enabled with other tools', function (): void { + it('sends includeServerSideToolInvocations when provider tools and custom tools are both present', function (): void { FixtureResponse::fakeResponseSequence('*', 'gemini/generate-text-with-search-grounding'); $tools = [ @@ -417,13 +416,19 @@ function (Request $request): bool { ]; Prism::text() - ->using(Provider::Gemini, 'gemini-2.0-flash') - ->withMaxSteps(3) + ->using(Provider::Gemini, 'gemini-3.1-pro-preview') + ->withMaxSteps(1) ->withTools($tools) ->withProviderTools([new ProviderTool('google_search')]) ->withPrompt('What sport fixtures are on today, and will I need a coat based on today\'s weather forecast?') ->asText(); - })->throws(PrismException::class, 'Use of provider tools with custom tools is not currently supported by Gemini.'); + + Http::assertSent(function (Request $request): bool { + $data = $request->data(); + + return ($data['tool_config']['includeServerSideToolInvocations'] ?? false) === true; + }); + }); it('adds file_search provider tool with options to the request', function (): void { FixtureResponse::fakeResponseSequence('*', 'gemini/generate-text-with-file-search'); @@ -620,3 +625,133 @@ function (Request $request): bool { }); }); }); + +describe('Top K for Gemini', function (): void { + it('sends topK in generationConfig', function (): void { + FixtureResponse::fakeResponseSequence('*', 'gemini/generate-text-with-a-prompt'); + + Prism::text() + ->using(Provider::Gemini, 'gemini-1.5-flash') + ->withPrompt('Who are you?') + ->usingTopK(40) + ->asText(); + + Http::assertSent(function (Request $request): true { + $data = $request->data(); + + expect($data['generationConfig']) + ->toHaveKey('topK') + ->and($data['generationConfig']['topK'])->toBe(40); + + return true; + }); + }); + + it('does not send topK when not set', function (): void { + FixtureResponse::fakeResponseSequence('*', 'gemini/generate-text-with-a-prompt'); + + Prism::text() + ->using(Provider::Gemini, 'gemini-1.5-flash') + ->withPrompt('Who are you?') + ->asText(); + + Http::assertSent(function (Request $request): true { + $data = $request->data(); + + expect($data['generationConfig'] ?? [])->not->toHaveKey('topK'); + + return true; + }); + }); + + it('sends topK alongside other generation config params', function (): void { + FixtureResponse::fakeResponseSequence('*', 'gemini/generate-text-with-a-prompt'); + + Prism::text() + ->using(Provider::Gemini, 'gemini-1.5-flash') + ->withPrompt('Who are you?') + ->usingTemperature(0.7) + ->usingTopP(0.9) + ->usingTopK(40) + ->withMaxTokens(100) + ->asText(); + + Http::assertSent(function (Request $request): true { + $data = $request->data(); + + expect($data['generationConfig']) + ->toHaveKey('temperature') + ->toHaveKey('topP') + ->toHaveKey('topK') + ->toHaveKey('maxOutputTokens') + ->and($data['generationConfig']['temperature'])->toBe(0.7) + ->and($data['generationConfig']['topP'])->toBe(0.9) + ->and($data['generationConfig']['topK'])->toBe(40) + ->and($data['generationConfig']['maxOutputTokens'])->toBe(100); + + return true; + }); + }); +}); + +describe('Flex Inference for Gemini', function (): void { + it('passes service_tier in the request body', function (): void { + FixtureResponse::fakeResponseSequence('*', 'gemini/generate-text-with-a-prompt'); + + Prism::text() + ->using(Provider::Gemini, 'gemini-2.5-flash') + ->withPrompt('Summarize this document.') + ->withProviderOptions(['serviceTier' => 'flex']) + ->asText(); + + Http::assertSent(function (Request $request): true { + $data = $request->data(); + + expect($data)->toHaveKey('service_tier') + ->and($data['service_tier'])->toBe('flex'); + + return true; + }); + }); + + it('does not include service_tier when not set', function (): void { + FixtureResponse::fakeResponseSequence('*', 'gemini/generate-text-with-a-prompt'); + + Prism::text() + ->using(Provider::Gemini, 'gemini-2.5-flash') + ->withPrompt('Hello') + ->asText(); + + Http::assertSent(function (Request $request): true { + $data = $request->data(); + + expect($data)->not->toHaveKey('service_tier'); + + return true; + }); + }); +}); + +it('excludes implicitly cached tokens from promptTokens', function (): void { + Http::fake([ + '*' => Http::response([ + 'candidates' => [[ + 'content' => ['parts' => [['text' => 'Hello!']], 'role' => 'model'], + 'finishReason' => 'STOP', + ]], + 'usageMetadata' => [ + 'promptTokenCount' => 100, + 'cachedContentTokenCount' => 60, + 'candidatesTokenCount' => 10, + ], + ]), + ])->preventStrayRequests(); + + $response = Prism::text() + ->using(Provider::Gemini, 'gemini-2.5-flash') + ->withPrompt('Hello') + ->asText(); + + expect($response->usage->promptTokens)->toBe(40) + ->and($response->usage->cacheReadInputTokens)->toBe(60); +}); diff --git a/tests/Providers/Gemini/SchemaMapTest.php b/tests/Providers/Gemini/SchemaMapTest.php index 3e4e317fc..ca0c2fadb 100644 --- a/tests/Providers/Gemini/SchemaMapTest.php +++ b/tests/Providers/Gemini/SchemaMapTest.php @@ -2,6 +2,8 @@ declare(strict_types=1); +use Prism\Prism\Contracts\HasSchemaType; +use Prism\Prism\Contracts\Schema; use Prism\Prism\Providers\Gemini\Maps\SchemaMap; use Prism\Prism\Schema\ArraySchema; use Prism\Prism\Schema\BooleanSchema; @@ -122,6 +124,64 @@ ]); }); +it('normalizes array-type nullable notation in properties to Gemini nullable format', function (): void { + // Simulates schemas passed as raw arrays (e.g. via Laravel AI SDK's ObjectSchema + // which uses Illuminate's JsonSchema serializer). That serializer produces + // ["integer", "null"] for nullable fields — a valid JSON Schema draft-4 format + // that Gemini does not support. Gemini requires "nullable": true instead. + $schema = new class implements HasSchemaType, Schema + { + public function name(): string + { + return 'schema_definition'; + } + + public function schemaType(): string + { + return 'object'; + } + + public function toArray(): array + { + return [ + 'type' => 'object', + 'properties' => [ + 'videoViews' => ['type' => 'integer', 'description' => 'Total video views'], + 'averageWatchTimeInSeconds' => ['type' => ['integer', 'null'], 'description' => 'Average watch time'], + 'score' => ['type' => ['number', 'null'], 'description' => 'Score'], + 'label' => ['type' => ['string', 'null'], 'description' => 'Label'], + ], + 'required' => ['videoViews'], + ]; + } + }; + + $map = (new SchemaMap($schema))->toArray(); + + expect($map['properties']['videoViews'])->toBe([ + 'type' => 'integer', + 'description' => 'Total video views', + ]); + + expect($map['properties']['averageWatchTimeInSeconds'])->toBe([ + 'type' => 'integer', + 'description' => 'Average watch time', + 'nullable' => true, + ]); + + expect($map['properties']['score'])->toBe([ + 'type' => 'number', + 'description' => 'Score', + 'nullable' => true, + ]); + + expect($map['properties']['label'])->toBe([ + 'type' => 'string', + 'description' => 'Label', + 'nullable' => true, + ]); +}); + it('does not map a raw schema', function (): void { $map = (new SchemaMap(new RawSchema( 'schema', diff --git a/tests/Providers/Groq/GroqTextTest.php b/tests/Providers/Groq/GroqTextTest.php index 82e924269..7d48cc97a 100644 --- a/tests/Providers/Groq/GroqTextTest.php +++ b/tests/Providers/Groq/GroqTextTest.php @@ -367,3 +367,51 @@ }); }); }); + +it('splits arguments embedded in the function name by Llama models', function (): void { + FixtureResponse::fakeResponseSequence('v1/chat/completions', 'groq/generate-text-with-mangled-tool-name'); + + $response = Prism::text() + ->using(Provider::Groq, 'llama-3.3-70b-versatile') + ->withTools([ + Tool::as('weather') + ->for('weather lookup') + ->withStringParameter('city', 'the city') + ->using(fn (string $city): string => 'The weather will be 75° and sunny'), + ]) + ->withMaxSteps(2) + ->withPrompt('Weather in Detroit?') + ->asText(); + + $firstStep = $response->steps[0]; + expect($firstStep->toolCalls[0]->name)->toBe('weather') + ->and($firstStep->toolCalls[0]->arguments())->toBe(['city' => 'Detroit']) + ->and($firstStep->toolResults[0]->result)->toBe('The weather will be 75° and sunny'); +}); + +it('excludes cached tokens from promptTokens', function (): void { + Http::fake([ + '*' => Http::response([ + 'id' => 'cache-test-1', + 'model' => 'llama-3.3-70b-versatile', + 'choices' => [[ + 'index' => 0, + 'message' => ['role' => 'assistant', 'content' => 'Hello!'], + 'finish_reason' => 'stop', + ]], + 'usage' => [ + 'prompt_tokens' => 100, + 'completion_tokens' => 10, + 'prompt_tokens_details' => ['cached_tokens' => 60], + ], + ]), + ])->preventStrayRequests(); + + $response = Prism::text() + ->using(Provider::Groq, 'llama-3.3-70b-versatile') + ->withPrompt('Hello') + ->asText(); + + expect($response->usage->promptTokens)->toBe(40) + ->and($response->usage->cacheReadInputTokens)->toBe(60); +}); diff --git a/tests/Providers/Groq/ToolTest.php b/tests/Providers/Groq/ToolTest.php index e42bd0eea..074f7e81c 100644 --- a/tests/Providers/Groq/ToolTest.php +++ b/tests/Providers/Groq/ToolTest.php @@ -6,6 +6,7 @@ use Prism\Prism\Providers\Groq\Maps\ToolMap; use Prism\Prism\Tool; +use stdClass; it('maps tools', function (): void { $tool = (new Tool) @@ -33,6 +34,35 @@ ]]); }); +it('maps tools without parameters to an object properties field', function (): void { + $tool = (new Tool) + ->as('ping') + ->for('Pings the service') + ->using(fn (): string => 'pong'); + + $mapped = ToolMap::map([$tool]); + + expect($mapped[0]['function']['parameters']['properties'])->toBeInstanceOf(stdClass::class) + ->and(json_encode($mapped[0]['function']['parameters']['properties']))->toBe('{}'); +}); + +it('relaxes boolean and number parameter types to also accept strings', function (): void { + $tool = (new Tool) + ->as('get_transactions') + ->for('Fetches transactions') + ->withNumberParameter('limit', 'maximum number of results') + ->withBooleanParameter('include_pending', 'include pending transactions') + ->withStringParameter('account', 'the account name') + ->using(fn (): string => '[]'); + + $properties = ToolMap::map([$tool])[0]['function']['parameters']['properties']; + + expect($properties['limit'])->not->toHaveKey('type') + ->and($properties['limit']['anyOf'])->toBe([['type' => 'number'], ['type' => 'string']]) + ->and($properties['include_pending']['anyOf'])->toBe([['type' => 'boolean'], ['type' => 'string']]) + ->and($properties['account']['type'])->toBe('string'); +}); + it('maps tools with strict mode', function (): void { $tool = (new Tool) ->as('search') diff --git a/tests/Providers/Mistral/AudioTest.php b/tests/Providers/Mistral/AudioTest.php index fc8b676c5..3b5cc9308 100644 --- a/tests/Providers/Mistral/AudioTest.php +++ b/tests/Providers/Mistral/AudioTest.php @@ -139,6 +139,31 @@ expect($response->additionalContent['segments'])->toHaveCount(2); }); + it('can transcribe with diarize parameter', function (): void { + FixtureResponse::fakeResponseSequence('audio/transcriptions', 'mistral/speech-to-text-basic'); + + $audioFile = Audio::fromBase64(base64_encode('usage-test-audio'), 'audio/flac'); + + $response = Prism::audio() + ->using('mistral', 'voxtral-mini-latest') + ->withInput($audioFile) + ->withProviderOptions([ + 'diarize' => true, + ]) + ->asText(); + + expect($response->text)->toBe('Hello, this is a test transcription.'); + + Http::assertSent(function (Request $request): bool { + $data = $request->data(); + + $diarizeField = collect($data)->firstWhere('name', 'diarize'); + + return str_contains($request->url(), 'audio/transcriptions') && + $diarizeField && $diarizeField['contents'] === true; + }); + }); + it('can transcribe with language parameter', function (): void { FixtureResponse::fakeResponseSequence('audio/transcriptions', 'mistral/speech-to-text-basic'); diff --git a/tests/Providers/Mistral/MessageMapTest.php b/tests/Providers/Mistral/MessageMapTest.php index aacb65d08..014b1c8a2 100644 --- a/tests/Providers/Mistral/MessageMapTest.php +++ b/tests/Providers/Mistral/MessageMapTest.php @@ -209,4 +209,4 @@ ); $messageMap(); -})->throws(PrismException::class, "The mistral provider does not support the mediums available in the provided `Prism\Prism\Providers\Mistral\Maps\DocumentMapper`. Pleae consult the Prism documentation for more information on which mediums the mistral provider supports."); +})->throws(PrismException::class, "The mistral provider does not support the mediums available in the provided `Prism\Prism\Providers\Mistral\Maps\DocumentMapper`. Please consult the Prism documentation for more information on which mediums the mistral provider supports."); diff --git a/tests/Providers/Mistral/MistralFimTest.php b/tests/Providers/Mistral/MistralFimTest.php new file mode 100644 index 000000000..e4c1a578d --- /dev/null +++ b/tests/Providers/Mistral/MistralFimTest.php @@ -0,0 +1,70 @@ +set('prism.providers.mistral.api_key', env('MISTRAL_API_KEY', 'sk-1234')); +}); + +describe('Fim completion', function (): void { + it('can generate fim completion', function (): void { + FixtureResponse::fakeResponseSequence('v1/fim/completions', 'mistral/fim-completion'); + + $response = Prism::fim() + ->using(Provider::Mistral, 'codestral-2405') + ->withPrompt('def add(a, b):') + ->withSuffix(' print("Done")') + ->asText(); + + expect($response->usage->promptTokens)->toBe(8); + expect($response->usage->completionTokens)->toBe(91); + expect($response->meta->id)->toBe('447e3e0d457e42e98248b5d2ef52a2a3'); + expect($response->meta->model)->toBe('codestral-2405'); + expect($response->text)->toBe('return a+b'); + expect($response->finishReason)->toBe(FinishReason::Stop); + + Http::assertSent(function (Request $request): true { + $data = $request->data(); + + expect($data['model'])->toBe('codestral-2405'); + expect($data['prompt'])->toBe('def add(a, b):'); + expect($data['suffix'])->toBe(' print("Done")'); + + return true; + }); + }); + + it('sets the rate limits on meta', function (): void { + $this->freezeTime(function (Carbon $time): void { + $time = $time->toImmutable(); + + FixtureResponse::fakeResponseSequence('v1/fim/completions', 'mistral/fim-completion', [ + 'ratelimitbysize-limit' => 500000, + 'ratelimitbysize-remaining' => 499900, + 'ratelimitbysize-reset' => 28, + ]); + + $response = Prism::fim() + ->using(Provider::Mistral, 'codestral-2405') + ->withPrompt('def mul(a, b):') + ->asText(); + + expect($response->meta->rateLimits[0])->toBeInstanceOf(ProviderRateLimit::class); + expect($response->meta->rateLimits[0]->name)->toEqual('tokens'); + expect($response->meta->rateLimits[0]->limit)->toEqual(500000); + expect($response->meta->rateLimits[0]->remaining)->toEqual(499900); + expect($response->meta->rateLimits[0]->resetsAt->equalTo($time->addSeconds(28)))->toBeTrue(); + }); + }); +}); diff --git a/tests/Providers/Mistral/MistralTextTest.php b/tests/Providers/Mistral/MistralTextTest.php index 90bf34405..e2b125daa 100644 --- a/tests/Providers/Mistral/MistralTextTest.php +++ b/tests/Providers/Mistral/MistralTextTest.php @@ -162,6 +162,25 @@ expect($response->steps->last()) ->additionalContent->thinking->toBe($expectedThinking); }); + + it('forwards reasoning_effort provider option', function (): void { + FixtureResponse::fakeResponseSequence('v1/chat/completions', 'mistral/generate-text-with-a-prompt'); + + Prism::text() + ->using(Provider::Mistral, 'mistral-small-latest') + ->withPrompt('Who are you?') + ->withProviderOptions([ + 'reasoning_effort' => 'high', + ]) + ->generate(); + + Http::assertSent(function (Request $request): bool { + $payload = $request->data(); + + return isset($payload['reasoning_effort']) + && $payload['reasoning_effort'] === 'high'; + }); + }); }); describe('Image support', function (): void { diff --git a/tests/Providers/Mistral/StreamTest.php b/tests/Providers/Mistral/StreamTest.php index 55673c68b..9803b4b71 100644 --- a/tests/Providers/Mistral/StreamTest.php +++ b/tests/Providers/Mistral/StreamTest.php @@ -4,6 +4,7 @@ namespace Tests\Providers\Mistral; +use Illuminate\Http\Client\Request; use Illuminate\Support\Facades\Http; use Prism\Prism\Enums\FinishReason; use Prism\Prism\Enums\Provider; @@ -248,6 +249,32 @@ }); }); +it('forwards reasoning_effort provider option for streaming requests', function (): void { + Http::fake([ + '*' => Http::response( + "data: {\"choices\": [{\"delta\": {\"content\": \"Hello\"}}]}\n\ndata: {\"choices\": [{\"delta\": {\"content\": \" world\"}, \"finish_reason\": \"stop\"}]}\n\n", + 200, + ['Content-Type' => 'text/event-stream'] + ), + ])->preventStrayRequests(); + + Prism::text() + ->using(Provider::Mistral, 'mistral-small-latest') + ->withPrompt('Test') + ->withProviderOptions([ + 'reasoning_effort' => 'high', + ]) + ->asStream() + ->current(); + + Http::assertSent(function (Request $request): bool { + $data = $request->data(); + + return isset($data['reasoning_effort']) + && $data['reasoning_effort'] === 'high'; + }); +}); + it('can handle event types correctly', function (): void { FixtureResponse::fakeStreamResponses('v1/chat/completions', 'mistral/stream-with-tools-1'); diff --git a/tests/Providers/Mistral/StructuredTest.php b/tests/Providers/Mistral/StructuredTest.php index 334b3b580..b4e971166 100644 --- a/tests/Providers/Mistral/StructuredTest.php +++ b/tests/Providers/Mistral/StructuredTest.php @@ -2,6 +2,8 @@ declare(strict_types=1); +use Illuminate\Http\Client\Request; +use Illuminate\Support\Facades\Http; use Prism\Prism\Enums\Provider; use Prism\Prism\Facades\Prism; use Prism\Prism\Schema\BooleanSchema; @@ -51,3 +53,32 @@ expect($response->usage->promptTokens)->toBe(45); expect($response->usage->completionTokens)->toBe(34); }); + +it('forwards reasoning_effort provider option for structured requests', function (): void { + FixtureResponse::fakeResponseSequence('chat/completions', 'mistral/structured'); + + $schema = new ObjectSchema( + 'output', + 'the output object', + [ + new StringSchema('weather', 'The weather forecast'), + ], + ['weather'] + ); + + Prism::structured() + ->withSchema($schema) + ->using(Provider::Mistral, 'mistral-large-latest') + ->withPrompt('What is the weather?') + ->withProviderOptions([ + 'reasoning_effort' => 'high', + ]) + ->asStructured(); + + Http::assertSent(function (Request $request): bool { + $payload = $request->data(); + + return isset($payload['reasoning_effort']) + && $payload['reasoning_effort'] === 'high'; + }); +}); diff --git a/tests/Providers/Mistral/StructuredWithToolsTest.php b/tests/Providers/Mistral/StructuredWithToolsTest.php new file mode 100644 index 000000000..5371da8b3 --- /dev/null +++ b/tests/Providers/Mistral/StructuredWithToolsTest.php @@ -0,0 +1,167 @@ +set('prism.providers.mistral.api_key', env('MISTRAL_API_KEY', 'sk-1234')); + + $this->schema = new ObjectSchema( + 'output', + 'the output object', + [ + new StringSchema('weather', 'The weather forecast'), + new StringSchema('game_time', 'The tigers game time'), + new BooleanSchema('coat_required', 'whether a coat is required'), + ], + ['weather', 'game_time', 'coat_required'] + ); + + $this->weatherTool = (new Tool) + ->as('weather') + ->for('useful when you need to search for current weather conditions') + ->withStringParameter('city', 'The city that you want the weather for') + ->using(fn (string $city): string => 'The weather will be 75º and sunny'); +}); + +describe('Structured output with tools for Mistral', function (): void { + it('calls tools then re-sends without tools for the final structured response', function (): void { + FixtureResponse::fakeResponseSequence('v1/chat/completions', 'mistral/structured-with-tool-call'); + + $response = Prism::structured() + ->using(Provider::Mistral, 'mistral-large-latest') + ->withSchema($this->schema) + ->withTools([$this->weatherTool]) + ->withPrompt('What time is the tigers game today and should I wear a coat?') + ->asStructured(); + + // Assert the tool call step + expect($response->steps)->toHaveCount(2); + + $firstStep = $response->steps[0]; + expect($firstStep->finishReason)->toBe(FinishReason::ToolCalls); + expect($firstStep->toolCalls)->toHaveCount(1); + expect($firstStep->toolCalls[0]->name)->toBe('weather'); + expect($firstStep->toolCalls[0]->arguments())->toBe(['city' => 'Detroit']); + expect($firstStep->toolResults)->toHaveCount(1); + expect($firstStep->toolResults[0]->result)->toBe('The weather will be 75º and sunny'); + + // Assert tool calls and results are aggregated onto the response + expect($response->toolCalls)->toHaveCount(1); + expect($response->toolResults)->toHaveCount(1); + + // Assert the final structured output + expect($response->finishReason)->toBe(FinishReason::Stop); + expect($response->structured)->toBeArray(); + expect($response->structured)->toHaveKeys([ + 'weather', + 'game_time', + 'coat_required', + ]); + expect($response->structured['weather'])->toBeString()->toBe('75º and sunny'); + expect($response->structured['game_time'])->toBeString()->toBe('3pm'); + expect($response->structured['coat_required'])->toBeBool()->toBeFalse(); + + // Assert metadata and summed usage + expect($response->meta->id)->toBe('9a2c3b41d6f04e8b8a1c0d9e7f5a3b21'); + expect($response->meta->model)->toBe('mistral-large-latest'); + expect($response->usage->promptTokens)->toBe(295); + expect($response->usage->completionTokens)->toBe(60); + + // Mistral does not allow tools and response_format together: the first + // request carries tools only, the final request carries json_schema only. + Http::assertSentInOrder([ + function (Request $request): bool { + $payload = $request->data(); + + expect($payload['tools'])->toHaveCount(1); + expect($payload['tools'][0]['function']['name'])->toBe('weather'); + expect($payload)->not->toHaveKey('response_format'); + + return true; + }, + function (Request $request): bool { + $payload = $request->data(); + + expect($payload)->not->toHaveKey('tools'); + expect($payload['response_format']['type'])->toBe('json_schema'); + expect($payload['response_format']['json_schema']['name'])->toBe('output'); + expect($payload['response_format']['json_schema']['strict'])->toBeTrue(); + + // The tool call conversation is carried into the final request. + $roles = array_column($payload['messages'], 'role'); + expect($roles)->toBe(['user', 'assistant', 'tool']); + expect($payload['messages'][2]['content'])->toBe('The weather will be 75º and sunny'); + + return true; + }, + ]); + }); + + it('discards unconstrained output and re-sends without tools when the model stops without calling them', function (): void { + FixtureResponse::fakeResponseSequence('v1/chat/completions', 'mistral/structured-with-unused-tools'); + + $response = Prism::structured() + ->using(Provider::Mistral, 'mistral-large-latest') + ->withSchema($this->schema) + ->withTools([$this->weatherTool]) + ->withPrompt('What time is the tigers game today and should I wear a coat?') + ->asStructured(); + + // The unconstrained first response is discarded: no tool step, only the + // final structured step remains. + expect($response->steps)->toHaveCount(1); + expect($response->toolCalls)->toBeEmpty(); + expect($response->toolResults)->toBeEmpty(); + + expect($response->finishReason)->toBe(FinishReason::Stop); + expect($response->structured)->toBeArray(); + expect($response->structured)->toHaveKeys([ + 'weather', + 'game_time', + 'coat_required', + ]); + expect($response->structured['weather'])->toBeString()->toBe('75º and sunny'); + expect($response->structured['game_time'])->toBeString()->toBe('3pm'); + expect($response->structured['coat_required'])->toBeBool()->toBeFalse(); + + expect($response->meta->id)->toBe('0f8c1d2e3a4b5c6d7e8f9a0b1c2d3e4f'); + expect($response->usage->promptTokens)->toBe(52); + expect($response->usage->completionTokens)->toBe(36); + + Http::assertSentInOrder([ + function (Request $request): bool { + $payload = $request->data(); + + expect($payload['tools'])->toHaveCount(1); + expect($payload)->not->toHaveKey('response_format'); + + return true; + }, + function (Request $request): bool { + $payload = $request->data(); + + expect($payload)->not->toHaveKey('tools'); + expect($payload['response_format']['type'])->toBe('json_schema'); + + // The discarded assistant text is not carried into the re-send. + $roles = array_column($payload['messages'], 'role'); + expect($roles)->toBe(['user']); + + return true; + }, + ]); + }); +}); diff --git a/tests/Providers/Mistral/ToolTest.php b/tests/Providers/Mistral/ToolTest.php index 821e60fc3..4a2d24f76 100644 --- a/tests/Providers/Mistral/ToolTest.php +++ b/tests/Providers/Mistral/ToolTest.php @@ -6,6 +6,7 @@ use Prism\Prism\Providers\Mistral\Maps\ToolMap; use Prism\Prism\Tool; +use stdClass; it('maps tools', function (): void { $tool = (new Tool) @@ -32,3 +33,15 @@ ], ]]); }); + +it('maps tools without parameters to an object properties field', function (): void { + $tool = (new Tool) + ->as('ping') + ->for('Pings the service') + ->using(fn (): string => 'pong'); + + $mapped = ToolMap::map([$tool]); + + expect($mapped[0]['function']['parameters']['properties'])->toBeInstanceOf(stdClass::class) + ->and(json_encode($mapped[0]['function']['parameters']['properties']))->toBe('{}'); +}); diff --git a/tests/Providers/Ollama/EmbeddingsTest.php b/tests/Providers/Ollama/EmbeddingsTest.php index 3f572ed91..f739c2dd6 100644 --- a/tests/Providers/Ollama/EmbeddingsTest.php +++ b/tests/Providers/Ollama/EmbeddingsTest.php @@ -83,6 +83,13 @@ ->fromInput('The food was delicious and the waiter...') ->asEmbeddings(); + Http::assertSent(function (Request $request): true { + expect($request->data()['dimensions'])->toBe(256); + expect($request->data())->not->toHaveKeys(['options']); + + return true; + }); + $embeddings = json_decode( file_get_contents('tests/Fixtures/ollama/embeddings-with-dimensions-1.json'), true diff --git a/tests/Providers/Ollama/StreamTest.php b/tests/Providers/Ollama/StreamTest.php index cfd2721a1..d223c1779 100644 --- a/tests/Providers/Ollama/StreamTest.php +++ b/tests/Providers/Ollama/StreamTest.php @@ -187,6 +187,28 @@ }); }); +it('sends think:false on stream when withReasoning(false) is used', function (): void { + FixtureResponse::fakeStreamResponses('api/chat', 'ollama/stream-without-thinking'); + + $response = Prism::text() + ->using('ollama', 'gpt-oss') + ->withPrompt('Test prompt') + ->withReasoning(false) + ->asStream(); + + foreach ($response as $chunk) { + break; + } + + Http::assertSent(function (Request $request): true { + $body = $request->data(); + expect($body)->toHaveKey('think'); + expect($body['think'])->toBe(false); + + return true; + }); +}); + it('includes keep_alive parameter when provided for streaming', function (): void { FixtureResponse::fakeStreamResponses('api/chat', 'ollama/stream-without-thinking'); diff --git a/tests/Providers/Ollama/TextTest.php b/tests/Providers/Ollama/TextTest.php index 63ddf9528..0c0b2beeb 100644 --- a/tests/Providers/Ollama/TextTest.php +++ b/tests/Providers/Ollama/TextTest.php @@ -148,6 +148,43 @@ return true; }); }); + + it('sends think:false when withReasoning(false) is used', function (): void { + FixtureResponse::fakeResponseSequence('api/chat', 'ollama/text-without-thinking'); + + Prism::text() + ->using('ollama', 'gpt-oss') + ->withPrompt('Test prompt') + ->withReasoning(false) + ->asText(); + + Http::assertSent(function (Request $request): true { + $body = $request->data(); + expect($body)->toHaveKey('think'); + expect($body['think'])->toBe(false); + + return true; + }); + }); + + it('honors explicit thinking provider option over withReasoning(false)', function (): void { + FixtureResponse::fakeResponseSequence('api/chat', 'ollama/text-with-thinking-enabled'); + + Prism::text() + ->using('ollama', 'gpt-oss') + ->withPrompt('Test prompt') + ->withProviderOptions(['thinking' => true]) + ->withReasoning(false) + ->asText(); + + Http::assertSent(function (Request $request): true { + $body = $request->data(); + expect($body)->toHaveKey('think'); + expect($body['think'])->toBe(true); + + return true; + }); + }); }); describe('Keep alive parameter', function (): void { diff --git a/tests/Providers/OpenAI/Batch/CancelTest.php b/tests/Providers/OpenAI/Batch/CancelTest.php new file mode 100644 index 000000000..cd753d954 --- /dev/null +++ b/tests/Providers/OpenAI/Batch/CancelTest.php @@ -0,0 +1,34 @@ +set('prism.providers.openai.api_key', env('OPENAI_API_KEY', 'sk-1234')); +}); + +it('can cancel a batch', function (): void { + Http::fake([ + '/v1/batches/*/cancel' => Http::response( + openaiFixture('batch-cancel-1.json'), + 200 + ), + ])->preventStrayRequests(); + + $provider = Prism::provider('openai'); + $result = $provider->cancelBatch(new CancelBatchRequest('batch_abc123')); + + expect($result)->toBeInstanceOf(BatchJob::class) + ->and($result->id)->toBe('batch_abc123') + ->and($result->status)->toBe(BatchStatus::Cancelling) + ->and($result->requestCounts->total)->toBe(2); +}); diff --git a/tests/Providers/OpenAI/Batch/CreateTest.php b/tests/Providers/OpenAI/Batch/CreateTest.php new file mode 100644 index 000000000..10afbcd87 --- /dev/null +++ b/tests/Providers/OpenAI/Batch/CreateTest.php @@ -0,0 +1,131 @@ +set('prism.providers.openai.api_key', env('OPENAI_API_KEY', 'sk-1234')); +}); + +it('can create a batch from an inputFileId', function (): void { + Http::fake([ + 'v1/batches' => Http::response(openaiFixture('batch-create-1.json'), 200), + ])->preventStrayRequests(); + + $provider = Prism::provider('openai'); + $result = $provider->batch(new BatchRequest(inputFileId: 'file-abc123')); + + expect($result)->toBeInstanceOf(BatchJob::class) + ->and($result->id)->toBe('batch_abc123') + ->and($result->status)->toBe(BatchStatus::Validating) + ->and($result->requestCounts->total)->toBe(2) + ->and($result->requestCounts->succeeded)->toBe(0) + ->and($result->requestCounts->failed)->toBe(0) + ->and($result->inputFileId)->toBe('file-abc123') + ->and($result->outputFileId)->toBeNull() + ->and($result->errorFileId)->toBeNull(); + + Http::assertSent(function (Request $request): bool { + $body = json_decode($request->body(), true); + + expect($body['input_file_id'])->toBe('file-abc123') + ->and($body['endpoint'])->toBe('/v1/responses') + ->and($body['completion_window'])->toBe('24h'); + + return true; + }); +}); + +it('can create a batch from items, uploading JSONL automatically', function (): void { + Http::fake([ + 'v1/files' => Http::response(openaiFixture('file-upload-1.json'), 200), + 'v1/batches' => Http::response(openaiFixture('batch-create-1.json'), 200), + ])->preventStrayRequests(); + + $provider = Prism::provider('openai'); + $result = $provider->batch( + new BatchRequest( + items: [ + new BatchRequestItem( + customId: 'request-1', + request: createOpenAITextRequest('Hello 1'), + ), + new BatchRequestItem( + customId: 'request-2', + request: createOpenAITextRequest('Hello 2'), + ), + ] + ) + ); + + expect($result)->toBeInstanceOf(BatchJob::class) + ->and($result->id)->toBe('batch_abc123') + ->and($result->status)->toBe(BatchStatus::Validating); + + Http::assertSentInOrder([ + // 1. File upload — JSONL content must be valid with correct structure + function (Request $request): bool { + $lines = array_filter( + explode("\n", $request->body()), + fn (string $line): bool => str_starts_with(trim($line), '{') + ); + + expect($lines)->toHaveCount(2); + + foreach ($lines as $line) { + $decoded = json_decode(trim($line), true); + expect($decoded)->toHaveKey('custom_id') + ->and($decoded)->toHaveKey('method', 'POST') + ->and($decoded)->toHaveKey('url', '/v1/responses') + ->and($decoded['body'])->toHaveKey('model', 'gpt-4o'); + } + + return Str::contains($request->url(), 'v1/files'); + }, + // 2. Batch create — must use the file ID returned by the upload + function (Request $request): bool { + $body = json_decode($request->body(), true); + + expect($body['input_file_id'])->toBe('file-DC8kDtzu39Q9PnLWRLVmLN') + ->and($body['endpoint'])->toBe('/v1/responses') + ->and($body['completion_window'])->toBe('24h'); + + return Str::contains($request->url(), 'v1/batches'); + }, + ]); +}); + +it('throws when both inputFileId and items are provided', function (): void { + Http::fake()->preventStrayRequests(); + + $provider = Prism::provider('openai'); + $provider->batch(new BatchRequest( + items: [ + new BatchRequestItem( + customId: 'request-1', + request: createOpenAITextRequest('Hello 1'), + ), + ], + inputFileId: 'file-abc123', + )); +})->throws(PrismException::class, 'OpenAI batch requires either "inputFileId" or "items", not both.'); + +it('throws when neither inputFileId nor items are provided', function (): void { + Http::fake()->preventStrayRequests(); + + $provider = Prism::provider('openai'); + $provider->batch(new BatchRequest); +})->throws(PrismException::class, 'OpenAI batch requires either "inputFileId" or "items".'); diff --git a/tests/Providers/OpenAI/Batch/Helpers.php b/tests/Providers/OpenAI/Batch/Helpers.php new file mode 100644 index 000000000..185188bcd --- /dev/null +++ b/tests/Providers/OpenAI/Batch/Helpers.php @@ -0,0 +1,21 @@ +using('openai', 'gpt-4o') + ->withPrompt($prompt) + ->toRequest(); +} + +function openaiFixture(string $name): string +{ + return file_get_contents(__DIR__.'/../../../Fixtures/openai/'.$name); +} diff --git a/tests/Providers/OpenAI/Batch/ListTest.php b/tests/Providers/OpenAI/Batch/ListTest.php new file mode 100644 index 000000000..d0630c60e --- /dev/null +++ b/tests/Providers/OpenAI/Batch/ListTest.php @@ -0,0 +1,39 @@ +set('prism.providers.openai.api_key', env('OPENAI_API_KEY', 'sk-1234')); +}); + +it('can list batches', function (): void { + Http::fake([ + 'https://api.openai.com/v1/batches*' => Http::response( + openaiFixture('batch-list-1.json'), + 200 + ), + ])->preventStrayRequests(); + + $provider = Prism::provider('openai'); + $result = $provider->listBatches(new ListBatchesRequest(limit: 10)); + + expect($result)->toBeInstanceOf(BatchListResult::class) + ->and($result->data)->toHaveCount(2) + ->and($result->hasMore)->toBeTrue() + ->and($result->lastId)->toBe('batch_def456') + ->and($result->data[0]->id)->toBe('batch_abc123') + ->and($result->data[0]->status)->toBe(BatchStatus::Completed) + ->and($result->data[0]->outputFileId)->toBe('file-output-xyz') + ->and($result->data[1]->id)->toBe('batch_def456') + ->and($result->data[1]->status)->toBe(BatchStatus::InProgress); +}); diff --git a/tests/Providers/OpenAI/Batch/ResultsTest.php b/tests/Providers/OpenAI/Batch/ResultsTest.php new file mode 100644 index 000000000..7577ff392 --- /dev/null +++ b/tests/Providers/OpenAI/Batch/ResultsTest.php @@ -0,0 +1,124 @@ +set('prism.providers.openai.api_key', env('OPENAI_API_KEY', 'sk-1234')); +}); + +it('can get batch results', function (): void { + Http::fake(function (Request $request) { + if (Str::contains($request->url(), '/files/') && Str::contains($request->url(), '/content')) { + return Http::response(openaiFixture('batch-results-1.jsonl'), 200); + } + + if (Str::contains($request->url(), '/batches/')) { + return Http::response(openaiFixture('batch-retrieve-completed-1.json'), 200); + } + + return Http::response('Not found', 404); + }); + + $provider = Prism::provider('openai'); + $results = $provider->getBatchResults(new GetBatchResultsRequest('batch_abc123')); + + expect($results)->toHaveCount(2); + + // Assert first result + expect($results[0])->toBeInstanceOf(BatchResultItem::class) + ->and($results[0]->customId)->toBe('my-first-request') + ->and($results[0]->status)->toBe(BatchResultStatus::Succeeded) + ->and($results[0]->text)->toBe('Hello! How can I help you today?') + ->and($results[0]->usage)->not->toBeNull() + ->and($results[0]->usage->promptTokens)->toBe(15) + ->and($results[0]->usage->completionTokens)->toBe(25) + ->and($results[0]->messageId)->toBe('resp_001') + ->and($results[0]->model)->toBe('gpt-4o-2024-08-06') + ->and($results[0]->errorType)->toBeNull() + ->and($results[0]->errorMessage)->toBeNull(); + + // Assert second result + expect($results[1])->toBeInstanceOf(BatchResultItem::class) + ->and($results[1]->customId)->toBe('my-second-request') + ->and($results[1]->status)->toBe(BatchResultStatus::Succeeded) + ->and($results[1]->text)->toBe('Nice to meet you!') + ->and($results[1]->usage)->not->toBeNull() + ->and($results[1]->errorType)->toBeNull() + ->and($results[1]->errorMessage)->toBeNull(); +}); + +it('can get mixed batch results', function (): void { + Http::fake(function (Request $request) { + if (Str::contains($request->url(), '/files/') && Str::contains($request->url(), '/content')) { + return Http::response(openaiFixture('batch-results-mixed-1.jsonl'), 200); + } + + if (Str::contains($request->url(), '/batches/')) { + return Http::response(openaiFixture('batch-retrieve-completed-1.json'), 200); + } + + return Http::response('Not found', 404); + }); + + $provider = Prism::provider('openai'); + $results = $provider->getBatchResults(new GetBatchResultsRequest('batch_abc123')); + + expect($results)->toHaveCount(3); + + // Assert succeeded result + expect($results[0]->customId)->toBe('req-success') + ->and($results[0]->status)->toBe(BatchResultStatus::Succeeded) + ->and($results[0]->text)->toBe('Hello!') + ->and($results[0]->errorType)->toBeNull() + ->and($results[0]->errorMessage)->toBeNull(); + + // Assert errored result + expect($results[1]->customId)->toBe('req-http-error') + ->and($results[1]->status)->toBe(BatchResultStatus::Errored) + ->and($results[1]->errorType)->toBe('http_error') + ->and($results[1]->text)->toBeNull(); + + // Assert expired result + expect($results[2]->customId)->toBe('req-expired') + ->and($results[2]->status)->toBe(BatchResultStatus::Expired) + ->and($results[2]->errorType)->toBe('batch_expired') + ->and($results[2]->errorMessage)->toBe('This batch has expired.') + ->and($results[2]->text)->toBeNull(); +}); + +it('returns an empty array when batch results are not yet available', function (): void { + Http::fake([ + 'https://api.openai.com/v1/batches/*' => Http::response( + json_encode([ + 'id' => 'batch_abc123', + 'object' => 'batch', + 'endpoint' => '/v1/responses', + 'status' => 'in_progress', + 'output_file_id' => null, + 'error_file_id' => null, + 'created_at' => 1727200644, + 'expires_at' => 1727287044, + 'completed_at' => null, + 'request_counts' => ['total' => 2, 'completed' => 0, 'failed' => 0], + ]), + 200 + ), + ])->preventStrayRequests(); + + $provider = Prism::provider('openai'); + $results = $provider->getBatchResults(new GetBatchResultsRequest('batch_abc123')); + + expect($results)->toBeArray()->toBeEmpty(); +}); diff --git a/tests/Providers/OpenAI/Batch/ResultsUnitTest.php b/tests/Providers/OpenAI/Batch/ResultsUnitTest.php new file mode 100644 index 000000000..d69d15275 --- /dev/null +++ b/tests/Providers/OpenAI/Batch/ResultsUnitTest.php @@ -0,0 +1,210 @@ + 'my-first-request', + 'response' => [ + 'status_code' => 200, + 'body' => [ + 'id' => 'resp_001', + 'model' => 'gpt-4o-2024-08-06', + 'output' => [[ + 'type' => 'message', + 'content' => [['type' => 'output_text', 'text' => 'Hello!']], + ]], + 'usage' => ['input_tokens' => 15, 'output_tokens' => 25], + ], + ], + 'error' => null, + ]), + json_encode([ + 'custom_id' => 'my-second-request', + 'response' => [ + 'status_code' => 200, + 'body' => [ + 'id' => 'resp_002', + 'model' => 'gpt-4o-2024-08-06', + 'output' => [[ + 'type' => 'message', + 'content' => [['type' => 'output_text', 'text' => 'Nice to meet you!']], + ]], + 'usage' => ['input_tokens' => 12, 'output_tokens' => 20], + ], + ], + 'error' => null, + ]), + ]); + + $results = new Results( + retrieveBatch: fn (string $id): BatchJob => makeBatchJob($id, 'file-output-123'), + downloadFile: fn (string $id): string => $jsonl, + ); + + $items = $results->handle('batch_abc'); + + expect($items)->toHaveCount(2) + ->and($items[0])->toBeInstanceOf(BatchResultItem::class) + ->and($items[0]->customId)->toBe('my-first-request') + ->and($items[0]->status)->toBe(BatchResultStatus::Succeeded) + ->and($items[0]->text)->toBe('Hello!') + ->and($items[0]->usage->promptTokens)->toBe(15) + ->and($items[0]->usage->completionTokens)->toBe(25) + ->and($items[0]->messageId)->toBe('resp_001') + ->and($items[0]->model)->toBe('gpt-4o-2024-08-06') + ->and($items[0]->errorType)->toBeNull() + ->and($items[1]->customId)->toBe('my-second-request') + ->and($items[1]->status)->toBe(BatchResultStatus::Succeeded) + ->and($items[1]->text)->toBe('Nice to meet you!'); +}); + +it('parses mixed results including errored and expired', function (): void { + $jsonl = implode("\n", [ + json_encode([ + 'custom_id' => 'req-success', + 'response' => [ + 'status_code' => 200, + 'body' => [ + 'id' => 'resp_001', + 'model' => 'gpt-4o', + 'output' => [[ + 'type' => 'message', + 'content' => [['type' => 'output_text', 'text' => 'Hello!']], + ]], + 'usage' => ['input_tokens' => 10, 'output_tokens' => 5], + ], + ], + 'error' => null, + ]), + json_encode([ + 'custom_id' => 'req-http-error', + 'response' => [ + 'status_code' => 400, + 'body' => ['error' => ['message' => 'Invalid model', 'type' => 'invalid_request_error']], + ], + 'error' => null, + ]), + json_encode([ + 'custom_id' => 'req-expired', + 'response' => null, + 'error' => ['code' => 'batch_expired', 'message' => 'This batch has expired.'], + ]), + ]); + + $results = new Results( + retrieveBatch: fn (string $id): BatchJob => makeBatchJob($id, 'file-output-456'), + downloadFile: fn (string $id): string => $jsonl, + ); + + $items = $results->handle('batch_abc'); + + expect($items)->toHaveCount(3) + ->and($items[0]->status)->toBe(BatchResultStatus::Succeeded) + ->and($items[0]->customId)->toBe('req-success') + ->and($items[1]->status)->toBe(BatchResultStatus::Errored) + ->and($items[1]->customId)->toBe('req-http-error') + ->and($items[1]->errorType)->toBe('http_error') + ->and($items[2]->status)->toBe(BatchResultStatus::Expired) + ->and($items[2]->customId)->toBe('req-expired') + ->and($items[2]->errorMessage)->toBe('This batch has expired.'); +}); + +it('skips blank lines in JSONL body', function (): void { + $jsonl = "\n\n".json_encode([ + 'custom_id' => 'req-1', + 'response' => [ + 'status_code' => 200, + 'body' => [ + 'id' => 'resp_001', + 'model' => 'gpt-4o', + 'output' => [[ + 'type' => 'message', + 'content' => [['type' => 'output_text', 'text' => 'Hi']], + ]], + 'usage' => ['input_tokens' => 5, 'output_tokens' => 3], + ], + ], + 'error' => null, + ])."\n\n"; + + $results = new Results( + retrieveBatch: fn (string $id): BatchJob => makeBatchJob($id, 'file-output-789'), + downloadFile: fn (string $id): string => $jsonl, + ); + + $items = $results->handle('batch_abc'); + + expect($items)->toHaveCount(1) + ->and($items[0]->customId)->toBe('req-1'); +}); + +it('returns an empty array when no output or error file is available', function (): void { + $results = new Results( + retrieveBatch: fn (string $id): BatchJob => makeBatchJob($id, null), + downloadFile: fn (string $id): string => '', + ); + + expect($results->handle('batch_abc'))->toBeArray()->toBeEmpty(); +}); + +it('forwards the batchId to the retrieve callback', function (): void { + $capturedId = null; + + $results = new Results( + retrieveBatch: function (string $id) use (&$capturedId): BatchJob { + $capturedId = $id; + + return makeBatchJob($id, 'file-xyz'); + }, + downloadFile: fn (string $id): string => '', + ); + + $results->handle('batch_my-specific-id'); + + expect($capturedId)->toBe('batch_my-specific-id'); +}); + +it('forwards the outputFileId to the download callback', function (): void { + $capturedFileId = null; + + $results = new Results( + retrieveBatch: fn (string $id): BatchJob => makeBatchJob($id, 'file-the-output'), + downloadFile: function (string $id) use (&$capturedFileId): string { + $capturedFileId = $id; + + return ''; + }, + ); + + $results->handle('batch_abc'); + + expect($capturedFileId)->toBe('file-the-output'); +}); diff --git a/tests/Providers/OpenAI/Batch/RetrieveTest.php b/tests/Providers/OpenAI/Batch/RetrieveTest.php new file mode 100644 index 000000000..a9b4642a5 --- /dev/null +++ b/tests/Providers/OpenAI/Batch/RetrieveTest.php @@ -0,0 +1,78 @@ +set('prism.providers.openai.api_key', env('OPENAI_API_KEY', 'sk-1234')); +}); + +it('can retrieve a completed batch', function (): void { + Http::fake([ + 'https://api.openai.com/v1/batches/*' => Http::response( + openaiFixture('batch-retrieve-completed-1.json'), + 200 + ), + ])->preventStrayRequests(); + + $provider = Prism::provider('openai'); + $result = $provider->retrieveBatch(new RetrieveBatchRequest('batch_abc123')); + + expect($result)->toBeInstanceOf(BatchJob::class) + ->and($result->id)->toBe('batch_abc123') + ->and($result->status)->toBe(BatchStatus::Completed) + ->and($result->requestCounts->succeeded)->toBe(2) + ->and($result->requestCounts->total)->toBe(2) + ->and($result->requestCounts->failed)->toBe(0) + ->and($result->inputFileId)->toBe('file-abc123') + ->and($result->outputFileId)->toBe('file-output-xyz') + ->and($result->endedAt)->not->toBeNull(); +}); + +it('throws when provider returns an error in response body', function (): void { + Http::fake([ + 'https://api.openai.com/v1/batches/*' => Http::response( + json_encode([ + 'error' => [ + 'message' => 'Batch not found.', + 'type' => 'not_found_error', + 'code' => 'batch_not_found', + ], + ]), + 200 + ), + ])->preventStrayRequests(); + + $provider = Prism::provider('openai'); + $provider->retrieveBatch(new RetrieveBatchRequest('batch_nonexistent')); +})->throws(PrismException::class, 'OpenAI Error: [not_found_error] Batch not found.'); + +it('maps errors from a failed batch', function (): void { + Http::fake([ + 'https://api.openai.com/v1/batches/*' => Http::response( + openaiFixture('batch-retrieve-failed-1.json'), + 200 + ), + ])->preventStrayRequests(); + + $provider = Prism::provider('openai'); + $result = $provider->retrieveBatch(new RetrieveBatchRequest('batch_abc123')); + + expect($result)->toBeInstanceOf(BatchJob::class) + ->and($result->status)->toBe(BatchStatus::Failed) + ->and($result->errors)->toHaveCount(2) + ->and($result->errors[0]['code'])->toBe('invalid_json_line') + ->and($result->errors[0]['message'])->toBe('Invalid JSON on line 3.') + ->and($result->errors[1]['code'])->toBe('token_limit_exceeded') + ->and($result->errors[1]['message'])->toBe('Request exceeds token limit.'); +}); diff --git a/tests/Providers/OpenAI/ChatCompletionsCitationsTest.php b/tests/Providers/OpenAI/ChatCompletionsCitationsTest.php new file mode 100644 index 000000000..e80022f2f --- /dev/null +++ b/tests/Providers/OpenAI/ChatCompletionsCitationsTest.php @@ -0,0 +1,120 @@ +set('prism.providers.openai.api_key', env('OPENAI_API_KEY', 'sk-test-1234')); + config()->set('prism.providers.openai.api_format', 'chat_completions'); +}); + +describe('ChatCompletionsCitationsMapper', function (): void { + it('maps citations with search results', function (): void { + $result = ChatCompletionsCitationsMapper::map( + ['https://example.com/1', 'https://example.com/2'], + [ + ['title' => 'First Source', 'url' => 'https://example.com/1', 'date' => '2025-07-23', 'snippet' => 'First snippet', 'source' => 'web'], + ['title' => 'Second Source', 'url' => 'https://example.com/2', 'snippet' => 'Second snippet', 'source' => 'web'], + ] + ); + + expect($result)->not->toBeNull(); + expect($result->citations)->toHaveCount(2); + + expect($result->citations[0]->sourceType)->toBe(CitationSourceType::Url); + expect($result->citations[0]->source)->toBe('https://example.com/1'); + expect($result->citations[0]->sourceTitle)->toBe('First Source'); + expect($result->citations[0]->sourceText)->toBe('First snippet'); + expect($result->citations[0]->additionalContent['date'])->toBe('2025-07-23'); + + expect($result->citations[1]->sourceType)->toBe(CitationSourceType::Url); + expect($result->citations[1]->source)->toBe('https://example.com/2'); + expect($result->citations[1]->sourceTitle)->toBe('Second Source'); + }); + + it('maps citations without search results', function (): void { + $result = ChatCompletionsCitationsMapper::map( + ['https://example.com/1', 'https://example.com/2'] + ); + + expect($result)->not->toBeNull(); + expect($result->citations)->toHaveCount(2); + + expect($result->citations[0]->source)->toBe('https://example.com/1'); + expect($result->citations[0]->sourceTitle)->toBeNull(); + expect($result->citations[0]->sourceText)->toBeNull(); + + expect($result->citations[1]->source)->toBe('https://example.com/2'); + }); + + it('returns null for empty citations array', function (): void { + $result = ChatCompletionsCitationsMapper::map([]); + + expect($result)->toBeNull(); + }); +}); + +describe('Stream with citations', function (): void { + it('includes citations in stream end event', function (): void { + FixtureResponse::fakeStreamResponses('chat/completions', 'openai-chat-completions/stream-with-citations'); + + $response = Prism::text() + ->using(Provider::OpenAI, 'sonar') + ->withPrompt('Search for something') + ->asStream(); + + $text = ''; + $lastEvent = null; + + foreach ($response as $event) { + $lastEvent = $event; + + if ($event instanceof TextDeltaEvent) { + $text .= $event->delta; + } + } + + expect($text)->toContain('According to [1] and [2], the answer is clear.'); + + expect($lastEvent)->toBeInstanceOf(StreamEndEvent::class); + expect($lastEvent->citations)->not->toBeNull(); + expect($lastEvent->citations)->toHaveCount(1); + + $citationPart = $lastEvent->citations[0]; + expect($citationPart->citations)->toHaveCount(2); + + expect($citationPart->citations[0]->sourceType)->toBe(CitationSourceType::Url); + expect($citationPart->citations[0]->source)->toBe('https://example.com/source1'); + expect($citationPart->citations[0]->sourceTitle)->toBe('Source One'); + expect($citationPart->citations[0]->sourceText)->toBe('First source snippet text'); + + expect($citationPart->citations[1]->source)->toBe('https://example.com/source2'); + expect($citationPart->citations[1]->sourceTitle)->toBe('Source Two'); + }); + + it('has null citations for streams without citation data', function (): void { + FixtureResponse::fakeStreamResponses('chat/completions', 'openai-chat-completions/stream-basic-text'); + + $response = Prism::text() + ->using(Provider::OpenAI, 'gpt-4o-mini') + ->withPrompt('Who are you?') + ->asStream(); + + $lastEvent = null; + foreach ($response as $event) { + $lastEvent = $event; + } + + expect($lastEvent)->toBeInstanceOf(StreamEndEvent::class); + expect($lastEvent->citations)->toBeNull(); + }); +}); diff --git a/tests/Providers/OpenAI/ChatCompletionsStreamTest.php b/tests/Providers/OpenAI/ChatCompletionsStreamTest.php new file mode 100644 index 000000000..dcf24bf9a --- /dev/null +++ b/tests/Providers/OpenAI/ChatCompletionsStreamTest.php @@ -0,0 +1,67 @@ +set('prism.providers.openai.api_key', env('OPENAI_API_KEY', 'sk-test-1234')); + config()->set('prism.providers.openai.api_format', 'chat_completions'); +}); + +describe('Streaming for OpenAI chat/completions', function (): void { + it('can generate text with a basic stream', function (): void { + FixtureResponse::fakeStreamResponses('chat/completions', 'openai-chat-completions/stream-basic-text'); + + $response = Prism::text() + ->using(Provider::OpenAI, 'gpt-4o-mini') + ->withPrompt('Who are you?') + ->asStream(); + + $text = ''; + $events = []; + + foreach ($response as $event) { + $events[] = $event; + + if ($event instanceof TextDeltaEvent) { + $text .= $event->delta; + } + } + + expect($events) + ->not->toBeEmpty() + ->and($text)->not->toBeEmpty() + ->and($text)->toContain("Hello! I'm an AI assistant. How can I help?"); + + $lastEvent = end($events); + expect($lastEvent)->toBeInstanceOf(StreamEndEvent::class); + expect($lastEvent->finishReason)->toBe(FinishReason::Stop); + }); + + it('stream end event has non-null usage', function (): void { + FixtureResponse::fakeStreamResponses('chat/completions', 'openai-chat-completions/stream-basic-text'); + + $response = Prism::text() + ->using(Provider::OpenAI, 'gpt-4o-mini') + ->withPrompt('Who are you?') + ->asStream(); + + $lastEvent = null; + foreach ($response as $event) { + $lastEvent = $event; + } + + expect($lastEvent)->toBeInstanceOf(StreamEndEvent::class); + expect($lastEvent->usage)->not->toBeNull(); + expect($lastEvent->usage->promptTokens)->toBe(10); + expect($lastEvent->usage->completionTokens)->toBe(13); + }); +}); diff --git a/tests/Providers/OpenAI/ChatCompletionsStructuredTest.php b/tests/Providers/OpenAI/ChatCompletionsStructuredTest.php new file mode 100644 index 000000000..866c87581 --- /dev/null +++ b/tests/Providers/OpenAI/ChatCompletionsStructuredTest.php @@ -0,0 +1,110 @@ +set('prism.providers.openai.api_key', env('OPENAI_API_KEY', 'sk-test-1234')); + config()->set('prism.providers.openai.api_format', 'chat_completions'); +}); + +describe('Structured output for OpenAI chat/completions', function (): void { + it('returns structured output', function (): void { + FixtureResponse::fakeResponseSequence('chat/completions', 'openai-chat-completions/structured'); + + $schema = new ObjectSchema( + 'output', + 'the output object', + [ + new StringSchema('weather', 'The weather forecast'), + new StringSchema('game_time', 'The tigers game time'), + new BooleanSchema('coat_required', 'whether a coat is required'), + ], + ['weather', 'game_time', 'coat_required'] + ); + + $response = Prism::structured() + ->withSchema($schema) + ->using('openai', 'gpt-4o-mini') + ->withSystemPrompt('The tigers game is at 3pm in Detroit, the temperature is expected to be 75º') + ->withPrompt('What time is the tigers game today and should I wear a coat?') + ->asStructured(); + + // Assert response type + expect($response)->toBeInstanceOf(StructuredResponse::class); + + // Assert structured data + expect($response->structured)->toBeArray(); + expect($response->structured)->toHaveKeys([ + 'weather', + 'game_time', + 'coat_required', + ]); + expect($response->structured['game_time'])->toBeString()->toBe('3pm'); + expect($response->structured['weather'])->toBeString()->toBe('75º'); + expect($response->structured['coat_required'])->toBeBool()->toBeFalse(); + + // Assert metadata + expect($response->meta->id)->toBe('chatcmpl-struct123'); + expect($response->meta->model)->toBe('gpt-4o-mini'); + expect($response->usage->promptTokens)->toBe(172); + expect($response->usage->completionTokens)->toBe(26); + }); + + it('sends requests to the chat/completions endpoint', function (): void { + FixtureResponse::fakeResponseSequence('chat/completions', 'openai-chat-completions/structured'); + + $schema = new ObjectSchema( + 'output', + 'the output object', + [ + new StringSchema('weather', 'The weather forecast'), + ], + ['weather'] + ); + + Prism::structured() + ->withSchema($schema) + ->using('openai', 'gpt-4o-mini') + ->withPrompt('What is the weather?') + ->asStructured(); + + Http::assertSent(fn (Request $request): bool => str_contains($request->url(), 'chat/completions') + && ! str_contains($request->url(), 'responses')); + }); + + it('sends response_format json_object', function (): void { + FixtureResponse::fakeResponseSequence('chat/completions', 'openai-chat-completions/structured'); + + $schema = new ObjectSchema( + 'output', + 'the output object', + [ + new StringSchema('weather', 'The weather forecast'), + ], + ['weather'] + ); + + Prism::structured() + ->withSchema($schema) + ->using('openai', 'gpt-4o-mini') + ->withPrompt('What is the weather?') + ->asStructured(); + + Http::assertSent(function (Request $request): bool { + expect($request->data()['response_format'])->toBe(['type' => 'json_object']); + + return true; + }); + }); +}); diff --git a/tests/Providers/OpenAI/ChatCompletionsTextTest.php b/tests/Providers/OpenAI/ChatCompletionsTextTest.php new file mode 100644 index 000000000..c46c6dc81 --- /dev/null +++ b/tests/Providers/OpenAI/ChatCompletionsTextTest.php @@ -0,0 +1,221 @@ +set('prism.providers.openai.api_key', env('OPENAI_API_KEY', 'sk-test-1234')); + config()->set('prism.providers.openai.api_format', 'chat_completions'); +}); + +describe('Text generation for OpenAI chat/completions', function (): void { + it('can generate text with a prompt', function (): void { + FixtureResponse::fakeResponseSequence('chat/completions', 'openai-chat-completions/generate-text'); + + $response = Prism::text() + ->using('openai', 'gpt-4o-mini') + ->withPrompt('Who are you?') + ->generate(); + + expect($response->usage->promptTokens)->toBe(13) + ->and($response->usage->completionTokens)->toBe(16) + ->and($response->meta->id)->toBe('chatcmpl-abc123def456') + ->and($response->meta->model)->toBe('gpt-4o-mini') + ->and($response->text)->toBe( + "I'm an AI assistant powered by OpenAI. How can I help you today?" + ); + }); + + it('can generate text with a system prompt', function (): void { + FixtureResponse::fakeResponseSequence('chat/completions', 'openai-chat-completions/generate-text-with-system-prompt'); + + $response = Prism::text() + ->using('openai', 'gpt-4o-mini') + ->withSystemPrompt('You are an ancient oracle.') + ->withPrompt('Who are you?') + ->generate(); + + expect($response->usage->promptTokens)->toBe(37) + ->and($response->usage->completionTokens)->toBe(14) + ->and($response->meta->id)->toBe('chatcmpl-sys123def456') + ->and($response->text)->toBe( + 'Greetings, mortal. I am the ancient oracle of the digital realm.' + ); + }); + + it('sends requests to the chat/completions endpoint', function (): void { + FixtureResponse::fakeResponseSequence('chat/completions', 'openai-chat-completions/generate-text'); + + Prism::text() + ->using('openai', 'gpt-4o-mini') + ->withPrompt('Who are you?') + ->generate(); + + Http::assertSent(fn (Request $request): bool => str_contains($request->url(), 'chat/completions') + && ! str_contains($request->url(), 'responses')); + }); + + it('sends messages in chat/completions format', function (): void { + FixtureResponse::fakeResponseSequence('chat/completions', 'openai-chat-completions/generate-text'); + + Prism::text() + ->using('openai', 'gpt-4o-mini') + ->withSystemPrompt('Be helpful.') + ->withPrompt('Who are you?') + ->generate(); + + Http::assertSent(function (Request $request): bool { + $messages = $request->data()['messages']; + + // System message should be role: system with content string + expect($messages[0]['role'])->toBe('system') + ->and($messages[0]['content'])->toBe('Be helpful.'); + + // User message should use 'text' type (not 'input_text') + expect($messages[1]['role'])->toBe('user') + ->and($messages[1]['content'][0]['type'])->toBe('text'); + + return true; + }); + }); + + it('can generate text using tools', function (): void { + FixtureResponse::fakeResponseSequence('chat/completions', 'openai-chat-completions/generate-text-with-tool-calls'); + + $tools = [ + Tool::as('weather') + ->for('useful when you need to search for current weather conditions') + ->withStringParameter('city', 'The city that you want the weather for') + ->using(fn (string $city): string => 'The weather will be 75° and sunny'), + ]; + + $response = Prism::text() + ->using('openai', 'gpt-4o-mini') + ->withTools($tools) + ->withMaxSteps(2) + ->withPrompt('What is the weather in Detroit?') + ->generate(); + + // Assert tool calls in the first step + $firstStep = $response->steps[0]; + expect($firstStep->toolCalls)->toHaveCount(1); + expect($firstStep->toolCalls[0]->name)->toBe('weather'); + expect($firstStep->toolCalls[0]->arguments())->toBe(['city' => 'Detroit']); + + // Verify the assistant message from step 1 is present in step 2's input messages + $secondStep = $response->steps[1]; + expect($secondStep->messages)->toHaveCount(3); + expect($secondStep->messages[0])->toBeInstanceOf(UserMessage::class); + expect($secondStep->messages[1])->toBeInstanceOf(AssistantMessage::class); + expect($secondStep->messages[1]->toolCalls)->toHaveCount(1); + expect($secondStep->messages[2])->toBeInstanceOf(ToolResultMessage::class); + + // Assert final text content + expect($response->text)->toBe( + 'The weather in Detroit is 75° and sunny. No coat needed!' + ); + }); + + it('sends tools in chat/completions format', function (): void { + FixtureResponse::fakeResponseSequence('chat/completions', 'openai-chat-completions/generate-text-with-tool-calls'); + + $tools = [ + Tool::as('weather') + ->for('useful when you need to search for current weather conditions') + ->withStringParameter('city', 'The city that you want the weather for') + ->using(fn (string $city): string => 'The weather will be 75° and sunny'), + ]; + + Prism::text() + ->using('openai', 'gpt-4o-mini') + ->withTools($tools) + ->withMaxSteps(2) + ->withPrompt('What is the weather in Detroit?') + ->generate(); + + Http::assertSent(function (Request $request): bool { + $tools = $request->data()['tools'] ?? null; + if ($tools === null) { + return true; // second request may not have tools + } + + // Chat/completions format: nested under 'function' key + expect($tools[0]['type'])->toBe('function') + ->and($tools[0]['function']['name'])->toBe('weather'); + + return true; + }); + }); +}); + +it('excludes cached tokens from promptTokens', function (): void { + Http::fake([ + '*' => Http::response([ + 'id' => 'cache-test-1', + 'model' => 'gpt-4o-mini', + 'choices' => [[ + 'index' => 0, + 'message' => ['role' => 'assistant', 'content' => 'Hello!'], + 'finish_reason' => 'stop', + ]], + 'usage' => [ + 'prompt_tokens' => 100, + 'completion_tokens' => 10, + 'prompt_tokens_details' => ['cached_tokens' => 60], + ], + ]), + ])->preventStrayRequests(); + + $response = Prism::text() + ->using('openai', 'gpt-4o-mini') + ->withPrompt('Hello') + ->asText(); + + expect($response->usage->promptTokens)->toBe(40) + ->and($response->usage->cacheReadInputTokens)->toBe(60); +}); + +it('passes text_verbosity through to chat/completions', function (): void { + FixtureResponse::fakeResponseSequence('chat/completions', 'openai-chat-completions/generate-text'); + + Prism::text() + ->using('openai', 'gpt-5') + ->withPrompt('Who are you?') + ->withProviderOptions(['text_verbosity' => 'low']) + ->generate(); + + Http::assertSent(function (Request $request): true { + $body = json_decode($request->body(), true); + + expect(data_get($body, 'verbosity'))->toBe('low'); + + return true; + }); +}); + +it('omits verbosity from chat/completions when text_verbosity is not set', function (): void { + FixtureResponse::fakeResponseSequence('chat/completions', 'openai-chat-completions/generate-text'); + + Prism::text() + ->using('openai', 'gpt-5') + ->withPrompt('Who are you?') + ->generate(); + + Http::assertSent(function (Request $request): true { + $body = json_decode($request->body(), true); + + expect($body)->not->toHaveKey('verbosity'); + + return true; + }); +}); diff --git a/tests/Providers/OpenAI/Files/DeleteTest.php b/tests/Providers/OpenAI/Files/DeleteTest.php new file mode 100644 index 000000000..05bf0aae0 --- /dev/null +++ b/tests/Providers/OpenAI/Files/DeleteTest.php @@ -0,0 +1,27 @@ +set('prism.providers.openai.api_key', env('OPENAI_API_KEY', 'sk-1234')); +}); + +it('can delete a file', function (): void { + $fileId = 'file-DC8kDtzu39Q9PnLWRLVmLN'; + + FixtureResponse::fakeResponseSequence("v1/files/$fileId", 'openai/file-delete'); + + $provider = Prism::provider('openai'); + $result = $provider->deleteFile(new DeleteFileRequest($fileId)); + + expect($result)->toBeInstanceOf(DeleteFileResult::class) + ->and($result->id)->toBe($fileId) + ->and($result->deleted)->toBeTrue(); +}); diff --git a/tests/Providers/OpenAI/Files/DownloadTest.php b/tests/Providers/OpenAI/Files/DownloadTest.php new file mode 100644 index 000000000..9ddde8f7a --- /dev/null +++ b/tests/Providers/OpenAI/Files/DownloadTest.php @@ -0,0 +1,24 @@ +set('prism.providers.openai.api_key', env('OPENAI_API_KEY', 'sk-123')); +}); + +it('can download a file', function (): void { + $fileId = 'file-DC8kDtzu39Q9PnLWRLVmLN'; + + FixtureResponse::fakeResponseSequence("v1/files/$fileId/content", 'openai/file-download'); + + $provider = Prism::provider('openai'); + $result = $provider->downloadFile(new DownloadFileRequest($fileId)); + + expect($result)->toBe('file content here'); +}); diff --git a/tests/Providers/OpenAI/Files/GetFileMetadataTest.php b/tests/Providers/OpenAI/Files/GetFileMetadataTest.php new file mode 100644 index 000000000..80c027855 --- /dev/null +++ b/tests/Providers/OpenAI/Files/GetFileMetadataTest.php @@ -0,0 +1,32 @@ +set('prism.providers.openai.api_key', env('OPENAI_API_KEY', 'sk-1234')); +}); + +it('can get file metadata', function (): void { + $fileId = 'file-DC8kDtzu39Q9PnLWRLVmLN'; + + FixtureResponse::fakeResponseSequence("v1/files/$fileId", 'openai/file-get-metadata'); + + $provider = Prism::provider('openai'); + $result = $provider->getFileMetadata(new GetFileMetadataRequest($fileId)); + + expect($result)->toBeInstanceOf(FileData::class) + ->and($result->id)->toBe($fileId) + ->and($result->filename)->toBe('data.txt') + ->and($result->mimeType)->toBeNull() + ->and($result->sizeBytes)->toBe(17) + ->and($result->createdAt)->not->toBeNull() + ->and($result->purpose)->toBe('user_data') + ->and($result->raw)->toBeArray(); +}); diff --git a/tests/Providers/OpenAI/Files/ListTest.php b/tests/Providers/OpenAI/Files/ListTest.php new file mode 100644 index 000000000..f29b66338 --- /dev/null +++ b/tests/Providers/OpenAI/Files/ListTest.php @@ -0,0 +1,38 @@ +set('prism.providers.openai.api_key', env('OPENAI_API_KEY', 'sk-1234')); +}); + +it('can list files', function (): void { + FixtureResponse::fakeResponseSequence('v1/files', 'openai/file-list'); + + $provider = Prism::provider('openai'); + $result = $provider->listFiles(new ListFilesRequest); + + expect($result)->toBeInstanceOf(FileListResult::class) + ->and($result->data)->toHaveCount(55) + ->and($result->hasMore)->toBeFalse() + ->and($result->firstId)->toBe('file-DC8kDtzu39Q9PnLWRLVmLN') + ->and($result->lastId)->toBe('file-EAlawNWTLJFO3VlA0m3T9soM'); +}); + +it('sends pagination parameters', function (): void { + FixtureResponse::fakeResponseSequence('v1/files?limit=10&after=file-abc123', 'openai/file-list'); + + $provider = Prism::provider('openai'); + $provider->listFiles(new ListFilesRequest(limit: 10, afterId: 'file-abc123')); + + Http::assertSent(fn (Request $request): bool => $request->toPsrRequest()->getUri()->getQuery() === 'limit=10&after=file-abc123'); +}); diff --git a/tests/Providers/OpenAI/Files/UploadTest.php b/tests/Providers/OpenAI/Files/UploadTest.php new file mode 100644 index 000000000..5dff5d9d9 --- /dev/null +++ b/tests/Providers/OpenAI/Files/UploadTest.php @@ -0,0 +1,40 @@ +set('prism.providers.openai.api_key', env('OPENAI_API_KEY', 'sk-1234')); +}); + +it('can upload a file', function (): void { + FixtureResponse::fakeResponseSequence('v1/files', 'openai/file-upload'); + + $provider = Prism::provider('openai'); + + $request = new UploadFileRequest( + filename: 'data.txt', + content: 'file content here', + ); + + $result = $provider->uploadFile($request); + + expect($result)->toBeInstanceOf(FileData::class) + ->and($result->id)->toBe('file-DC8kDtzu39Q9PnLWRLVmLN') + ->and($result->filename)->toBe('data.txt') + ->and($result->mimeType)->toBeNull() + ->and($result->sizeBytes)->toBe(17) + ->and($result->createdAt)->not->toBeNull() + ->and($result->purpose)->toBe('user_data') + ->and($result->raw)->toBeArray(); + + Http::assertSent(fn (Request $request): bool => str_contains($request->url(), 'v1/files')); +}); diff --git a/tests/Providers/OpenAI/MessageMapTest.php b/tests/Providers/OpenAI/MessageMapTest.php index 812f18472..8b4254c52 100644 --- a/tests/Providers/OpenAI/MessageMapTest.php +++ b/tests/Providers/OpenAI/MessageMapTest.php @@ -168,15 +168,6 @@ ); expect($messageMap())->toBe([ - [ - 'role' => 'assistant', - 'content' => [ - [ - 'type' => 'output_text', - 'text' => 'I am Nyx', - ], - ], - ], [ 'id' => 'tool_1234', 'call_id' => 'call_1234', @@ -186,6 +177,15 @@ 'query' => 'Laravel collection methods', ]), ], + [ + 'role' => 'assistant', + 'content' => [ + [ + 'type' => 'output_text', + 'text' => 'I am Nyx', + ], + ], + ], ]); }); @@ -215,6 +215,68 @@ ]); }); +it('maps assistant message with multiple tool calls and different reasoning ids', function (): void { + $messageMap = new MessageMap( + messages: [ + new AssistantMessage('I am Nyx', [ + new ToolCall( + 'tool_1', + 'search', + ['query' => 'Laravel collection methods'], + 'call_1', + 'rs_1', + [], + ), + new ToolCall( + 'tool_2', + 'search', + ['query' => 'Laravel query builder'], + 'call_2', + 'rs_2', + [], + ), + ]), + ], + systemPrompts: [] + ); + + expect($messageMap())->toBe([ + [ + 'type' => 'reasoning', + 'id' => 'rs_1', + 'summary' => [], + ], + [ + 'id' => 'tool_1', + 'call_id' => 'call_1', + 'type' => 'function_call', + 'name' => 'search', + 'arguments' => json_encode(['query' => 'Laravel collection methods']), + ], + [ + 'type' => 'reasoning', + 'id' => 'rs_2', + 'summary' => [], + ], + [ + 'id' => 'tool_2', + 'call_id' => 'call_2', + 'type' => 'function_call', + 'name' => 'search', + 'arguments' => json_encode(['query' => 'Laravel query builder']), + ], + [ + 'role' => 'assistant', + 'content' => [ + [ + 'type' => 'output_text', + 'text' => 'I am Nyx', + ], + ], + ], + ]); +}); + it('maps tool result messages', function (): void { $messageMap = new MessageMap( messages: [ diff --git a/tests/Providers/OpenAI/StructuredModeResolverTest.php b/tests/Providers/OpenAI/StructuredModeResolverTest.php new file mode 100644 index 000000000..2a469cebb --- /dev/null +++ b/tests/Providers/OpenAI/StructuredModeResolverTest.php @@ -0,0 +1,60 @@ +toBe(StructuredMode::Structured); +})->with([ + 'gpt-4o-mini', + 'gpt-4o-mini-2024-07-18', + 'gpt-4o-2024-08-06', + '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-5', + 'gpt-5-mini', + 'gpt-5-nano', + 'gpt-5.1', + 'gpt-5.2', + 'gpt-5.4', +]); + +it('resolves json mode for unsupported structured models', function (): void { + expect(StructuredModeResolver::forModel('gpt-3.5-turbo'))->toBe(StructuredMode::Json); +}); + +it('throws for unsupported models', function (): void { + StructuredModeResolver::forModel('o1-mini'); +})->throws(PrismException::class, 'Structured output is not supported for o1-mini'); + +it('resolves structured mode for fine-tuned models based on supported base models', function (string $model): void { + expect(StructuredModeResolver::forModel($model))->toBe(StructuredMode::Structured); +})->with([ + 'ft:gpt-4o:my-org:custom-name:abc123', + 'ft:gpt-4o-mini:my-org:custom-name:abc123', + 'ft:gpt-4o-mini-2024-07-18:my-org:custom-name:abc123', + 'ft:gpt-4.1-mini:company:model-name:hash', + 'ft:gpt-4.1:company:model-name:hash', + 'ft:gpt-4.1-mini-2025-04-14:company:model-name:hash', + 'ft:gpt-4o-2024-08-06:my-org:custom-name:abc123', +]); + +it('resolves json mode for fine-tuned models based on unsupported structured base models', function (): void { + expect(StructuredModeResolver::forModel('ft:gpt-3.5-turbo:my-org:custom-name:abc123'))->toBe(StructuredMode::Json); +}); + +it('throws for fine-tuned models based on unsupported base models', function (): void { + StructuredModeResolver::forModel('ft:o1-mini:my-org:custom-name:abc123'); +})->throws(PrismException::class, 'Structured output is not supported for ft:o1-mini:my-org:custom-name:abc123'); diff --git a/tests/Providers/OpenAI/StructuredTest.php b/tests/Providers/OpenAI/StructuredTest.php index 052d48b2f..08bad834d 100644 --- a/tests/Providers/OpenAI/StructuredTest.php +++ b/tests/Providers/OpenAI/StructuredTest.php @@ -6,6 +6,7 @@ use Illuminate\Http\Client\Request; use Illuminate\Support\Facades\Http; +use Prism\Prism\Enums\FinishReason; use Prism\Prism\Enums\Provider; use Prism\Prism\Enums\StructuredMode; use Prism\Prism\Exceptions\PrismException; @@ -642,7 +643,7 @@ } }); -it('includes status details for unknown finish reasons', function (): void { +it('handles unknown finish reasons gracefully', function (): void { FixtureResponse::fakeResponseSequence('v1/responses', 'openai/structured-unknown-finish-reason'); $schema = new ObjectSchema( @@ -654,10 +655,63 @@ ['weather'] ); - expect(fn () => Prism::structured() + $response = Prism::structured() ->withSchema($schema) ->using(Provider::OpenAI, 'gpt-4o') ->withPrompt('What is the weather?') - ->asStructured() - )->toThrow(PrismException::class, 'some_future_type'); + ->asStructured(); + + expect($response->finishReason)->toBe(FinishReason::Unknown); +}); + +it('passes text_verbosity through to the responses API', function (): void { + FixtureResponse::fakeResponseSequence('v1/responses', 'openai/structured-structured-mode'); + + $schema = new ObjectSchema( + 'output', + 'the output object', + [new StringSchema('weather', 'The weather forecast')], + ['weather'] + ); + + Prism::structured() + ->withSchema($schema) + ->using(Provider::OpenAI, 'gpt-5') + ->withPrompt('What is the weather?') + ->withProviderOptions(['text_verbosity' => 'low']) + ->asStructured(); + + Http::assertSent(function (Request $request): true { + $body = json_decode($request->body(), true); + + expect(data_get($body, 'text.verbosity'))->toBe('low') + ->and(data_get($body, 'text.format'))->not->toBeNull(); + + return true; + }); +}); + +it('omits text.verbosity when text_verbosity is not set', function (): void { + FixtureResponse::fakeResponseSequence('v1/responses', 'openai/structured-structured-mode'); + + $schema = new ObjectSchema( + 'output', + 'the output object', + [new StringSchema('weather', 'The weather forecast')], + ['weather'] + ); + + Prism::structured() + ->withSchema($schema) + ->using(Provider::OpenAI, 'gpt-5') + ->withPrompt('What is the weather?') + ->asStructured(); + + Http::assertSent(function (Request $request): true { + $body = json_decode($request->body(), true); + + expect(data_get($body, 'text'))->not->toHaveKey('verbosity'); + + return true; + }); }); diff --git a/tests/Providers/OpenAI/Support/StructuredModeResolverTest.php b/tests/Providers/OpenAI/Support/StructuredModeResolverTest.php new file mode 100644 index 000000000..bddb2a8a4 --- /dev/null +++ b/tests/Providers/OpenAI/Support/StructuredModeResolverTest.php @@ -0,0 +1,66 @@ +toBe(StructuredMode::Structured); +})->with([ + 'gpt-4o', + 'gpt-4o-mini', + 'gpt-4o-mini-2024-07-18', + 'gpt-4o-2024-08-06', + 'chatgpt-4o-latest', + 'o3-mini', + 'o3-mini-2025-01-31', +]); + +it('resolves structured mode for gpt-4.1+ family models', function (string $model): void { + expect(StructuredModeResolver::forModel($model))->toBe(StructuredMode::Structured); +})->with([ + 'gpt-4.1', + 'gpt-4.1-nano', + 'gpt-4.1-mini', + 'gpt-4.1-nano-2025-04-14', + 'gpt-4.1-mini-2025-04-14', + 'gpt-4.5-preview', + 'gpt-4.5-preview-2025-02-27', +]); + +it('resolves structured mode for gpt-5 family models', function (string $model): void { + expect(StructuredModeResolver::forModel($model))->toBe(StructuredMode::Structured); +})->with([ + 'gpt-5', + 'gpt-5-mini', + 'gpt-5-nano', + 'gpt-5-mini-2025-08-07', + 'gpt-5-nano-2025-08-07', + 'gpt-5.1', + 'gpt-5.1-2025-10-01', + 'gpt-5.2', + 'gpt-5.2-2025-12-11', + 'gpt-5.4', +]); + +it('resolves json mode for models without structured support', function (string $model): void { + expect(StructuredModeResolver::forModel($model))->toBe(StructuredMode::Json); +})->with([ + 'gpt-4-turbo', + 'gpt-4-0125-preview', + 'gpt-3.5-turbo', + 'some-custom-model', +]); + +it('throws for unsupported models', function (string $model): void { + StructuredModeResolver::forModel($model); +})->with([ + 'o1-mini', + 'o1-mini-2024-09-12', + 'o1-preview', + 'o1-preview-2024-09-12', +])->throws(PrismException::class); diff --git a/tests/Providers/OpenAI/TextTest.php b/tests/Providers/OpenAI/TextTest.php index 7055ec614..9448167e1 100644 --- a/tests/Providers/OpenAI/TextTest.php +++ b/tests/Providers/OpenAI/TextTest.php @@ -598,6 +598,46 @@ ]); }); +it('maps withReasoning(false) to reasoning.effort=minimal', function (): void { + FixtureResponse::fakeResponseSequence('v1/responses', 'openai/text-reasoning-effort'); + + Prism::text() + ->using('openai', 'gpt-5') + ->withPrompt('Who are you?') + ->withReasoning(false) + ->asText(); + + Http::assertSent(fn (Request $request): bool => $request->data()['reasoning']['effort'] === 'minimal'); +}); + +it('does not include reasoning key when withReasoning is not called', function (): void { + FixtureResponse::fakeResponseSequence('v1/responses', 'openai/text-reasoning-effort'); + + Prism::text() + ->using('openai', 'gpt-5') + ->withPrompt('Who are you?') + ->asText(); + + Http::assertSent(function (Request $request): true { + expect($request->data())->not->toHaveKey('reasoning'); + + return true; + }); +}); + +it('honors explicit reasoning provider option over withReasoning(false)', function (): void { + FixtureResponse::fakeResponseSequence('v1/responses', 'openai/text-reasoning-effort'); + + Prism::text() + ->using('openai', 'gpt-5') + ->withPrompt('Who are you?') + ->withProviderOptions(['reasoning' => ['effort' => 'high']]) + ->withReasoning(false) + ->asText(); + + Http::assertSent(fn (Request $request): bool => $request->data()['reasoning']['effort'] === 'high'); +}); + describe('provider tool results', function (): void { it('captures web search provider tool in providerToolCalls', function (): void { FixtureResponse::fakeResponseSequence('v1/responses', 'openai/generate-text-with-web-search-citations'); @@ -886,12 +926,14 @@ } }); -it('includes status details for unknown finish reasons', function (): void { +it('handles unknown finish reasons gracefully', function (): void { FixtureResponse::fakeResponseSequence('v1/responses', 'openai/text-unknown-finish-reason'); - expect(fn () => Prism::text() + $response = Prism::text() ->using('openai', 'gpt-4o') ->withPrompt('Hello') - ->asText() - )->toThrow(PrismException::class, 'some_future_type'); + ->asText(); + + expect($response->text)->toBe('Some response'); + expect($response->finishReason)->toBe(FinishReason::Unknown); }); diff --git a/tests/Providers/OpenRouter/ExceptionHandlingTest.php b/tests/Providers/OpenRouter/ExceptionHandlingTest.php index 5ddfe598d..2ae9330a3 100644 --- a/tests/Providers/OpenRouter/ExceptionHandlingTest.php +++ b/tests/Providers/OpenRouter/ExceptionHandlingTest.php @@ -18,12 +18,13 @@ ); }); -function createMockResponse(int $statusCode, array $json = [], array $headers = []): Response +function createMockResponse(int $statusCode, array $json = [], array $headers = [], ?string $body = null): Response { $mockResponse = Mockery::mock(Response::class); $mockResponse->shouldReceive('getStatusCode')->andReturn($statusCode); $mockResponse->shouldReceive('status')->andReturn($statusCode); $mockResponse->shouldReceive('json')->andReturn($json); + $mockResponse->shouldReceive('body')->andReturn($body ?? (string) json_encode($json)); $mockResponse->shouldReceive('toPsrResponse')->andReturn(new PsrResponse($statusCode)); if (isset($headers['retry-after'])) { @@ -158,7 +159,43 @@ function createMockResponse(int $statusCode, array $json = [], array $headers = $exception = new RequestException($mockResponse); expect(fn () => $this->provider->handleRequestException('test-model', $exception)) - ->toThrow(PrismException::class, 'OpenRouter Bad Request: Invalid schema for response_format: Missing required field'); + ->toThrow(PrismException::class, 'OpenRouter Bad Request (Azure): Invalid schema for response_format: Missing required field'); +}); + +it('extracts error message from metadata.raw with top-level message key (Bedrock-style)', function (): void { + $mockResponse = createMockResponse(400, [ + 'error' => [ + 'code' => 400, + 'message' => 'Provider returned error', + 'metadata' => [ + 'raw' => '{"message":"messages.0.content.1.image.source.base64.data: At least one of the image dimensions exceed max allowed size: 8000 pixels"}', + 'provider_name' => 'Amazon Bedrock', + ], + ], + ]); + $exception = new RequestException($mockResponse); + + expect(fn () => $this->provider->handleRequestException('test-model', $exception)) + ->toThrow( + PrismException::class, + 'OpenRouter Bad Request (Amazon Bedrock): messages.0.content.1.image.source.base64.data: At least one of the image dimensions exceed max allowed size: 8000 pixels' + ); +}); + +it('includes provider_name in the message when no metadata.raw is present', function (): void { + $mockResponse = createMockResponse(400, [ + 'error' => [ + 'code' => 400, + 'message' => 'Provider returned error', + 'metadata' => [ + 'provider_name' => 'Together', + ], + ], + ]); + $exception = new RequestException($mockResponse); + + expect(fn () => $this->provider->handleRequestException('test-model', $exception)) + ->toThrow(PrismException::class, 'OpenRouter Bad Request (Together): Provider returned error'); }); it('falls back to error.message when metadata.raw is missing', function (): void { @@ -190,18 +227,35 @@ function createMockResponse(int $statusCode, array $json = [], array $headers = ->toThrow(PrismException::class, 'OpenRouter Bad Request: Provider returned error'); }); -it('falls back to error.message when metadata.raw contains invalid JSON', function (): void { +it('attaches http status and raw response body to the exception on 400', function (): void { + $payload = ['error' => ['code' => 400, 'message' => 'Provider returned error']]; + $mockResponse = createMockResponse(400, $payload); + $exception = new RequestException($mockResponse); + + try { + $this->provider->handleRequestException('test-model', $exception); + } catch (PrismException $e) { + expect($e->httpStatus)->toBe(400); + expect($e->responseBody)->toContain('Provider returned error'); + + return; + } + + $this->fail('Expected PrismException was not thrown'); +}); + +it('uses metadata.raw as the message when it is a non-JSON string', function (): void { $mockResponse = createMockResponse(400, [ 'error' => [ 'code' => 400, 'message' => 'Provider returned error', 'metadata' => [ - 'raw' => 'not valid json', + 'raw' => 'upstream service unavailable', ], ], ]); $exception = new RequestException($mockResponse); expect(fn () => $this->provider->handleRequestException('test-model', $exception)) - ->toThrow(PrismException::class, 'OpenRouter Bad Request: Provider returned error'); + ->toThrow(PrismException::class, 'OpenRouter Bad Request: upstream service unavailable'); }); diff --git a/tests/Providers/OpenRouter/MessageMapTest.php b/tests/Providers/OpenRouter/MessageMapTest.php index c67099ec5..f9d820782 100644 --- a/tests/Providers/OpenRouter/MessageMapTest.php +++ b/tests/Providers/OpenRouter/MessageMapTest.php @@ -329,6 +329,68 @@ ->toContain(base64_encode(file_get_contents('tests/Fixtures/test-pdf.pdf'))); }); +it('maps assistant message with reasoning', function (): void { + $messageMap = new MessageMap( + messages: [ + new AssistantMessage('The answer is 4.', [], [ + 'reasoning' => 'Let me think step by step. 2 + 2 = 4.', + ]), + ], + systemPrompts: [] + ); + + expect($messageMap())->toBe([[ + 'role' => 'assistant', + 'content' => 'The answer is 4.', + 'reasoning' => 'Let me think step by step. 2 + 2 = 4.', + ]]); +}); + +it('maps assistant message with reasoning_details', function (): void { + $reasoningDetails = [ + [ + 'type' => 'reasoning.encrypted', + 'data' => 'gAAAAABo71-Jjh1ipTHmxLg2Jub6BwOV', + 'id' => 'rs_045e88aab38b', + 'format' => 'openai-responses-v1', + 'index' => 0, + ], + ]; + + $messageMap = new MessageMap( + messages: [ + new AssistantMessage('The answer is 4.', [], [ + 'reasoning_details' => $reasoningDetails, + ]), + ], + systemPrompts: [] + ); + + expect($messageMap())->toBe([[ + 'role' => 'assistant', + 'content' => 'The answer is 4.', + 'reasoning_details' => $reasoningDetails, + ]]); +}); + +it('maps assistant message without reasoning when additionalContent is empty', function (): void { + $messageMap = new MessageMap( + messages: [ + new AssistantMessage('Hello'), + ], + systemPrompts: [] + ); + + $mapped = $messageMap(); + + expect($mapped[0])->not->toHaveKey('reasoning'); + expect($mapped[0])->not->toHaveKey('reasoning_details'); + expect($mapped[0])->toBe([ + 'role' => 'assistant', + 'content' => 'Hello', + ]); +}); + it('maps user messages with documents from url', function (): void { $messageMap = new MessageMap( messages: [ diff --git a/tests/Providers/OpenRouter/StreamTest.php b/tests/Providers/OpenRouter/StreamTest.php index af61bc271..2f3528c89 100644 --- a/tests/Providers/OpenRouter/StreamTest.php +++ b/tests/Providers/OpenRouter/StreamTest.php @@ -248,6 +248,63 @@ expect($streamEndEvents)->not->toBeEmpty(); }); +it('sends correct tool payload when streaming with a parameterless tool', function (): void { + FixtureResponse::fakeStreamResponses('v1/chat/completions', 'openrouter/stream-text-with-parameterless-tool'); + + $currentTime = '08:00:00'; + + $timeTool = Tool::as('time') + ->for('Get the current time') + ->using(fn (): string => $currentTime); + + $searchTool = Tool::as('search') + ->for('Search the web') + ->withStringParameter('query', 'the search query') + ->using(fn (string $query): string => 'results'); + + $response = Prism::text() + ->using(Provider::OpenRouter, 'openai/gpt-5') + ->withTools([$timeTool, $searchTool]) + ->withMaxSteps(3) + ->withPrompt('What time is it?') + ->asStream(); + + $text = ''; + $toolCallEvents = []; + + foreach ($response as $event) { + if ($event instanceof TextDeltaEvent) { + $text .= $event->delta; + } + + if ($event instanceof ToolCallEvent) { + $toolCallEvents[] = $event; + } + } + + expect($toolCallEvents)->toHaveCount(1); + expect($toolCallEvents[0]->toolCall->name)->toBe('time'); + expect($toolCallEvents[0]->toolCall->arguments())->toBe([]); + expect($text)->toContain('The current time is '.$currentTime); + + Http::assertSent(function (Request $request): bool { + $tools = $request->data()['tools']; + + // Tool without parameters: no 'parameters' key in function + $timeFunction = $tools[0]['function']; + expect($timeFunction['name'])->toBe('time'); + expect($timeFunction)->not->toHaveKey('parameters'); + + // Tool with parameters keeps its 'parameters' key + $searchFunction = $tools[1]['function']; + expect($searchFunction['name'])->toBe('search'); + expect($searchFunction)->toHaveKey('parameters'); + expect($searchFunction['parameters']['properties'])->toHaveKey('query'); + + return true; + }); +}); + it('can handle reasoning/thinking tokens in streaming', function (): void { FixtureResponse::fakeStreamResponses('v1/chat/completions', 'openrouter/stream-text-with-reasoning'); @@ -292,6 +349,42 @@ $streamEndEvent = array_values($streamEndEvents)[0]; expect($streamEndEvent->usage)->not->toBeNull(); expect($streamEndEvent->usage->thoughtTokens)->toBe(12); + + expect($streamEndEvent->additionalContent)->toHaveKey('reasoning'); + expect($streamEndEvent->additionalContent['reasoning'])->toContain('math problem'); +}); + +it('captures reasoning_details from streaming responses', function (): void { + FixtureResponse::fakeStreamResponses('v1/chat/completions', 'openrouter/stream-text-with-reasoning-details'); + + $response = Prism::text() + ->using(Provider::OpenRouter, 'openai/gpt-5') + ->withPrompt('What is 2 + 2?') + ->asStream(); + + $events = []; + $text = ''; + + foreach ($response as $event) { + $events[] = $event; + + if ($event instanceof TextDeltaEvent) { + $text .= $event->delta; + } + } + + expect($text)->toBe('The answer is 4.'); + + $thinkingStartEvents = array_filter($events, fn (StreamEvent $e): bool => $e instanceof ThinkingStartEvent); + expect($thinkingStartEvents)->toBeEmpty(); + + $streamEndEvent = array_values(array_filter($events, fn (StreamEvent $e): bool => $e instanceof StreamEndEvent))[0]; + expect($streamEndEvent->additionalContent)->toHaveKey('reasoning_details'); + expect($streamEndEvent->additionalContent['reasoning_details'])->toHaveCount(1); + expect($streamEndEvent->additionalContent['reasoning_details'][0]['type'])->toBe('reasoning.encrypted'); + expect($streamEndEvent->additionalContent['reasoning_details'][0]['id'])->toBe('rs_045e88aab38b'); + + expect($streamEndEvent->usage->thoughtTokens)->toBe(15); }); it('emits step start and step finish events', function (): void { diff --git a/tests/Providers/OpenRouter/TextTest.php b/tests/Providers/OpenRouter/TextTest.php index 8408374ec..5d07e2804 100644 --- a/tests/Providers/OpenRouter/TextTest.php +++ b/tests/Providers/OpenRouter/TextTest.php @@ -169,6 +169,30 @@ expect($response->finishReason)->toBe(FinishReason::Stop); }); +it('extracts reasoning from non-streaming response', function (): void { + FixtureResponse::fakeResponseSequence('v1/chat/completions', 'openrouter/generate-text-with-reasoning'); + + $response = Prism::text() + ->using(Provider::OpenRouter, 'anthropic/claude-3.7-sonnet') + ->withPrompt('What is 2 + 2?') + ->asText(); + + expect($response)->toBeInstanceOf(TextResponse::class); + expect($response->text)->toBe('The answer to 2 + 2 is 4.'); + + expect($response->additionalContent['reasoning']) + ->toBe('Let me think about this simple math problem. 2 + 2 equals 4. This is basic arithmetic.'); + + expect($response->additionalContent['reasoning_details']) + ->toBeArray() + ->toHaveCount(1); + expect($response->additionalContent['reasoning_details'][0]['type'])->toBe('reasoning.text'); + expect($response->additionalContent['reasoning_details'][0]['text']) + ->toBe('Let me think about this simple math problem. 2 + 2 equals 4. This is basic arithmetic.'); + + expect($response->usage->thoughtTokens)->toBe(18); +}); + it('forwards advanced provider options to openrouter', function (): void { FixtureResponse::fakeResponseSequence('v1/chat/completions', 'openrouter/generate-text-with-a-prompt'); @@ -230,3 +254,30 @@ && $payload['verbosity'] === 'medium'; }); }); + +it('excludes cached tokens from promptTokens', function (): void { + Http::fake([ + '*' => Http::response([ + 'id' => 'gen-cache-1', + 'model' => 'openai/gpt-4o', + 'choices' => [[ + 'index' => 0, + 'message' => ['role' => 'assistant', 'content' => 'Hello!'], + 'finish_reason' => 'stop', + ]], + 'usage' => [ + 'prompt_tokens' => 100, + 'completion_tokens' => 10, + 'prompt_tokens_details' => ['cached_tokens' => 60], + ], + ]), + ])->preventStrayRequests(); + + $response = Prism::text() + ->using(Provider::OpenRouter, 'openai/gpt-4o') + ->withPrompt('Hello') + ->asText(); + + expect($response->usage->promptTokens)->toBe(40) + ->and($response->usage->cacheReadInputTokens)->toBe(60); +}); diff --git a/tests/Providers/OpenRouter/ToolCallMapTest.php b/tests/Providers/OpenRouter/ToolCallMapTest.php new file mode 100644 index 000000000..13e19a393 --- /dev/null +++ b/tests/Providers/OpenRouter/ToolCallMapTest.php @@ -0,0 +1,58 @@ + 'call_1', + 'function' => [ + 'name' => 'get_weather', + 'arguments' => '{"city":"Detroit"}', + ], + ], + ]); + + expect($toolCalls)->toHaveCount(1) + ->and($toolCalls[0]->name)->toBe('get_weather') + ->and($toolCalls[0]->arguments())->toBe(['city' => 'Detroit']); +}); + +it('does not decode eagerly, so malformed JSON surfaces as a handled PrismException', function (): void { + $toolCalls = ToolCallMap::map([ + [ + 'id' => 'call_1', + 'function' => [ + 'name' => 'get_weather', + 'arguments' => '{"city":"Detroit"', + ], + ], + ]); + + // Mapping itself must not throw a raw TypeError (previously json_decode + // returned null and the ToolCall constructor rejected it). + expect($toolCalls[0]->name)->toBe('get_weather'); + + // Decoding happens lazily via arguments(); malformed JSON is a handled + // Prism error, not a raw PHP TypeError. + expect(fn (): array => $toolCalls[0]->arguments()) + ->toThrow(PrismException::class, 'not valid JSON'); +}); + +it('treats missing arguments as an empty argument list', function (): void { + $toolCalls = ToolCallMap::map([ + [ + 'id' => 'call_1', + 'function' => [ + 'name' => 'ping', + ], + ], + ]); + + expect($toolCalls[0]->arguments())->toBe([]); +}); diff --git a/tests/Providers/OpenRouter/ToolTest.php b/tests/Providers/OpenRouter/ToolTest.php index be9ba6fa2..17f6338f7 100644 --- a/tests/Providers/OpenRouter/ToolTest.php +++ b/tests/Providers/OpenRouter/ToolTest.php @@ -31,6 +31,69 @@ ]]); }); +it('generates parameters only once from conditional spread', function (): void { + $withParams = (new Tool) + ->as('search') + ->for('Search the web') + ->withStringParameter('query', 'the search query') + ->withNumberParameter('limit', 'max results', required: false) + ->using(fn (): string => '[results]'); + + $withoutParams = (new Tool) + ->as('get_time') + ->for('Returns the current time') + ->using(fn (): string => now()->toISOString()); + + $mapped = ToolMap::map([$withParams, $withoutParams]); + + // Tool WITH parameters: parameters is generated once by the conditional spread + $searchFn = $mapped[0]['function']; + expect($searchFn)->toHaveKey('parameters'); + expect($searchFn['parameters'])->toBe([ + 'type' => 'object', + 'properties' => [ + 'query' => [ + 'description' => 'the search query', + 'type' => 'string', + ], + 'limit' => [ + 'description' => 'max results', + 'type' => 'number', + ], + ], + 'required' => ['query'], + ]); + + // Tool WITHOUT parameters: conditional spread adds nothing, + // and no static duplicate exists to add it back + $timeFn = $mapped[1]['function']; + expect($timeFn)->not->toHaveKey('parameters'); + expect(array_keys($timeFn))->toBe(['name', 'description']); +}); + +it('proves parameters is generated by conditional spread not static duplicate', function (): void { + // Mock a tool where hasParameters()=true but parametersAsArray()=[] + // KEY 1 (conditional spread) produces: 'properties' => new \stdClass (JSON: {}) + // KEY 2 (static duplicate) produces: 'properties' => [] (JSON: []) + // If KEY 2 overwrites KEY 1, properties will be [] not stdClass + + $tool = Mockery::mock(Tool::class); + $tool->shouldReceive('name')->andReturn('mock_tool'); + $tool->shouldReceive('description')->andReturn('A mock tool'); + $tool->shouldReceive('hasParameters')->andReturn(true); + $tool->shouldReceive('parametersAsArray')->andReturn([]); // empty + $tool->shouldReceive('requiredParameters')->andReturn([]); + $tool->shouldReceive('providerOptions')->with('strict')->andReturn(null); + + $mapped = ToolMap::map([$tool]); + $properties = $mapped[0]['function']['parameters']['properties']; + + // KEY 1 wraps empty properties in stdClass (serializes as JSON {}) + // KEY 2 would leave it as [] (serializes as JSON []) + // If this is stdClass, KEY 1 produced it. If array, KEY 2 overwrote it. + expect($properties)->toBeInstanceOf(stdClass::class, 'parameters.properties should be stdClass from conditional spread, not [] from static duplicate'); +}); + it('maps tools with strict mode', function (): void { $tool = (new Tool) ->as('search') diff --git a/tests/Providers/Qwen/EmbeddingsTest.php b/tests/Providers/Qwen/EmbeddingsTest.php new file mode 100644 index 000000000..859150c0e --- /dev/null +++ b/tests/Providers/Qwen/EmbeddingsTest.php @@ -0,0 +1,75 @@ +set('prism.providers.qwen.api_key', env('QWEN_API_KEY')); +}); + +it('returns embeddings from input', function (): void { + FixtureResponse::fakeResponseSequence('text-embedding/text-embedding', 'qwen/embeddings'); + + $response = Prism::embeddings() + ->using(Provider::Qwen, 'text-embedding-v4') + ->fromInput('Hello, how are you?') + ->asEmbeddings(); + + $embeddings = json_decode( + file_get_contents('tests/Fixtures/qwen/embeddings-1.json'), + true + ); + + $embeddings = array_map( + fn (array $item): Embedding => Embedding::fromArray($item['embedding']), + data_get($embeddings, 'output.embeddings') + ); + + expect($response->meta->model)->toBe('text-embedding-v4'); + expect($response->meta->id)->toBe('118f9c73-7878-927a-92da-4a276b224421'); + + expect($response->embeddings)->toBeArray(); + expect($response->embeddings[0]->embedding)->toBe($embeddings[0]->embedding); + expect($response->usage->tokens)->toBe(7); +}); + +it('sends correct request to embeddings endpoint', function (): void { + FixtureResponse::fakeResponseSequence('text-embedding/text-embedding', 'qwen/embeddings'); + + Prism::embeddings() + ->using(Provider::Qwen, 'text-embedding-v4') + ->fromInput('Hello, how are you?') + ->asEmbeddings(); + + Http::assertSent(function (Request $request): bool { + $body = json_decode($request->body(), true); + + return $body['model'] === 'text-embedding-v4' + && $body['input'] === ['texts' => ['Hello, how are you?']]; + }); +}); + +it('supports dimensions provider option', function (): void { + FixtureResponse::fakeResponseSequence('text-embedding/text-embedding', 'qwen/embeddings'); + + Prism::embeddings() + ->using(Provider::Qwen, 'text-embedding-v4') + ->withProviderOptions([ + 'dimensions' => 512, + ]) + ->fromInput('Hello, how are you?') + ->asEmbeddings(); + + Http::assertSent(function (Request $request): bool { + $body = json_decode($request->body(), true); + + return $body['model'] === 'text-embedding-v4' + && $body['parameters']['dimensions'] === 512; + }); +}); diff --git a/tests/Providers/Qwen/ExceptionHandlingTest.php b/tests/Providers/Qwen/ExceptionHandlingTest.php new file mode 100644 index 000000000..bf0ca8530 --- /dev/null +++ b/tests/Providers/Qwen/ExceptionHandlingTest.php @@ -0,0 +1,121 @@ +provider = new Qwen( + apiKey: 'test-key', + url: 'https://dashscope-intl.aliyuncs.com/api/v1' + ); +}); + +function createQwenMockResponse(int $statusCode, array $json = [], array $headers = []): Response +{ + $mockResponse = Mockery::mock(Response::class); + $mockResponse->shouldReceive('getStatusCode')->andReturn($statusCode); + $mockResponse->shouldReceive('status')->andReturn($statusCode); + $mockResponse->shouldReceive('json')->andReturn($json); + $mockResponse->shouldReceive('toPsrResponse')->andReturn(new PsrResponse($statusCode)); + + if (isset($headers['retry-after'])) { + $mockResponse->shouldReceive('hasHeader')->with('retry-after')->andReturn(true); + $mockResponse->shouldReceive('header')->with('retry-after')->andReturn($headers['retry-after']); + } else { + $mockResponse->shouldReceive('hasHeader')->with('retry-after')->andReturn(false); + } + + return $mockResponse; +} + +it('handles Arrearage errors', function (): void { + $mockResponse = createQwenMockResponse(400, [ + 'code' => 'Arrearage', + 'message' => 'Your account has insufficient balance', + ]); + $exception = new RequestException($mockResponse); + + expect(fn () => $this->provider->handleRequestException('qwen-plus', $exception)) + ->toThrow(PrismException::class, 'Qwen Account Arrearage: Your account has insufficient balance'); +}); + +it('handles DataInspectionFailed errors', function (): void { + $mockResponse = createQwenMockResponse(400, [ + 'code' => 'DataInspectionFailed', + 'message' => 'Input or output data may contain inappropriate content.', + ]); + $exception = new RequestException($mockResponse); + + expect(fn () => $this->provider->handleRequestException('qwen-plus', $exception)) + ->toThrow(PrismException::class, 'Qwen Content Moderation Failed: Input or output data may contain inappropriate content.'); +}); + +it('handles data_inspection_failed errors (snake_case variant)', function (): void { + $mockResponse = createQwenMockResponse(400, [ + 'code' => 'data_inspection_failed', + 'message' => 'Content moderation failed.', + ]); + $exception = new RequestException($mockResponse); + + expect(fn () => $this->provider->handleRequestException('qwen-plus', $exception)) + ->toThrow(PrismException::class, 'Qwen Content Moderation Failed: Content moderation failed.'); +}); + +it('handles rate limit errors (429)', function (): void { + $mockResponse = createQwenMockResponse(429, [ + 'code' => '', + 'message' => 'Rate limit exceeded', + ]); + $exception = new RequestException($mockResponse); + + expect(fn () => $this->provider->handleRequestException('qwen-plus', $exception)) + ->toThrow(PrismRateLimitedException::class); +}); + +it('handles overloaded errors (503)', function (): void { + $mockResponse = createQwenMockResponse(503, [ + 'code' => '', + 'message' => 'Service temporarily unavailable', + ]); + $exception = new RequestException($mockResponse); + + expect(fn () => $this->provider->handleRequestException('qwen-plus', $exception)) + ->toThrow(PrismProviderOverloadedException::class); +}); + +it('handles errors with detailed error information', function (): void { + $mockResponse = createQwenMockResponse(400, [ + 'code' => 'invalid_request_error', + 'message' => 'Invalid request parameters', + ]); + $exception = new RequestException($mockResponse); + + expect(fn () => $this->provider->handleRequestException('qwen-plus', $exception)) + ->toThrow(PrismException::class, 'Qwen Error [400]: invalid_request_error - Invalid request parameters'); +}); + +it('handles errors without error type', function (): void { + $mockResponse = createQwenMockResponse(500, [ + 'code' => '', + 'message' => 'Internal server error', + ]); + $exception = new RequestException($mockResponse); + + expect(fn () => $this->provider->handleRequestException('qwen-plus', $exception)) + ->toThrow(PrismException::class, 'Qwen Error [500]: Internal server error'); +}); + +it('handles errors without any error details', function (): void { + $mockResponse = createQwenMockResponse(500, []); + $exception = new RequestException($mockResponse); + + expect(fn () => $this->provider->handleRequestException('qwen-plus', $exception)) + ->toThrow(PrismException::class, 'Qwen Error [500]: Unknown error'); +}); diff --git a/tests/Providers/Qwen/ImagesTest.php b/tests/Providers/Qwen/ImagesTest.php new file mode 100644 index 000000000..3af3609ac --- /dev/null +++ b/tests/Providers/Qwen/ImagesTest.php @@ -0,0 +1,355 @@ +set('prism.providers.qwen.api_key', env('QWEN_API_KEY')); + config()->set('prism.providers.qwen.url', env('QWEN_URL', 'https://dashscope-intl.aliyuncs.com/api/v1')); +}); + +it('can generate an image with qwen-image-max', function (): void { + FixtureResponse::fakeResponseSequence( + 'multimodal-generation/generation', + 'qwen/image-generation' + ); + + $response = Prism::image() + ->using('qwen', 'qwen-image-max') + ->withPrompt('A cute baby sea otter floating on its back in calm blue water') + ->withProviderOptions([ + 'size' => '1328*1328', + ]) + ->withClientOptions(['timeout' => 120]) + ->generate(); + + expect($response->firstImage())->not->toBeNull(); + expect($response->firstImage()->hasUrl())->toBeTrue(); + expect($response->firstImage()->url)->toContain('dashscope-result'); + expect($response->imageCount())->toBe(1); + expect($response->meta->id)->toBe('5e859e62-93a1-4222-b9ea-b4e2ac543e1a'); + expect($response->meta->model)->toBe('qwen-image-max'); + expect($response->additionalContent)->toBe([ + 'image_count' => 1, + 'width' => 1328, + 'height' => 1328, + ]); +}); + +it('can generate an image with provider options', function (): void { + Http::fake([ + 'dashscope-intl.aliyuncs.com/api/v1/services/aigc/multimodal-generation/generation' => Http::response([ + 'output' => [ + 'choices' => [ + [ + 'finish_reason' => 'stop', + 'message' => [ + 'content' => [ + [ + 'image' => 'https://dashscope-result.oss-cn-shanghai.aliyuncs.com/options-image.png', + ], + ], + 'role' => 'assistant', + ], + ], + ], + ], + 'usage' => [ + 'image_count' => 1, + 'width' => 928, + 'height' => 1664, + ], + 'request_id' => 'req-img-004', + ], 200), + ]); + + $response = Prism::image() + ->using('qwen', 'qwen-image-max') + ->withPrompt('A sunset over mountains') + ->withProviderOptions([ + 'size' => '928*1664', + 'negative_prompt' => 'low resolution, low quality', + 'prompt_extend' => true, + 'watermark' => false, + 'seed' => 42, + ]) + ->generate(); + + expect($response->firstImage()->url)->toBe('https://dashscope-result.oss-cn-shanghai.aliyuncs.com/options-image.png'); + + Http::assertSent(function (Request $request): bool { + $data = $request->data(); + + return $data['model'] === 'qwen-image-max' + && $data['parameters']['size'] === '928*1664' + && $data['parameters']['negative_prompt'] === 'low resolution, low quality' + && $data['parameters']['prompt_extend'] === true + && $data['parameters']['watermark'] === false + && $data['parameters']['seed'] === 42; + }); +}); + +it('sends request to correct dashscope native api endpoint', function (): void { + Http::fake([ + 'dashscope-intl.aliyuncs.com/api/v1/services/aigc/multimodal-generation/generation' => Http::response([ + 'output' => [ + 'choices' => [ + [ + 'finish_reason' => 'stop', + 'message' => [ + 'content' => [ + [ + 'image' => 'https://dashscope-result.oss-cn-shanghai.aliyuncs.com/test.png', + ], + ], + 'role' => 'assistant', + ], + ], + ], + ], + 'usage' => [ + 'image_count' => 1, + 'width' => 1664, + 'height' => 928, + ], + 'request_id' => 'req-endpoint-006', + ], 200), + ]); + + Prism::image() + ->using('qwen', 'qwen-image-max') + ->withPrompt('Test endpoint') + ->generate(); + + Http::assertSent( + // Verify it uses the DashScope native API, not the OpenAI-compatible endpoint + fn (Request $request): bool => str_contains($request->url(), '/api/v1/services/aigc/multimodal-generation/generation') + && ! str_contains($request->url(), '/compatible-mode/')); +}); + +it('handles generation failure', function (): void { + Http::fake([ + 'dashscope-intl.aliyuncs.com/api/v1/services/aigc/multimodal-generation/generation' => Http::response([ + 'code' => 'InvalidParameter', + 'message' => 'num_images_per_prompt must be 1', + ], 400), + ]); + + Prism::image() + ->using('qwen', 'qwen-image-max') + ->withPrompt('This will fail') + ->withProviderOptions([ + 'n' => 3, + ]) + ->generate(); +})->throws(PrismException::class); + +it('can edit an image with qwen-image-edit-max', function (): void { + FixtureResponse::fakeResponseSequence( + 'multimodal-generation/generation', + 'qwen/image-edit' + ); + + $response = Prism::image() + ->using('qwen', 'qwen-image-edit-max') + ->withPrompt('生成一张符合深度图的图像,遵循以下描述:一辆红色的破旧的自行车停在一条泥泞的小路上', [ + Image::fromUrl('https://example.com/input-image.png'), + ]) + ->withProviderOptions([ + 'n' => 2, + 'size' => '1024*1536', + 'prompt_extend' => true, + 'watermark' => false, + ]) + ->generate(); + + expect($response->imageCount())->toBe(2); + expect($response->firstImage()->hasUrl())->toBeTrue(); + expect($response->firstImage()->url)->toContain('dashscope-result'); + expect($response->meta->id)->toBe('a1b2c3d4-5e6f-7890-abcd-ef1234567890'); + expect($response->additionalContent)->toBe([ + 'image_count' => 2, + 'width' => 1024, + 'height' => 1536, + ]); +}); + +it('sends image editing request with correct content structure', function (): void { + Http::fake([ + 'dashscope-intl.aliyuncs.com/api/v1/services/aigc/multimodal-generation/generation' => Http::response([ + 'output' => [ + 'choices' => [ + [ + 'finish_reason' => 'stop', + 'message' => [ + 'role' => 'assistant', + 'content' => [ + ['image' => 'https://dashscope-result.oss.aliyuncs.com/edited.png'], + ], + ], + ], + ], + ], + 'usage' => ['image_count' => 1, 'width' => 1024, 'height' => 1024], + 'request_id' => 'req-edit-001', + ], 200), + ]); + + Prism::image() + ->using('qwen', 'qwen-image-edit-max') + ->withPrompt('Make the background blue', [ + Image::fromUrl('https://example.com/photo.png'), + ]) + ->withProviderOptions([ + 'n' => 1, + 'negative_prompt' => 'low quality', + ]) + ->generate(); + + Http::assertSent(function (Request $request): bool { + $data = $request->data(); + $content = $data['input']['messages'][0]['content']; + + // Image should come before text in the content array + return $data['model'] === 'qwen-image-edit-max' + && count($content) === 2 + && isset($content[0]['image']) + && $content[0]['image'] === 'https://example.com/photo.png' + && $content[1]['text'] === 'Make the background blue' + && $data['parameters']['n'] === 1 + && $data['parameters']['negative_prompt'] === 'low quality'; + }); +}); + +it('sends multiple images for multi-image fusion editing', function (): void { + Http::fake([ + 'dashscope-intl.aliyuncs.com/api/v1/services/aigc/multimodal-generation/generation' => Http::response([ + 'output' => [ + 'choices' => [ + [ + 'finish_reason' => 'stop', + 'message' => [ + 'role' => 'assistant', + 'content' => [ + ['image' => 'https://dashscope-result.oss.aliyuncs.com/fused.png'], + ], + ], + ], + ], + ], + 'usage' => ['image_count' => 1, 'width' => 1024, 'height' => 1536], + 'request_id' => 'req-edit-002', + ], 200), + ]); + + Prism::image() + ->using('qwen', 'qwen-image-edit-max') + ->withPrompt('图1中的女生穿着图2中的黑色裙子按图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(); + + Http::assertSent(function (Request $request): bool { + $data = $request->data(); + $content = $data['input']['messages'][0]['content']; + + // 3 images + 1 text = 4 content items + return count($content) === 4 + && $content[0]['image'] === 'https://example.com/person.png' + && $content[1]['image'] === 'https://example.com/dress.png' + && $content[2]['image'] === 'https://example.com/pose.png' + && isset($content[3]['text']); + }); +}); + +it('handles base64 images in editing request', function (): void { + Http::fake([ + 'dashscope-intl.aliyuncs.com/api/v1/services/aigc/multimodal-generation/generation' => Http::response([ + 'output' => [ + 'choices' => [ + [ + 'finish_reason' => 'stop', + 'message' => [ + 'role' => 'assistant', + 'content' => [ + ['image' => 'https://dashscope-result.oss.aliyuncs.com/b64-edited.png'], + ], + ], + ], + ], + ], + 'usage' => ['image_count' => 1, 'width' => 1024, 'height' => 1024], + 'request_id' => 'req-edit-003', + ], 200), + ]); + + Prism::image() + ->using('qwen', 'qwen-image-edit-max') + ->withPrompt('Add a hat', [ + Image::fromBase64('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk', 'image/png'), + ]) + ->generate(); + + Http::assertSent(function (Request $request): bool { + $data = $request->data(); + $content = $data['input']['messages'][0]['content']; + + return count($content) === 2 + && str_starts_with((string) $content[0]['image'], 'data:image/png;base64,') + && $content[1]['text'] === 'Add a hat'; + }); +}); + +it('generates request without parameters when no options provided', function (): void { + Http::fake([ + 'dashscope-intl.aliyuncs.com/api/v1/services/aigc/multimodal-generation/generation' => Http::response([ + 'output' => [ + 'choices' => [ + [ + 'finish_reason' => 'stop', + 'message' => [ + 'content' => [ + [ + 'image' => 'https://dashscope-result.oss-cn-shanghai.aliyuncs.com/no-params.png', + ], + ], + 'role' => 'assistant', + ], + ], + ], + ], + 'usage' => [ + 'image_count' => 1, + 'width' => 1664, + 'height' => 928, + ], + 'request_id' => 'req-no-params-008', + ], 200), + ]); + + Prism::image() + ->using('qwen', 'qwen-image-max') + ->withPrompt('Simple prompt') + ->generate(); + + Http::assertSent(function (Request $request): bool { + $data = $request->data(); + + return $data['model'] === 'qwen-image-max' + && ! isset($data['parameters']); + }); +}); diff --git a/tests/Providers/Qwen/MessageMapTest.php b/tests/Providers/Qwen/MessageMapTest.php new file mode 100644 index 000000000..1c52f8dc8 --- /dev/null +++ b/tests/Providers/Qwen/MessageMapTest.php @@ -0,0 +1,235 @@ +toBe([[ + 'role' => 'user', + 'content' => 'Who are you?', + ]]); +}); + +it('maps user messages with images from path', function (): void { + $messageMap = new MessageMap( + messages: [ + new UserMessage('Who are you?', [ + Image::fromLocalPath('tests/Fixtures/diamond.png'), + ]), + ], + systemPrompts: [] + ); + + $mappedMessage = $messageMap(); + + // DashScope native multimodal format: images + text in content array + expect(data_get($mappedMessage, '0.content'))->toHaveCount(2); + + // Image comes first as {"image": "data:...;base64,..."} + expect(data_get($mappedMessage, '0.content.0.image')) + ->toStartWith('data:image/png;base64,'); + expect(data_get($mappedMessage, '0.content.0.image')) + ->toContain(base64_encode(file_get_contents('tests/Fixtures/diamond.png'))); + + // Text comes last as {"text": "..."} + expect(data_get($mappedMessage, '0.content.1.text')) + ->toBe('Who are you?'); +}); + +it('maps user messages with images from base64', function (): void { + $messageMap = new MessageMap( + messages: [ + new UserMessage('Who are you?', [ + Image::fromBase64(base64_encode(file_get_contents('tests/Fixtures/diamond.png')), 'image/png'), + ]), + ], + systemPrompts: [] + ); + + $mappedMessage = $messageMap(); + + expect(data_get($mappedMessage, '0.content'))->toHaveCount(2); + + expect(data_get($mappedMessage, '0.content.0.image')) + ->toStartWith('data:image/png;base64,'); + expect(data_get($mappedMessage, '0.content.0.image')) + ->toContain(base64_encode(file_get_contents('tests/Fixtures/diamond.png'))); + + expect(data_get($mappedMessage, '0.content.1.text')) + ->toBe('Who are you?'); +}); + +it('maps user messages with images from url', function (): void { + $messageMap = new MessageMap( + messages: [ + new UserMessage('Who are you?', [ + Image::fromUrl('https://prismphp.com/storage/diamond.png'), + ]), + ], + systemPrompts: [] + ); + + $mappedMessage = $messageMap(); + + expect(data_get($mappedMessage, '0.content'))->toHaveCount(2); + + expect(data_get($mappedMessage, '0.content.0.image')) + ->toBe('https://prismphp.com/storage/diamond.png'); + + expect(data_get($mappedMessage, '0.content.1.text')) + ->toBe('Who are you?'); +}); + +it('maps assistant message', function (): void { + $messageMap = new MessageMap( + messages: [ + new AssistantMessage('I am Qwen'), + ], + systemPrompts: [] + ); + + expect($messageMap())->toContain([ + 'role' => 'assistant', + 'content' => 'I am Qwen', + ]); +}); + +it('maps assistant message with tool calls', function (): void { + $messageMap = new MessageMap( + messages: [ + new AssistantMessage('I am Qwen', [ + new ToolCall( + 'tool_1234', + 'weather', + [ + 'city' => 'Detroit', + ] + ), + ]), + ], + systemPrompts: [] + ); + + expect($messageMap())->toBe([[ + 'role' => 'assistant', + 'content' => 'I am Qwen', + 'tool_calls' => [[ + 'id' => 'tool_1234', + 'type' => 'function', + 'function' => [ + 'name' => 'weather', + 'arguments' => json_encode([ + 'city' => 'Detroit', + ]), + ], + ]], + ]]); +}); + +it('maps tool result messages', function (): void { + $messageMap = new MessageMap( + messages: [ + new ToolResultMessage([ + new ToolResult( + 'tool_1234', + 'weather', + [ + 'city' => 'Detroit', + ], + '[weather results]' + ), + ]), + ], + systemPrompts: [] + ); + + expect($messageMap())->toBe([[ + 'role' => 'tool', + 'tool_call_id' => 'tool_1234', + 'content' => '[weather results]', + ]]); +}); + +it('maps user messages with multiple images', function (): void { + $messageMap = new MessageMap( + messages: [ + new UserMessage('这些是什么?', [ + Image::fromUrl('https://example.com/dog.jpeg'), + Image::fromUrl('https://example.com/tiger.png'), + Image::fromUrl('https://example.com/rabbit.png'), + ]), + ], + systemPrompts: [] + ); + + $mappedMessage = $messageMap(); + + // 3 images + 1 text = 4 content items + expect(data_get($mappedMessage, '0.content'))->toHaveCount(4); + + expect(data_get($mappedMessage, '0.content.0.image'))->toBe('https://example.com/dog.jpeg'); + expect(data_get($mappedMessage, '0.content.1.image'))->toBe('https://example.com/tiger.png'); + expect(data_get($mappedMessage, '0.content.2.image'))->toBe('https://example.com/rabbit.png'); + expect(data_get($mappedMessage, '0.content.3.text'))->toBe('这些是什么?'); +}); + +it('detects images in messages via hasImages', function (): void { + $withImages = new MessageMap( + messages: [ + new UserMessage('Describe this', [ + Image::fromUrl('https://example.com/photo.png'), + ]), + ], + systemPrompts: [] + ); + + $withoutImages = new MessageMap( + messages: [ + new UserMessage('Hello'), + ], + systemPrompts: [] + ); + + expect($withImages->hasImages())->toBeTrue(); + expect($withoutImages->hasImages())->toBeFalse(); +}); + +it('maps system prompt', function (): void { + $messageMap = new MessageMap( + messages: [new UserMessage('Who are you?')], + systemPrompts: [ + new SystemMessage('MODEL ADOPTS ROLE of [PERSONA: Nyx the Cthulhu]'), + new SystemMessage('But my friends call me Nyx'), + ] + ); + + expect($messageMap())->toBe([ + [ + 'role' => 'system', + 'content' => 'MODEL ADOPTS ROLE of [PERSONA: Nyx the Cthulhu]', + ], + [ + 'role' => 'system', + 'content' => 'But my friends call me Nyx', + ], + [ + 'role' => 'user', + 'content' => 'Who are you?', + ], + ]); +}); diff --git a/tests/Providers/Qwen/StreamTest.php b/tests/Providers/Qwen/StreamTest.php new file mode 100644 index 000000000..b4141f88d --- /dev/null +++ b/tests/Providers/Qwen/StreamTest.php @@ -0,0 +1,329 @@ +set('prism.providers.qwen.api_key', env('QWEN_API_KEY')); + config()->set('prism.providers.qwen.url', env('QWEN_URL', 'https://dashscope-intl.aliyuncs.com/api/v1')); +}); + +it('can generate text with a basic stream', function (): void { + FixtureResponse::fakeStreamResponses('text-generation/generation', 'qwen/stream-basic-text'); + + $response = Prism::text() + ->using(Provider::Qwen, 'qwen-plus') + ->withPrompt('Who are you?') + ->asStream(); + + $text = ''; + $events = []; + + foreach ($response as $event) { + $events[] = $event; + + if ($event instanceof TextDeltaEvent) { + $text .= $event->delta; + } + } + + expect($events) + ->not->toBeEmpty() + ->and($text)->not->toBeEmpty(); + + $lastEvent = end($events); + expect($lastEvent)->toBeInstanceOf(StreamEndEvent::class); + expect($lastEvent->finishReason)->toBe(FinishReason::Stop); + + // Verify the HTTP request + Http::assertSent(function (Request $request): bool { + $body = json_decode($request->body(), true); + + return str_contains($request->url(), 'text-generation/generation') + && $body['model'] === 'qwen-plus'; + }); +}); + +it('can generate text using tools with streaming', function (): void { + FixtureResponse::fakeStreamResponses('text-generation/generation', 'qwen/stream-with-tools'); + + $tools = [ + Tool::as('get_weather') + ->for('useful when you need to search for current weather conditions') + ->withStringParameter('city', 'The city that you want the weather for') + ->using(fn (string $city): string => "The weather will be 75° and sunny in {$city}"), + + Tool::as('search_tool') + ->for('useful for searching current events or data') + ->withStringParameter('query', 'The detailed search query') + ->using(fn (string $query): string => "Search results for: {$query}"), + ]; + + $response = Prism::text() + ->using(Provider::Qwen, 'qwen-plus') + ->withTools($tools) + ->withMaxSteps(4) + ->withPrompt('What is the weather in Detroit?') + ->asStream(); + + $text = ''; + $events = []; + $toolCallEvents = []; + $toolResultEvents = []; + + foreach ($response as $event) { + $events[] = $event; + + if ($event instanceof TextDeltaEvent) { + $text .= $event->delta; + } + + if ($event instanceof ToolCallEvent) { + $toolCallEvents[] = $event; + expect($event->toolCall->name) + ->toBeString() + ->and($event->toolCall->name)->not + ->toBeEmpty() + ->and($event->toolCall->arguments())->toBeArray(); + } + + if ($event instanceof ToolResultEvent) { + $toolResultEvents[] = $event; + } + + if ($event instanceof StreamEndEvent) { + expect($event->finishReason)->toBeInstanceOf(FinishReason::class); + } + } + + expect($events)->not->toBeEmpty(); + expect($toolCallEvents)->not->toBeEmpty(); + expect($toolResultEvents)->not->toBeEmpty(); + + // Verify only one StreamStartEvent and one StreamEndEvent + $streamStartEvents = array_filter($events, fn (StreamEvent $event): bool => $event instanceof StreamStartEvent); + $streamEndEvents = array_filter($events, fn (StreamEvent $event): bool => $event instanceof StreamEndEvent); + expect($streamStartEvents)->toHaveCount(1); + expect($streamEndEvents)->toHaveCount(1); + + // Verify the HTTP request + Http::assertSent(function (Request $request): bool { + $body = json_decode($request->body(), true); + + return str_contains($request->url(), 'text-generation/generation') + && isset($body['parameters']['tools']) + && $body['model'] === 'qwen-plus'; + }); +}); + +it('handles max_tokens parameter correctly', function (): void { + FixtureResponse::fakeStreamResponses('text-generation/generation', 'qwen/stream-max-tokens'); + + $response = Prism::text() + ->using(Provider::Qwen, 'qwen-plus') + ->withMaxTokens(1000) + ->withPrompt('Who are you?') + ->asStream(); + + foreach ($response as $event) { + // Process stream + } + + // Verify the HTTP request + Http::assertSent(function (Request $request): bool { + $body = json_decode($request->body(), true); + + return str_contains($request->url(), 'text-generation/generation') + && $body['parameters']['max_tokens'] === 1000; + }); +}); + +it('handles system prompts correctly', function (): void { + FixtureResponse::fakeStreamResponses('text-generation/generation', 'qwen/stream-system-prompt'); + + $response = Prism::text() + ->using(Provider::Qwen, 'qwen-plus') + ->withSystemPrompt('You are a helpful assistant.') + ->withPrompt('Who are you?') + ->asStream(); + + foreach ($response as $event) { + // Process stream + } + + Http::assertSent(function (Request $request): bool { + $body = json_decode($request->body(), true); + + return count($body['input']['messages']) === 2 + && $body['input']['messages'][0]['role'] === 'system' + && $body['input']['messages'][1]['role'] === 'user'; + }); +}); + +it('can handle reasoning/thinking tokens in streaming', function (): void { + FixtureResponse::fakeStreamResponses('text-generation/generation', 'qwen/stream-with-reasoning'); + + $response = Prism::text() + ->using(Provider::Qwen, 'qwq-plus') + ->withPrompt('Solve this complex math problem: What is 4 * 8?') + ->asStream(); + + $thinkingContent = ''; + $regularContent = ''; + $thinkingEvents = 0; + $textDeltaEvents = 0; + + foreach ($response as $event) { + if ($event instanceof ThinkingEvent) { + $thinkingContent .= $event->delta; + $thinkingEvents++; + } elseif ($event instanceof TextDeltaEvent) { + $regularContent .= $event->delta; + $textDeltaEvents++; + } + } + + expect($thinkingEvents) + ->toBeGreaterThan(0) + ->and($textDeltaEvents)->toBeGreaterThan(0) + ->and($thinkingContent)->not + ->toBeEmpty() + ->and($regularContent)->not + ->toBeEmpty() + ->and($thinkingContent)->toContain('answer') + ->and($regularContent)->toContain('32'); + + Http::assertSent(function (Request $request): bool { + $data = json_decode($request->body(), true); + + return str_contains($request->url(), 'text-generation/generation') + && $data['model'] === 'qwq-plus' + && $data['parameters']['result_format'] === 'message' + && $data['parameters']['incremental_output'] === true; + }); +}); + +it('emits step start and step finish events', function (): void { + FixtureResponse::fakeStreamResponses('text-generation/generation', 'qwen/stream-basic-text'); + + $response = Prism::text() + ->using(Provider::Qwen, 'qwen-plus') + ->withPrompt('Who are you?') + ->asStream(); + + $events = []; + + foreach ($response as $event) { + $events[] = $event; + } + + // Check for StepStartEvent after StreamStartEvent + $stepStartEvents = array_filter($events, fn (StreamEvent $e): bool => $e instanceof StepStartEvent); + expect($stepStartEvents)->toHaveCount(1); + + // Check for StepFinishEvent before StreamEndEvent + $stepFinishEvents = array_filter($events, fn (StreamEvent $e): bool => $e instanceof StepFinishEvent); + expect($stepFinishEvents)->toHaveCount(1); + + // Verify order: StreamStart -> StepStart -> ... -> StepFinish -> StreamEnd + $eventTypes = array_map(get_class(...), $events); + $streamStartIndex = array_search(StreamStartEvent::class, $eventTypes); + $stepStartIndex = array_search(StepStartEvent::class, $eventTypes); + $stepFinishIndex = array_search(StepFinishEvent::class, $eventTypes); + $streamEndIndex = array_search(StreamEndEvent::class, $eventTypes); + + expect($streamStartIndex)->toBeLessThan($stepStartIndex); + expect($stepStartIndex)->toBeLessThan($stepFinishIndex); + expect($stepFinishIndex)->toBeLessThan($streamEndIndex); +}); + +it('emits multiple step events with tool calls', function (): void { + FixtureResponse::fakeStreamResponses('text-generation/generation', 'qwen/stream-with-tools'); + + $tools = [ + Tool::as('get_weather') + ->for('useful when you need to search for current weather conditions') + ->withStringParameter('city', 'The city that you want the weather for') + ->using(fn (string $city): string => "The weather will be 75° and sunny in {$city}"), + Tool::as('search_tool') + ->for('useful for searching current events or data') + ->withStringParameter('query', 'The detailed search query') + ->using(fn (string $query): string => "Search results for: {$query}"), + ]; + + $response = Prism::text() + ->using(Provider::Qwen, 'qwen-plus') + ->withTools($tools) + ->withMaxSteps(4) + ->withPrompt('What is the weather in Detroit?') + ->asStream(); + + $events = []; + + foreach ($response as $event) { + $events[] = $event; + } + + // With tool calls, we should have multiple step start/finish pairs + $stepStartEvents = array_filter($events, fn (StreamEvent $e): bool => $e instanceof StepStartEvent); + $stepFinishEvents = array_filter($events, fn (StreamEvent $e): bool => $e instanceof StepFinishEvent); + + // At least 2 steps: one for tool call, one for final response + expect(count($stepStartEvents))->toBeGreaterThanOrEqual(2); + expect(count($stepFinishEvents))->toBeGreaterThanOrEqual(2); + + // Verify step start/finish pairs are balanced + expect(count($stepStartEvents))->toBe(count($stepFinishEvents)); +}); + +it('sends StreamEndEvent using tools with streaming and max steps = 1', function (): void { + FixtureResponse::fakeStreamResponses('text-generation/generation', 'qwen/stream-with-tools'); + + $tools = [ + Tool::as('get_weather') + ->for('useful when you need to search for current weather conditions') + ->withStringParameter('city', 'The city that you want the weather for') + ->using(fn (string $city): string => "The weather will be 75° and sunny in {$city}"), + + Tool::as('search_tool') + ->for('useful for searching current events or data') + ->withStringParameter('query', 'The detailed search query') + ->using(fn (string $query): string => "Search results for: {$query}"), + ]; + + $response = Prism::text() + ->using(Provider::Qwen, 'qwen-plus') + ->withTools($tools) + ->withMaxSteps(1) + ->withPrompt('What time is the tigers game today and should I wear a coat?') + ->asStream(); + + $events = []; + + foreach ($response as $event) { + $events[] = $event; + } + + expect($events)->not->toBeEmpty(); + + $lastEvent = end($events); + expect($lastEvent)->toBeInstanceOf(StreamEndEvent::class); +}); diff --git a/tests/Providers/Qwen/StructuredTest.php b/tests/Providers/Qwen/StructuredTest.php new file mode 100644 index 000000000..1aa9af13c --- /dev/null +++ b/tests/Providers/Qwen/StructuredTest.php @@ -0,0 +1,156 @@ +withSchema($schema) + ->using(Provider::Qwen, 'qwen-plus') + ->withSystemPrompt('The tigers game is at 3pm in Detroit, the temperature is expected to be 75º') + ->withPrompt('What time is the tigers game today and should I wear a coat?') + ->asStructured(); + + // Assert request format + Http::assertSent(function (Request $request): bool { + $data = $request->data(); + $messages = $data['input']['messages']; + + return str_contains($request->url(), 'text-generation/generation') + && $data['model'] === 'qwen-plus' + && $data['parameters']['result_format'] === 'message' + && $data['parameters']['response_format'] === ['type' => 'json_object'] + // System prompt → user prompt → JSON schema instruction + && $messages[0]['role'] === 'system' + && $messages[1]['role'] === 'user' + && $messages[2]['role'] === 'system' + && str_contains((string) $messages[2]['content'], 'JSON'); + }); + + // Assert response type + expect($response)->toBeInstanceOf(StructuredResponse::class); + + // Assert structured data + expect($response->structured)->toBeArray(); + expect($response->structured)->toHaveKeys([ + 'weather', + 'game_time', + 'coat_required', + ]); + expect($response->structured['weather'])->toBeString()->toBe('75º'); + expect($response->structured['game_time'])->toBeString()->toBe('3pm'); + expect($response->structured['coat_required'])->toBeBool()->toBeFalse(); + + // Assert metadata + expect($response->meta->id)->toBe('c566fb8b-d0a3-92d0-b77a-ae1496d9f32f'); + expect($response->meta->model)->toBe('qwen-plus'); + expect($response->usage->promptTokens)->toBe(216); + expect($response->usage->completionTokens)->toBe(27); +}); + +it('returns structured output using json_schema mode', function (): void { + FixtureResponse::fakeResponseSequence('text-generation/generation', 'qwen/structured'); + + $schema = new ObjectSchema( + 'output', + 'the output object', + [ + new StringSchema('weather', 'The weather forecast'), + new StringSchema('game_time', 'The tigers game time'), + new BooleanSchema('coat_required', 'whether a coat is required'), + ], + ['weather', 'game_time', 'coat_required'] + ); + + $response = Prism::structured() + ->withSchema($schema) + ->using(Provider::Qwen, 'qwen-plus') + ->usingStructuredMode(StructuredMode::Structured) + ->withSystemPrompt('The tigers game is at 3pm in Detroit, the temperature is expected to be 75º') + ->withPrompt('What time is the tigers game today and should I wear a coat?') + ->asStructured(); + + // Assert request format + Http::assertSent(function (Request $request): bool { + $data = $request->data(); + $messages = $data['input']['messages']; + $responseFormat = $data['parameters']['response_format']; + + return str_contains($request->url(), 'text-generation/generation') + && $data['model'] === 'qwen-plus' + && $data['parameters']['result_format'] === 'message' + // json_schema mode sends response_format with json_schema type + && $responseFormat['type'] === 'json_schema' + && $responseFormat['json_schema']['name'] === 'output' + && isset($responseFormat['json_schema']['schema']) + && $responseFormat['json_schema']['strict'] === true + // No JSON schema system message appended (only system prompt + user prompt) + && count($messages) === 2 + && $messages[0]['role'] === 'system' + && $messages[1]['role'] === 'user'; + }); + + // Assert response type + expect($response)->toBeInstanceOf(StructuredResponse::class); + + // Assert structured data + expect($response->structured)->toBeArray(); + expect($response->structured)->toHaveKeys([ + 'weather', + 'game_time', + 'coat_required', + ]); +}); + +it('uses json_object mode by default in auto mode', function (): void { + FixtureResponse::fakeResponseSequence('text-generation/generation', 'qwen/structured'); + + $schema = new ObjectSchema( + 'output', + 'the output object', + [ + new StringSchema('weather', 'The weather forecast'), + ], + ['weather'] + ); + + $response = Prism::structured() + ->withSchema($schema) + ->using(Provider::Qwen, 'qwen-plus') + ->withPrompt('What is the weather?') + ->asStructured(); + + // Auto mode should default to json_object + Http::assertSent(function (Request $request): bool { + $data = $request->data(); + + return $data['parameters']['response_format'] === ['type' => 'json_object'] + // Should have the JSON schema system message appended + && $data['input']['messages'][count($data['input']['messages']) - 1]['role'] === 'system'; + }); + + expect($response)->toBeInstanceOf(StructuredResponse::class); +}); diff --git a/tests/Providers/Qwen/TextTest.php b/tests/Providers/Qwen/TextTest.php new file mode 100644 index 000000000..33022e624 --- /dev/null +++ b/tests/Providers/Qwen/TextTest.php @@ -0,0 +1,281 @@ +set('prism.providers.qwen.api_key', env('QWEN_API_KEY')); +}); + +it('can generate text with a prompt', function (): void { + FixtureResponse::fakeResponseSequence('text-generation/generation', 'qwen/generate-text-with-a-prompt'); + + $response = Prism::text() + ->using(Provider::Qwen, 'qwen-plus') + ->withPrompt('Who are you?') + ->generate(); + + Http::assertSent(function (Request $request): bool { + $data = $request->data(); + + return str_contains($request->url(), 'text-generation/generation') + && $data['model'] === 'qwen-plus' + && $data['input']['messages'][0]['role'] === 'user' + && $data['input']['messages'][0]['content'] === 'Who are you?' + && $data['parameters']['result_format'] === 'message' + && ! isset($data['parameters']['tools']); + }); + + // Assert response type + expect($response)->toBeInstanceOf(TextResponse::class); + + // Assert usage + expect($response->usage->promptTokens)->toBe(12); + expect($response->usage->completionTokens)->toBe(135); + + // Assert metadata + expect($response->meta->id)->toBe('e56ea9d3-9cc7-9aa1-97fc-3affd9815a94'); + expect($response->meta->model)->toBe('qwen-plus'); + + expect($response->text)->toContain("I'm Qwen"); + + // Assert finish reason + expect($response->finishReason)->toBe(FinishReason::Stop); +}); + +it('can generate text with a system prompt', function (): void { + FixtureResponse::fakeResponseSequence('text-generation/generation', 'qwen/generate-text-with-system-prompt'); + + $response = Prism::text() + ->using(Provider::Qwen, 'qwen-plus') + ->withSystemPrompt('MODEL ADOPTS ROLE of [PERSONA: Nyx the Cthulhu]!') + ->withPrompt('Who are you?') + ->generate(); + + Http::assertSent(function (Request $request): bool { + $data = $request->data(); + $messages = $data['input']['messages']; + + return $messages[0]['role'] === 'system' + && $messages[0]['content'] === 'MODEL ADOPTS ROLE of [PERSONA: Nyx the Cthulhu]!' + && $messages[1]['role'] === 'user' + && $messages[1]['content'] === 'Who are you?'; + }); + + // Assert response type + expect($response)->toBeInstanceOf(TextResponse::class); + + // Assert usage + expect($response->usage->promptTokens)->toBe(36); + expect($response->usage->completionTokens)->toBe(361); + + // Assert metadata + expect($response->meta->id)->toBe('bbb36202-7639-9a7b-9a45-7730b39e8b41'); + expect($response->meta->model)->toBe('qwen-plus'); + expect($response->text)->toContain('Nyx'); + + // Assert finish reason + expect($response->finishReason)->toBe(FinishReason::Stop); +}); + +it('can generate text using multiple tools and multiple steps', function (): void { + FixtureResponse::fakeResponseSequence('text-generation/generation', 'qwen/generate-text-with-multiple-tools'); + + $tools = [ + Tool::as('weather') + ->for('useful when you need to search for current weather conditions') + ->withStringParameter('city', 'The city that you want the weather for') + ->using(fn (string $city): string => 'The weather will be 75° and sunny'), + Tool::as('search') + ->for('useful for searching curret events or data') + ->withStringParameter('query', 'The detailed search query') + ->using(fn (string $query): string => 'The tigers game is at 3pm in detroit'), + ]; + + $response = Prism::text() + ->using(Provider::Qwen, 'qwen-plus') + ->withTools($tools) + ->withMaxSteps(4) + ->withPrompt('What time is the tigers game today and should I wear a coat?') + ->generate(); + + // Assert response type + expect($response)->toBeInstanceOf(TextResponse::class); + + // Assert tool calls in the first step + $firstStep = $response->steps[0]; + expect($firstStep->toolCalls)->toHaveCount(2); + expect($firstStep->toolCalls[0]->name)->toBe('search'); + expect($firstStep->toolCalls[0]->arguments())->toBe([ + 'query' => 'Tigers game schedule today', + ]); + + expect($firstStep->toolCalls[1]->name)->toBe('weather'); + expect($firstStep->toolCalls[1]->arguments())->toBe([ + 'city' => 'Detroit', + ]); + + // There should be 2 steps + expect($response->steps)->toHaveCount(2); + + // Verify the assistant message from step 1 is present in step 2's input messages + $secondStep = $response->steps[1]; + expect($secondStep->messages)->toHaveCount(3); + expect($secondStep->messages[0])->toBeInstanceOf(UserMessage::class); + expect($secondStep->messages[1])->toBeInstanceOf(AssistantMessage::class); + expect($secondStep->messages[1]->toolCalls)->toHaveCount(2); + expect($secondStep->messages[1]->toolCalls[0]->name)->toBe('search'); + expect($secondStep->messages[1]->toolCalls[1]->name)->toBe('weather'); + expect($secondStep->messages[2])->toBeInstanceOf(ToolResultMessage::class); + + // Assert usage (cumulative across all steps) + expect($response->usage->promptTokens)->toBe(570); + expect($response->usage->completionTokens)->toBe(78); + + // Assert response + expect($response->meta->id)->toBe('1a419c1a-8e16-9808-9ac2-5dfeffb2741f'); + expect($response->meta->model)->toBe('qwen-plus'); + + // Assert final text content + expect($response->text)->toContain('3 PM'); + expect($response->text)->toContain('75°'); + + // Assert finish reason + expect($response->finishReason)->toBe(FinishReason::Stop); +}); + +it('can generate text with images using VL model (multimodal)', function (): void { + Http::fake([ + 'dashscope-intl.aliyuncs.com/api/v1/services/aigc/multimodal-generation/generation' => Http::response([ + 'status_code' => 200, + 'request_id' => 'vl-req-001', + 'code' => '', + 'message' => '', + 'output' => [ + 'choices' => [ + [ + 'finish_reason' => 'stop', + 'message' => [ + 'role' => 'assistant', + 'content' => 'The first image shows a dog and a girl, the second shows a tiger, and the third shows a rabbit.', + ], + ], + ], + ], + 'usage' => [ + 'input_tokens' => 1500, + 'output_tokens' => 25, + 'total_tokens' => 1525, + ], + ], 200), + ]); + + $response = Prism::text() + ->using(Provider::Qwen, 'qwen-vl-max') + ->withMessages([ + new UserMessage('这些是什么?', [ + Image::fromUrl('https://dashscope.oss-cn-beijing.aliyuncs.com/images/dog_and_girl.jpeg'), + Image::fromUrl('https://dashscope.oss-cn-beijing.aliyuncs.com/images/tiger.png'), + Image::fromUrl('https://dashscope.oss-cn-beijing.aliyuncs.com/images/rabbit.png'), + ]), + ]) + ->generate(); + + expect($response)->toBeInstanceOf(TextResponse::class); + expect($response->text)->toContain('dog'); + expect($response->usage->promptTokens)->toBe(1500); + expect($response->usage->completionTokens)->toBe(25); + expect($response->meta->id)->toBe('vl-req-001'); + expect($response->finishReason)->toBe(FinishReason::Stop); + + // Assert it routes to the multimodal endpoint (not text-generation) + Http::assertSent(function (Request $request): bool { + $data = $request->data(); + $content = $data['input']['messages'][0]['content']; + + return str_contains($request->url(), 'multimodal-generation/generation') + && ! str_contains($request->url(), 'text-generation') + && is_array($content) + && count($content) === 4 + && $content[0]['image'] === 'https://dashscope.oss-cn-beijing.aliyuncs.com/images/dog_and_girl.jpeg' + && $content[1]['image'] === 'https://dashscope.oss-cn-beijing.aliyuncs.com/images/tiger.png' + && $content[2]['image'] === 'https://dashscope.oss-cn-beijing.aliyuncs.com/images/rabbit.png' + && $content[3]['text'] === '这些是什么?'; + }); +}); + +it('routes to text-generation endpoint when no images present', function (): void { + FixtureResponse::fakeResponseSequence('text-generation/generation', 'qwen/generate-text-with-a-prompt'); + + Prism::text() + ->using(Provider::Qwen, 'qwen-plus') + ->withPrompt('Hello') + ->generate(); + + Http::assertSent(fn (Request $request): bool => str_contains($request->url(), 'text-generation/generation') + && ! str_contains($request->url(), 'multimodal-generation')); +}); + +it('sends generation parameters correctly', function (): void { + FixtureResponse::fakeResponseSequence('text-generation/generation', 'qwen/generate-text-with-a-prompt'); + + Prism::text() + ->using(Provider::Qwen, 'qwen-plus') + ->withMaxTokens(500) + ->usingTemperature(0.7) + ->usingTopP(0.9) + ->withPrompt('Hello') + ->generate(); + + Http::assertSent(function (Request $request): bool { + $data = $request->data(); + + return $data['parameters']['max_tokens'] === 500 + && $data['parameters']['temperature'] === 0.7 + && $data['parameters']['top_p'] === 0.9 + && $data['parameters']['result_format'] === 'message'; + }); +}); + +it('excludes cached tokens from promptTokens', function (): void { + Http::fake([ + '*' => Http::response([ + 'status_code' => 200, + 'request_id' => 'cache-test-1', + 'code' => '', + 'message' => '', + 'output' => [ + 'choices' => [[ + 'message' => ['role' => 'assistant', 'content' => 'Hello!'], + 'finish_reason' => 'stop', + ]], + ], + 'usage' => [ + 'input_tokens' => 100, + 'output_tokens' => 10, + 'total_tokens' => 110, + 'prompt_tokens_details' => ['cached_tokens' => 60], + ], + ]), + ])->preventStrayRequests(); + + $response = Prism::text() + ->using(Provider::Qwen, 'qwen-plus') + ->withPrompt('Hello') + ->asText(); + + expect($response->usage->promptTokens)->toBe(40) + ->and($response->usage->cacheReadInputTokens)->toBe(60); +}); diff --git a/tests/Providers/Qwen/ToolChoiceMapTest.php b/tests/Providers/Qwen/ToolChoiceMapTest.php new file mode 100644 index 000000000..4e92965ed --- /dev/null +++ b/tests/Providers/Qwen/ToolChoiceMapTest.php @@ -0,0 +1,22 @@ +toBe('auto'); +}); + +it('maps null tool choice', function (): void { + expect(ToolChoiceMap::map(null))->toBeNull(); +}); + +it('throws exception for string tool choice (forcing specific tool)', function (): void { + expect(fn (): ?string => ToolChoiceMap::map('weather')) + ->toThrow( + InvalidArgumentException::class, + 'Qwen does not support forcing a specific tool. Only "auto" and "none" are supported.' + ); +}); diff --git a/tests/Providers/Qwen/ToolTest.php b/tests/Providers/Qwen/ToolTest.php new file mode 100644 index 000000000..bccc0ae6a --- /dev/null +++ b/tests/Providers/Qwen/ToolTest.php @@ -0,0 +1,62 @@ +as('weather') + ->for('Searching for weather conditions') + ->withStringParameter('city', 'the city to get weather for') + ->using(fn (): string => '[Weather results]'); + + expect(ToolMap::map([$tool]))->toBe([[ + 'type' => 'function', + 'function' => [ + 'name' => $tool->name(), + 'description' => $tool->description(), + 'parameters' => [ + 'type' => 'object', + 'properties' => [ + 'city' => [ + 'description' => 'the city to get weather for', + 'type' => 'string', + ], + ], + 'required' => $tool->requiredParameters(), + ], + ], + ]]); +}); + +it('maps tools with strict mode', function (): void { + $tool = (new Tool) + ->as('weather') + ->for('Searching for weather conditions') + ->withStringParameter('city', 'the city to get weather for') + ->using(fn (): string => '[Weather results]') + ->withProviderOptions([ + 'strict' => true, + ]); + + expect(ToolMap::map([$tool]))->toBe([[ + 'type' => 'function', + 'function' => [ + 'name' => $tool->name(), + 'description' => $tool->description(), + 'parameters' => [ + 'type' => 'object', + 'properties' => [ + 'city' => [ + 'description' => 'the city to get weather for', + 'type' => 'string', + ], + ], + 'required' => $tool->requiredParameters(), + ], + ], + 'strict' => true, + ]]); +}); diff --git a/tests/Providers/Replicate/RecordReplicateFixtures.php b/tests/Providers/Replicate/RecordReplicateFixtures.php new file mode 100644 index 000000000..5f65e13bd --- /dev/null +++ b/tests/Providers/Replicate/RecordReplicateFixtures.php @@ -0,0 +1,56 @@ +markTestSkipped('Real REPLICATE_API_KEY not configured'); + } + config()->set('prism.providers.replicate.api_key', $apiKey); + config()->set('prism.providers.replicate.polling_interval', 1000); // 1 second + config()->set('prism.providers.replicate.max_wait_time', 120); // 2 minutes +}); + +describe('Record Replicate Fixtures', function (): void { + it('records a real text generation response', function (): void { + $response = Prism::text() + ->using('replicate', 'meta/meta-llama-3.1-8b-instruct') + ->withPrompt('Hello, world!') + ->withMaxTokens(50) + ->generate(); + + expect($response->text)->not->toBeEmpty() + ->and($response->steps)->toHaveCount(1) + ->and($response->steps[0]->meta->id)->not->toBeEmpty(); + + // Output for manual verification + dump([ + 'text' => $response->text, + 'meta' => [ + 'id' => $response->steps[0]->meta->id, + 'model' => $response->steps[0]->meta->model, + ], + 'metrics' => $response->steps[0]->additionalContent['metrics'] ?? null, + ]); + })->skip('Run manually with: ./vendor/bin/pest tests/Providers/Replicate/RecordReplicateFixtures.php'); + + it('records text generation with system prompt', function (): void { + $response = Prism::text() + ->using('replicate', 'meta/meta-llama-3.1-8b-instruct') + ->withSystemPrompt('You are a helpful assistant.') + ->withPrompt('Who are you?') + ->withMaxTokens(50) + ->generate(); + + expect($response->text)->not->toBeEmpty(); + + dump([ + 'text' => $response->text, + ]); + })->skip('Run manually with: ./vendor/bin/pest tests/Providers/Replicate/RecordReplicateFixtures.php'); +}); diff --git a/tests/Providers/Replicate/ReplicateAudioTest.php b/tests/Providers/Replicate/ReplicateAudioTest.php new file mode 100644 index 000000000..7003b6a94 --- /dev/null +++ b/tests/Providers/Replicate/ReplicateAudioTest.php @@ -0,0 +1,134 @@ +set('prism.providers.replicate.api_key', env('REPLICATE_API_KEY', 'r8_test1234')); + config()->set('prism.providers.replicate.polling_interval', 10); + config()->set('prism.providers.replicate.max_wait_time', 10); +}); + +describe('Text-to-Speech for Replicate', function (): void { + it('can generate audio from text', function (): void { + $createResponse = json_decode(file_get_contents(__DIR__.'/../../Fixtures/replicate/text-to-speech-basic-1.json'), true); + $completedResponse = json_decode(file_get_contents(__DIR__.'/../../Fixtures/replicate/text-to-speech-basic-2.json'), true); + $predictionId = $createResponse['id']; + $audioUrl = $completedResponse['output']; + + Http::fake([ + 'https://api.replicate.com/v1/predictions' => Http::response($createResponse, 201), + "https://api.replicate.com/v1/predictions/{$predictionId}" => Http::response($completedResponse, 200), + $audioUrl => Http::response('fake-audio-content', 200), + ]); + + $response = Prism::audio() + ->using('replicate', 'jaaari/kokoro-82m') + ->withInput('Hello world!') + ->withVoice('af_alloy') + ->asAudio(); + + expect($response->audio)->not->toBeNull() + ->and($response->audio->hasBase64())->toBeTrue() + ->and($response->audio->base64)->not->toBeEmpty(); + }); + + it('can generate audio with different voice', function (): void { + $createResponse = json_decode(file_get_contents(__DIR__.'/../../Fixtures/replicate/text-to-speech-different-voice-1.json'), true); + $completedResponse = json_decode(file_get_contents(__DIR__.'/../../Fixtures/replicate/text-to-speech-different-voice-2.json'), true); + $predictionId = $createResponse['id']; + $audioUrl = $completedResponse['output']; + + Http::fake([ + 'https://api.replicate.com/v1/predictions' => Http::response($createResponse, 201), + "https://api.replicate.com/v1/predictions/{$predictionId}" => Http::response($completedResponse, 200), + $audioUrl => Http::response('fake-audio-content', 200), + ]); + + $response = Prism::audio() + ->using('replicate', 'jaaari/kokoro-82m') + ->withInput('Testing echo voice') + ->withVoice('af_bella') + ->asAudio(); + + expect($response->audio)->not->toBeNull() + ->and($response->audio->hasBase64())->toBeTrue(); + }); + + it('can generate audio with provider options', function (): void { + $createResponse = json_decode(file_get_contents(__DIR__.'/../../Fixtures/replicate/text-to-speech-long-text-1.json'), true); + $completedResponse = json_decode(file_get_contents(__DIR__.'/../../Fixtures/replicate/text-to-speech-long-text-2.json'), true); + $predictionId = $createResponse['id']; + $audioUrl = $completedResponse['output']; + + Http::fake([ + 'https://api.replicate.com/v1/predictions' => Http::response($createResponse, 201), + "https://api.replicate.com/v1/predictions/{$predictionId}" => Http::response($completedResponse, 200), + $audioUrl => Http::response('fake-audio-content', 200), + ]); + + Prism::audio() + ->using('replicate', 'jaaari/kokoro-82m') + ->withInput('This is a longer piece of text to test the text-to-speech capabilities.') + ->withVoice('af_sky') + ->withProviderOptions([ + 'voice' => 'af_sky', + 'speed' => 1.0, + ]) + ->asAudio(); + + Http::assertSent(function ($request): bool { + if (! str_contains((string) $request->url(), 'predictions')) { + return false; + } + + $body = json_decode((string) $request->body(), true); + + return isset($body['input']['text']) + && isset($body['input']['voice']) + && $body['input']['voice'] === 'af_sky'; + }); + }); +}); + +describe('Speech-to-Text for Replicate', function (): void { + it('can transcribe WAV audio file from data URL', function (): void { + FixtureResponse::fakeResponseSequence('*', 'replicate/speech-to-text-wav'); + + $audioPath = __DIR__.'/../../Fixtures/sample-audio.wav'; + $audioContent = file_get_contents($audioPath); + $base64Audio = base64_encode($audioContent); + $dataUrl = 'data:audio/wav;base64,'.$base64Audio; + + $response = Prism::audio() + ->using('replicate', 'vaibhavs10/incredibly-fast-whisper') + ->withInput(new Audio(url: $dataUrl)) + ->asText(); + + expect($response->text)->toContain('Kids') + ->and($response->additionalContent['metrics']['predict_time'])->toBeGreaterThan(0); + }); + + it('can transcribe MP3 audio file from data URL', function (): void { + FixtureResponse::fakeResponseSequence('*', 'replicate/speech-to-text-mp3'); + + $audioPath = __DIR__.'/../../Fixtures/slightly-caffeinated-36.mp3'; + $audioContent = file_get_contents($audioPath); + $base64Audio = base64_encode($audioContent); + $dataUrl = 'data:audio/mpeg;base64,'.$base64Audio; + + $response = Prism::audio() + ->using('replicate', 'vaibhavs10/incredibly-fast-whisper') + ->withInput(new Audio(url: $dataUrl)) + ->asText(); + + expect($response->text)->not->toBeEmpty() + ->and($response->additionalContent['metrics']['predict_time'])->toBeGreaterThan(0); + }); +}); diff --git a/tests/Providers/Replicate/ReplicateEmbeddingsTest.php b/tests/Providers/Replicate/ReplicateEmbeddingsTest.php new file mode 100644 index 000000000..6934bf352 --- /dev/null +++ b/tests/Providers/Replicate/ReplicateEmbeddingsTest.php @@ -0,0 +1,62 @@ +set('prism.providers.replicate.api_key', env('REPLICATE_API_KEY', 'r8_test1234')); + config()->set('prism.providers.replicate.polling_interval', 10); + config()->set('prism.providers.replicate.max_wait_time', 10); +}); + +describe('Embeddings for Replicate', function (): void { + it('returns embeddings from input', function (): void { + FixtureResponse::fakeResponseSequence('*', 'replicate/embeddings-single-input'); + + $response = Prism::embeddings() + ->using('replicate', 'mark3labs/embeddings-gte-base') + ->fromInput('The food was delicious and the waiter...') + ->asEmbeddings(); + + expect($response->embeddings)->toBeArray() + ->and($response->embeddings)->toHaveCount(1) + ->and($response->embeddings[0])->toBeInstanceOf(Embedding::class) + ->and($response->embeddings[0]->embedding)->toBeArray() + ->and($response->embeddings[0]->embedding)->not->toBeEmpty() + ->and($response->usage->tokens)->toBeGreaterThan(0); + }); + + it('works with multiple embeddings', function (): void { + FixtureResponse::fakeResponseSequence('*', 'replicate/embeddings-multiple-inputs'); + + $response = Prism::embeddings() + ->using('replicate', 'mark3labs/embeddings-gte-base') + ->fromArray([ + 'The food was delicious.', + 'The drinks were not so good', + ]) + ->asEmbeddings(); + + expect($response->embeddings)->toBeArray() + ->and($response->embeddings)->toHaveCount(2) + ->and($response->embeddings[0]->embedding)->toBeArray() + ->and($response->embeddings[1]->embedding)->toBeArray() + ->and($response->usage->tokens)->toBeGreaterThan(0); + }); + + it('includes model information in meta', function (): void { + FixtureResponse::fakeResponseSequence('*', 'replicate/embeddings-single-input'); + + $response = Prism::embeddings() + ->using('replicate', 'mark3labs/embeddings-gte-base') + ->fromInput('Test input') + ->asEmbeddings(); + + expect($response->meta->model)->toBe('mark3labs/embeddings-gte-base'); + }); +}); diff --git a/tests/Providers/Replicate/ReplicateImagesTest.php b/tests/Providers/Replicate/ReplicateImagesTest.php new file mode 100644 index 000000000..5ddc3b59a --- /dev/null +++ b/tests/Providers/Replicate/ReplicateImagesTest.php @@ -0,0 +1,162 @@ +set('prism.providers.replicate.api_key', env('REPLICATE_API_KEY', 'r8_test1234')); + config()->set('prism.providers.replicate.polling_interval', 10); + config()->set('prism.providers.replicate.max_wait_time', 10); +}); + +describe('Image Generation for Replicate', function (): void { + it('can generate an image with flux-schnell', function (): void { + $createResponse = json_decode(file_get_contents(__DIR__.'/../../Fixtures/replicate/generate-image-basic-1.json'), true); + $completedResponse = json_decode(file_get_contents(__DIR__.'/../../Fixtures/replicate/generate-image-basic-2.json'), true); + $predictionId = $createResponse['id']; + $imageUrl = $completedResponse['output'][0]; + + Http::fake([ + 'https://api.replicate.com/v1/predictions' => Http::response($createResponse, 201), + "https://api.replicate.com/v1/predictions/{$predictionId}" => Http::response($completedResponse, 200), + $imageUrl => Http::response('fake-image-content', 200), + ]); + + $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(); + + expect($response->firstImage())->not->toBeNull() + ->and($response->firstImage()->hasUrl())->toBeTrue() + ->and($response->firstImage()->url)->not->toBeEmpty() + ->and($response->firstImage()->hasBase64())->toBeTrue() + ->and($response->firstImage()->base64)->not->toBeEmpty() + ->and($response->imageCount())->toBe(1); + }); + + it('can generate an image with provider options', function (): void { + FixtureResponse::fakeResponseSequence('*', 'replicate/generate-image-with-options'); + + Prism::image() + ->using('replicate', 'black-forest-labs/flux-schnell') + ->withPrompt('A mountain landscape at sunset') + ->withProviderOptions([ + 'aspect_ratio' => '16:9', + 'output_format' => 'png', + ]) + ->generate(); + + Http::assertSent(function ($request): bool { + if (! str_contains((string) $request->url(), 'predictions')) { + return false; + } + + $body = json_decode((string) $request->body(), true); + + return isset($body['input']['prompt']) + && $body['input']['prompt'] === 'A mountain landscape at sunset' + && isset($body['input']['aspect_ratio']) + && $body['input']['aspect_ratio'] === '16:9'; + }); + }); + + it('includes meta information in response', function (): void { + $createResponse = json_decode(file_get_contents(__DIR__.'/../../Fixtures/replicate/generate-image-basic-1.json'), true); + $completedResponse = json_decode(file_get_contents(__DIR__.'/../../Fixtures/replicate/generate-image-basic-2.json'), true); + $predictionId = $createResponse['id']; + $imageUrl = $completedResponse['output'][0]; + + Http::fake([ + 'https://api.replicate.com/v1/predictions' => Http::response($createResponse, 201), + "https://api.replicate.com/v1/predictions/{$predictionId}" => Http::response($completedResponse, 200), + $imageUrl => Http::response('fake-image-content', 200), + ]); + + $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(); + + expect($response->meta->id)->not->toBeEmpty() + ->and($response->meta->model)->toBe('black-forest-labs/flux-schnell'); + }); +}); + +describe('Image download hardening', function (): void { + function fakeReplicatePrediction(string $imageUrl): void + { + $createResponse = json_decode(file_get_contents(__DIR__.'/../../Fixtures/replicate/generate-image-basic-1.json'), true); + $completedResponse = json_decode(file_get_contents(__DIR__.'/../../Fixtures/replicate/generate-image-basic-2.json'), true); + // Override the output URL in both responses — sync mode uses the + // create response directly. + if (isset($createResponse['output'])) { + $createResponse['output'] = [$imageUrl]; + } + $completedResponse['output'] = [$imageUrl]; + $predictionId = $createResponse['id']; + + Http::fake([ + 'https://api.replicate.com/v1/predictions' => Http::response($createResponse, 201), + "https://api.replicate.com/v1/predictions/{$predictionId}" => Http::response($completedResponse, 200), + '*' => Http::response('fake-image-content', 200), + ]); + } + + it('does not download from non-https urls', function (): void { + fakeReplicatePrediction('http://replicate.delivery/pbxt/image.webp'); + + $response = Prism::image() + ->using('replicate', 'black-forest-labs/flux-schnell') + ->withPrompt('otter') + ->generate(); + + expect($response->firstImage()->base64)->toBeNull() + ->and($response->firstImage()->url)->toBe('http://replicate.delivery/pbxt/image.webp'); + + Http::assertNotSent(fn ($request): bool => str_contains((string) $request->url(), 'pbxt/image.webp')); + }); + + it('does not download from hosts outside the allowlist', function (): void { + fakeReplicatePrediction('https://169.254.169.254/latest/meta-data'); + + $response = Prism::image() + ->using('replicate', 'black-forest-labs/flux-schnell') + ->withPrompt('otter') + ->generate(); + + expect($response->firstImage()->base64)->toBeNull(); + + Http::assertNotSent(fn ($request): bool => str_contains((string) $request->url(), '169.254.169.254')); + }); + + it('downloads from allowlisted subdomains over https', function (): void { + fakeReplicatePrediction('https://cdn.replicate.delivery/pbxt/image.webp'); + + $response = Prism::image() + ->using('replicate', 'black-forest-labs/flux-schnell') + ->withPrompt('otter') + ->generate(); + + expect($response->firstImage()->base64)->not->toBeNull() + ->and(base64_decode((string) $response->firstImage()->base64))->toBe('fake-image-content'); + }); + + it('honors a custom download host allowlist', function (): void { + config()->set('prism.providers.replicate.download_hosts', ['my-gateway.example.com']); + + fakeReplicatePrediction('https://my-gateway.example.com/outputs/image.webp'); + + $response = Prism::image() + ->using('replicate', 'black-forest-labs/flux-schnell') + ->withPrompt('otter') + ->generate(); + + expect($response->firstImage()->base64)->not->toBeNull(); + }); +}); diff --git a/tests/Providers/Replicate/ReplicateSSEStreamTest.php b/tests/Providers/Replicate/ReplicateSSEStreamTest.php new file mode 100644 index 000000000..34128a3d7 --- /dev/null +++ b/tests/Providers/Replicate/ReplicateSSEStreamTest.php @@ -0,0 +1,139 @@ +set('prism.providers.replicate.api_key', env('REPLICATE_API_KEY')); + config()->set('prism.providers.replicate.polling_interval', 1000); + config()->set('prism.providers.replicate.max_wait_time', 120); +}); + +describe('Real-time SSE Streaming for Replicate', function (): void { + it('can stream text in real-time using SSE', function (): void { + // This test requires a real Replicate API key and makes real API calls + if (! env('REPLICATE_API_KEY') || env('REPLICATE_API_KEY') === 'r8_test1234') { + $this->markTestSkipped('Requires real REPLICATE_API_KEY environment variable'); + } + + $response = Prism::text() + ->using('replicate', 'meta/meta-llama-3-8b-instruct') + ->withPrompt('Say hello in 5 words or less') + ->withMaxTokens(20) + ->asStream(); + + $text = ''; + $events = []; + $deltaCount = 0; + $receivedStreamStart = false; + $receivedTextStart = false; + $receivedTextDelta = false; + $receivedTextComplete = false; + $receivedStreamEnd = false; + + foreach ($response as $event) { + $events[] = $event; + + if ($event instanceof StreamStartEvent) { + $receivedStreamStart = true; + } + + if ($event instanceof TextStartEvent) { + $receivedTextStart = true; + } + + if ($event instanceof TextDeltaEvent) { + $text .= $event->delta; + $deltaCount++; + $receivedTextDelta = true; + } + + if ($event instanceof TextCompleteEvent) { + $receivedTextComplete = true; + } + + if ($event instanceof StreamEndEvent) { + $receivedStreamEnd = true; + } + } + + // Verify all events were received + expect($receivedStreamStart)->toBeTrue('Should receive StreamStartEvent') + ->and($receivedTextStart)->toBeTrue('Should receive TextStartEvent') + ->and($receivedTextDelta)->toBeTrue('Should receive at least one TextDeltaEvent') + ->and($receivedTextComplete)->toBeTrue('Should receive TextCompleteEvent') + ->and($receivedStreamEnd)->toBeTrue('Should receive StreamEndEvent'); + + // Verify text was generated + expect($text)->not->toBeEmpty('Should have generated some text') + ->and($deltaCount)->toBeGreaterThan(0, 'Should have received multiple deltas'); + })->group('integration', 'sse', 'slow'); + + it('receives tokens in real-time without waiting for completion', function (): void { + // This test verifies that tokens arrive progressively, not all at once + if (! env('REPLICATE_API_KEY') || env('REPLICATE_API_KEY') === 'r8_test1234') { + $this->markTestSkipped('Requires real REPLICATE_API_KEY environment variable'); + } + + $response = Prism::text() + ->using('replicate', 'meta/meta-llama-3-8b-instruct') + ->withPrompt('Count from 1 to 10') + ->withMaxTokens(50) + ->asStream(); + + $timestamps = []; + $firstDeltaTime = null; + $lastDeltaTime = null; + + foreach ($response as $event) { + if ($event instanceof TextDeltaEvent) { + $currentTime = microtime(true); + $timestamps[] = $currentTime; + + if ($firstDeltaTime === null) { + $firstDeltaTime = $currentTime; + } + + $lastDeltaTime = $currentTime; + } + } + + // Verify we received multiple delta events (proof of streaming, not batch) + expect($timestamps)->toHaveCount(count($timestamps)) + ->and(count($timestamps))->toBeGreaterThan(1, 'Should receive multiple token deltas for streaming'); + + // If tokens were buffered and sent all at once (simulated streaming), + // we would likely get very few deltas. Real SSE typically sends many small chunks. + // For a "count from 1 to 10" prompt, we should get multiple deltas. + expect(count($timestamps))->toBeGreaterThan(5, 'Real SSE should produce many small token chunks'); + })->group('integration', 'sse', 'slow'); + + it('handles SSE stream errors gracefully', function (): void { + // Test that errors in the stream are properly handled + if (! env('REPLICATE_API_KEY') || env('REPLICATE_API_KEY') === 'r8_test1234') { + $this->markTestSkipped('Requires real REPLICATE_API_KEY environment variable'); + } + + // Use an invalid model to trigger an error + expect(function (): void { + $response = Prism::text() + ->using('replicate', 'invalid/model-does-not-exist') + ->withPrompt('This should fail') + ->asStream(); + + // Try to consume the stream + foreach ($response as $event) { + // Should throw before we get here + } + })->toThrow(PrismException::class); + })->group('integration', 'sse', 'slow'); +})->group('integration'); diff --git a/tests/Providers/Replicate/ReplicateStreamTest.php b/tests/Providers/Replicate/ReplicateStreamTest.php new file mode 100644 index 000000000..2a9cc6ce6 --- /dev/null +++ b/tests/Providers/Replicate/ReplicateStreamTest.php @@ -0,0 +1,127 @@ +set('prism.providers.replicate.api_key', env('REPLICATE_API_KEY', 'r8_test1234')); + config()->set('prism.providers.replicate.polling_interval', 10); + config()->set('prism.providers.replicate.max_wait_time', 10); +}); + +describe('Streaming for Replicate', function (): void { + it('can generate text with a basic stream', function (): void { + FixtureResponse::fakeResponseSequence('*', 'replicate/generate-text-with-a-prompt'); + + $response = Prism::text() + ->using('replicate', 'meta/meta-llama-3.1-405b-instruct') + ->withPrompt('Hello, world!') + ->asStream(); + + $text = ''; + $events = []; + $deltaCount = 0; + + foreach ($response as $event) { + $events[] = $event; + + if ($event instanceof TextDeltaEvent) { + $text .= $event->delta; + $deltaCount++; + } + } + + expect($events)->not->toBeEmpty() + ->and($text)->not->toBeEmpty() + ->and($deltaCount)->toBeGreaterThan(0); + }); + + it('emits all expected stream events', function (): void { + FixtureResponse::fakeResponseSequence('*', 'replicate/generate-text-with-a-prompt'); + + $response = Prism::text() + ->using('replicate', 'meta/meta-llama-3.1-405b-instruct') + ->withPrompt('Hello, world!') + ->asStream(); + + $hasStreamStart = false; + $hasTextStart = false; + $hasTextDelta = false; + $hasTextComplete = false; + $hasStreamEnd = false; + + foreach ($response as $event) { + if ($event instanceof StreamStartEvent) { + $hasStreamStart = true; + } + if ($event instanceof TextStartEvent) { + $hasTextStart = true; + } + if ($event instanceof TextDeltaEvent) { + $hasTextDelta = true; + } + if ($event instanceof TextCompleteEvent) { + $hasTextComplete = true; + } + if ($event instanceof StreamEndEvent) { + $hasStreamEnd = true; + } + } + + expect($hasStreamStart)->toBeTrue() + ->and($hasTextStart)->toBeTrue() + ->and($hasTextDelta)->toBeTrue() + ->and($hasTextComplete)->toBeTrue() + ->and($hasStreamEnd)->toBeTrue(); + }); + + it('includes usage information in stream end event', function (): void { + FixtureResponse::fakeResponseSequence('*', 'replicate/generate-text-with-a-prompt'); + + $response = Prism::text() + ->using('replicate', 'meta/meta-llama-3.1-405b-instruct') + ->withPrompt('Hello, world!') + ->asStream(); + + $streamEndEvent = null; + + foreach ($response as $event) { + if ($event instanceof StreamEndEvent) { + $streamEndEvent = $event; + } + } + + expect($streamEndEvent)->not->toBeNull() + ->and($streamEndEvent->usage->promptTokens)->toBeGreaterThan(0) + ->and($streamEndEvent->usage->completionTokens)->toBeGreaterThan(0); + }); + + it('reconstructs full text from deltas', function (): void { + FixtureResponse::fakeResponseSequence('*', 'replicate/generate-text-with-system-prompt'); + + $response = Prism::text() + ->using('replicate', 'meta/meta-llama-3.1-405b-instruct') + ->withSystemPrompt('You are a helpful assistant') + ->withPrompt('Say hello') + ->asStream(); + + $streamedText = ''; + + foreach ($response as $event) { + if ($event instanceof TextDeltaEvent) { + $streamedText .= $event->delta; + } + } + + expect($streamedText)->not->toBeEmpty(); + }); +}); diff --git a/tests/Providers/Replicate/ReplicateStructuredTest.php b/tests/Providers/Replicate/ReplicateStructuredTest.php new file mode 100644 index 000000000..ea7703a0b --- /dev/null +++ b/tests/Providers/Replicate/ReplicateStructuredTest.php @@ -0,0 +1,95 @@ +set('prism.providers.replicate.api_key', env('REPLICATE_API_KEY', 'r8_test1234')); + config()->set('prism.providers.replicate.polling_interval', 10); + config()->set('prism.providers.replicate.max_wait_time', 10); +}); + +describe('Structured Output for Replicate', function (): void { + it('returns structured output with JSON mode', function (): void { + FixtureResponse::fakeResponseSequence('*', 'replicate/structured-json-mode'); + + $schema = new ObjectSchema( + 'weather_info', + 'weather and game information', + [ + new StringSchema('weather', 'The weather forecast'), + new StringSchema('game_time', 'The game time'), + new BooleanSchema('coat_required', 'whether a coat is required'), + ], + ['weather', 'game_time', 'coat_required'] + ); + + $response = Prism::structured() + ->withSchema($schema) + ->using('replicate', 'meta/meta-llama-3.1-405b-instruct') + ->withPrompt('What time is the tigers game today and should I wear a coat?') + ->asStructured(); + + expect($response->structured)->toBeArray() + ->and($response->structured)->toHaveKeys([ + 'weather', + 'game_time', + 'coat_required', + ]) + ->and($response->structured['weather'])->toBeString() + ->and($response->structured['game_time'])->toBeString() + ->and($response->structured['coat_required'])->toBeBool(); + }); + + it('can handle simple structured output', function (): void { + FixtureResponse::fakeResponseSequence('*', 'replicate/structured-simple'); + + $schema = new ObjectSchema( + 'person', + 'person information', + [ + new StringSchema('name', 'The person name'), + new StringSchema('role', 'The person role'), + ], + ['name', 'role'] + ); + + $response = Prism::structured() + ->withSchema($schema) + ->using('replicate', 'meta/meta-llama-3.1-405b-instruct') + ->withPrompt('Tell me about Albert Einstein') + ->asStructured(); + + expect($response->structured)->toBeArray() + ->and($response->structured)->toHaveKeys(['name', 'role']); + }); + + it('includes usage information in response', function (): void { + FixtureResponse::fakeResponseSequence('*', 'replicate/structured-json-mode'); + + $schema = new ObjectSchema( + 'output', + 'output object', + [ + new StringSchema('result', 'The result'), + ], + ['result'] + ); + + $response = Prism::structured() + ->withSchema($schema) + ->using('replicate', 'meta/meta-llama-3.1-405b-instruct') + ->withPrompt('Say hello') + ->asStructured(); + + expect($response->usage->promptTokens)->toBeGreaterThan(0) + ->and($response->usage->completionTokens)->toBeGreaterThan(0); + }); +}); diff --git a/tests/Providers/Replicate/ReplicateTextTest.php b/tests/Providers/Replicate/ReplicateTextTest.php new file mode 100644 index 000000000..1f54725cd --- /dev/null +++ b/tests/Providers/Replicate/ReplicateTextTest.php @@ -0,0 +1,53 @@ +set('prism.providers.replicate.api_key', env('REPLICATE_API_KEY', 'r8_test1234')); + config()->set('prism.providers.replicate.polling_interval', 10); // Fast polling for tests + config()->set('prism.providers.replicate.max_wait_time', 10); +}); + +describe('Text generation for Replicate', function (): void { + it('can generate text with a prompt', function (): void { + FixtureResponse::fakeResponseSequence('*', 'replicate/generate-text-with-a-prompt'); + + $response = Prism::text() + ->using('replicate', 'meta/meta-llama-3.1-405b-instruct') + ->withPrompt('Hello, world!') + ->withMaxTokens(100) + ->generate(); + + expect($response->text)->toContain('Hello') + ->and($response->steps[0]->additionalContent['metrics']['predict_time'])->toBeGreaterThan(0) + ->and($response->steps[0]->additionalContent['metrics']['total_time'])->toBeGreaterThan(0); + }); + + it('can generate text with system prompt', function (): void { + FixtureResponse::fakeResponseSequence('*', 'replicate/generate-text-with-system-prompt'); + + $response = Prism::text() + ->using('replicate', 'meta/meta-llama-3.1-405b-instruct') + ->withSystemPrompt('You are a helpful assistant.') + ->withPrompt('Who are you?') + ->generate(); + + expect($response->text)->toContain('Hello'); + }); + + it('handles model with version specified', function (): void { + FixtureResponse::fakeResponseSequence('*', 'replicate/generate-text-with-version'); + + $response = Prism::text() + ->using('replicate', 'meta/meta-llama-3.1-405b-instruct:v1') + ->withPrompt('Hello!') + ->generate(); + + expect($response->text)->toContain('Hello'); + }); +}); diff --git a/tests/Providers/Requesty/RequestyTextTest.php b/tests/Providers/Requesty/RequestyTextTest.php new file mode 100644 index 000000000..e6645729a --- /dev/null +++ b/tests/Providers/Requesty/RequestyTextTest.php @@ -0,0 +1,96 @@ +set('prism.providers.requesty.api_key', env('REQUESTY_API_KEY', 'req-1234')); +}); + +it('can generate text with a prompt', function (): void { + FixtureResponse::fakeResponseSequence('chat/completions', 'requesty/generate-text-with-a-prompt'); + + $response = Prism::text() + ->using(Provider::Requesty, 'openai/gpt-4o') + ->withPrompt('Who are you?') + ->asText(); + + expect($response->text)->toBe("Hello! I'm an AI assistant routed through Requesty. How can I help you today?"); + expect($response->finishReason)->toBe(FinishReason::Stop); + expect($response->usage->promptTokens)->toBe(7); + expect($response->usage->completionTokens)->toBe(19); + expect($response->meta->id)->toBe('req-12345'); + expect($response->meta->model)->toBe('openai/gpt-4o'); + + Http::assertSent(function (Request $request): bool { + expect($request->url())->toContain('chat/completions'); + expect($request->data()['model'])->toBe('openai/gpt-4o'); + expect($request->data()['messages'])->toBe([[ + 'role' => 'user', + 'content' => [[ + 'type' => 'text', + 'text' => 'Who are you?', + ]], + ]]); + + return true; + }); +}); + +it('resolves gracefully on an unknown finish reason', function (): void { + Http::fake([ + '*' => Http::response([ + 'id' => 'req-67890', + 'model' => 'openai/gpt-4o', + 'choices' => [[ + 'index' => 0, + 'message' => ['role' => 'assistant', 'content' => 'Partial output.'], + 'finish_reason' => 'some_new_reason', + ]], + 'usage' => ['prompt_tokens' => 5, 'completion_tokens' => 3], + ]), + ])->preventStrayRequests(); + + $response = Prism::text() + ->using(Provider::Requesty, 'openai/gpt-4o') + ->withPrompt('Hi') + ->asText(); + + expect($response->text)->toBe('Partial output.'); + expect($response->finishReason)->toBe(FinishReason::Unknown); +}); + +it('excludes cached tokens from promptTokens', function (): void { + Http::fake([ + '*' => Http::response([ + 'id' => 'cache-test-1', + 'model' => 'openai/gpt-4o', + 'choices' => [[ + 'index' => 0, + 'message' => ['role' => 'assistant', 'content' => 'Hello!'], + 'finish_reason' => 'stop', + ]], + 'usage' => [ + 'prompt_tokens' => 100, + 'completion_tokens' => 10, + 'prompt_tokens_details' => ['cached_tokens' => 60], + ], + ]), + ])->preventStrayRequests(); + + $response = Prism::text() + ->using(Provider::Requesty, 'openai/gpt-4o') + ->withPrompt('Hello') + ->asText(); + + expect($response->usage->promptTokens)->toBe(40) + ->and($response->usage->cacheReadInputTokens)->toBe(60); +}); diff --git a/tests/Providers/Requesty/StreamTest.php b/tests/Providers/Requesty/StreamTest.php new file mode 100644 index 000000000..4c2ad2c91 --- /dev/null +++ b/tests/Providers/Requesty/StreamTest.php @@ -0,0 +1,42 @@ +set('prism.providers.requesty.api_key', env('REQUESTY_API_KEY', 'req-1234')); +}); + +it('excludes cached tokens from streamed promptTokens', function (): void { + FixtureResponse::fakeStreamResponses('chat/completions', 'requesty/stream-cached-tokens'); + + $response = Prism::text() + ->using(Provider::Requesty, 'openai/gpt-4o') + ->withPrompt('Who are you?') + ->asStream(); + + $text = ''; + $endEvent = null; + + foreach ($response as $event) { + if ($event instanceof TextDeltaEvent) { + $text .= $event->delta; + } + + if ($event instanceof StreamEndEvent) { + $endEvent = $event; + } + } + + expect($text)->toBe('Hello there') + ->and($endEvent)->toBeInstanceOf(StreamEndEvent::class) + ->and($endEvent->usage->promptTokens)->toBe(40) + ->and($endEvent->usage->cacheReadInputTokens)->toBe(60); +}); diff --git a/tests/Providers/Requesty/ToolTest.php b/tests/Providers/Requesty/ToolTest.php new file mode 100644 index 000000000..e7bb8778d --- /dev/null +++ b/tests/Providers/Requesty/ToolTest.php @@ -0,0 +1,62 @@ +as('search') + ->for('Searching the web') + ->withStringParameter('query', 'the detailed search query') + ->using(fn (): string => '[Search results]'); + + expect(ToolMap::map([$tool]))->toBe([[ + 'type' => 'function', + 'function' => [ + 'name' => $tool->name(), + 'description' => $tool->description(), + 'parameters' => [ + 'type' => 'object', + 'properties' => [ + 'query' => [ + 'description' => 'the detailed search query', + 'type' => 'string', + ], + ], + 'required' => $tool->requiredParameters(), + ], + ], + ]]); +}); + +it('omits the parameters key for parameterless tools', function (): void { + $tool = (new Tool) + ->as('ping') + ->for('Pings the service') + ->using(fn (): string => 'pong'); + + $mapped = ToolMap::map([$tool]); + + expect($mapped[0]['function'])->not->toHaveKey('parameters') + ->and(array_keys($mapped[0]['function']))->toBe(['name', 'description']); +}); + +it('wraps empty parameters as an object when hasParameters is inconsistent', function (): void { + $tool = Mockery::mock(Tool::class); + $tool->shouldReceive('name')->andReturn('mock_tool'); + $tool->shouldReceive('description')->andReturn('A mock tool'); + $tool->shouldReceive('hasParameters')->andReturn(true); + $tool->shouldReceive('parametersAsArray')->andReturn([]); + $tool->shouldReceive('requiredParameters')->andReturn([]); + $tool->shouldReceive('providerOptions')->with('strict')->andReturn(null); + + $mapped = ToolMap::map([$tool]); + + expect($mapped[0]['function']['parameters']['properties'])->toBeInstanceOf(stdClass::class); +}); diff --git a/tests/Providers/Vertex/VertexEmbeddingsTest.php b/tests/Providers/Vertex/VertexEmbeddingsTest.php new file mode 100644 index 000000000..496e3aa70 --- /dev/null +++ b/tests/Providers/Vertex/VertexEmbeddingsTest.php @@ -0,0 +1,84 @@ +set('prism.providers.vertex.project_id', 'test-project'); + config()->set('prism.providers.vertex.region', 'us-central1'); + config()->set('prism.providers.vertex.access_token', 'test-access-token'); +}); + +describe('Embeddings for Vertex', function (): void { + it('can generate embeddings', function (): void { + FixtureResponse::fakeResponseSequence('*', 'vertex/embeddings'); + + $response = Prism::embeddings() + ->using(Provider::Vertex, 'text-embedding-004') + ->fromInput('Hello, world!') + ->generate(); + + expect($response->embeddings)->toHaveCount(1) + ->and($response->embeddings[0]->embedding)->toBeArray() + ->and($response->embeddings[0]->embedding)->toHaveCount(10) + ->and($response->usage->tokens)->toBe(20) + ->and($response->meta->model)->toBe('text-embedding-004'); + }); + + it('sends requests to the correct Vertex AI endpoint for embeddings', function (): void { + FixtureResponse::fakeResponseSequence('*', 'vertex/embeddings'); + + Prism::embeddings() + ->using(Provider::Vertex, 'text-embedding-004') + ->fromInput('Hello, world!') + ->generate(); + + Http::assertSent(function (Request $request): bool { + expect($request->url())->toContain('us-central1-aiplatform.googleapis.com') + ->and($request->url())->toContain('projects/test-project') + ->and($request->url())->toContain('locations/us-central1') + ->and($request->url())->toContain('publishers/google/models') + ->and($request->url())->toContain('text-embedding-004:predict') + ->and($request->hasHeader('Authorization'))->toBeTrue() + ->and($request->header('Authorization')[0])->toBe('Bearer test-access-token'); + + return true; + }); + }); + + it('includes content in request body', function (): void { + FixtureResponse::fakeResponseSequence('*', 'vertex/embeddings'); + + Prism::embeddings() + ->using(Provider::Vertex, 'text-embedding-004') + ->fromInput('Hello, world!') + ->generate(); + + Http::assertSent(function (Request $request): bool { + $data = $request->data(); + + expect($data['instances'])->toHaveCount(1) + ->and($data['instances'][0]['content'])->toBe('Hello, world!'); + + return true; + }); + }); + + it('throws exception for multiple inputs', function (): void { + FixtureResponse::fakeResponseSequence('*', 'vertex/embeddings'); + + Prism::embeddings() + ->using(Provider::Vertex, 'text-embedding-004') + ->fromInput('Hello') + ->fromInput('World') + ->generate(); + })->throws(PrismException::class, 'Vertex Error: Prism currently only supports one input at a time with Vertex AI.'); +}); diff --git a/tests/Providers/Vertex/VertexExceptionHandlingTest.php b/tests/Providers/Vertex/VertexExceptionHandlingTest.php new file mode 100644 index 000000000..9bd115d53 --- /dev/null +++ b/tests/Providers/Vertex/VertexExceptionHandlingTest.php @@ -0,0 +1,87 @@ +set('prism.providers.vertex.project_id', 'test-project'); + config()->set('prism.providers.vertex.region', 'us-central1'); + config()->set('prism.providers.vertex.access_token', 'test-access-token'); +}); + +describe('Exception handling for Vertex', function (): void { + it('throws PrismRateLimitedException on 429 response', function (): void { + Http::fake([ + '*' => Http::response([ + 'error' => [ + 'code' => 429, + 'message' => 'Resource has been exhausted', + 'status' => 'RESOURCE_EXHAUSTED', + ], + ], 429), + ]); + + Prism::text() + ->using(Provider::Vertex, 'gemini-1.5-flash') + ->withPrompt('Hello') + ->asText(); + })->throws(PrismRateLimitedException::class); + + it('throws PrismException on error response', function (): void { + Http::fake([ + '*' => Http::response([ + 'error' => [ + 'code' => 400, + 'message' => 'Invalid request', + 'status' => 'INVALID_ARGUMENT', + ], + ], 400), + ]); + + Prism::text() + ->using(Provider::Vertex, 'gemini-1.5-flash') + ->withPrompt('Hello') + ->asText(); + })->throws(PrismException::class); + + it('throws PrismException on authentication error', function (): void { + Http::fake([ + '*' => Http::response([ + 'error' => [ + 'code' => 401, + 'message' => 'Request had invalid authentication credentials', + 'status' => 'UNAUTHENTICATED', + ], + ], 401), + ]); + + Prism::text() + ->using(Provider::Vertex, 'gemini-1.5-flash') + ->withPrompt('Hello') + ->asText(); + })->throws(PrismException::class); + + it('throws PrismException on permission denied', function (): void { + Http::fake([ + '*' => Http::response([ + 'error' => [ + 'code' => 403, + 'message' => 'Permission denied on resource', + 'status' => 'PERMISSION_DENIED', + ], + ], 403), + ]); + + Prism::text() + ->using(Provider::Vertex, 'gemini-1.5-flash') + ->withPrompt('Hello') + ->asText(); + })->throws(PrismException::class); +}); diff --git a/tests/Providers/Vertex/VertexStreamTest.php b/tests/Providers/Vertex/VertexStreamTest.php new file mode 100644 index 000000000..e78c4b5ac --- /dev/null +++ b/tests/Providers/Vertex/VertexStreamTest.php @@ -0,0 +1,106 @@ +set('prism.providers.vertex.project_id', 'test-project'); + config()->set('prism.providers.vertex.region', 'us-central1'); + config()->set('prism.providers.vertex.access_token', 'test-access-token'); +}); + +describe('Streaming for Vertex', function (): void { + it('can stream text responses', function (): void { + FixtureResponse::fakeStreamResponses('*', 'vertex/stream-basic'); + + $events = []; + $fullText = ''; + + $stream = Prism::text() + ->using(Provider::Vertex, 'gemini-1.5-flash') + ->withPrompt('Hello') + ->asStream(); + + foreach ($stream as $event) { + $events[] = $event; + + if ($event instanceof TextDeltaEvent) { + $fullText .= $event->delta; + } + } + + // Should have stream start and end events + expect(collect($events)->first())->toBeInstanceOf(StreamStartEvent::class); + expect(collect($events)->last())->toBeInstanceOf(StreamEndEvent::class); + + // Should have text delta events + $textDeltas = collect($events)->filter(fn ($e): bool => $e instanceof TextDeltaEvent); + expect($textDeltas)->toHaveCount(3); + + // Full text should be concatenated correctly + expect($fullText)->toBe('Hello, I am a helpful AI assistant.'); + + // Stream end should have finish reason + $endEvent = collect($events)->last(); + expect($endEvent->finishReason)->toBe(FinishReason::Stop); + }); + + it('sends streaming requests to the correct Vertex AI endpoint', function (): void { + FixtureResponse::fakeStreamResponses('*', 'vertex/stream-basic'); + + $stream = Prism::text() + ->using(Provider::Vertex, 'gemini-1.5-flash') + ->withPrompt('Hello') + ->asStream(); + + // Consume the stream + foreach ($stream as $event) { + // Just iterate to trigger the request + } + + Http::assertSent(function (Request $request): bool { + expect($request->url())->toContain('us-central1-aiplatform.googleapis.com') + ->and($request->url())->toContain('projects/test-project') + ->and($request->url())->toContain('locations/us-central1') + ->and($request->url())->toContain('publishers/google/models') + ->and($request->url())->toContain('gemini-1.5-flash:streamGenerateContent') + ->and($request->url())->toContain('alt=sse') + ->and($request->hasHeader('Authorization'))->toBeTrue() + ->and($request->header('Authorization')[0])->toBe('Bearer test-access-token'); + + return true; + }); + }); + + it('stream start event contains model and provider info', function (): void { + FixtureResponse::fakeStreamResponses('*', 'vertex/stream-basic'); + + $stream = Prism::text() + ->using(Provider::Vertex, 'gemini-1.5-flash') + ->withPrompt('Hello') + ->asStream(); + + $startEvent = null; + foreach ($stream as $event) { + if ($event instanceof StreamStartEvent) { + $startEvent = $event; + break; + } + } + + expect($startEvent)->not->toBeNull() + ->and($startEvent->provider)->toBe('vertex') + ->and($startEvent->model)->toBe('gemini-1.5-flash'); + }); +}); diff --git a/tests/Providers/Vertex/VertexStructuredTest.php b/tests/Providers/Vertex/VertexStructuredTest.php new file mode 100644 index 000000000..132fd0c30 --- /dev/null +++ b/tests/Providers/Vertex/VertexStructuredTest.php @@ -0,0 +1,109 @@ +set('prism.providers.vertex.project_id', 'test-project'); + config()->set('prism.providers.vertex.region', 'us-central1'); + config()->set('prism.providers.vertex.access_token', 'test-access-token'); +}); + +describe('Structured output for Vertex', function (): void { + it('can generate structured output', function (): void { + FixtureResponse::fakeResponseSequence('*', 'vertex/structured-response'); + + $schema = new ObjectSchema( + name: 'user', + description: 'A user object', + properties: [ + new StringSchema('name', 'The user\'s name'), + new NumberSchema('age', 'The user\'s age'), + new StringSchema('email', 'The user\'s email'), + ], + requiredFields: ['name', 'age', 'email'] + ); + + $response = Prism::structured() + ->using(Provider::Vertex, 'gemini-1.5-flash') + ->withSchema($schema) + ->withPrompt('Generate a user object for John Doe, age 30, with email john.doe@example.com') + ->generate(); + + expect($response->structured)->toBe([ + 'name' => 'John Doe', + 'age' => 30, + 'email' => 'john.doe@example.com', + ]) + ->and($response->finishReason)->toBe(FinishReason::Stop) + ->and($response->usage->promptTokens)->toBe(50) + ->and($response->usage->completionTokens)->toBe(25); + }); + + it('sends requests with response schema to Vertex AI', function (): void { + FixtureResponse::fakeResponseSequence('*', 'vertex/structured-response'); + + $schema = new ObjectSchema( + name: 'user', + description: 'A user object', + properties: [ + new StringSchema('name', 'The user\'s name'), + new NumberSchema('age', 'The user\'s age'), + ], + requiredFields: ['name', 'age'] + ); + + Prism::structured() + ->using(Provider::Vertex, 'gemini-1.5-flash') + ->withSchema($schema) + ->withPrompt('Generate a user') + ->generate(); + + Http::assertSent(function (Request $request): bool { + $data = $request->data(); + + expect($data['generationConfig'])->toHaveKey('response_mime_type', 'application/json') + ->and($data['generationConfig'])->toHaveKey('response_schema'); + + return true; + }); + }); + + it('sends requests to the correct Vertex AI endpoint for structured output', function (): void { + FixtureResponse::fakeResponseSequence('*', 'vertex/structured-response'); + + $schema = new ObjectSchema( + name: 'test', + description: 'Test', + properties: [ + new StringSchema('value', 'Test value'), + ], + requiredFields: ['value'] + ); + + Prism::structured() + ->using(Provider::Vertex, 'gemini-1.5-flash') + ->withSchema($schema) + ->withPrompt('Test') + ->generate(); + + Http::assertSent(function (Request $request): bool { + expect($request->url())->toContain('us-central1-aiplatform.googleapis.com') + ->and($request->url())->toContain('projects/test-project') + ->and($request->url())->toContain('gemini-1.5-flash:generateContent'); + + return true; + }); + }); +}); diff --git a/tests/Providers/Vertex/VertexTextTest.php b/tests/Providers/Vertex/VertexTextTest.php new file mode 100644 index 000000000..6930f6a19 --- /dev/null +++ b/tests/Providers/Vertex/VertexTextTest.php @@ -0,0 +1,321 @@ +set('prism.providers.vertex.project_id', 'test-project'); + config()->set('prism.providers.vertex.region', 'us-central1'); + config()->set('prism.providers.vertex.access_token', 'test-access-token'); +}); + +describe('Text generation for Vertex', function (): void { + it('can generate text with a prompt', function (): void { + FixtureResponse::fakeResponseSequence('*', 'vertex/generate-text-with-a-prompt'); + + $response = Prism::text() + ->using(Provider::Vertex, 'gemini-1.5-flash') + ->withPrompt('Who are you?') + ->withMaxTokens(10) + ->asText(); + + expect($response->text)->toBe( + "I am a large language model, trained by Google. I am an AI, and I don't have a name, feelings, or personal experiences. My purpose is to process information and respond to a wide range of prompts and questions in a helpful and informative way.\n" + ) + ->and($response->usage->promptTokens)->toBe(4) + ->and($response->usage->completionTokens)->toBe(57) + ->and($response->meta->id)->toBe('') + ->and($response->meta->model)->toBe('gemini-1.5-flash') + ->and($response->finishReason)->toBe(FinishReason::Stop); + }); + + it('can generate text with a system prompt', function (): void { + FixtureResponse::fakeResponseSequence('*', 'vertex/generate-text-with-system-prompt'); + + $response = Prism::text() + ->using(Provider::Vertex, 'gemini-1.5-flash') + ->withSystemPrompt('You are a helpful AI assistant named Prism generated by echolabs') + ->withPrompt('Who are you?') + ->asText(); + + expect($response->text)->toBe('I am Prism, a helpful AI assistant created by echo labs.') + ->and($response->usage->promptTokens)->toBe(17) + ->and($response->usage->completionTokens)->toBe(14) + ->and($response->meta->id)->toBe('') + ->and($response->meta->model)->toBe('gemini-1.5-flash') + ->and($response->finishReason)->toBe(FinishReason::Stop); + }); + + it('can generate text using multiple tools and multiple steps', function (): void { + FixtureResponse::fakeResponseSequence('*', 'vertex/generate-text-with-multiple-tools'); + + $tools = [ + (new Tool) + ->as('get_weather') + ->for('use this tool when you need to get weather for the city') + ->withStringParameter('city', 'The city that you want the weather for') + ->using(fn (string $city): string => 'The weather will be 45° and cold'), + (new Tool) + ->as('search_games') + ->for('useful for searching current games times in the city') + ->withStringParameter('city', 'The city that you want the game times for') + ->using(fn (string $city): string => 'The tigers game is at 3pm in detroit'), + ]; + + $response = Prism::text() + ->using(Provider::Vertex, 'gemini-1.5-flash') + ->withTools($tools) + ->withMaxSteps(5) + ->withPrompt('What time is the tigers game today in Detroit and should I wear a coat? please check all the details from tools') + ->asText(); + + // Assert tool calls in the first step + $firstStep = $response->steps[0]; + expect($firstStep->toolCalls)->toHaveCount(2); + expect($firstStep->toolCalls[0]->name)->toBe('search_games'); + expect($firstStep->toolCalls[0]->arguments())->toBe([ + 'city' => 'Detroit', + ]); + expect($firstStep->toolCalls[1]->name)->toBe('get_weather'); + expect($firstStep->toolCalls[1]->arguments())->toBe([ + 'city' => 'Detroit', + ]); + + // Verify the assistant message from step 1 is present in step 2's input messages + $secondStep = $response->steps[1]; + expect($secondStep->messages)->toHaveCount(3); + expect($secondStep->messages[0])->toBeInstanceOf(UserMessage::class); + expect($secondStep->messages[1])->toBeInstanceOf(AssistantMessage::class); + expect($secondStep->messages[1]->toolCalls)->toHaveCount(2); + expect($secondStep->messages[1]->toolCalls[0]->name)->toBe('search_games'); + expect($secondStep->messages[1]->toolCalls[1]->name)->toBe('get_weather'); + expect($secondStep->messages[2])->toBeInstanceOf(ToolResultMessage::class); + + // Assert usage (combined from both responses) + expect($response->usage->promptTokens)->toBe(350) + ->and($response->usage->completionTokens)->toBe(42); + + // Assert response + expect($response->meta->id)->toBe('') + ->and($response->meta->model)->toBe('gemini-1.5-flash') + ->and($response->text)->toBe('The tigers game is at 3pm today in Detroit. The weather will be 45° and cold, so you should wear a coat.'); + }); +}); + +describe('Image support with Vertex', function (): void { + it('can send images from path', function (): void { + FixtureResponse::fakeResponseSequence('*', 'vertex/image-detection'); + + $response = Prism::text() + ->using(Provider::Vertex, 'gemini-1.5-flash') + ->withMessages([ + new UserMessage( + 'What is this image', + additionalContent: [ + Image::fromLocalPath('tests/Fixtures/diamond.png'), + ], + ), + ]) + ->asText(); + + // Assert response + expect($response->text)->toBe("That's an illustration of a **diamond**. More specifically, it's a stylized, geometric representation of a diamond, often used as an icon or symbol") + ->and($response->usage->promptTokens)->toBe(263) + ->and($response->usage->completionTokens)->toBe(35) + ->and($response->meta->id)->toBe('') + ->and($response->meta->model)->toBe('gemini-1.5-flash') + ->and($response->finishReason)->toBe(FinishReason::Stop); + + // Assert request format + Http::assertSent(function (Request $request): bool { + $message = $request->data()['contents'][0]['parts']; + + expect($message[0])->toBe([ + 'text' => 'What is this image', + ]); + + expect($message[1]['inline_data'])->toHaveKeys(['mime_type', 'data']); + expect($message[1]['inline_data']['mime_type'])->toBe('image/png'); + expect($message[1]['inline_data']['data'])->toBe( + base64_encode(file_get_contents('tests/Fixtures/diamond.png')) + ); + + return true; + }); + }); + + it('can send images from base64', function (): void { + FixtureResponse::fakeResponseSequence('*', 'vertex/image-detection'); + + $response = Prism::text() + ->using(Provider::Vertex, 'gemini-1.5-flash') + ->withMessages([ + new UserMessage( + 'What is this image', + additionalContent: [ + Image::fromBase64( + base64_encode(file_get_contents('tests/Fixtures/diamond.png')), + 'image/png' + ), + ], + ), + ]) + ->asText(); + + Http::assertSent(function (Request $request): bool { + $message = $request->data()['contents'][0]['parts']; + + expect($message[0])->toBe([ + 'text' => 'What is this image', + ]); + + expect($message[1]['inline_data'])->toHaveKeys(['mime_type', 'data']); + expect($message[1]['inline_data']['mime_type'])->toBe('image/png'); + expect($message[1]['inline_data']['data'])->toBe( + base64_encode(file_get_contents('tests/Fixtures/diamond.png')) + ); + + return true; + }); + }); +}); + +describe('Request format for Vertex', function (): void { + it('sends requests to the correct Vertex AI endpoint', function (): void { + FixtureResponse::fakeResponseSequence('*', 'vertex/generate-text-with-a-prompt'); + + Prism::text() + ->using(Provider::Vertex, 'gemini-1.5-flash') + ->withPrompt('Hello') + ->asText(); + + Http::assertSent(function (Request $request): bool { + expect($request->url())->toContain('us-central1-aiplatform.googleapis.com') + ->and($request->url())->toContain('projects/test-project') + ->and($request->url())->toContain('locations/us-central1') + ->and($request->url())->toContain('publishers/google/models') + ->and($request->url())->toContain('gemini-1.5-flash:generateContent') + ->and($request->hasHeader('Authorization'))->toBeTrue() + ->and($request->header('Authorization')[0])->toBe('Bearer test-access-token'); + + return true; + }); + }); + + it('uses global hostname when region is global', function (): void { + config()->set('prism.providers.vertex.region', 'global'); + FixtureResponse::fakeResponseSequence('*', 'vertex/generate-text-with-a-prompt'); + + Prism::text() + ->using(Provider::Vertex, 'gemini-1.5-flash') + ->withPrompt('Hello') + ->asText(); + + Http::assertSent(function (Request $request): bool { + expect($request->url())->toContain('https://aiplatform.googleapis.com') + ->and($request->url())->not->toContain('global-aiplatform.googleapis.com') + ->and($request->url())->toContain('projects/test-project') + ->and($request->url())->toContain('locations/global') + ->and($request->url())->toContain('publishers/google/models'); + + return true; + }); + }); + + it('uses multi-region eu hostname when region is eu', function (): void { + config()->set('prism.providers.vertex.region', 'eu'); + FixtureResponse::fakeResponseSequence('*', 'vertex/generate-text-with-a-prompt'); + + Prism::text() + ->using(Provider::Vertex, 'gemini-1.5-flash') + ->withPrompt('Hello') + ->asText(); + + Http::assertSent(function (Request $request): bool { + expect($request->url())->toContain('https://aiplatform.eu.rep.googleapis.com') + ->and($request->url())->toContain('projects/test-project') + ->and($request->url())->toContain('locations/eu') + ->and($request->url())->toContain('publishers/google/models'); + + return true; + }); + }); + + it('uses multi-region us hostname when region is us', function (): void { + config()->set('prism.providers.vertex.region', 'us'); + FixtureResponse::fakeResponseSequence('*', 'vertex/generate-text-with-a-prompt'); + + Prism::text() + ->using(Provider::Vertex, 'gemini-1.5-flash') + ->withPrompt('Hello') + ->asText(); + + Http::assertSent(function (Request $request): bool { + expect($request->url())->toContain('https://aiplatform.us.rep.googleapis.com') + ->and($request->url())->toContain('projects/test-project') + ->and($request->url())->toContain('locations/us') + ->and($request->url())->toContain('publishers/google/models'); + + return true; + }); + }); + + it('includes generation config in request', function (): void { + FixtureResponse::fakeResponseSequence('*', 'vertex/generate-text-with-a-prompt'); + + Prism::text() + ->using(Provider::Vertex, 'gemini-1.5-flash') + ->withPrompt('Hello') + ->withMaxTokens(100) + ->usingTemperature(0.7) + ->usingTopP(0.9) + ->asText(); + + Http::assertSent(function (Request $request): bool { + $data = $request->data(); + + expect($data['generationConfig'])->toHaveKey('maxOutputTokens', 100) + ->and($data['generationConfig'])->toHaveKey('temperature', 0.7) + ->and($data['generationConfig'])->toHaveKey('topP', 0.9); + + return true; + }); + }); +}); + +it('excludes cached tokens from promptTokens', function (): void { + Http::fake([ + '*' => Http::response([ + 'candidates' => [[ + 'content' => ['parts' => [['text' => 'Hello!']], 'role' => 'model'], + 'finishReason' => 'STOP', + ]], + 'usageMetadata' => [ + 'promptTokenCount' => 100, + 'cachedContentTokenCount' => 60, + 'candidatesTokenCount' => 10, + ], + ]), + ])->preventStrayRequests(); + + $response = Prism::text() + ->using('vertex', 'gemini-1.5-flash') + ->withPrompt('Hello') + ->asText(); + + expect($response->usage->promptTokens)->toBe(40) + ->and($response->usage->cacheReadInputTokens)->toBe(60); +}); diff --git a/tests/Providers/XAI/ImagesTest.php b/tests/Providers/XAI/ImagesTest.php new file mode 100644 index 000000000..0dd04025b --- /dev/null +++ b/tests/Providers/XAI/ImagesTest.php @@ -0,0 +1,442 @@ +set('prism.providers.xai.api_key', env('XAI_API_KEY', 'xai-123')); +}); + +it('can generate an image with grok-imagine-image', function (): void { + Http::fake([ + 'api.x.ai/v1/images/generations' => Http::response([ + 'created' => 1713833628, + 'data' => [ + [ + 'url' => 'https://example.com/generated-image.png', + ], + ], + 'usage' => [ + 'prompt_tokens' => 15, + 'completion_tokens' => 0, + ], + ], 200), + ]); + + $response = Prism::image() + ->using('xai', 'grok-imagine-image') + ->withPrompt('A cute baby sea otter') + ->generate(); + + expect($response->firstImage())->not->toBeNull(); + expect($response->firstImage()->url)->toBe('https://example.com/generated-image.png'); + expect($response->firstImage()->hasUrl())->toBeTrue(); + expect($response->usage->promptTokens)->toBe(15); + expect($response->imageCount())->toBe(1); + + Http::assertSent(function (Request $request): bool { + $data = $request->data(); + + return $request->url() === 'https://api.x.ai/v1/images/generations' && + $data['model'] === 'grok-imagine-image' && + $data['prompt'] === 'A cute baby sea otter'; + }); +}); + +it('can generate an image with base64 response format', function (): void { + Http::fake([ + 'api.x.ai/v1/images/generations' => Http::response([ + 'created' => 1713833628, + 'data' => [ + [ + 'b64_json' => 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==', + ], + ], + 'usage' => [ + 'prompt_tokens' => 20, + 'completion_tokens' => 0, + ], + ], 200), + ]); + + $response = Prism::image() + ->using('xai', 'grok-imagine-image') + ->withPrompt('A mountain sunset') + ->withProviderOptions([ + 'response_format' => 'b64_json', + ]) + ->generate(); + + expect($response->firstImage())->not->toBeNull(); + expect($response->firstImage()->base64)->toBe('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg=='); + expect($response->firstImage()->hasBase64())->toBeTrue(); + expect($response->firstImage()->hasUrl())->toBeFalse(); + + Http::assertSent(function (Request $request): bool { + $data = $request->data(); + + return $data['model'] === 'grok-imagine-image' && + $data['prompt'] === 'A mountain sunset' && + $data['response_format'] === 'b64_json'; + }); +}); + +it('forwards xai-specific options in request', function (): void { + Http::fake([ + 'api.x.ai/v1/images/generations' => Http::response([ + 'created' => 1713833628, + 'data' => [ + [ + 'url' => 'https://example.com/wide-image.png', + ], + ], + 'usage' => [ + 'prompt_tokens' => 18, + 'completion_tokens' => 0, + ], + ], 200), + ]); + + $response = Prism::image() + ->using('xai', 'grok-imagine-image') + ->withPrompt('A panoramic mountain landscape') + ->withProviderOptions([ + 'aspect_ratio' => '16:9', + 'resolution' => '2k', + ]) + ->generate(); + + expect($response->firstImage()->url)->toBe('https://example.com/wide-image.png'); + + Http::assertSent(function (Request $request): bool { + $data = $request->data(); + + return $data['model'] === 'grok-imagine-image' && + $data['prompt'] === 'A panoramic mountain landscape' && + $data['aspect_ratio'] === '16:9' && + $data['resolution'] === '2k'; + }); +}); + +it('can generate multiple images', function (): void { + Http::fake([ + 'api.x.ai/v1/images/generations' => Http::response([ + 'created' => 1713833628, + 'data' => [ + [ + 'url' => 'https://example.com/image-1.png', + ], + [ + 'url' => 'https://example.com/image-2.png', + ], + ], + 'usage' => [ + 'prompt_tokens' => 12, + 'completion_tokens' => 0, + ], + ], 200), + ]); + + $response = Prism::image() + ->using('xai', 'grok-imagine-image') + ->withPrompt('Abstract art') + ->withProviderOptions([ + 'n' => 2, + ]) + ->generate(); + + expect($response->imageCount())->toBe(2); + expect($response->images[0]->url)->toBe('https://example.com/image-1.png'); + expect($response->images[1]->url)->toBe('https://example.com/image-2.png'); + + Http::assertSent(function (Request $request): bool { + $data = $request->data(); + + return $data['n'] === 2; + }); +}); + +it('includes usage information in response', function (): void { + Http::fake([ + 'api.x.ai/v1/images/generations' => Http::response([ + 'created' => 1713833628, + 'data' => [ + [ + 'url' => 'https://example.com/usage-test.png', + ], + ], + 'usage' => [ + 'prompt_tokens' => 22, + 'completion_tokens' => 5, + ], + ], 200), + ]); + + $response = Prism::image() + ->using('xai', 'grok-imagine-image') + ->withPrompt('Test usage tracking') + ->generate(); + + expect($response->usage->promptTokens)->toBe(22); + expect($response->usage->completionTokens)->toBe(5); +}); + +it('includes meta information in response', function (): void { + Http::fake([ + 'api.x.ai/v1/images/generations' => Http::response([ + 'id' => 'img_abc123', + 'model' => 'grok-imagine-image', + 'created' => 1713833628, + 'data' => [ + [ + 'url' => 'https://example.com/meta-test.png', + ], + ], + 'usage' => [ + 'prompt_tokens' => 15, + 'completion_tokens' => 0, + ], + ], 200), + ]); + + $response = Prism::image() + ->using('xai', 'grok-imagine-image') + ->withPrompt('Test meta information') + ->generate(); + + expect($response->meta->id)->toBe('img_abc123'); + expect($response->meta->model)->toBe('grok-imagine-image'); +}); + +it('can generate an image using the Provider enum', function (): void { + Http::fake([ + 'api.x.ai/v1/images/generations' => Http::response([ + 'created' => 1713833628, + 'data' => [ + [ + 'url' => 'https://example.com/enum-test.png', + ], + ], + 'usage' => [ + 'prompt_tokens' => 10, + 'completion_tokens' => 0, + ], + ], 200), + ]); + + $response = Prism::image() + ->using(Provider::XAI, 'grok-imagine-image') + ->withPrompt('A blue circle') + ->generate(); + + expect($response->firstImage()->url)->toBe('https://example.com/enum-test.png'); +}); + +it('extracts revised prompt from response', function (): void { + Http::fake([ + 'api.x.ai/v1/images/generations' => Http::response([ + 'created' => 1713833628, + 'data' => [ + [ + 'url' => 'https://example.com/revised-test.png', + 'revised_prompt' => 'A highly detailed cute baby sea otter floating in calm water', + ], + ], + 'usage' => [ + 'prompt_tokens' => 15, + 'completion_tokens' => 0, + ], + ], 200), + ]); + + $response = Prism::image() + ->using('xai', 'grok-imagine-image') + ->withPrompt('A cute baby sea otter') + ->generate(); + + expect($response->firstImage()->hasRevisedPrompt())->toBeTrue(); + expect($response->firstImage()->revisedPrompt)->toBe('A highly detailed cute baby sea otter floating in calm water'); +}); + +it('handles response with both url and base64', function (): void { + Http::fake([ + 'api.x.ai/v1/images/generations' => Http::response([ + 'created' => 1713833628, + 'data' => [ + [ + 'url' => 'https://example.com/both-test.png', + 'b64_json' => 'iVBORw0KGgoAAAANSUhEUg==', + ], + ], + 'usage' => [ + 'prompt_tokens' => 10, + 'completion_tokens' => 0, + ], + ], 200), + ]); + + $response = Prism::image() + ->using('xai', 'grok-imagine-image') + ->withPrompt('A test image') + ->generate(); + + expect($response->firstImage()->hasUrl())->toBeTrue(); + expect($response->firstImage()->hasBase64())->toBeTrue(); + expect($response->firstImage()->url)->toBe('https://example.com/both-test.png'); + expect($response->firstImage()->base64)->toBe('iVBORw0KGgoAAAANSUhEUg=='); +}); + +it('does not send null provider options in request', function (): void { + Http::fake([ + 'api.x.ai/v1/images/generations' => Http::response([ + 'created' => 1713833628, + 'data' => [ + [ + 'url' => 'https://example.com/null-test.png', + ], + ], + 'usage' => [ + 'prompt_tokens' => 10, + 'completion_tokens' => 0, + ], + ], 200), + ]); + + $response = Prism::image() + ->using('xai', 'grok-imagine-image') + ->withPrompt('A simple test') + ->generate(); + + expect($response->firstImage())->not->toBeNull(); + + Http::assertSent(function (Request $request): bool { + $data = $request->data(); + + return $data['model'] === 'grok-imagine-image' && + $data['prompt'] === 'A simple test' && + ! array_key_exists('n', $data) && + ! array_key_exists('response_format', $data) && + ! array_key_exists('aspect_ratio', $data) && + ! array_key_exists('resolution', $data); + }); +}); + +it('passes through unknown provider options', function (): void { + Http::fake([ + 'api.x.ai/v1/images/generations' => Http::response([ + 'created' => 1713833628, + 'data' => [ + [ + 'url' => 'https://example.com/passthrough-test.png', + ], + ], + 'usage' => [ + 'prompt_tokens' => 10, + 'completion_tokens' => 0, + ], + ], 200), + ]); + + $response = Prism::image() + ->using('xai', 'grok-imagine-image') + ->withPrompt('A test image') + ->withProviderOptions([ + 'some_future_option' => 'value', + ]) + ->generate(); + + expect($response->firstImage())->not->toBeNull(); + + Http::assertSent(function (Request $request): bool { + $data = $request->data(); + + return $data['some_future_option'] === 'value'; + }); +}); + +it('throws exception on error response', function (): void { + Http::fake([ + 'api.x.ai/v1/images/generations' => Http::response([ + 'error' => [ + 'type' => 'invalid_request_error', + 'message' => 'Invalid prompt', + ], + ], 200), + ]); + + Prism::image() + ->using('xai', 'grok-imagine-image') + ->withPrompt('bad prompt') + ->generate(); +})->throws(PrismException::class, 'XAI Error: [invalid_request_error] Invalid prompt'); + +it('includes raw response data', function (): void { + $rawResponse = [ + 'created' => 1713833628, + 'data' => [ + [ + 'url' => 'https://example.com/raw-test.png', + ], + ], + 'usage' => [ + 'prompt_tokens' => 10, + 'completion_tokens' => 0, + ], + ]; + + Http::fake([ + 'api.x.ai/v1/images/generations' => Http::response($rawResponse, 200), + ]); + + $response = Prism::image() + ->using('xai', 'grok-imagine-image') + ->withPrompt('Test raw data') + ->generate(); + + expect($response->raw)->toBe($rawResponse); +}); + +it('handles empty data array in response', function (): void { + Http::fake([ + 'api.x.ai/v1/images/generations' => Http::response([ + 'created' => 1713833628, + 'data' => [], + 'usage' => [ + 'prompt_tokens' => 10, + 'completion_tokens' => 0, + ], + ], 200), + ]); + + $response = Prism::image() + ->using('xai', 'grok-imagine-image') + ->withPrompt('Empty result test') + ->generate(); + + expect($response->imageCount())->toBe(0); + expect($response->firstImage())->toBeNull(); +}); + +it('throws rate limited exception on 429 response', function (): void { + Http::fake([ + 'api.x.ai/v1/images/generations' => Http::response([ + 'error' => [ + 'type' => 'rate_limit_error', + 'message' => 'Rate limit exceeded', + ], + ], 429), + ]); + + Prism::image() + ->using('xai', 'grok-imagine-image') + ->withPrompt('Rate limited test') + ->generate(); +})->throws(PrismRateLimitedException::class); diff --git a/tests/Providers/XAI/MessageMapTest.php b/tests/Providers/XAI/MessageMapTest.php index a779356fd..63a3cc648 100644 --- a/tests/Providers/XAI/MessageMapTest.php +++ b/tests/Providers/XAI/MessageMapTest.php @@ -13,7 +13,7 @@ use Prism\Prism\ValueObjects\ToolCall; use Prism\Prism\ValueObjects\ToolResult; -it('maps user messages', function (): void { +it('maps user messages as plain string when no images', function (): void { $messageMap = new MessageMap( messages: [ new UserMessage('Who are you?'), @@ -23,9 +23,7 @@ expect($messageMap())->toBe([[ 'role' => 'user', - 'content' => [ - ['type' => 'text', 'text' => 'Who are you?'], - ], + 'content' => 'Who are you?', ]]); }); @@ -146,9 +144,7 @@ ], [ 'role' => 'user', - 'content' => [ - ['type' => 'text', 'text' => 'Who are you?'], - ], + 'content' => 'Who are you?', ], ]); }); diff --git a/tests/Providers/XAI/StructuredTest.php b/tests/Providers/XAI/StructuredTest.php index d85cc4e92..b79a62625 100644 --- a/tests/Providers/XAI/StructuredTest.php +++ b/tests/Providers/XAI/StructuredTest.php @@ -155,6 +155,63 @@ }); }); +it('handles content returned as object instead of string', function (): void { + // Some OpenAI-compatible providers (e.g. Cloudflare Workers AI) return + // content as a parsed object instead of a JSON string with no "parsed" field. + Http::fake([ + 'v1/chat/completions' => Http::response([ + 'id' => 'chatcmpl-test', + 'object' => 'chat.completion', + 'created' => 1731710832, + 'model' => '@cf/meta/llama-3.3-70b-instruct-fp8-fast', + 'choices' => [ + [ + 'index' => 0, + 'message' => [ + 'role' => 'assistant', + 'content' => [ + 'title' => 'Inception', + 'rating' => '4.5', + 'summary' => 'A mind-bending masterpiece.', + ], + 'refusal' => null, + ], + 'finish_reason' => 'stop', + ], + ], + 'usage' => [ + 'prompt_tokens' => 49, + 'completion_tokens' => 32, + 'total_tokens' => 81, + ], + ]), + ]); + + $schema = new ObjectSchema( + name: 'movie_review', + description: 'A structured movie review', + properties: [ + new StringSchema('title', 'The movie title'), + new StringSchema('rating', 'Rating out of 5 stars'), + new StringSchema('summary', 'Brief review summary'), + ], + requiredFields: ['title', 'rating', 'summary'] + ); + + $response = Prism::structured() + ->using(Provider::XAI, 'grok-4') + ->withSchema($schema) + ->withPrompt('Review the movie Inception') + ->asStructured(); + + expect($response->structured) + ->toBeArray() + ->and($response->structured)->toHaveKeys(['title', 'rating', 'summary']) + ->and($response->structured['title'])->toBe('Inception') + ->and($response->structured['rating'])->toBe('4.5') + ->and($response->structured['summary'])->toBe('A mind-bending masterpiece.'); +}); + it('handles empty structured response gracefully', function (): void { Http::fake([ 'v1/chat/completions' => Http::response([ diff --git a/tests/Providers/XAI/ToolTest.php b/tests/Providers/XAI/ToolTest.php index 545df4e02..170c2ac30 100644 --- a/tests/Providers/XAI/ToolTest.php +++ b/tests/Providers/XAI/ToolTest.php @@ -6,6 +6,7 @@ use Prism\Prism\Providers\XAI\Maps\ToolMap; use Prism\Prism\Tool; +use stdClass; it('maps tools', function (): void { $tool = (new Tool) @@ -32,3 +33,15 @@ ], ]]); }); + +it('maps tools without parameters to an object properties field', function (): void { + $tool = (new Tool) + ->as('ping') + ->for('Pings the service') + ->using(fn (): string => 'pong'); + + $mapped = ToolMap::map([$tool]); + + expect($mapped[0]['function']['parameters']['properties'])->toBeInstanceOf(stdClass::class) + ->and(json_encode($mapped[0]['function']['parameters']['properties']))->toBe('{}'); +}); diff --git a/tests/Providers/XAI/XAITextTest.php b/tests/Providers/XAI/XAITextTest.php index 7d8f7a9bc..ac46c23c1 100644 --- a/tests/Providers/XAI/XAITextTest.php +++ b/tests/Providers/XAI/XAITextTest.php @@ -338,3 +338,30 @@ ->asText(); })->throws(PrismRateLimitedException::class); + +it('excludes cached tokens from promptTokens', function (): void { + Http::fake([ + '*' => Http::response([ + 'id' => 'cache-test-1', + 'model' => 'grok-2', + 'choices' => [[ + 'index' => 0, + 'message' => ['role' => 'assistant', 'content' => 'Hello!'], + 'finish_reason' => 'stop', + ]], + 'usage' => [ + 'prompt_tokens' => 100, + 'completion_tokens' => 10, + 'prompt_tokens_details' => ['cached_tokens' => 60], + ], + ]), + ])->preventStrayRequests(); + + $response = Prism::text() + ->using(Provider::XAI, 'grok-2') + ->withPrompt('Hello') + ->asText(); + + expect($response->usage->promptTokens)->toBe(40) + ->and($response->usage->cacheReadInputTokens)->toBe(60); +}); diff --git a/tests/Providers/Z/StreamTest.php b/tests/Providers/Z/StreamTest.php new file mode 100644 index 000000000..b34efc37e --- /dev/null +++ b/tests/Providers/Z/StreamTest.php @@ -0,0 +1,352 @@ +set('prism.providers.z.api_key', env('Z_API_KEY', 'zai-123')); +}); + +it('can generate text with a basic stream', function (): void { + FixtureResponse::fakeStreamResponses('chat/completions', 'z/stream-basic-text'); + + $response = Prism::text() + ->using(Provider::Z, 'glm-5') + ->withPrompt('Who are you?') + ->asStream(); + + $text = ''; + $events = []; + + foreach ($response as $event) { + $events[] = $event; + + if ($event instanceof TextDeltaEvent) { + $text .= $event->delta; + } + } + + expect($events)->not->toBeEmpty() + ->and($text)->not->toBeEmpty(); + + $eventTypes = array_map(get_class(...), $events); + + expect($eventTypes)->toContain(StreamStartEvent::class) + ->and($eventTypes)->toContain(StreamEndEvent::class) + ->and(count(array_filter($eventTypes, fn (string $t): bool => $t === TextDeltaEvent::class))) + ->toBeGreaterThan(0); + + $lastEvent = end($events); + + expect($lastEvent)->toBeInstanceOf(StreamEndEvent::class) + ->and($lastEvent->finishReason)->toBeInstanceOf(FinishReason::class); + + $streamStartEvent = $events[array_search(StreamStartEvent::class, $eventTypes)]; + + expect($streamStartEvent)->toBeInstanceOf(StreamStartEvent::class) + ->and($streamStartEvent->model)->toBe('glm-5') + ->and($streamStartEvent->provider)->toBe('z'); + + Http::assertSent(function (Request $request): bool { + $body = json_decode($request->body(), true); + + return $request->url() === 'https://api.z.ai/api/paas/v4/chat/completions' + && $body['stream'] === true + && $body['model'] === 'glm-5'; + }); +}); + +it('can generate text using tools with streaming', function (): void { + FixtureResponse::fakeStreamResponses('chat/completions', 'z/stream-with-tools'); + + $tools = [ + Tool::as('get_weather') + ->for('useful when you need to search for current weather conditions') + ->withStringParameter('city', 'The city that you want weather for') + ->using(fn (string $city): string => "The weather in {$city} will be 75° and sunny"), + + Tool::as('search_games') + ->for('useful for searching current games times in city') + ->withStringParameter('city', 'The city that you want game times for') + ->using(fn (string $city): string => "The tigers game is at 3pm in {$city}"), + ]; + + $response = Prism::text() + ->using(Provider::Z, 'glm-5') + ->withTools($tools) + ->withMaxSteps(4) + ->withPrompt('What time is the tigers game today in Detroit and should I wear a coat? please check all the details from tools') + ->asStream(); + + $events = []; + $toolCallEvents = []; + + foreach ($response as $event) { + $events[] = $event; + + if ($event instanceof ToolCallEvent) { + $toolCallEvents[] = $event; + expect($event->toolCall->name) + ->toBeString() + ->and($event->toolCall->name)->not->toBeEmpty(); + } + + if ($event instanceof ToolResultEvent) { + expect($event->toolResult->result)->not->toBeEmpty(); + } + } + + expect($events)->not->toBeEmpty() + ->and($toolCallEvents)->not->toBeEmpty(); + + $streamStartEvents = array_filter($events, fn (StreamEvent $e): bool => $e instanceof StreamStartEvent); + $streamEndEvents = array_filter($events, fn (StreamEvent $e): bool => $e instanceof StreamEndEvent); + + expect($streamStartEvents)->toHaveCount(1) + ->and($streamEndEvents)->toHaveCount(1); + + Http::assertSent(function (Request $request): bool { + $body = json_decode($request->body(), true); + + return $request->url() === 'https://api.z.ai/api/paas/v4/chat/completions' + && isset($body['tools']) + && $body['stream'] === true + && $body['model'] === 'glm-5'; + }); +}); + +it('handles max_tokens parameter correctly', function (): void { + FixtureResponse::fakeStreamResponses('chat/completions', 'z/stream-max-tokens'); + + $response = Prism::text() + ->using(Provider::Z, 'glm-5') + ->withMaxTokens(1000) + ->withPrompt('Who are you?') + ->asStream(); + + /** @phpstan-ignore-next-line */ + collect($response); + + Http::assertSent(function (Request $request): bool { + $body = json_decode($request->body(), true); + + return $request->url() === 'https://api.z.ai/api/paas/v4/chat/completions' + && $body['max_tokens'] === 1000; + }); +}); + +it('handles system prompts correctly', function (): void { + FixtureResponse::fakeStreamResponses('chat/completions', 'z/stream-system-prompt'); + + $response = Prism::text() + ->using(Provider::Z, 'glm-5') + ->withSystemPrompt('You are a helpful assistant.') + ->withPrompt('Who are you?') + ->asStream(); + + /** @phpstan-ignore-next-line */ + collect($response); + + Http::assertSent(function (Request $request): bool { + $body = json_decode($request->body(), true); + + return count($body['messages']) === 2 + && $body['messages'][0]['role'] === 'system' + && $body['messages'][1]['role'] === 'user'; + }); +}); + +it('emits step start and step finish events', function (): void { + FixtureResponse::fakeStreamResponses('chat/completions', 'z/stream-basic-text'); + + $response = Prism::text() + ->using(Provider::Z, 'glm-5') + ->withPrompt('Who are you?') + ->asStream(); + + $events = []; + + foreach ($response as $event) { + $events[] = $event; + } + + $stepStartEvents = array_filter($events, fn (StreamEvent $e): bool => $e instanceof StepStartEvent); + + expect($stepStartEvents)->toHaveCount(1); + + $stepFinishEvents = array_filter($events, fn (StreamEvent $e): bool => $e instanceof StepFinishEvent); + + expect($stepFinishEvents)->toHaveCount(1); + + $eventTypes = array_map(get_class(...), $events); + $streamStartIndex = array_search(StreamStartEvent::class, $eventTypes); + $stepStartIndex = array_search(StepStartEvent::class, $eventTypes); + $stepFinishIndex = array_search(StepFinishEvent::class, $eventTypes); + $streamEndIndex = array_search(StreamEndEvent::class, $eventTypes); + + expect($streamStartIndex)->toBeLessThan($stepStartIndex) + ->and($stepStartIndex)->toBeLessThan($stepFinishIndex) + ->and($stepFinishIndex)->toBeLessThan($streamEndIndex); +}); + +it('emits multiple step events with tool calls', function (): void { + FixtureResponse::fakeStreamResponses('chat/completions', 'z/stream-with-tools'); + + $tools = [ + Tool::as('get_weather') + ->for('useful when you need to search for current weather conditions') + ->withStringParameter('city', 'The city that you want weather for') + ->using(fn (string $city): string => "The weather in {$city} will be 75° and sunny"), + + Tool::as('search_games') + ->for('useful for searching current games times in city') + ->withStringParameter('city', 'The city that you want game times for') + ->using(fn (string $city): string => "The tigers game is at 3pm in {$city}"), + ]; + + $response = Prism::text() + ->using(Provider::Z, 'glm-5') + ->withTools($tools) + ->withMaxSteps(4) + ->withPrompt('What is weather in Detroit?') + ->asStream(); + + $events = []; + + foreach ($response as $event) { + $events[] = $event; + } + + $stepStartEvents = array_filter($events, fn (StreamEvent $e): bool => $e instanceof StepStartEvent); + $stepFinishEvents = array_filter($events, fn (StreamEvent $e): bool => $e instanceof StepFinishEvent); + + expect(count($stepStartEvents))->toBeGreaterThanOrEqual(2) + ->and(count($stepFinishEvents))->toBeGreaterThanOrEqual(2) + ->and(count($stepStartEvents))->toBe(count($stepFinishEvents)); +}); + +it('sends StreamEndEvent using tools with streaming and max steps = 1', function (): void { + FixtureResponse::fakeStreamResponses('chat/completions', 'z/stream-with-tools'); + + $tools = [ + Tool::as('get_weather') + ->for('useful when you need to search for current weather conditions') + ->withStringParameter('city', 'The city that you want weather for') + ->using(fn (string $city): string => "The weather in {$city} will be 75° and sunny"), + + Tool::as('search_games') + ->for('useful for searching current games times in city') + ->withStringParameter('city', 'The city that you want game times for') + ->using(fn (string $city): string => "The tigers game is at 3pm in {$city}"), + ]; + + $response = Prism::text() + ->using(Provider::Z, 'glm-5') + ->withTools($tools) + ->withMaxSteps(1) + ->withPrompt('What time is the tigers game today in Detroit and should I wear a coat? please check all the details from tools') + ->asStream(); + + $events = []; + + foreach ($response as $event) { + $events[] = $event; + } + + expect($events)->not->toBeEmpty(); + + $lastEvent = end($events); + expect($lastEvent)->toBeInstanceOf(StreamEndEvent::class); +}); + +it('handles specific tool choice in streaming', function (): void { + FixtureResponse::fakeStreamResponses('chat/completions', 'z/stream-with-required-tool-call'); + + $tools = [ + Tool::as('weather') + ->for('useful when you need to search for current weather conditions') + ->withStringParameter('city', 'The city that you want the weather for') + ->using(fn (string $city): string => 'The weather will be 75° and sunny'), + Tool::as('search') + ->for('useful for searching current events or data') + ->withStringParameter('query', 'The detailed search query') + ->using(fn (string $query): string => 'The tigers game is at 3pm in detroit'), + ]; + + $response = Prism::text() + ->using(Provider::Z, 'z-model') + ->withPrompt('Do something') + ->withTools($tools) + ->withToolChoice(ToolChoice::Auto) + ->asStream(); + + $events = []; + + foreach ($response as $event) { + $events[] = $event; + } + + expect($events)->not->toBeEmpty(); + + $toolCallEvents = array_values(array_filter($events, fn (StreamEvent $e): bool => $e instanceof ToolCallEvent)); + + expect($toolCallEvents)->not->toBeEmpty() + ->and($toolCallEvents[0]->toolCall->name)->toBeIn(['weather', 'search']); +}); + +it('throws PrismRateLimitedException for 429 in streaming', function (): void { + Http::fake([ + '*' => Http::response( + status: 429, + ), + ])->preventStrayRequests(); + + $response = Prism::text() + ->using(Provider::Z, 'z-model') + ->withPrompt('Who are you?') + ->asStream(); + + /** @phpstan-ignore-next-line */ + collect($response); + +})->throws(PrismRateLimitedException::class); + +it('excludes cached tokens from streamed promptTokens', function (): void { + FixtureResponse::fakeStreamResponses('chat/completions', 'z/stream-cached-tokens'); + + $response = Prism::text() + ->using(Provider::Z, 'glm-5') + ->withPrompt('Who are you?') + ->asStream(); + + $endEvent = null; + + foreach ($response as $event) { + if ($event instanceof StreamEndEvent) { + $endEvent = $event; + } + } + + expect($endEvent)->toBeInstanceOf(StreamEndEvent::class) + ->and($endEvent->usage->promptTokens)->toBe(40) + ->and($endEvent->usage->cacheReadInputTokens)->toBe(60); +}); diff --git a/tests/Providers/Z/ToolTest.php b/tests/Providers/Z/ToolTest.php new file mode 100644 index 000000000..53f2f94b8 --- /dev/null +++ b/tests/Providers/Z/ToolTest.php @@ -0,0 +1,47 @@ +as('search') + ->for('Searching the web') + ->withStringParameter('query', 'the detailed search query') + ->using(fn (): string => '[Search results]'); + + expect(ToolMap::map([$tool]))->toBe([[ + 'type' => 'function', + 'function' => [ + 'name' => $tool->name(), + 'description' => $tool->description(), + 'parameters' => [ + 'type' => 'object', + 'properties' => [ + 'query' => [ + 'description' => 'the detailed search query', + 'type' => 'string', + ], + ], + 'required' => $tool->requiredParameters(), + ], + ], + ]]); +}); + +it('maps tools without parameters to an object properties field', function (): void { + $tool = (new Tool) + ->as('ping') + ->for('Pings the service') + ->using(fn (): string => 'pong'); + + $mapped = ToolMap::map([$tool]); + + expect($mapped[0]['function']['parameters']['properties'])->toBeInstanceOf(stdClass::class) + ->and(json_encode($mapped[0]['function']['parameters']['properties']))->toBe('{}'); +}); diff --git a/tests/Providers/Z/ZStructuredTest.php b/tests/Providers/Z/ZStructuredTest.php index 7a4f4f98c..dea3ac388 100644 --- a/tests/Providers/Z/ZStructuredTest.php +++ b/tests/Providers/Z/ZStructuredTest.php @@ -77,7 +77,8 @@ 'question_type' => 'database_optimization', 'move_to_next_question' => true, ]) - ->and($response->usage->promptTokens)->toBe(1309) + ->and($response->usage->promptTokens)->toBe(82) + ->and($response->usage->cacheReadInputTokens)->toBe(1227) ->and($response->usage->completionTokens)->toBe(129) ->and($response->meta->id)->toBe('chatcmpl-123') ->and($response->meta->model)->toBe('z-model'); diff --git a/tests/Providers/Z/ZTextTest.php b/tests/Providers/Z/ZTextTest.php index 96889ee3e..c35c6a91a 100644 --- a/tests/Providers/Z/ZTextTest.php +++ b/tests/Providers/Z/ZTextTest.php @@ -50,7 +50,8 @@ ->asText(); expect($response)->toBeInstanceOf(TextResponse::class) - ->and($response->usage->promptTokens)->toBe(190) + ->and($response->usage->promptTokens)->toBe(1) + ->and($response->usage->cacheReadInputTokens)->toBe(189) ->and($response->usage->completionTokens)->toBe(166) ->and($response->meta->id)->toBe('202512161952121dd7efde49d14dc9') ->and($response->meta->model)->toBe('z-model') @@ -96,7 +97,8 @@ ->and($firstStep->toolCalls[1]->arguments())->toBe([ 'city' => 'Detroit', ]) - ->and($response->usage->promptTokens)->toBe(616) + ->and($response->usage->promptTokens)->toBe(72) + ->and($response->usage->cacheReadInputTokens)->toBe(544) ->and($response->usage->completionTokens)->toBe(319) ->and($response->meta->id)->toBe('20251216203244b8311d53051b4c17') ->and($response->meta->model)->toBe('z-model') @@ -263,3 +265,30 @@ })->throws(PrismRateLimitedException::class); }); + +it('excludes cached tokens from promptTokens', function (): void { + Http::fake([ + '*' => Http::response([ + 'id' => 'cache-test-1', + 'model' => 'glm-5', + 'choices' => [[ + 'index' => 0, + 'message' => ['role' => 'assistant', 'content' => 'Hello!'], + 'finish_reason' => 'stop', + ]], + 'usage' => [ + 'prompt_tokens' => 100, + 'completion_tokens' => 10, + 'prompt_tokens_details' => ['cached_tokens' => 60], + ], + ]), + ])->preventStrayRequests(); + + $response = Prism::text() + ->using(Provider::Z, 'glm-5') + ->withPrompt('Hello') + ->asText(); + + expect($response->usage->promptTokens)->toBe(40) + ->and($response->usage->cacheReadInputTokens)->toBe(60); +}); diff --git a/tests/SchemaTest.php b/tests/SchemaTest.php index fe79774af..c4a46d2a2 100644 --- a/tests/SchemaTest.php +++ b/tests/SchemaTest.php @@ -163,6 +163,7 @@ 'active', 'inactive', 'suspended', + null, ], 'type' => ['string', 'null'], ], @@ -218,7 +219,7 @@ expect($enumSchema->toArray())->toBe([ 'description' => 'sick or fever temp', - 'enum' => [98.6, 100, 'unknown', 105], + 'enum' => [98.6, 100, 'unknown', 105, null], 'type' => [ 'number', 'string', diff --git a/tests/Streaming/Adapters/BroadcastAdapterTest.php b/tests/Streaming/Adapters/BroadcastAdapterTest.php index 85a45c842..296757681 100644 --- a/tests/Streaming/Adapters/BroadcastAdapterTest.php +++ b/tests/Streaming/Adapters/BroadcastAdapterTest.php @@ -440,6 +440,7 @@ function createBroadcastEventGenerator(array $events): Generator 'cache_write_input_tokens' => null, 'cache_read_input_tokens' => null, 'thought_tokens' => null, + 'cost' => null, ]); return true; diff --git a/tests/Streaming/PrismStreamIntegrationTest.php b/tests/Streaming/PrismStreamIntegrationTest.php index 191bb7898..4dd5be1e9 100644 --- a/tests/Streaming/PrismStreamIntegrationTest.php +++ b/tests/Streaming/PrismStreamIntegrationTest.php @@ -1011,4 +1011,86 @@ expect($array)->toHaveKey('timestamp'); } }); + + it('step finish event has default zero usage when fake response has no explicit usage', function (): void { + Prism::fake([ + TextResponseFake::make()->withText('Test'), + ]); + + $events = iterator_to_array( + Prism::text() + ->using('openai', 'gpt-4') + ->withPrompt('Test') + ->asStream() + ); + + $stepFinishEvents = array_values(array_filter( + $events, + fn (StreamEvent $e): bool => $e instanceof StepFinishEvent + )); + + expect($stepFinishEvents)->not->toBeEmpty(); + expect($stepFinishEvents[0]->usage)->not->toBeNull(); + expect($stepFinishEvents[0]->usage->promptTokens)->toBe(0); + expect($stepFinishEvents[0]->usage->completionTokens)->toBe(0); + }); + + it('step finish event contains usage when fake response has usage', function (): void { + Prism::fake([ + TextResponseFake::make() + ->withText('Test') + ->withUsage(new Usage(100, 50)), + ]); + + $events = iterator_to_array( + Prism::text() + ->using('openai', 'gpt-4') + ->withPrompt('Test') + ->asStream() + ); + + $stepFinishEvents = array_values(array_filter( + $events, + fn (StreamEvent $e): bool => $e instanceof StepFinishEvent + )); + + expect($stepFinishEvents)->not->toBeEmpty(); + expect($stepFinishEvents[0]->usage)->not->toBeNull(); + expect($stepFinishEvents[0]->usage->promptTokens)->toBe(100); + expect($stepFinishEvents[0]->usage->completionTokens)->toBe(50); + }); + + it('step finish event toArray includes usage when set', function (): void { + Prism::fake([ + TextResponseFake::make() + ->withText('Test') + ->withUsage(new Usage(20, 10)), + ]); + + $events = iterator_to_array( + Prism::text() + ->using('openai', 'gpt-4') + ->withPrompt('Test') + ->asStream() + ); + + $stepFinishEvents = array_values(array_filter( + $events, + fn (StreamEvent $e): bool => $e instanceof StepFinishEvent + )); + + expect($stepFinishEvents)->not->toBeEmpty(); + $array = $stepFinishEvents[0]->toArray(); + expect($array)->toHaveKey('usage'); + expect($array['usage'])->not->toBeNull(); + expect($array['usage']['prompt_tokens'])->toBe(20); + expect($array['usage']['completion_tokens'])->toBe(10); + }); + + it('step finish event toArray has null usage when event is constructed without usage', function (): void { + $event = new StepFinishEvent(id: 'test-id', timestamp: 1234567890); + $array = $event->toArray(); + expect($array)->toHaveKey('usage'); + expect($array['usage'])->toBeNull(); + }); }); diff --git a/tests/Structured/PendingRequestTest.php b/tests/Structured/PendingRequestTest.php index 316f7df78..554c07258 100644 --- a/tests/Structured/PendingRequestTest.php +++ b/tests/Structured/PendingRequestTest.php @@ -92,6 +92,19 @@ ->and($request->providerOptions())->toBe($providerOptions); }); +test('it sets top k', function (): void { + $schema = new StringSchema('test', 'test description'); + + $request = $this->pendingRequest + ->using(Provider::OpenAI, 'gpt-4') + ->withSchema($schema) + ->withPrompt('Test prompt') + ->usingTopK(40) + ->toRequest(); + + expect($request->topK())->toBe(40); +}); + test('you can run toRequest multiple times', function (): void { $request = $this->pendingRequest ->using(Provider::OpenAI, 'gpt-4') diff --git a/tests/Structured/RequestTest.php b/tests/Structured/RequestTest.php index e8e953933..4be16e813 100644 --- a/tests/Structured/RequestTest.php +++ b/tests/Structured/RequestTest.php @@ -19,6 +19,7 @@ function createStructuredRequestWithToolChoice(string|ToolChoice|null $toolChoic maxTokens: null, temperature: null, topP: null, + topK: null, clientOptions: [], clientRetry: [3, 100], schema: new ObjectSchema( diff --git a/tests/Structured/ResponseBuilderTest.php b/tests/Structured/ResponseBuilderTest.php index 1d6ae1122..eb9f02c35 100644 --- a/tests/Structured/ResponseBuilderTest.php +++ b/tests/Structured/ResponseBuilderTest.php @@ -208,6 +208,49 @@ ->and($response->toolResults[2]->result)->toBe('High'); }); +test('StructuredResponseBuilder aggregates cost across steps', function (): void { + $builder = new ResponseBuilder; + + $builder->addStep(new Step( + text: 'intermediate', + finishReason: FinishReason::Length, + usage: new Usage(promptTokens: 10, completionTokens: 5, cost: 0.0015), + meta: new Meta('step1', 'test-model'), + messages: [], + systemPrompts: [], + )); + + $builder->addStep(new Step( + text: '{"value":42}', + finishReason: FinishReason::Stop, + usage: new Usage(promptTokens: 3, completionTokens: 2, cost: 0.0008), + meta: new Meta('step2', 'test-model'), + messages: [], + systemPrompts: [], + )); + + $response = $builder->toResponse(); + + expect($response->usage->cost)->toBe(0.0023); +}); + +test('StructuredResponseBuilder keeps cost null when no steps have cost', function (): void { + $builder = new ResponseBuilder; + + $builder->addStep(new Step( + text: '{"value":42}', + finishReason: FinishReason::Stop, + usage: new Usage(promptTokens: 10, completionTokens: 5), + meta: new Meta('step1', 'test-model'), + messages: [], + systemPrompts: [], + )); + + $response = $builder->toResponse(); + + expect($response->usage->cost)->toBeNull(); +}); + test('StructuredResponseBuilder returns empty arrays when no tool calls or results exist', function (): void { $builder = new ResponseBuilder; diff --git a/tests/TestDoubles/TestProvider.php b/tests/TestDoubles/TestProvider.php index 296b08503..c2380a232 100644 --- a/tests/TestDoubles/TestProvider.php +++ b/tests/TestDoubles/TestProvider.php @@ -5,6 +5,10 @@ namespace Tests\TestDoubles; use Generator; +use Prism\Prism\Audio\ProviderIdResponse; +use Prism\Prism\Audio\SpeechToTextAsyncRequest; +use Prism\Prism\Audio\SpeechToTextRequest; +use Prism\Prism\Audio\TextResponse as AudioTextResponse; use Prism\Prism\Embeddings\Request as EmbeddingRequest; use Prism\Prism\Embeddings\Response as EmbeddingResponse; use Prism\Prism\Enums\FinishReason; @@ -24,7 +28,7 @@ class TestProvider extends Provider { - public StructuredRequest|TextRequest|EmbeddingRequest|ImageRequest $request; + public StructuredRequest|TextRequest|EmbeddingRequest|ImageRequest|SpeechToTextRequest|SpeechToTextAsyncRequest $request; /** @var array */ public array $clientOptions; @@ -32,7 +36,7 @@ class TestProvider extends Provider /** @var array */ public array $clientRetry; - /** @var array */ + /** @var array */ public array $responses = []; public $callCount = 0; @@ -115,7 +119,27 @@ public function stream(TextRequest $request): Generator throw PrismException::unsupportedProviderAction(__METHOD__, class_basename($this)); } - public function withResponse(StructuredResponse|TextResponse $response): Provider + #[\Override] + public function speechToTextProviderId(SpeechToTextRequest $request): ProviderIdResponse + { + $this->callCount++; + + $this->request = $request; + + return $this->responses[$this->callCount - 1] ?? new ProviderIdResponse('provider-id'); + } + + #[\Override] + public function speechToTextAsync(SpeechToTextAsyncRequest $request): AudioTextResponse + { + $this->callCount++; + + $this->request = $request; + + return $this->responses[$this->callCount - 1] ?? new AudioTextResponse('Async transcript'); + } + + public function withResponse(StructuredResponse|TextResponse|EmbeddingResponse|ImageResponse|AudioTextResponse|ProviderIdResponse $response): Provider { $this->responses[] = $response; diff --git a/tests/Text/PendingRequestTest.php b/tests/Text/PendingRequestTest.php index 0b4c4fb13..1bf8ce821 100644 --- a/tests/Text/PendingRequestTest.php +++ b/tests/Text/PendingRequestTest.php @@ -168,6 +168,16 @@ enum TestModel: string expect($generated->topP())->toBe(0.9); }); +test('it sets top k', function (): void { + $request = $this->pendingRequest + ->using(Provider::OpenAI, 'gpt-4') + ->usingTopK(40); + + $generated = $request->toRequest(); + + expect($generated->topK())->toBe(40); +}); + test('it sets max steps', function (): void { $request = $this->pendingRequest ->using(Provider::OpenAI, 'gpt-4') diff --git a/tests/Text/RequestTest.php b/tests/Text/RequestTest.php index 7085bf602..dfe97e6fd 100644 --- a/tests/Text/RequestTest.php +++ b/tests/Text/RequestTest.php @@ -17,6 +17,7 @@ function createTextRequestWithToolChoice(string|ToolChoice|null $toolChoice): Re maxTokens: null, temperature: null, topP: null, + topK: null, tools: [], clientOptions: [], clientRetry: [3, 100], diff --git a/tests/Text/ResponseBuilderTest.php b/tests/Text/ResponseBuilderTest.php index 290737b79..b303c0b17 100644 --- a/tests/Text/ResponseBuilderTest.php +++ b/tests/Text/ResponseBuilderTest.php @@ -45,3 +45,55 @@ ->and($response->finishReason)->toBe(FinishReason::Stop) ->and($response->steps)->toHaveCount(2); }); + +test('TextResponseBuilder aggregates cost across steps', function (): void { + $builder = new ResponseBuilder; + + $builder->addStep(new Step( + text: 'hello ', + finishReason: FinishReason::Length, + toolCalls: [], + toolResults: [], + providerToolCalls: [], + usage: new Usage(promptTokens: 5, completionTokens: 0, cost: 0.001), + meta: new Meta('s1', 'test-model'), + messages: [], + systemPrompts: [], + )); + + $builder->addStep(new Step( + text: 'world', + finishReason: FinishReason::Stop, + toolCalls: [], + toolResults: [], + providerToolCalls: [], + usage: new Usage(promptTokens: 2, completionTokens: 3, cost: 0.002), + meta: new Meta('s2', 'test-model'), + messages: [], + systemPrompts: [], + )); + + $response = $builder->toResponse(); + + expect($response->usage->cost)->toBe(0.003); +}); + +test('TextResponseBuilder keeps cost null when no steps have cost', function (): void { + $builder = new ResponseBuilder; + + $builder->addStep(new Step( + text: 'hello', + finishReason: FinishReason::Stop, + toolCalls: [], + toolResults: [], + providerToolCalls: [], + usage: new Usage(promptTokens: 5, completionTokens: 3), + meta: new Meta('s1', 'test-model'), + messages: [], + systemPrompts: [], + )); + + $response = $builder->toResponse(); + + expect($response->usage->cost)->toBeNull(); +}); diff --git a/tests/ToolApprovalTest.php b/tests/ToolApprovalTest.php new file mode 100644 index 000000000..5631d32c4 --- /dev/null +++ b/tests/ToolApprovalTest.php @@ -0,0 +1,1265 @@ +callToolsWithPending($tools, $toolCalls, $hasPendingToolCalls, $approvalRequests); + } + + public function stream(array $tools, array $toolCalls, string $messageId, array &$toolResults, bool &$hasPendingToolCalls = false): Generator + { + return $this->callToolsAndYieldEventsWithPending($tools, $toolCalls, $messageId, $toolResults, $hasPendingToolCalls); + } + + public function resolve(Request $request): void + { + $this->resolveToolApprovals($request); + } + + public function resolveStream(Request $request, string $messageId, ?StreamState $state = null): Generator + { + return $this->resolveToolApprovalsAndYieldEvents($request, $messageId); + } +} + +function getResolvedToolResults(Request $request): array +{ + foreach (array_reverse($request->messages()) as $message) { + if ($message instanceof ToolResultMessage) { + return $message->toolResults; + } + } + + return []; +} + +function approvalTextRequest(array $messages = [], array $tools = []): Request +{ + return new Request( + model: 'test-model', + providerKey: 'test', + systemPrompts: [], + prompt: null, + messages: $messages, + maxSteps: 5, + maxTokens: null, + temperature: null, + topP: null, + tools: $tools, + clientOptions: [], + clientRetry: [0], + toolChoice: ToolChoice::Auto, + ); +} + +describe('Tool::requiresApproval()', function (): void { + it('defaults to not requiring approval', function (): void { + $tool = (new Tool) + ->as('test') + ->for('Test tool') + ->using(fn (): string => 'result'); + + expect($tool->needsApproval())->toBeFalse(); + }); + + it('can be marked as requiring approval with static true', function (): void { + $tool = (new Tool) + ->as('test') + ->for('Test tool') + ->using(fn (): string => 'result') + ->requiresApproval(); + + expect($tool->needsApproval())->toBeTrue(); + }); + + it('can be marked as requiring approval with explicit true', function (): void { + $tool = (new Tool) + ->as('test') + ->for('Test tool') + ->using(fn (): string => 'result') + ->requiresApproval(true); + + expect($tool->needsApproval())->toBeTrue(); + }); + + it('can be set to not require approval with false', function (): void { + $tool = (new Tool) + ->as('test') + ->for('Test tool') + ->using(fn (): string => 'result') + ->requiresApproval(false); + + expect($tool->needsApproval())->toBeFalse(); + }); + + it('supports dynamic approval via closure', function (): void { + $tool = (new Tool) + ->as('transfer') + ->for('Transfer money') + ->withNumberParameter('amount', 'Amount') + ->using(fn (float $amount): string => "Transferred {$amount}") + ->requiresApproval(fn (array $args): bool => $args['amount'] > 1000); + + expect($tool->needsApproval(['amount' => 500]))->toBeFalse(); + expect($tool->needsApproval(['amount' => 1500]))->toBeTrue(); + }); + + it('hasApprovalConfigured returns true for static or closure without invoking closure', function (): void { + $staticTool = (new Tool)->as('a')->for('A')->using(fn (): string => '')->requiresApproval(); + expect($staticTool->hasApprovalConfigured())->toBeTrue(); + + $closureTool = (new Tool)->as('b')->for('B')->using(fn (): string => '') + ->requiresApproval(fn (array $args): bool => $args['x'] > 0); + expect($closureTool->hasApprovalConfigured())->toBeTrue(); + + $disabledTool = (new Tool)->as('c')->for('C')->using(fn (): string => '')->requiresApproval(false); + expect($disabledTool->hasApprovalConfigured())->toBeFalse(); + }); +}); + +describe('Phase 1: filterServerExecutedToolCalls with approval tools', function (): void { + it('skips approval-required tools and sets pending flag', function (): void { + $normalTool = (new Tool) + ->as('normal_tool') + ->for('A normal tool') + ->using(fn (): string => 'result'); + + $approvalTool = (new Tool) + ->as('approval_tool') + ->for('Needs approval') + ->using(fn (): string => 'result') + ->requiresApproval(); + + $toolCalls = [ + new ToolCall(id: 'call-1', name: 'normal_tool', arguments: []), + new ToolCall(id: 'call-2', name: 'approval_tool', arguments: []), + ]; + + $handler = new ToolApprovalTestHandler; + $hasPendingToolCalls = false; + $results = $handler->execute([$normalTool, $approvalTool], $toolCalls, $hasPendingToolCalls); + + expect($results)->toHaveCount(1) + ->and($results[0]->toolName)->toBe('normal_tool') + ->and($hasPendingToolCalls)->toBeTrue(); + }); + + it('emits ToolApprovalRequestEvent in streaming for approval-required tools', function (): void { + $approvalTool = (new Tool) + ->as('dangerous_tool') + ->for('Dangerous operation') + ->using(fn (): string => 'result') + ->requiresApproval(); + + $toolCalls = [ + new ToolCall(id: 'call-1', name: 'dangerous_tool', arguments: ['action' => 'delete']), + ]; + + $handler = new ToolApprovalTestHandler; + $toolResults = []; + $hasPendingToolCalls = false; + $events = []; + + foreach ($handler->stream([$approvalTool], $toolCalls, 'msg-123', $toolResults, $hasPendingToolCalls) as $event) { + $events[] = $event; + } + + expect($events)->toHaveCount(1) + ->and($events[0])->toBeInstanceOf(ToolApprovalRequestEvent::class) + ->and($events[0]->toolCall->id)->toBe('call-1') + ->and($events[0]->toolCall->name)->toBe('dangerous_tool') + ->and($events[0]->messageId)->toBe('msg-123') + ->and($hasPendingToolCalls)->toBeTrue() + ->and($toolResults)->toBeEmpty(); + }); + + it('yields ToolApprovalRequestEvent after server-executed tool results in streaming', function (): void { + $normalTool = (new Tool) + ->as('normal') + ->for('Normal tool') + ->using(fn (): string => 'normal result'); + + $approvalTool = (new Tool) + ->as('approval') + ->for('Approval tool') + ->using(fn (): string => 'should not run') + ->requiresApproval(); + + $toolCalls = [ + new ToolCall(id: 'call-1', name: 'normal', arguments: []), + new ToolCall(id: 'call-2', name: 'approval', arguments: []), + ]; + + $handler = new ToolApprovalTestHandler; + $toolResults = []; + $hasPendingToolCalls = false; + $events = []; + + foreach ($handler->stream([$normalTool, $approvalTool], $toolCalls, 'msg-123', $toolResults, $hasPendingToolCalls) as $event) { + $events[] = $event; + } + + expect($events)->toHaveCount(2) + ->and($events[0])->toBeInstanceOf(ToolResultEvent::class) + ->and($events[0]->toolResult->toolCallId)->toBe('call-1') + ->and($events[0]->toolResult->result)->toBe('normal result') + ->and($events[1])->toBeInstanceOf(ToolApprovalRequestEvent::class) + ->and($events[1]->toolCall->id)->toBe('call-2') + ->and($hasPendingToolCalls)->toBeTrue() + ->and($toolResults)->toHaveCount(1); + }); + + it('handles mixed tools: normal, client-executed, and approval-required', function (): void { + $normalTool = (new Tool) + ->as('normal') + ->for('Normal tool') + ->using(fn (): string => 'normal result'); + + $clientTool = (new Tool) + ->as('client') + ->for('Client tool') + ->clientExecuted(); + + $approvalTool = (new Tool) + ->as('approval') + ->for('Approval tool') + ->using(fn (): string => 'should not run') + ->requiresApproval(); + + $toolCalls = [ + new ToolCall(id: 'call-1', name: 'normal', arguments: []), + new ToolCall(id: 'call-2', name: 'client', arguments: []), + new ToolCall(id: 'call-3', name: 'approval', arguments: []), + ]; + + $handler = new ToolApprovalTestHandler; + $hasPendingToolCalls = false; + $results = $handler->execute([$normalTool, $clientTool, $approvalTool], $toolCalls, $hasPendingToolCalls); + + expect($results)->toHaveCount(1) + ->and($results[0]->toolName)->toBe('normal') + ->and($hasPendingToolCalls)->toBeTrue(); + }); + + it('skips tool with dynamic approval only when closure returns true', function (): void { + $tool = (new Tool) + ->as('transfer') + ->for('Transfer money') + ->withNumberParameter('amount', 'Amount') + ->using(fn (float $amount): string => "Transferred {$amount}") + ->requiresApproval(fn (array $args): bool => $args['amount'] > 1000); + + $smallTransfer = new ToolCall(id: 'call-1', name: 'transfer', arguments: ['amount' => 500]); + $largeTransfer = new ToolCall(id: 'call-2', name: 'transfer', arguments: ['amount' => 2000]); + + $handler = new ToolApprovalTestHandler; + + $hasPending = false; + $results = $handler->execute([$tool], [$smallTransfer], $hasPending); + expect($results)->toHaveCount(1) + ->and($results[0]->result)->toBe('Transferred 500') + ->and($hasPending)->toBeFalse(); + + $hasPending = false; + $results = $handler->execute([$tool], [$largeTransfer], $hasPending); + expect($results)->toBeEmpty() + ->and($hasPending)->toBeTrue(); + }); +}); + +describe('Phase 2: resolveToolApprovals', function (): void { + it('returns empty when no ToolResultMessage with toolApprovalResponses exists', function (): void { + $tool = (new Tool) + ->as('test') + ->for('Test') + ->using(fn (): string => 'result') + ->requiresApproval(); + + $request = approvalTextRequest( + messages: [new UserMessage('hello')], + tools: [$tool], + ); + + $handler = new ToolApprovalTestHandler; + $handler->resolve($request); + + expect(getResolvedToolResults($request))->toBeEmpty(); + }); + + it('executes approved tools', function (): void { + $tool = (new Tool) + ->as('delete_file') + ->for('Delete a file') + ->withStringParameter('path', 'File path') + ->using(fn (string $path): string => "Deleted: {$path}") + ->requiresApproval(); + + $request = approvalTextRequest( + messages: [ + new UserMessage('delete the file'), + new AssistantMessage( + content: 'I will delete the file.', + toolCalls: [ + new ToolCall(id: 'call-1', name: 'delete_file', arguments: ['path' => '/tmp/test.txt']), + ], + additionalContent: [], + toolApprovalRequests: [ + new ToolApprovalRequest(approvalId: 'approval-1', toolCallId: 'call-1'), + ], + ), + new ToolResultMessage([], [ + new ToolApprovalResponse(approvalId: 'approval-1', approved: true), + ]), + ], + tools: [$tool], + ); + + $handler = new ToolApprovalTestHandler; + $handler->resolve($request); + + $results = getResolvedToolResults($request); + expect($results)->toHaveCount(1) + ->and($results[0]->toolName)->toBe('delete_file') + ->and($results[0]->result)->toBe('Deleted: /tmp/test.txt') + ->and($results[0]->toolCallId)->toBe('call-1'); + + $messages = $request->messages(); + $lastMessage = end($messages); + expect($lastMessage)->toBeInstanceOf(ToolResultMessage::class); + }); + + it('creates denial result for denied tools', function (): void { + $tool = (new Tool) + ->as('delete_file') + ->for('Delete a file') + ->withStringParameter('path', 'File path') + ->using(fn (string $path): string => "Deleted: {$path}") + ->requiresApproval(); + + $request = approvalTextRequest( + messages: [ + new UserMessage('delete the file'), + new AssistantMessage( + content: 'I will delete the file.', + toolCalls: [ + new ToolCall(id: 'call-1', name: 'delete_file', arguments: ['path' => '/tmp/test.txt']), + ], + additionalContent: [], + toolApprovalRequests: [ + new ToolApprovalRequest(approvalId: 'approval-1', toolCallId: 'call-1'), + ], + ), + new ToolResultMessage([], [ + new ToolApprovalResponse(approvalId: 'approval-1', approved: false, reason: 'Too dangerous'), + ]), + ], + tools: [$tool], + ); + + $handler = new ToolApprovalTestHandler; + $handler->resolve($request); + + $results = getResolvedToolResults($request); + expect($results)->toHaveCount(1) + ->and($results[0]->toolName)->toBe('delete_file') + ->and($results[0]->result)->toBe('Too dangerous') + ->and($results[0]->toolCallId)->toBe('call-1'); + }); + + it('adds denial when no approval response provided for approval-required tool', function (): void { + $tool = (new Tool) + ->as('delete_file') + ->for('Delete a file') + ->withStringParameter('path', 'File path') + ->using(fn (string $path): string => "Deleted: {$path}") + ->requiresApproval(); + + $request = approvalTextRequest( + messages: [ + new UserMessage('delete the file'), + new AssistantMessage( + content: 'I will delete the file.', + toolCalls: [ + new ToolCall(id: 'call-1', name: 'delete_file', arguments: ['path' => '/tmp/test.txt']), + ], + additionalContent: [], + toolApprovalRequests: [ + new ToolApprovalRequest(approvalId: 'approval-1', toolCallId: 'call-1'), + ], + ), + new ToolResultMessage([], []), + ], + tools: [$tool], + ); + + $handler = new ToolApprovalTestHandler; + $handler->resolve($request); + + $results = getResolvedToolResults($request); + expect($results)->toHaveCount(1) + ->and($results[0]->result)->toBe('No approval response provided') + ->and($results[0]->toolCallId)->toBe('call-1'); + }); + + it('replaces ToolResultMessage with toolApprovalResponses with merged ToolResultMessage', function (): void { + $tool = (new Tool) + ->as('test_tool') + ->for('Test') + ->using(fn (): string => 'result') + ->requiresApproval(); + + $request = approvalTextRequest( + messages: [ + new UserMessage('test'), + new AssistantMessage( + content: '', + toolCalls: [ + new ToolCall(id: 'call-1', name: 'test_tool', arguments: []), + ], + additionalContent: [], + toolApprovalRequests: [ + new ToolApprovalRequest(approvalId: 'approval-1', toolCallId: 'call-1'), + ], + ), + new ToolResultMessage([], [ + new ToolApprovalResponse(approvalId: 'approval-1', approved: true), + ]), + ], + tools: [$tool], + ); + + $handler = new ToolApprovalTestHandler; + $handler->resolve($request); + + $toolResultMessages = collect($request->messages())->filter(fn ($m): bool => $m instanceof ToolResultMessage); + expect($toolResultMessages)->toHaveCount(1); + + $toolResultMessage = $toolResultMessages->first(); + expect($toolResultMessage->toolResults)->toHaveCount(1) + ->and($toolResultMessage->toolResults[0]->result)->toBe('result') + ->and($toolResultMessage->toolApprovalResponses)->toHaveCount(1) + ->and($toolResultMessage->toolApprovalResponses[0]->approvalId)->toBe('approval-1') + ->and($toolResultMessage->toolApprovalResponses[0]->approved)->toBeTrue(); + }); + + it('yields ToolResultEvent for each resolved tool in streaming', function (): void { + $tool = (new Tool) + ->as('delete_file') + ->for('Delete a file') + ->withStringParameter('path', 'File path') + ->using(fn (string $path): string => "Deleted: {$path}") + ->requiresApproval(); + + $request = approvalTextRequest( + messages: [ + new UserMessage('delete'), + new AssistantMessage( + content: '', + toolCalls: [ + new ToolCall(id: 'call-1', name: 'delete_file', arguments: ['path' => '/tmp/a.txt']), + new ToolCall(id: 'call-2', name: 'delete_file', arguments: ['path' => '/tmp/b.txt']), + ], + additionalContent: [], + toolApprovalRequests: [ + new ToolApprovalRequest(approvalId: 'approval-1', toolCallId: 'call-1'), + new ToolApprovalRequest(approvalId: 'approval-2', toolCallId: 'call-2'), + ], + ), + new ToolResultMessage([], [ + new ToolApprovalResponse(approvalId: 'approval-1', approved: true), + new ToolApprovalResponse(approvalId: 'approval-2', approved: false, reason: 'Keep this file'), + ]), + ], + tools: [$tool], + ); + + $handler = new ToolApprovalTestHandler; + $events = []; + + foreach ($handler->resolveStream($request, 'msg-456') as $event) { + $events[] = $event; + } + + expect($events)->toHaveCount(2) + ->and($events[0])->toBeInstanceOf(ToolResultEvent::class) + ->and($events[0]->toolResult->toolCallId)->toBe('call-1') + ->and($events[0]->toolResult->result)->toBe('Deleted: /tmp/a.txt') + ->and($events[0]->success)->toBeTrue() + ->and($events[1])->toBeInstanceOf(ToolResultEvent::class) + ->and($events[1]->toolResult->toolCallId)->toBe('call-2') + ->and($events[1]->toolResult->result)->toBe('Keep this file') + ->and($events[1]->success)->toBeFalse(); + }); + + it('yields ToolResultEvents for approved tools before request is updated for LLM continuation', function (): void { + $tool = (new Tool) + ->as('delete_file') + ->for('Delete a file') + ->withStringParameter('path', 'File path') + ->using(fn (string $path): string => "Deleted: {$path}") + ->requiresApproval(); + + $request = approvalTextRequest( + messages: [ + new UserMessage('delete files'), + new AssistantMessage( + content: '', + toolCalls: [ + new ToolCall(id: 'call-1', name: 'delete_file', arguments: ['path' => '/tmp/a.txt']), + new ToolCall(id: 'call-2', name: 'delete_file', arguments: ['path' => '/tmp/b.txt']), + new ToolCall(id: 'call-3', name: 'delete_file', arguments: ['path' => '/tmp/c.txt']), + ], + additionalContent: [], + toolApprovalRequests: [ + new ToolApprovalRequest(approvalId: 'approval-1', toolCallId: 'call-1'), + new ToolApprovalRequest(approvalId: 'approval-2', toolCallId: 'call-2'), + new ToolApprovalRequest(approvalId: 'approval-3', toolCallId: 'call-3'), + ], + ), + new ToolResultMessage([], [ + new ToolApprovalResponse(approvalId: 'approval-1', approved: true), + new ToolApprovalResponse(approvalId: 'approval-2', approved: true), + new ToolApprovalResponse(approvalId: 'approval-3', approved: false, reason: 'Skip this'), + ]), + ], + tools: [$tool], + ); + + $handler = new ToolApprovalTestHandler; + $events = iterator_to_array($handler->resolveStream($request, 'msg-789')); + + // All ToolResultEvents must be yielded before generator completes (i.e. before LLM would continue) + expect($events)->toHaveCount(3) + ->and($events[0])->toBeInstanceOf(ToolResultEvent::class) + ->and($events[0]->toolResult->toolCallId)->toBe('call-1') + ->and($events[0]->toolResult->result)->toBe('Deleted: /tmp/a.txt') + ->and($events[1])->toBeInstanceOf(ToolResultEvent::class) + ->and($events[1]->toolResult->toolCallId)->toBe('call-2') + ->and($events[1]->toolResult->result)->toBe('Deleted: /tmp/b.txt') + ->and($events[2])->toBeInstanceOf(ToolResultEvent::class) + ->and($events[2]->toolResult->toolCallId)->toBe('call-3') + ->and($events[2]->toolResult->result)->toBe('Skip this') + ->and($events[2]->success)->toBeFalse(); + + // Request is only updated with merged ToolResultMessage after all events are yielded + $results = getResolvedToolResults($request); + expect($results)->toHaveCount(3) + ->and($results[0]->result)->toBe('Deleted: /tmp/a.txt') + ->and($results[1]->result)->toBe('Deleted: /tmp/b.txt') + ->and($results[2]->result)->toBe('Skip this'); + }); + + it('yields ToolResultEvents for approved tools in order before generator completes', function (): void { + $approvalTool = (new Tool) + ->as('transfer') + ->for('Transfer money') + ->withNumberParameter('amount', 'Amount') + ->using(fn (float $amount): string => "Transferred {$amount}") + ->requiresApproval(); + + $request = approvalTextRequest( + messages: [ + new UserMessage('transfer money'), + new AssistantMessage( + content: '', + toolCalls: [ + new ToolCall(id: 'call-1', name: 'transfer', arguments: ['amount' => 100]), + new ToolCall(id: 'call-2', name: 'transfer', arguments: ['amount' => 500]), + ], + additionalContent: [], + toolApprovalRequests: [ + new ToolApprovalRequest(approvalId: 'approval-1', toolCallId: 'call-1'), + new ToolApprovalRequest(approvalId: 'approval-2', toolCallId: 'call-2'), + ], + ), + new ToolResultMessage([], [ + new ToolApprovalResponse(approvalId: 'approval-1', approved: true), + new ToolApprovalResponse(approvalId: 'approval-2', approved: true), + ]), + ], + tools: [$approvalTool], + ); + + $handler = new ToolApprovalTestHandler; + $events = iterator_to_array($handler->resolveStream($request, 'msg-stream')); + + // All ToolResultEvents must appear before the generator is exhausted (simulating "before LLM continues") + expect($events)->toHaveCount(2) + ->and($events[0])->toBeInstanceOf(ToolResultEvent::class) + ->and($events[0]->toolResult->result)->toBe('Transferred 100') + ->and($events[0]->success)->toBeTrue() + ->and($events[1])->toBeInstanceOf(ToolResultEvent::class) + ->and($events[1]->toolResult->result)->toBe('Transferred 500') + ->and($events[1]->success)->toBeTrue(); + }); + + it('merges resolved tool results with existing client-executed results', function (): void { + $approvalTool = (new Tool) + ->as('delete_file') + ->for('Delete a file') + ->withStringParameter('path', 'File path') + ->using(fn (string $path): string => "Deleted: {$path}") + ->requiresApproval(); + + $request = approvalTextRequest( + messages: [ + new UserMessage('search and delete'), + new AssistantMessage( + content: '', + toolCalls: [ + new ToolCall(id: 'call-1', name: 'search', arguments: []), + new ToolCall(id: 'call-2', name: 'delete_file', arguments: ['path' => '/tmp/test.txt']), + ], + additionalContent: [], + toolApprovalRequests: [ + new ToolApprovalRequest(approvalId: 'approval-2', toolCallId: 'call-2'), + ], + ), + new ToolResultMessage( + toolResults: [ + new ToolResult(toolCallId: 'call-1', toolName: 'search', args: [], result: 'client search result'), + ], + toolApprovalResponses: [ + new ToolApprovalResponse(approvalId: 'approval-2', approved: true), + ], + ), + ], + tools: [ + (new Tool)->as('search')->for('Search')->using(fn (): string => '')->clientExecuted(), + $approvalTool, + ], + ); + + $handler = new ToolApprovalTestHandler; + $handler->resolve($request); + + $toolResultMessage = collect($request->messages())->first(fn ($m): bool => $m instanceof ToolResultMessage); + expect($toolResultMessage->toolResults)->toHaveCount(2) + ->and($toolResultMessage->toolResults[0]->toolCallId)->toBe('call-1') + ->and($toolResultMessage->toolResults[0]->result)->toBe('client search result') + ->and($toolResultMessage->toolResults[1]->toolCallId)->toBe('call-2') + ->and($toolResultMessage->toolResults[1]->result)->toBe('Deleted: /tmp/test.txt'); + }); + + it('skips approval-required tools that already have a result in the tool message', function (): void { + $tool = (new Tool) + ->as('delete_file') + ->for('Delete a file') + ->withStringParameter('path', 'File path') + ->using(fn (string $path): string => "Deleted: {$path}") + ->requiresApproval(); + + $request = approvalTextRequest( + messages: [ + new UserMessage('delete'), + new AssistantMessage( + content: '', + toolCalls: [ + new ToolCall(id: 'call-1', name: 'delete_file', arguments: ['path' => '/tmp/a.txt']), + new ToolCall(id: 'call-2', name: 'delete_file', arguments: ['path' => '/tmp/b.txt']), + ], + additionalContent: [], + toolApprovalRequests: [ + new ToolApprovalRequest(approvalId: 'approval-1', toolCallId: 'call-1'), + new ToolApprovalRequest(approvalId: 'approval-2', toolCallId: 'call-2'), + ], + ), + new ToolResultMessage( + toolResults: [ + new ToolResult(toolCallId: 'call-1', toolName: 'delete_file', args: ['path' => '/tmp/a.txt'], result: 'Already executed'), + ], + toolApprovalResponses: [ + new ToolApprovalResponse(approvalId: 'approval-2', approved: false, reason: 'User denied'), + ], + ), + ], + tools: [$tool], + ); + + $handler = new ToolApprovalTestHandler; + $handler->resolve($request); + + $results = getResolvedToolResults($request); + expect($results)->toHaveCount(2) + ->and($results[0]->toolCallId)->toBe('call-1') + ->and($results[0]->result)->toBe('Already executed') + ->and($results[1]->toolCallId)->toBe('call-2') + ->and($results[1]->result)->toBe('User denied'); + }); + + it('only resolves tool calls with approval responses, skipping others', function (): void { + $normalTool = (new Tool) + ->as('search') + ->for('Search the web') + ->using(fn (): string => 'search results'); + + $approvalTool = (new Tool) + ->as('delete_file') + ->for('Delete a file') + ->withStringParameter('path', 'File path') + ->using(fn (string $path): string => "Deleted: {$path}") + ->requiresApproval(); + + $request = approvalTextRequest( + messages: [ + new UserMessage('search and delete'), + new AssistantMessage( + content: '', + toolCalls: [ + new ToolCall(id: 'call-1', name: 'search', arguments: []), + new ToolCall(id: 'call-2', name: 'delete_file', arguments: ['path' => '/tmp/test.txt']), + ], + additionalContent: [], + toolApprovalRequests: [ + new ToolApprovalRequest(approvalId: 'approval-2', toolCallId: 'call-2'), + ], + ), + new ToolResultMessage([], [ + new ToolApprovalResponse(approvalId: 'approval-2', approved: true), + ]), + ], + tools: [$normalTool, $approvalTool], + ); + + $handler = new ToolApprovalTestHandler; + $handler->resolve($request); + + $results = getResolvedToolResults($request); + expect($results)->toHaveCount(1) + ->and($results[0]->toolName)->toBe('delete_file') + ->and($results[0]->result)->toBe('Deleted: /tmp/test.txt'); + }); + + it('adds denial for last assistant when no approval message follows it', function (): void { + $tool = (new Tool) + ->as('test_tool') + ->for('Test') + ->using(fn (): string => 'result') + ->requiresApproval(); + + $messages = [ + new UserMessage('first request'), + new AssistantMessage( + content: '', + toolCalls: [ + new ToolCall(id: 'call-old', name: 'test_tool', arguments: []), + ], + additionalContent: [], + toolApprovalRequests: [ + new ToolApprovalRequest(approvalId: 'approval-old', toolCallId: 'call-old'), + ], + ), + new ToolResultMessage([], [ + new ToolApprovalResponse(approvalId: 'approval-old', approved: true), + ]), + new ToolResultMessage([ + new ToolResult(toolCallId: 'call-old', toolName: 'test_tool', args: [], result: 'done'), + ]), + new UserMessage('second request'), + new AssistantMessage( + content: '', + toolCalls: [ + new ToolCall(id: 'call-new', name: 'test_tool', arguments: []), + ], + additionalContent: [], + toolApprovalRequests: [ + new ToolApprovalRequest(approvalId: 'approval-new', toolCallId: 'call-new'), + ], + ), + ]; + + $request = approvalTextRequest( + messages: $messages, + tools: [$tool], + ); + + $handler = new ToolApprovalTestHandler; + $handler->resolve($request); + + $results = getResolvedToolResults($request); + expect($results)->toHaveCount(1) + ->and($results[0]->toolCallId)->toBe('call-new') + ->and($results[0]->result)->toBe('No approval response provided'); + }); + + it('resolves approval message that comes after the last assistant message', function (): void { + $tool = (new Tool) + ->as('test_tool') + ->for('Test') + ->using(fn (): string => 'result') + ->requiresApproval(); + + $request = approvalTextRequest( + messages: [ + new UserMessage('first request'), + new AssistantMessage( + content: '', + toolCalls: [ + new ToolCall(id: 'call-old', name: 'test_tool', arguments: []), + ], + additionalContent: [], + toolApprovalRequests: [ + new ToolApprovalRequest(approvalId: 'approval-old', toolCallId: 'call-old'), + ], + ), + new ToolResultMessage([], [ + new ToolApprovalResponse(approvalId: 'approval-old', approved: true), + ]), + new ToolResultMessage([ + new ToolResult(toolCallId: 'call-old', toolName: 'test_tool', args: [], result: 'done'), + ]), + new UserMessage('second request'), + new AssistantMessage( + content: '', + toolCalls: [ + new ToolCall(id: 'call-new', name: 'test_tool', arguments: []), + ], + additionalContent: [], + toolApprovalRequests: [ + new ToolApprovalRequest(approvalId: 'approval-new', toolCallId: 'call-new'), + ], + ), + new ToolResultMessage([], [ + new ToolApprovalResponse(approvalId: 'approval-new', approved: true), + ]), + ], + tools: [$tool], + ); + + $handler = new ToolApprovalTestHandler; + $handler->resolve($request); + + $results = getResolvedToolResults($request); + expect($results)->toHaveCount(1) + ->and($results[0]->toolCallId)->toBe('call-new') + ->and($results[0]->result)->toBe('result'); + }); + + it('handles denial without explicit reason using default message', function (): void { + $tool = (new Tool) + ->as('test_tool') + ->for('Test') + ->using(fn (): string => 'result') + ->requiresApproval(); + + $request = approvalTextRequest( + messages: [ + new UserMessage('test'), + new AssistantMessage( + content: '', + toolCalls: [ + new ToolCall(id: 'call-1', name: 'test_tool', arguments: []), + ], + additionalContent: [], + toolApprovalRequests: [ + new ToolApprovalRequest(approvalId: 'approval-1', toolCallId: 'call-1'), + ], + ), + new ToolResultMessage([], [ + new ToolApprovalResponse(approvalId: 'approval-1', approved: false), + ]), + ], + tools: [$tool], + ); + + $handler = new ToolApprovalTestHandler; + $handler->resolve($request); + + $results = getResolvedToolResults($request); + expect($results)->toHaveCount(1) + ->and($results[0]->result)->toBe('User denied tool execution'); + }); + + it('does not yield StreamStartEvent when stream has already started', function (): void { + $tool = (new Tool) + ->as('delete_file') + ->for('Delete a file') + ->withStringParameter('path', 'File path') + ->using(fn (string $path): string => "Deleted: {$path}") + ->requiresApproval(); + + $request = approvalTextRequest( + messages: [ + new UserMessage('delete'), + new AssistantMessage( + content: '', + toolCalls: [ + new ToolCall(id: 'call-1', name: 'delete_file', arguments: ['path' => '/tmp/a.txt']), + ], + additionalContent: [], + toolApprovalRequests: [ + new ToolApprovalRequest(approvalId: 'approval-1', toolCallId: 'call-1'), + ], + ), + new ToolResultMessage([], [ + new ToolApprovalResponse(approvalId: 'approval-1', approved: true), + ]), + ], + tools: [$tool], + ); + + $state = new StreamState; + $state->markStreamStarted(); + + $handler = new ToolApprovalTestHandler; + $events = iterator_to_array($handler->resolveStream($request, 'msg-nostart', $state)); + + expect($events)->toHaveCount(1) + ->and($events[0])->toBeInstanceOf(ToolResultEvent::class) + ->and($events[0]->toolResult->toolCallId)->toBe('call-1'); + }); + + it('does not yield StreamStartEvent when no tool results are produced', function (): void { + $tool = (new Tool) + ->as('search') + ->for('Search') + ->withStringParameter('query', 'Query') + ->using(fn (string $query): string => "Results for: {$query}"); + + $request = approvalTextRequest( + messages: [ + new UserMessage('search'), + new AssistantMessage( + content: '', + toolCalls: [ + new ToolCall(id: 'call-1', name: 'search', arguments: ['query' => 'test']), + ], + ), + new ToolResultMessage([ + new ToolResult(toolCallId: 'call-1', toolName: 'search', args: ['query' => 'test'], result: 'Results'), + ]), + ], + tools: [$tool], + ); + + $state = new StreamState; + $handler = new ToolApprovalTestHandler; + $events = iterator_to_array($handler->resolveStream($request, 'msg-none', $state)); + + expect($events)->toHaveCount(0); + expect($state->hasStreamStarted())->toBeFalse(); + }); + + it('does not yield StreamStartEvent when no StreamState is provided', function (): void { + $tool = (new Tool) + ->as('delete_file') + ->for('Delete a file') + ->withStringParameter('path', 'File path') + ->using(fn (string $path): string => "Deleted: {$path}") + ->requiresApproval(); + + $request = approvalTextRequest( + messages: [ + new UserMessage('delete'), + new AssistantMessage( + content: '', + toolCalls: [ + new ToolCall(id: 'call-1', name: 'delete_file', arguments: ['path' => '/tmp/a.txt']), + ], + additionalContent: [], + toolApprovalRequests: [ + new ToolApprovalRequest(approvalId: 'approval-1', toolCallId: 'call-1'), + ], + ), + new ToolResultMessage([], [ + new ToolApprovalResponse(approvalId: 'approval-1', approved: true), + ]), + ], + tools: [$tool], + ); + + $handler = new ToolApprovalTestHandler; + $events = iterator_to_array($handler->resolveStream($request, 'msg-nostate')); + + expect($events)->toHaveCount(1) + ->and($events[0])->toBeInstanceOf(ToolResultEvent::class) + ->and($events[0]->toolResult->toolCallId)->toBe('call-1'); + }); + + it('returns empty when message history has no AssistantMessage', function (): void { + $tool = (new Tool) + ->as('test_tool') + ->for('Test') + ->using(fn (): string => 'result') + ->requiresApproval(); + + $request = approvalTextRequest( + messages: [ + new UserMessage('hello'), + new ToolResultMessage([], [ + new ToolApprovalResponse(approvalId: 'approval-1', approved: true), + ]), + ], + tools: [$tool], + ); + + $handler = new ToolApprovalTestHandler; + $handler->resolve($request); + + expect(getResolvedToolResults($request))->toBeEmpty(); + }); + + it('returns empty when ToolResultMessage appears before AssistantMessage', function (): void { + $tool = (new Tool) + ->as('test_tool') + ->for('Test') + ->using(fn (): string => 'result') + ->requiresApproval(); + + $request = approvalTextRequest( + messages: [ + new UserMessage('hello'), + new ToolResultMessage([], [ + new ToolApprovalResponse(approvalId: 'approval-1', approved: true), + ]), + new AssistantMessage( + content: 'I will do it.', + toolCalls: [ + new ToolCall(id: 'call-1', name: 'test_tool', arguments: []), + ], + additionalContent: [], + toolApprovalRequests: [ + new ToolApprovalRequest(approvalId: 'approval-1', toolCallId: 'call-1'), + ], + ), + ], + tools: [$tool], + ); + + $handler = new ToolApprovalTestHandler; + $handler->resolve($request); + + $results = getResolvedToolResults($request); + expect($results)->toHaveCount(1) + ->and($results[0]->result)->toBe('No approval response provided'); + }); +}); + +describe('Phase 1: concurrent tools with approval', function (): void { + it('executes concurrent tools and skips approval-required tools', function (): void { + $concurrentTool = (new Tool) + ->as('fast_lookup') + ->for('Fast lookup') + ->withStringParameter('query', 'Query') + ->using(fn (string $query): string => "Found: {$query}") + ->concurrent(); + + $approvalTool = (new Tool) + ->as('delete_file') + ->for('Delete a file') + ->withStringParameter('path', 'File path') + ->using(fn (string $path): string => "Deleted: {$path}") + ->requiresApproval(); + + $anotherConcurrentTool = (new Tool) + ->as('quick_check') + ->for('Quick check') + ->withStringParameter('item', 'Item') + ->using(fn (string $item): string => "Checked: {$item}") + ->concurrent(); + + $toolCalls = [ + new ToolCall(id: 'call-1', name: 'fast_lookup', arguments: ['query' => 'test']), + new ToolCall(id: 'call-2', name: 'delete_file', arguments: ['path' => '/tmp/file.txt']), + new ToolCall(id: 'call-3', name: 'quick_check', arguments: ['item' => 'status']), + ]; + + $handler = new ToolApprovalTestHandler; + $hasPendingToolCalls = false; + $results = $handler->execute( + [$concurrentTool, $approvalTool, $anotherConcurrentTool], + $toolCalls, + $hasPendingToolCalls, + ); + + expect($results)->toHaveCount(2) + ->and($results[0]->toolName)->toBe('fast_lookup') + ->and($results[0]->result)->toBe('Found: test') + ->and($results[1]->toolName)->toBe('quick_check') + ->and($results[1]->result)->toBe('Checked: status') + ->and($hasPendingToolCalls)->toBeTrue(); + }); + + it('executes concurrent tools and skips approval-required tools in streaming', function (): void { + $concurrentTool = (new Tool) + ->as('fast_lookup') + ->for('Fast lookup') + ->withStringParameter('query', 'Query') + ->using(fn (string $query): string => "Found: {$query}") + ->concurrent(); + + $approvalTool = (new Tool) + ->as('delete_file') + ->for('Delete a file') + ->withStringParameter('path', 'File path') + ->using(fn (string $path): string => "Deleted: {$path}") + ->requiresApproval(); + + $toolCalls = [ + new ToolCall(id: 'call-1', name: 'fast_lookup', arguments: ['query' => 'test']), + new ToolCall(id: 'call-2', name: 'delete_file', arguments: ['path' => '/tmp/file.txt']), + ]; + + $handler = new ToolApprovalTestHandler; + $toolResults = []; + $hasPendingToolCalls = false; + $events = []; + + foreach ($handler->stream([$concurrentTool, $approvalTool], $toolCalls, 'msg-123', $toolResults, $hasPendingToolCalls) as $event) { + $events[] = $event; + } + + $toolResultEvents = array_filter($events, fn ($e): bool => $e instanceof ToolResultEvent); + $approvalEvents = array_filter($events, fn ($e): bool => $e instanceof ToolApprovalRequestEvent); + + expect($toolResults)->toHaveCount(1) + ->and($toolResults[0]->toolName)->toBe('fast_lookup') + ->and(array_values($toolResultEvents))->toHaveCount(1) + ->and(array_values($approvalEvents))->toHaveCount(1) + ->and($hasPendingToolCalls)->toBeTrue(); + }); +}); + +describe('ToolApprovalRequest value object', function (): void { + it('serializes to array correctly', function (): void { + $request = new ToolApprovalRequest( + approvalId: 'approval-1', + toolCallId: 'call-1', + ); + + expect($request->toArray())->toBe([ + 'approval_id' => 'approval-1', + 'tool_call_id' => 'call-1', + ]); + }); +}); + +describe('ToolApprovalResponse value object', function (): void { + it('serializes to array correctly', function (): void { + $response = new ToolApprovalResponse( + approvalId: 'call-123', + approved: true, + reason: 'User confirmed', + ); + + expect($response->toArray())->toBe([ + 'approval_id' => 'call-123', + 'approved' => true, + 'reason' => 'User confirmed', + ]); + }); +}); + +describe('ToolResultMessage with toolApprovalResponses', function (): void { + it('finds approval responses by approval ID', function (): void { + $message = new ToolResultMessage([], [ + new ToolApprovalResponse(approvalId: 'call-1', approved: true), + new ToolApprovalResponse(approvalId: 'call-2', approved: false), + ]); + + $found = $message->findByApprovalId('call-1'); + expect($found)->not->toBeNull() + ->and($found->approved)->toBeTrue(); + + $found2 = $message->findByApprovalId('call-2'); + expect($found2)->not->toBeNull() + ->and($found2->approved)->toBeFalse(); + + expect($message->findByApprovalId('nonexistent'))->toBeNull(); + }); + + it('serializes tool approval responses in toArray', function (): void { + $message = new ToolResultMessage([], [ + new ToolApprovalResponse(approvalId: 'call-1', approved: true), + ]); + + $array = $message->toArray(); + expect($array['type'])->toBe('tool_result') + ->and($array['tool_approval_responses'])->toHaveCount(1) + ->and($array['tool_approval_responses'][0]['approval_id'])->toBe('call-1') + ->and($array['tool_approval_responses'][0]['approved'])->toBeTrue(); + }); + + it('serializes both tool results and approval responses in toArray', function (): void { + $message = new ToolResultMessage( + toolResults: [ + new ToolResult(toolCallId: 'call-1', toolName: 'delete', args: [], result: 'done'), + ], + toolApprovalResponses: [ + new ToolApprovalResponse(approvalId: 'call-1', approved: true), + ], + ); + + $array = $message->toArray(); + expect($array['tool_results'])->toHaveCount(1) + ->and($array['tool_approval_responses'])->toHaveCount(1) + ->and($array['tool_approval_responses'][0]['approval_id'])->toBe('call-1') + ->and($array['tool_approval_responses'][0]['approved'])->toBeTrue(); + }); +}); + +describe('AssistantMessage with toolApprovalRequests', function (): void { + it('serializes tool approval requests in toArray', function (): void { + $message = new AssistantMessage( + content: '', + toolCalls: [ + new ToolCall(id: 'call-1', name: 'delete', arguments: []), + ], + additionalContent: [], + toolApprovalRequests: [ + new ToolApprovalRequest(approvalId: 'approval-1', toolCallId: 'call-1'), + ], + ); + + $array = $message->toArray(); + expect($array['tool_approval_requests'])->toHaveCount(1) + ->and($array['tool_approval_requests'][0])->toBe([ + 'approval_id' => 'approval-1', + 'tool_call_id' => 'call-1', + ]); + }); +}); + +describe('ToolApprovalRequestEvent', function (): void { + it('has correct event type', function (): void { + $event = new ToolApprovalRequestEvent( + id: 'evt-1', + timestamp: 1234567890, + toolCall: new ToolCall(id: 'call-1', name: 'test_tool', arguments: ['key' => 'value']), + messageId: 'msg-1', + approvalId: 'approval-1', + ); + + expect($event->type())->toBe(StreamEventType::ToolApprovalRequest); + + $array = $event->toArray(); + expect($array['approval_id'])->toBe('approval-1') + ->and($array['tool_name'])->toBe('test_tool') + ->and($array['tool_id'])->toBe('call-1') + ->and($array['arguments'])->toBe(['key' => 'value']) + ->and($array['message_id'])->toBe('msg-1'); + }); +}); diff --git a/tests/ToolTest.php b/tests/ToolTest.php index f7865ec8b..9ca08564f 100644 --- a/tests/ToolTest.php +++ b/tests/ToolTest.php @@ -9,6 +9,7 @@ use Prism\Prism\Schema\BooleanSchema; use Prism\Prism\Schema\StringSchema; use Prism\Prism\Tool; +use Prism\Prism\ValueObjects\ToolError; it('can return tool details', function (): void { $searchTool = (new Tool) @@ -85,6 +86,47 @@ public function __invoke(string $query): string ->toBe('The event is at 3pm eastern'); }); +it('handles invokable subclass with using($this) without circular reference', function (): void { + $tool = new class extends Tool + { + public function __construct() + { + parent::__construct(); + $this->as('test_tool') + ->for('A test tool') + ->withParameter(new StringSchema('query', 'the query')) + ->using($this); + } + + public function __invoke(string $query): string + { + return "Result: $query"; + } + }; + + expect($tool->handle(query: 'hello'))->toBe('Result: hello'); +}); + +it('invokable subclass works without calling using() at all', function (): void { + $tool = new class extends Tool + { + public function __construct() + { + parent::__construct(); + $this->as('auto_tool') + ->for('Auto-detected invokable') + ->withParameter(new StringSchema('input', 'the input')); + } + + public function __invoke(string $input): string + { + return "Auto: $input"; + } + }; + + expect($tool->handle(input: 'test'))->toBe('Auto: test'); +}); + it('can have fluent parameters', function (): void { $tool = (new Tool) ->as('test tool') @@ -181,3 +223,60 @@ public function __invoke(string $query): string $searchTool->handle('What time is the event?'); }); + +enum ToolTestPriority: string +{ + case Low = 'low'; + case High = 'high'; +} + +enum ToolTestLevel: int +{ + case One = 1; + case Two = 2; +} + +it('coerces string arguments into declared scalar types', function (): void { + $tool = (new Tool) + ->as('transactions') + ->for('fetches transactions') + ->withNumberParameter('limit', 'max results') + ->withBooleanParameter('include_pending', 'include pending') + ->using(function (int $limit, bool $include_pending, float $ratio = 1.0): string { + expect($limit)->toBe(5) + ->and($include_pending)->toBeTrue() + ->and($ratio)->toBe(0.5); + + return 'ok'; + }); + + expect($tool->handle(limit: '5', include_pending: 'true', ratio: '0.5'))->toBe('ok'); +}); + +it('converts string arguments to BackedEnum handler parameters', function (): void { + $tool = (new Tool) + ->as('prioritize') + ->for('sets priority') + ->withStringParameter('priority', 'the priority') + ->using(function (ToolTestPriority $priority, ToolTestLevel $level = ToolTestLevel::One): string { + expect($priority)->toBe(ToolTestPriority::High) + ->and($level)->toBe(ToolTestLevel::Two); + + return 'ok'; + }); + + expect($tool->handle(priority: 'high', level: '2'))->toBe('ok'); +}); + +it('leaves unknown arguments to the existing validation handling', function (): void { + $tool = (new Tool) + ->as('search') + ->for('searches') + ->withStringParameter('query', 'the query') + ->using(fn (string $query): string => $query); + + $result = $tool->handle(query: 'hello', made_up_argument: 'noise'); + + expect($result)->toBeInstanceOf(ToolError::class) + ->and($result->message)->toContain('Parameter validation error'); +}); diff --git a/tests/Tools/LaravelMcpToolTest.php b/tests/Tools/LaravelMcpToolTest.php index d06faa8a9..86cb928c3 100644 --- a/tests/Tools/LaravelMcpToolTest.php +++ b/tests/Tools/LaravelMcpToolTest.php @@ -64,9 +64,11 @@ public function schema(JsonSchema $schema): array $result = $tool->handle(); - expect($result)->toContain('"status":"success"') - ->and($result)->toContain('"id":123') - ->and($result)->toContain('"name":"Test"'); + $decoded = json_decode($result, true); + + expect($decoded['status'])->toBe('success') + ->and($decoded['data']['id'])->toBe(123) + ->and($decoded['data']['name'])->toBe('Test'); }); it('can handle a tool that returns a ResponseFactory from Response::make()', function (): void { diff --git a/tests/Unit/Batch/BatchTypesTest.php b/tests/Unit/Batch/BatchTypesTest.php new file mode 100644 index 000000000..23f09d5ff --- /dev/null +++ b/tests/Unit/Batch/BatchTypesTest.php @@ -0,0 +1,191 @@ +value)->toBe('validating'); + expect(BatchStatus::InProgress->value)->toBe('in_progress'); + expect(BatchStatus::Finalizing->value)->toBe('finalizing'); + expect(BatchStatus::Completed->value)->toBe('completed'); + expect(BatchStatus::Failed->value)->toBe('failed'); + expect(BatchStatus::Cancelling->value)->toBe('cancelling'); + expect(BatchStatus::Cancelled->value)->toBe('cancelled'); + expect(BatchStatus::Expired->value)->toBe('expired'); + expect(BatchStatus::cases())->toHaveCount(8); +}); + +it('can create BatchResultStatus from all values', function (): void { + expect(BatchResultStatus::Succeeded->value)->toBe('succeeded'); + expect(BatchResultStatus::Errored->value)->toBe('errored'); + expect(BatchResultStatus::Canceled->value)->toBe('canceled'); + expect(BatchResultStatus::Expired->value)->toBe('expired'); + expect(BatchResultStatus::cases())->toHaveCount(4); +}); + +it('can create BatchJob with all fields', function (): void { + $job = new BatchJob( + id: 'batch_123', + status: BatchStatus::Completed, + requestCounts: new BatchJobRequestCounts( + processing: 0, + succeeded: 10, + failed: 2, + canceled: 1, + expired: 0, + total: 13, + ), + createdAt: '2024-09-24T18:00:00Z', + expiresAt: '2024-09-25T18:00:00Z', + endedAt: '2024-09-24T19:00:00Z', + resultsUrl: 'https://example.com/results', + outputFileId: 'file-out-123', + errorFileId: 'file-err-456', + ); + + expect($job->id)->toBe('batch_123'); + expect($job->status)->toBe(BatchStatus::Completed); + expect($job->requestCounts->processing)->toBe(0); + expect($job->requestCounts->succeeded)->toBe(10); + expect($job->requestCounts->failed)->toBe(2); + expect($job->requestCounts->canceled)->toBe(1); + expect($job->requestCounts->expired)->toBe(0); + expect($job->requestCounts->total)->toBe(13); + expect($job->createdAt)->toBe('2024-09-24T18:00:00Z'); + expect($job->expiresAt)->toBe('2024-09-25T18:00:00Z'); + expect($job->endedAt)->toBe('2024-09-24T19:00:00Z'); + expect($job->resultsUrl)->toBe('https://example.com/results'); + expect($job->outputFileId)->toBe('file-out-123'); + expect($job->errorFileId)->toBe('file-err-456'); +}); + +it('can create BatchJob with minimal fields', function (): void { + $job = new BatchJob( + id: 'batch_456', + status: BatchStatus::InProgress, + requestCounts: new BatchJobRequestCounts, + ); + + expect($job->id)->toBe('batch_456'); + expect($job->status)->toBe(BatchStatus::InProgress); + expect($job->requestCounts->processing)->toBe(0); + expect($job->requestCounts->succeeded)->toBe(0); + expect($job->requestCounts->failed)->toBe(0); + expect($job->requestCounts->canceled)->toBe(0); + expect($job->requestCounts->expired)->toBe(0); + expect($job->requestCounts->total)->toBe(0); + expect($job->createdAt)->toBeNull(); + expect($job->expiresAt)->toBeNull(); + expect($job->endedAt)->toBeNull(); + expect($job->resultsUrl)->toBeNull(); + expect($job->outputFileId)->toBeNull(); + expect($job->errorFileId)->toBeNull(); +}); + +it('can create BatchListResult', function (): void { + $jobs = [ + new BatchJob('b1', BatchStatus::Completed, new BatchJobRequestCounts), + new BatchJob('b2', BatchStatus::InProgress, new BatchJobRequestCounts), + ]; + + $result = new BatchListResult( + data: $jobs, + hasMore: true, + lastId: 'b2', + ); + + expect($result->data)->toHaveCount(2); + expect($result->data[0]->id)->toBe('b1'); + expect($result->data[1]->id)->toBe('b2'); + expect($result->hasMore)->toBeTrue(); + expect($result->lastId)->toBe('b2'); +}); + +it('can create BatchListResult with defaults', function (): void { + $result = new BatchListResult(data: []); + + expect($result->data)->toBeEmpty(); + expect($result->hasMore)->toBeFalse(); + expect($result->lastId)->toBeNull(); +}); + +it('can create BatchJobRequestCounts with defaults', function (): void { + $counts = new BatchJobRequestCounts; + + expect($counts->processing)->toBe(0); + expect($counts->succeeded)->toBe(0); + expect($counts->failed)->toBe(0); + expect($counts->canceled)->toBe(0); + expect($counts->expired)->toBe(0); + expect($counts->total)->toBe(0); +}); + +it('can create BatchResultItem for succeeded result', function (): void { + $item = new BatchResultItem( + customId: 'req-1', + status: BatchResultStatus::Succeeded, + text: 'Hello!', + usage: new Usage(promptTokens: 10, completionTokens: 5), + messageId: 'msg_123', + model: 'claude-3-5-sonnet-20240620', + ); + + expect($item->customId)->toBe('req-1'); + expect($item->status)->toBe(BatchResultStatus::Succeeded); + expect($item->text)->toBe('Hello!'); + expect($item->usage->promptTokens)->toBe(10); + expect($item->usage->completionTokens)->toBe(5); + expect($item->messageId)->toBe('msg_123'); + expect($item->model)->toBe('claude-3-5-sonnet-20240620'); + expect($item->errorType)->toBeNull(); + expect($item->errorMessage)->toBeNull(); +}); + +it('can create BatchResultItem for errored result', function (): void { + $item = new BatchResultItem( + customId: 'req-2', + status: BatchResultStatus::Errored, + errorType: 'invalid_request', + errorMessage: 'Model not found.', + ); + + expect($item->customId)->toBe('req-2'); + expect($item->status)->toBe(BatchResultStatus::Errored); + expect($item->text)->toBeNull(); + expect($item->usage)->toBeNull(); + expect($item->messageId)->toBeNull(); + expect($item->model)->toBeNull(); + expect($item->errorType)->toBe('invalid_request'); + expect($item->errorMessage)->toBe('Model not found.'); +}); + +it('can create BatchResultItem for canceled result', function (): void { + $item = new BatchResultItem( + customId: 'req-3', + status: BatchResultStatus::Canceled, + ); + + expect($item->customId)->toBe('req-3'); + expect($item->status)->toBe(BatchResultStatus::Canceled); + expect($item->text)->toBeNull(); + expect($item->errorType)->toBeNull(); +}); + +it('can create BatchResultItem for expired result', function (): void { + $item = new BatchResultItem( + customId: 'req-4', + status: BatchResultStatus::Expired, + ); + + expect($item->customId)->toBe('req-4'); + expect($item->status)->toBe(BatchResultStatus::Expired); +}); diff --git a/tests/Unit/Streaming/Events/StreamEndEventTest.php b/tests/Unit/Streaming/Events/StreamEndEventTest.php index 56782e9b8..66c88a37c 100644 --- a/tests/Unit/Streaming/Events/StreamEndEventTest.php +++ b/tests/Unit/Streaming/Events/StreamEndEventTest.php @@ -95,6 +95,7 @@ 'cache_write_input_tokens' => 25, 'cache_read_input_tokens' => 10, 'thought_tokens' => 5, + 'cost' => null, ], 'citations' => null, ]); @@ -125,6 +126,7 @@ 'cache_write_input_tokens' => null, 'cache_read_input_tokens' => null, 'thought_tokens' => null, + 'cost' => null, ], 'citations' => null, ]); diff --git a/tests/Unit/Streaming/StreamStateTest.php b/tests/Unit/Streaming/StreamStateTest.php index 888ca892f..ac2df7ce4 100644 --- a/tests/Unit/Streaming/StreamStateTest.php +++ b/tests/Unit/Streaming/StreamStateTest.php @@ -597,7 +597,8 @@ completionTokens: 50, cacheWriteInputTokens: 25, cacheReadInputTokens: 10, - thoughtTokens: 5 + thoughtTokens: 5, + cost: 0.0042, ); $state->withUsage($usage); @@ -607,7 +608,48 @@ ->and($state->usage()->completionTokens)->toBe(50) ->and($state->usage()->cacheWriteInputTokens)->toBe(25) ->and($state->usage()->cacheReadInputTokens)->toBe(10) - ->and($state->usage()->thoughtTokens)->toBe(5); + ->and($state->usage()->thoughtTokens)->toBe(5) + ->and($state->usage()->cost)->toBe(0.0042); +}); + +it('addUsage accumulates cost from multiple usages', function (): void { + $state = new StreamState; + + $state->addUsage(new Usage( + promptTokens: 50, + completionTokens: 25, + cost: 0.002, + )); + + $state->addUsage(new Usage( + promptTokens: 30, + completionTokens: 15, + cost: 0.001, + )); + + expect($state->usage()->promptTokens)->toBe(80) + ->and($state->usage()->completionTokens)->toBe(40) + ->and($state->usage()->cost)->toBe(0.003); +}); + +it('addUsage keeps cost null when no usage has cost', function (): void { + $state = new StreamState; + + $state->addUsage(new Usage(promptTokens: 50, completionTokens: 25)); + $state->addUsage(new Usage(promptTokens: 30, completionTokens: 15)); + + expect($state->usage()->promptTokens)->toBe(80) + ->and($state->usage()->completionTokens)->toBe(40) + ->and($state->usage()->cost)->toBeNull(); +}); + +it('addUsage treats null cost as zero when accumulating with non-null cost', function (): void { + $state = new StreamState; + + $state->addUsage(new Usage(promptTokens: 50, completionTokens: 25, cost: 0.002)); + $state->addUsage(new Usage(promptTokens: 30, completionTokens: 15)); + + expect($state->usage()->cost)->toBe(0.002); }); it('handles MessagePartWithCitations with all fields', function (): void { diff --git a/tests/ValueObjects/ToolCallTest.php b/tests/ValueObjects/ToolCallTest.php index 6768f37f1..6df994585 100644 --- a/tests/ValueObjects/ToolCallTest.php +++ b/tests/ValueObjects/ToolCallTest.php @@ -2,6 +2,7 @@ declare(strict_types=1); +use Prism\Prism\Exceptions\PrismException; use Prism\Prism\ValueObjects\ToolCall; it('handles empty string arguments correctly', function (): void { @@ -67,7 +68,51 @@ expect($toolCall->arguments())->toBe($arguments); }); -it('throws exception for malformed JSON string arguments', function (): void { +it('escapes raw control characters inside string values instead of dropping them', function (): void { + $toolCall = new ToolCall( + id: 'test-id', + name: 'test-tool', + arguments: "{\"code\": \"line one\nline two\tindented\"}" + ); + + expect($toolCall->arguments())->toBe([ + 'code' => "line one\nline two\tindented", + ]); +}); + +it('drops invalid control characters outside string values', function (): void { + $toolCall = new ToolCall( + id: 'test-id', + name: 'test-tool', + arguments: "{\"param\":\x01 \"value\"}" + ); + + expect($toolCall->arguments())->toBe(['param' => 'value']); +}); + +it('does not mangle escape sequences already present in valid JSON', function (): void { + $toolCall = new ToolCall( + id: 'test-id', + name: 'test-tool', + arguments: '{"text": "a\\nb \\"quoted\\" c\\\\d"}' + ); + + expect($toolCall->arguments())->toBe([ + 'text' => "a\nb \"quoted\" c\\d", + ]); +}); + +it('handles JSON null string arguments correctly', function (): void { + $toolCall = new ToolCall( + id: 'test-id', + name: 'test-tool', + arguments: 'null' + ); + + expect($toolCall->arguments())->toBe([]); +}); + +it('throws a handled PrismException for malformed JSON string arguments', function (): void { $toolCall = new ToolCall( id: 'test-id', name: 'test-tool', @@ -75,5 +120,9 @@ ); expect($toolCall->arguments)->toBe('{"invalid json"'); - expect($toolCall->arguments(...))->toThrow(JsonException::class); + + // Malformed arguments surface as a handled PrismException (wrapping the + // underlying JsonException) so the tool-execution loop can turn it into a + // tool result the model sees, rather than crashing with a raw exception. + expect($toolCall->arguments(...))->toThrow(PrismException::class, 'not valid JSON'); }); diff --git a/tests/ValueObjects/UsageTest.php b/tests/ValueObjects/UsageTest.php new file mode 100644 index 000000000..63a04b8cd --- /dev/null +++ b/tests/ValueObjects/UsageTest.php @@ -0,0 +1,93 @@ +promptTokens)->toBe(100) + ->and($usage->completionTokens)->toBe(50) + ->and($usage->cacheWriteInputTokens)->toBeNull() + ->and($usage->cacheReadInputTokens)->toBeNull() + ->and($usage->thoughtTokens)->toBeNull() + ->and($usage->cost)->toBeNull(); +}); + +it('constructs with all parameters including cost', function (): void { + $usage = new Usage( + promptTokens: 100, + completionTokens: 50, + cacheWriteInputTokens: 25, + cacheReadInputTokens: 10, + thoughtTokens: 5, + cost: 0.00325, + ); + + expect($usage->promptTokens)->toBe(100) + ->and($usage->completionTokens)->toBe(50) + ->and($usage->cacheWriteInputTokens)->toBe(25) + ->and($usage->cacheReadInputTokens)->toBe(10) + ->and($usage->thoughtTokens)->toBe(5) + ->and($usage->cost)->toBe(0.00325); +}); + +it('converts to array without cost', function (): void { + $usage = new Usage( + promptTokens: 100, + completionTokens: 50, + ); + + expect($usage->toArray())->toBe([ + 'prompt_tokens' => 100, + 'completion_tokens' => 50, + 'cache_write_input_tokens' => null, + 'cache_read_input_tokens' => null, + 'thought_tokens' => null, + 'cost' => null, + ]); +}); + +it('converts to array with cost', function (): void { + $usage = new Usage( + promptTokens: 100, + completionTokens: 50, + cacheWriteInputTokens: 25, + cacheReadInputTokens: 10, + thoughtTokens: 5, + cost: 0.0042, + ); + + expect($usage->toArray())->toBe([ + 'prompt_tokens' => 100, + 'completion_tokens' => 50, + 'cache_write_input_tokens' => 25, + 'cache_read_input_tokens' => 10, + 'thought_tokens' => 5, + 'cost' => 0.0042, + ]); +}); + +it('allows zero cost', function (): void { + $usage = new Usage( + promptTokens: 0, + completionTokens: 0, + cost: 0.0, + ); + + expect($usage->cost)->toBe(0.0); +}); + +it('allows very small cost values', function (): void { + $usage = new Usage( + promptTokens: 10, + completionTokens: 5, + cost: 0.000001, + ); + + expect($usage->cost)->toBe(0.000001); +});