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/src/Exceptions/PrismException.php b/src/Exceptions/PrismException.php index 9c9f852d8..bfc019d13 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`'); @@ -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 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..a2db00dbd 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; } /** @@ -158,7 +158,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 +182,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 +260,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 +335,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 +346,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; } /** @@ -397,7 +432,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(); diff --git a/src/Providers/OpenRouter/Handlers/Structured.php b/src/Providers/OpenRouter/Handlers/Structured.php index 43b8376fd..3f4e0b220 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; @@ -106,7 +108,7 @@ protected function handleToolCalls(array $data, Request $request): StructuredRes $request = $request->addMessage(new AssistantMessage( data_get($data, 'choices.0.message.content') ?? '', $toolCalls, - [] + $this->extractReasoning($data), )); $request = $request->addMessage(new ToolResultMessage($toolResults)); $request->resetToolChoice(); @@ -154,8 +156,9 @@ 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: (int) data_get($data, 'usage.prompt_tokens', 0), + completionTokens: (int) data_get($data, 'usage.completion_tokens', 0), + thoughtTokens: $this->extractThoughtTokens($data), ), meta: new Meta( id: data_get($data, 'id', ''), @@ -163,7 +166,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..4773e5016 100644 --- a/src/Providers/OpenRouter/Handlers/Text.php +++ b/src/Providers/OpenRouter/Handlers/Text.php @@ -10,6 +10,7 @@ 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 +29,7 @@ class Text { use BuildsRequestOptions; use CallsTools; + use ExtractsReasoning; use MapsFinishReason; use ValidatesResponses; @@ -65,7 +67,7 @@ protected function handleToolCalls(array $data, Request $request): TextResponse $request = $request->addMessage(new AssistantMessage( data_get($data, 'choices.0.message.content') ?? '', $toolCalls, - [] + $this->extractReasoning($data), )); $request = $request->addMessage(new ToolResultMessage($toolResults)); $request->resetToolChoice(); @@ -123,8 +125,9 @@ 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: (int) data_get($data, 'usage.prompt_tokens', 0), + completionTokens: (int) data_get($data, 'usage.completion_tokens', 0), + thoughtTokens: $this->extractThoughtTokens($data), ), meta: new Meta( id: data_get($data, 'id', ''), @@ -132,7 +135,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..5be779b36 100644 --- a/src/Providers/OpenRouter/Maps/MessageMap.php +++ b/src/Providers/OpenRouter/Maps/MessageMap.php @@ -163,6 +163,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([ @@ -175,12 +180,16 @@ protected function mapAssistantMessage(AssistantMessage $message): void ], ], '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/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/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-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/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..5e11c47e0 100644 --- a/tests/Providers/OpenRouter/StreamTest.php +++ b/tests/Providers/OpenRouter/StreamTest.php @@ -292,6 +292,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..ca16e1ab9 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');