Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
84 changes: 58 additions & 26 deletions docs/providers/openrouter.md
Original file line number Diff line number Diff line change
Expand Up @@ -228,42 +228,21 @@ 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()
->using(Provider::OpenRouter, '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)
'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:
Expand All @@ -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.
Expand Down
17 changes: 14 additions & 3 deletions src/Exceptions/PrismException.php
Original file line number Diff line number Diff line change
Expand Up @@ -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`');
Expand Down Expand Up @@ -55,9 +59,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
Expand Down
80 changes: 80 additions & 0 deletions src/Providers/OpenRouter/Concerns/ExtractsErrorDetails.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
<?php

declare(strict_types=1);

namespace Prism\Prism\Providers\OpenRouter\Concerns;

use JsonException;

trait ExtractsErrorDetails
{
/**
* Extracts the most informative error message and provider name out of an
* OpenRouter error payload. OpenRouter wraps upstream provider errors in
* `error.metadata.raw`, and the shape of that raw payload varies by
* provider (Azure/OpenAI nest under `error.message`, Bedrock and others
* put `message` at the top level, and some return a plain string).
*
* @param array<string, mixed> $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) : '';
}
}
32 changes: 32 additions & 0 deletions src/Providers/OpenRouter/Concerns/ExtractsReasoning.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
<?php

declare(strict_types=1);

namespace Prism\Prism\Providers\OpenRouter\Concerns;

use Illuminate\Support\Arr;

trait ExtractsReasoning
{
/**
* @param array<string, mixed> $data
* @return array<string, mixed>
*/
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<string, mixed> $data
*/
protected function extractThoughtTokens(array $data): ?int
{
$tokens = data_get($data, 'usage.completion_tokens_details.reasoning_tokens');

return $tokens !== null ? (int) $tokens : null;
}
}
23 changes: 15 additions & 8 deletions src/Providers/OpenRouter/Concerns/ValidatesResponses.php
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@

trait ValidatesResponses
{
use ExtractsErrorDetails;

/**
* @param array<string, mixed> $data
*/
Expand All @@ -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
));
}
Expand Down
Loading
Loading