From fa31fbf86e07579df564bad6100a22ea7c1c9374 Mon Sep 17 00:00:00 2001 From: Graham Campbell Date: Tue, 31 Mar 2026 00:12:44 +0100 Subject: [PATCH 1/2] Anthropic adaptive thinking --- docs/providers/anthropic.md | 98 ++++++--- .../Anthropic/Concerns/ExtractsThinking.php | 3 +- .../Anthropic/Handlers/Structured.php | 33 ++- .../NativeOutputFormatStructuredStrategy.php | 4 +- .../ToolStructuredStrategy.php | 3 +- src/Providers/Anthropic/Handlers/Text.php | 33 ++- .../AnthropicStructuredRequestTest.php | 106 ++++++++- .../Anthropic/AnthropicTextRequestTest.php | 98 ++++++++- .../Providers/Anthropic/AnthropicTextTest.php | 188 +++++++++++----- tests/Providers/Anthropic/StreamTest.php | 206 +++++++++++++----- tests/Providers/Anthropic/StructuredTest.php | 77 ++++++- .../Anthropic/StructuredWithToolsTest.php | 28 +++ 12 files changed, 710 insertions(+), 167 deletions(-) diff --git a/docs/providers/anthropic.md b/docs/providers/anthropic.md index 8592d760e..2612d86e0 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; 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/Handlers/Structured.php b/src/Providers/Anthropic/Handlers/Structured.php index 244f1747b..20c626605 100644 --- a/src/Providers/Anthropic/Handlers/Structured.php +++ b/src/Providers/Anthropic/Handlers/Structured.php @@ -93,14 +93,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 +101,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 +139,27 @@ protected static function buildTools(StructuredRequest $request): array return array_merge($providerTools, $tools); } + /** + * @return array|null + */ + private static function resolveThinking(PrismRequest $request): ?array + { + 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 */ 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..59a13b3c1 100644 --- a/src/Providers/Anthropic/Handlers/Text.php +++ b/src/Providers/Anthropic/Handlers/Text.php @@ -75,14 +75,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,6 +83,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, ]); } @@ -198,6 +194,27 @@ protected static function buildTools(TextRequest $request): array return array_merge($providerTools, $tools); } + /** + * @return array|null + */ + private static function resolveThinking(PrismRequest $request): ?array + { + 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/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..55e834db7 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,7 @@ }); }); -it('sends correct thinking mode with default budget tokens', function (): void { +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 +329,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; }); diff --git a/tests/Providers/Anthropic/AnthropicTextTest.php b/tests/Providers/Anthropic/AnthropicTextTest.php index 55ec9f1fb..ca75e8511 100644 --- a/tests/Providers/Anthropic/AnthropicTextTest.php +++ b/tests/Providers/Anthropic/AnthropicTextTest.php @@ -506,79 +506,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-sonnet-4-6') + ->withPrompt('What is the meaning of life, the universe and everything in popular fiction?') + ->withProviderOptions(['thinking' => ['type' => 'adaptive']]) + ->asText(); - $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(); + 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(); - $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->steps->first()) + ->additionalContent->thinking->toContain('Tigers') + ->additionalContent->thinking_signature->not->toBeEmpty(); - 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->last()->messages[1]) + ->additionalContent->thinking->toContain('Tigers') + ->additionalContent->thinking_signature->not->toBeEmpty(); + }); - expect($response->messages->last()) - ->additionalContent->thinking->toBe($expected_thinking) - ->additionalContent->thinking_signature->toBe($expected_signature); - }); + it('sends adaptive thinking payload', function (): void { + $response = Prism::text() + ->using('anthropic', 'claude-sonnet-4-6') + ->withPrompt('Test') + ->withProviderOptions(['thinking' => ['type' => 'adaptive']]); - 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()); - $payload = Text::buildHttpRequestPayload($response->toRequest()); + expect(data_get($payload, 'thinking'))->toBe(['type' => 'adaptive']); + }); - expect(data_get($payload, 'thinking.budget_tokens'))->toBe(2048); + it('sends effort via output_config', function (): void { + $response = Prism::text() + ->using('anthropic', 'claude-sonnet-4-6') + ->withPrompt('Test') + ->withProviderOptions(['effort' => 'medium']); + + $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); + }); }); }); diff --git a/tests/Providers/Anthropic/StreamTest.php b/tests/Providers/Anthropic/StreamTest.php index 6927430ac..65016a98b 100644 --- a/tests/Providers/Anthropic/StreamTest.php +++ b/tests/Providers/Anthropic/StreamTest.php @@ -529,82 +529,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); + + $thinkingDeltas = $events->where( + fn (StreamEvent $event): bool => $event->type() === StreamEventType::ThinkingDelta + ); - $events = collect($response); + $thinkingDeltas + ->each(function (StreamEvent $event): void { + expect($event)->toBeInstanceOf(ThinkingEvent::class); + }); - expect($events->where(fn ($event): bool => $event->type() === StreamEventType::ThinkingStart)->sole()) - ->toBeInstanceOf(ThinkingStartEvent::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('sends adaptive thinking payload 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']]) + ->asStream(); - $thinkingDeltas = $events->where( - fn (StreamEvent $event): bool => $event->type() === StreamEventType::ThinkingDelta - ); + collect($response); - $thinkingDeltas - ->each(function (StreamEvent $event): void { - expect($event)->toBeInstanceOf(ThinkingEvent::class); + 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']); }); + }); - expect($thinkingDeltas->count())->toBeGreaterThan(10); + it('sends effort in stream request', function (): void { + FixtureResponse::fakeStreamResponses('v1/messages', 'anthropic/stream-with-extended-thinking'); - expect($thinkingDeltas->first()->delta)->not->toBeEmpty(); + $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($events->where(fn ($event): bool => $event->type() === StreamEventType::ThinkingComplete)->sole()) - ->toBeInstanceOf(ThinkingCompleteEvent::class); - }); + collect($response); - it('can process streams with thinking enabled with custom budget', function (): void { - FixtureResponse::fakeStreamResponses('v1/messages', 'anthropic/stream-with-extended-thinking'); + Http::assertSent(function (Request $request): bool { + $body = json_decode($request->body(), true); - $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(); + return isset($body['output_config']['effort']) + && $body['output_config']['effort'] === 'medium'; + }); + }); - collect($response); + it('includes thinking_signature in StreamEndEvent additionalContent', function (): void { + FixtureResponse::fakeStreamResponses('v1/messages', 'anthropic/stream-with-extended-thinking'); - // Verify custom budget was sent - Http::assertSent(function (Request $request) use ($customBudget): bool { - $body = json_decode($request->body(), true); + $response = Prism::text() + ->using(Provider::Anthropic, 'claude-sonnet-4-6') + ->withPrompt('What is the meaning of life?') + ->withProviderOptions(['thinking' => ['type' => 'adaptive']]) + ->asStream(); - return isset($body['thinking']) - && $body['thinking']['type'] === 'enabled' - && $body['thinking']['budget_tokens'] === $customBudget; + $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(); }); }); - 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); + expect($events->where(fn ($event): bool => $event->type() === StreamEventType::ThinkingStart)->sole()) + ->toBeInstanceOf(ThinkingStartEvent::class); - $streamEndEvent = $events->last(); + $thinkingDeltas = $events->where( + fn (StreamEvent $event): bool => $event->type() === StreamEventType::ThinkingDelta + ); - 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 + ->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(); + }); }); }); 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'); From 120c4736d59ebcea50c4f1f367844d1d78002b1b Mon Sep 17 00:00:00 2001 From: Graham Campbell Date: Tue, 31 Mar 2026 00:17:48 +0100 Subject: [PATCH 2/2] Fix arch tests --- src/Providers/Anthropic/Handlers/Structured.php | 2 +- src/Providers/Anthropic/Handlers/Text.php | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Providers/Anthropic/Handlers/Structured.php b/src/Providers/Anthropic/Handlers/Structured.php index 20c626605..2ec675e46 100644 --- a/src/Providers/Anthropic/Handlers/Structured.php +++ b/src/Providers/Anthropic/Handlers/Structured.php @@ -142,7 +142,7 @@ protected static function buildTools(StructuredRequest $request): array /** * @return array|null */ - private static function resolveThinking(PrismRequest $request): ?array + protected static function resolveThinking(PrismRequest $request): ?array { if ($request->providerOptions('thinking.type') === 'adaptive') { return ['type' => 'adaptive']; diff --git a/src/Providers/Anthropic/Handlers/Text.php b/src/Providers/Anthropic/Handlers/Text.php index 59a13b3c1..6279bccb7 100644 --- a/src/Providers/Anthropic/Handlers/Text.php +++ b/src/Providers/Anthropic/Handlers/Text.php @@ -197,7 +197,7 @@ protected static function buildTools(TextRequest $request): array /** * @return array|null */ - private static function resolveThinking(PrismRequest $request): ?array + protected static function resolveThinking(PrismRequest $request): ?array { if ($request->providerOptions('thinking.type') === 'adaptive') { return ['type' => 'adaptive'];