Skip to content
Merged
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
9 changes: 9 additions & 0 deletions Classes/Domain/Model/CompletionResponse.php
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ public function __construct(
public string $provider = '',
public ?array $toolCalls = null,
public ?array $metadata = null,
public ?string $thinking = null,
) {}

/**
Expand Down Expand Up @@ -60,6 +61,14 @@ public function hasToolCalls(): bool
return $this->toolCalls !== null && $this->toolCalls !== [];
}

/**
* Check if the response contains thinking/reasoning content.
*/
public function hasThinking(): bool
{
return $this->thinking !== null && trim($this->thinking) !== '';
}

/**
* Get the text content (alias for content property).
*/
Expand Down
21 changes: 21 additions & 0 deletions Classes/Provider/AbstractProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -316,16 +316,37 @@ protected function createCompletionResponse(
string $model,
UsageStatistics $usage,
?string $finishReason = null,
?string $thinking = null,
): CompletionResponse {
return new CompletionResponse(
content: $content,
model: $model,
usage: $usage,
finishReason: $finishReason ?? 'stop',
provider: $this->getIdentifier(),
thinking: $thinking,
);
}

/**
* Extract and remove <think>...</think> blocks from content.
*
* @return array{string, string|null} [cleanContent, thinkingContent]
*/
protected function extractThinkingBlocks(string $content): array
{
$thinking = null;
if (preg_match_all('#<think>([\s\S]*?)</think>#i', $content, $matches)) {
$thinking = trim(implode("\n", $matches[1]));
// Replace with space to prevent word-gluing (e.g. "foo<think>...</think>bar" → "foo bar")
$cleaned = preg_replace('#<think>[\s\S]*?</think>#i', ' ', $content) ?? $content;
// Normalize horizontal whitespace (spaces/tabs) but preserve newlines for formatting
$content = trim((string)preg_replace('/[ \t]+/', ' ', $cleaned));
}

return [$content, $thinking !== '' ? $thinking : null];
}

/**
* @param array<int, array<int, float>> $embeddings
*/
Expand Down
19 changes: 18 additions & 1 deletion Classes/Provider/ClaudeProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -146,14 +146,22 @@ public function chatCompletion(array $messages, array $options = []): Completion
$response = $this->sendRequest('messages', $payload);

$content = '';
$nativeThinkingBlocks = [];
$contentBlocks = $this->getList($response, 'content');
foreach ($contentBlocks as $block) {
$blockArray = $this->asArray($block);
if ($this->getString($blockArray, 'type') === 'text') {
$blockType = $this->getString($blockArray, 'type');
if ($blockType === 'text') {
$content .= $this->getString($blockArray, 'text');
} elseif ($blockType === 'thinking') {
$nativeThinkingBlocks[] = $this->getString($blockArray, 'thinking');
}
}

$nativeThinking = implode("\n", $nativeThinkingBlocks);
[$content, $inlineThinking] = $this->extractThinkingBlocks($content);
$allThinking = trim(($nativeThinking !== '' ? $nativeThinking . "\n" : '') . ($inlineThinking ?? ''));

$usage = $this->getArray($response, 'usage');

return $this->createCompletionResponse(
Expand All @@ -164,6 +172,7 @@ public function chatCompletion(array $messages, array $options = []): Completion
completionTokens: $this->getInt($usage, 'output_tokens'),
),
finishReason: $this->mapStopReason($this->getString($response, 'stop_reason', 'end_turn')),
thinking: $allThinking !== '' ? $allThinking : null,
);
}

Expand Down Expand Up @@ -219,6 +228,7 @@ public function chatCompletionWithTools(array $messages, array $tools, array $op
$response = $this->sendRequest('messages', $payload);

$content = '';
$nativeThinkingBlocks = [];
$toolCalls = [];

$contentBlocks = $this->getList($response, 'content');
Expand All @@ -227,6 +237,8 @@ public function chatCompletionWithTools(array $messages, array $tools, array $op
$blockType = $this->getString($blockArray, 'type');
if ($blockType === 'text') {
$content .= $this->getString($blockArray, 'text');
} elseif ($blockType === 'thinking') {
$nativeThinkingBlocks[] = $this->getString($blockArray, 'thinking');
} elseif ($blockType === 'tool_use') {
$toolCalls[] = [
'id' => $this->getString($blockArray, 'id'),
Expand All @@ -239,6 +251,10 @@ public function chatCompletionWithTools(array $messages, array $tools, array $op
}
}

$nativeThinking = implode("\n", $nativeThinkingBlocks);
[$content, $inlineThinking] = $this->extractThinkingBlocks($content);
$allThinking = trim(($nativeThinking !== '' ? $nativeThinking . "\n" : '') . ($inlineThinking ?? ''));

$usage = $this->getArray($response, 'usage');

return new CompletionResponse(
Expand All @@ -251,6 +267,7 @@ public function chatCompletionWithTools(array $messages, array $tools, array $op
finishReason: $this->mapStopReason($this->getString($response, 'stop_reason', 'end_turn')),
provider: $this->getIdentifier(),
toolCalls: $toolCalls !== [] ? $toolCalls : null,
thinking: $allThinking !== '' ? $allThinking : null,
);
}

Expand Down
10 changes: 7 additions & 3 deletions Classes/Provider/GeminiProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -118,7 +118,8 @@ public function chatCompletion(array $messages, array $options = []): Completion
$contentObj = $this->getArray($candidate, 'content');
$parts = $this->getList($contentObj, 'parts');
$firstPart = $this->asArray($parts[0] ?? []);
$content = $this->getString($firstPart, 'text');
$rawContent = $this->getString($firstPart, 'text');
[$content, $thinking] = $this->extractThinkingBlocks($rawContent);
$usage = $this->getArray($response, 'usageMetadata');

return $this->createCompletionResponse(
Expand All @@ -129,6 +130,7 @@ public function chatCompletion(array $messages, array $options = []): Completion
completionTokens: $this->getInt($usage, 'candidatesTokenCount'),
),
finishReason: $this->mapFinishReason($this->getString($candidate, 'finishReason', 'STOP')),
thinking: $thinking,
);
}

Expand Down Expand Up @@ -178,14 +180,14 @@ public function chatCompletionWithTools(array $messages, array $tools, array $op
$contentObj = $this->getArray($candidate, 'content');
$parts = $this->getList($contentObj, 'parts');

$content = '';
$rawContent = '';
$toolCalls = [];

foreach ($parts as $part) {
$partArray = $this->asArray($part);
$text = $this->getNullableString($partArray, 'text');
if ($text !== null) {
$content .= $text;
$rawContent .= $text;
}

$functionCall = $this->getArray($partArray, 'functionCall');
Expand All @@ -201,6 +203,7 @@ public function chatCompletionWithTools(array $messages, array $tools, array $op
}
}

[$content, $thinking] = $this->extractThinkingBlocks($rawContent);
$usage = $this->getArray($response, 'usageMetadata');

return new CompletionResponse(
Expand All @@ -213,6 +216,7 @@ public function chatCompletionWithTools(array $messages, array $tools, array $op
finishReason: $this->mapFinishReason($this->getString($candidate, 'finishReason', 'STOP')),
provider: $this->getIdentifier(),
toolCalls: $toolCalls !== [] ? $toolCalls : null,
thinking: $thinking,
);
}

Expand Down
10 changes: 8 additions & 2 deletions Classes/Provider/OpenAiProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -122,14 +122,17 @@ public function chatCompletion(array $messages, array $options = []): Completion
$message = $this->getArray($choice, 'message');
$usage = $this->getArray($response, 'usage');

[$content, $thinking] = $this->extractThinkingBlocks($this->getString($message, 'content'));

return $this->createCompletionResponse(
content: $this->getString($message, 'content'),
content: $content,
model: $this->getString($response, 'model', $model),
usage: $this->createUsageStatistics(
promptTokens: $this->getInt($usage, 'prompt_tokens'),
completionTokens: $this->getInt($usage, 'completion_tokens'),
),
finishReason: $this->getString($choice, 'finish_reason', 'stop'),
thinking: $thinking,
);
}

Expand Down Expand Up @@ -182,8 +185,10 @@ public function chatCompletionWithTools(array $messages, array $tools, array $op
}
}

[$content, $thinking] = $this->extractThinkingBlocks($this->getString($message, 'content'));

return new CompletionResponse(
content: $this->getString($message, 'content'),
content: $content,
model: $this->getString($response, 'model', $model),
usage: $this->createUsageStatistics(
promptTokens: $this->getInt($usage, 'prompt_tokens'),
Expand All @@ -192,6 +197,7 @@ public function chatCompletionWithTools(array $messages, array $tools, array $op
finishReason: $this->getString($choice, 'finish_reason', 'stop'),
provider: $this->getIdentifier(),
toolCalls: $toolCalls,
thinking: $thinking,
);
}

Expand Down
10 changes: 8 additions & 2 deletions Classes/Provider/OpenRouterProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -288,8 +288,10 @@ public function chatCompletion(array $messages, array $options = []): Completion
$message = $this->getArray($choice, 'message');
$usage = $this->getArray($response, 'usage');

[$content, $thinking] = $this->extractThinkingBlocks($this->getString($message, 'content'));

return new CompletionResponse(
content: $this->getString($message, 'content'),
content: $content,
model: $this->getString($response, 'model', $model),
usage: $this->createUsageStatistics(
promptTokens: $this->getInt($usage, 'prompt_tokens'),
Expand All @@ -305,6 +307,7 @@ public function chatCompletion(array $messages, array $options = []): Completion
'completion' => $response['native_tokens_completion'] ?? null,
],
],
thinking: $thinking,
);
}

Expand Down Expand Up @@ -365,8 +368,10 @@ public function chatCompletionWithTools(array $messages, array $tools, array $op
}
}

[$content, $thinking] = $this->extractThinkingBlocks($this->getString($message, 'content'));

return new CompletionResponse(
content: $this->getString($message, 'content'),
content: $content,
model: $this->getString($response, 'model', $model),
usage: $this->createUsageStatistics(
promptTokens: $this->getInt($usage, 'prompt_tokens'),
Expand All @@ -379,6 +384,7 @@ public function chatCompletionWithTools(array $messages, array $tools, array $op
'actual_provider' => $this->getString($response, 'provider', 'unknown'),
'cost' => $response['total_cost'] ?? null,
],
thinking: $thinking,
);
}

Expand Down
66 changes: 66 additions & 0 deletions Tests/Unit/Domain/Model/CompletionResponseTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -202,6 +202,72 @@ public function getTextReturnsContent(): void
self::assertEquals($response->content, $response->getText());
}

#[Test]
public function constructorSetsThinkingProperty(): void
{
$usage = new UsageStatistics(100, 50, 150);

$response = new CompletionResponse(
content: 'Hello',
model: 'gpt-4o',
usage: $usage,
thinking: 'I need to think about this',
);

self::assertEquals('I need to think about this', $response->thinking);
}

#[Test]
public function thinkingDefaultsToNull(): void
{
$response = new CompletionResponse(
content: 'test',
model: 'gpt-4',
usage: new UsageStatistics(10, 5, 15),
);

self::assertNull($response->thinking);
}

#[Test]
public function hasThinkingReturnsTrueWhenPresent(): void
{
$response = new CompletionResponse(
content: 'result',
model: 'gpt-4',
usage: new UsageStatistics(10, 20, 30),
thinking: 'reasoning content',
);

self::assertTrue($response->hasThinking());
}

#[Test]
public function hasThinkingReturnsFalseWhenNull(): void
{
$response = new CompletionResponse(
content: 'result',
model: 'gpt-4',
usage: new UsageStatistics(10, 20, 30),
thinking: null,
);

self::assertFalse($response->hasThinking());
}

#[Test]
public function hasThinkingReturnsFalseWhenEmpty(): void
{
$response = new CompletionResponse(
content: 'result',
model: 'gpt-4',
usage: new UsageStatistics(10, 20, 30),
thinking: '',
);

self::assertFalse($response->hasThinking());
}

#[Test]
public function responseIsImmutable(): void
{
Expand Down
Loading
Loading