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
5 changes: 4 additions & 1 deletion src/Gateway/Anthropic/Concerns/ParsesTextResponses.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

use Illuminate\Support\Collection;
use Laravel\Ai\Exceptions\AiException;
use Laravel\Ai\Gateway\Concerns\DecodesStructuredOutput;
use Laravel\Ai\Gateway\StepResponse;
use Laravel\Ai\Providers\Provider;
use Laravel\Ai\Responses\Data\FinishReason;
Expand All @@ -14,6 +15,8 @@

trait ParsesTextResponses
{
use DecodesStructuredOutput;

/**
* Validate the Anthropic response data.
*
Expand Down Expand Up @@ -72,7 +75,7 @@ protected function buildStepResponse(
$structuredData = $this->extractStructuredOutput($content);

if (empty($structuredData) && filled($text)) {
$structuredData = json_decode($text, true) ?? [];
$structuredData = $this->decodeStructuredOutput($text);
}
}

Expand Down
12 changes: 2 additions & 10 deletions src/Gateway/Bedrock/BedrockTextGateway.php
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
use Laravel\Ai\Enums\Lab;
use Laravel\Ai\Gateway\Bedrock\Concerns\CreatesBedrockClient;
use Laravel\Ai\Gateway\Bedrock\Concerns\MapsAttachments;
use Laravel\Ai\Gateway\Concerns\DecodesStructuredOutput;
use Laravel\Ai\Gateway\Concerns\DelegatesToTextGenerationLoop;
use Laravel\Ai\Gateway\Concerns\HandlesFailoverErrors;
use Laravel\Ai\Gateway\StepContext;
Expand Down Expand Up @@ -49,6 +50,7 @@
class BedrockTextGateway implements EmbeddingGateway, StepTextGateway, TextGateway
{
use CreatesBedrockClient;
use DecodesStructuredOutput;
use DelegatesToTextGenerationLoop;
use HandlesFailoverErrors;
use MapsAttachments;
Expand Down Expand Up @@ -212,16 +214,6 @@ protected function parseTextResponse(array $result, TextProvider $provider, stri
);
}

/**
* Decode the structured output JSON, falling back to an empty array on failure.
*/
protected function decodeStructuredOutput(string $json): array
{
$structured = json_decode($json, true);

return json_last_error() === JSON_ERROR_NONE ? $structured : [];
}

/**
* Stream a single Converse step, returning the parsed step response.
*
Expand Down
36 changes: 36 additions & 0 deletions src/Gateway/Concerns/DecodesStructuredOutput.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
<?php

namespace Laravel\Ai\Gateway\Concerns;

trait DecodesStructuredOutput
{
/**
* Decode a structured output payload, tolerating markdown code fences.
*/
protected function decodeStructuredOutput(?string $text): array
{
if ($text === null || trim($text) === '') {
return [];
}

$payload = $this->stripJsonCodeFence($text);

$decoded = json_decode($payload, true);

return is_array($decoded) ? $decoded : [];
}

/**
* Strip a wrapping markdown code fence from the payload, if present.
*/
private function stripJsonCodeFence(string $text): string
{
$trimmed = trim($text);

if (preg_match('/^```(?:json)?\s*(.*?)\s*```$/si', $trimmed, $matches) === 1) {
return trim($matches[1]);
}

return $trimmed;
}
}
5 changes: 4 additions & 1 deletion src/Gateway/DeepSeek/Concerns/ParsesTextResponses.php
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
namespace Laravel\Ai\Gateway\DeepSeek\Concerns;

use Laravel\Ai\Exceptions\AiException;
use Laravel\Ai\Gateway\Concerns\DecodesStructuredOutput;
use Laravel\Ai\Gateway\StepResponse;
use Laravel\Ai\Providers\Provider;
use Laravel\Ai\Responses\Data\FinishReason;
Expand All @@ -12,6 +13,8 @@

trait ParsesTextResponses
{
use DecodesStructuredOutput;

/**
* Validate the DeepSeek response data.
*
Expand Down Expand Up @@ -66,7 +69,7 @@ protected function parseTextResponse(
finishReason: $this->extractFinishReason($choice),
usage: $this->extractUsage($data),
meta: new Meta($provider->name(), $model),
structured: $structured ? (json_decode($text, true) ?? []) : null,
structured: $structured ? $this->decodeStructuredOutput($text) : null,
providerContentBlocks: $providerContentBlocks,
);
}
Expand Down
5 changes: 4 additions & 1 deletion src/Gateway/Gemini/Concerns/ParsesTextResponses.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
use Illuminate\Support\Collection;
use Illuminate\Support\Str;
use Laravel\Ai\Exceptions\AiException;
use Laravel\Ai\Gateway\Concerns\DecodesStructuredOutput;
use Laravel\Ai\Gateway\StepResponse;
use Laravel\Ai\Providers\Provider;
use Laravel\Ai\Responses\Data\FinishReason;
Expand All @@ -15,6 +16,8 @@

trait ParsesTextResponses
{
use DecodesStructuredOutput;

/**
* Validate the Gemini response data.
*
Expand Down Expand Up @@ -52,7 +55,7 @@ protected function parseTextResponse(
finishReason: $this->extractFinishReason($data, $rawToolCalls),
usage: $this->extractUsage($data),
meta: new Meta($provider->name(), $model, $this->extractCitations($data)),
structured: $structured ? (json_decode($text, true) ?? []) : null,
structured: $structured ? $this->decodeStructuredOutput($text) : null,
providerContentBlocks: $this->sanitizeRequestParts($this->excludeThinkingParts($parts)),
);
}
Expand Down
5 changes: 4 additions & 1 deletion src/Gateway/Groq/Concerns/ParsesTextResponses.php
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
namespace Laravel\Ai\Gateway\Groq\Concerns;

use Laravel\Ai\Exceptions\AiException;
use Laravel\Ai\Gateway\Concerns\DecodesStructuredOutput;
use Laravel\Ai\Gateway\StepResponse;
use Laravel\Ai\Providers\Provider;
use Laravel\Ai\Responses\Data\FinishReason;
Expand All @@ -12,6 +13,8 @@

trait ParsesTextResponses
{
use DecodesStructuredOutput;

/**
* Validate the Groq response data.
*
Expand Down Expand Up @@ -58,7 +61,7 @@ protected function parseTextResponse(
finishReason: $finishReason,
usage: $usage,
meta: new Meta($provider->name(), $model),
structured: $structured ? (json_decode($text, true) ?? []) : null,
structured: $structured ? $this->decodeStructuredOutput($text) : null,
);
}

Expand Down
5 changes: 4 additions & 1 deletion src/Gateway/Mistral/Concerns/ParsesTextResponses.php
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
namespace Laravel\Ai\Gateway\Mistral\Concerns;

use Laravel\Ai\Exceptions\AiException;
use Laravel\Ai\Gateway\Concerns\DecodesStructuredOutput;
use Laravel\Ai\Gateway\StepResponse;
use Laravel\Ai\Providers\Provider;
use Laravel\Ai\Responses\Data\FinishReason;
Expand All @@ -12,6 +13,8 @@

trait ParsesTextResponses
{
use DecodesStructuredOutput;

/**
* Validate the Mistral response data.
*
Expand Down Expand Up @@ -56,7 +59,7 @@ protected function parseTextResponse(
finishReason: $this->extractFinishReason($choice),
usage: $this->extractUsage($data),
meta: new Meta($provider->name(), $model),
structured: $structured ? (json_decode($text, true) ?? []) : null,
structured: $structured ? $this->decodeStructuredOutput($text) : null,
);
}

Expand Down
5 changes: 4 additions & 1 deletion src/Gateway/Ollama/Concerns/ParsesTextResponses.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

use Illuminate\Support\Str;
use Laravel\Ai\Exceptions\AiException;
use Laravel\Ai\Gateway\Concerns\DecodesStructuredOutput;
use Laravel\Ai\Gateway\StepResponse;
use Laravel\Ai\Providers\Provider;
use Laravel\Ai\Responses\Data\FinishReason;
Expand All @@ -13,6 +14,8 @@

trait ParsesTextResponses
{
use DecodesStructuredOutput;

/**
* Validate the Ollama response data.
*
Expand Down Expand Up @@ -59,7 +62,7 @@ protected function parseTextResponse(
finishReason: filled($mappedToolCalls) ? FinishReason::ToolCalls : $this->extractFinishReason($data),
usage: $this->extractUsage($data),
meta: new Meta($provider->name(), $model),
structured: $structured ? (json_decode($text, true) ?? []) : null,
structured: $structured ? $this->decodeStructuredOutput($text) : null,
);
}

Expand Down
5 changes: 4 additions & 1 deletion src/Gateway/OpenAi/Concerns/ParsesTextResponses.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

use Illuminate\Support\Collection;
use Laravel\Ai\Exceptions\AiException;
use Laravel\Ai\Gateway\Concerns\DecodesStructuredOutput;
use Laravel\Ai\Gateway\StepResponse;
use Laravel\Ai\Providers\Provider;
use Laravel\Ai\Responses\Data\FinishReason;
Expand All @@ -14,6 +15,8 @@

trait ParsesTextResponses
{
use DecodesStructuredOutput;

/**
* Validate the OpenAI response data.
*
Expand Down Expand Up @@ -57,7 +60,7 @@ protected function parseTextResponse(
finishReason: $this->extractFinishReason($data),
usage: $this->extractUsage($data),
meta: new Meta($provider->name(), $data['model'] ?? '', $this->extractCitations($output)),
structured: $structured ? (json_decode($text, true) ?? []) : null,
structured: $structured ? $this->decodeStructuredOutput($text) : null,
continuationToken: $data['id'] ?? '',
);
}
Expand Down
5 changes: 4 additions & 1 deletion src/Gateway/OpenRouter/Concerns/ParsesTextResponses.php
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
namespace Laravel\Ai\Gateway\OpenRouter\Concerns;

use Laravel\Ai\Exceptions\AiException;
use Laravel\Ai\Gateway\Concerns\DecodesStructuredOutput;
use Laravel\Ai\Gateway\StepResponse;
use Laravel\Ai\Providers\Provider;
use Laravel\Ai\Responses\Data\FinishReason;
Expand All @@ -12,6 +13,8 @@

trait ParsesTextResponses
{
use DecodesStructuredOutput;

/**
* Validate the OpenRouter response data.
*
Expand Down Expand Up @@ -55,7 +58,7 @@ protected function parseTextResponse(
finishReason: $this->extractFinishReason($choice),
usage: $this->extractUsage($data),
meta: new Meta($provider->name(), $model),
structured: $structured ? (json_decode($text, true) ?? []) : null,
structured: $structured ? $this->decodeStructuredOutput($text) : null,
);
}

Expand Down
5 changes: 4 additions & 1 deletion src/Gateway/Xai/Concerns/ParsesTextResponses.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

use Illuminate\Support\Collection;
use Laravel\Ai\Exceptions\AiException;
use Laravel\Ai\Gateway\Concerns\DecodesStructuredOutput;
use Laravel\Ai\Gateway\StepResponse;
use Laravel\Ai\Providers\Provider;
use Laravel\Ai\Responses\Data\FinishReason;
Expand All @@ -14,6 +15,8 @@

trait ParsesTextResponses
{
use DecodesStructuredOutput;

/**
* Validate the xAI response data.
*
Expand Down Expand Up @@ -64,7 +67,7 @@ protected function parseTextResponse(
finishReason: $finishReason,
usage: $usage,
meta: new Meta($provider->name(), $model, $citations),
structured: $structured ? (json_decode($text, true) ?? []) : null,
structured: $structured ? $this->decodeStructuredOutput($text) : null,
continuationToken: $data['id'] ?? null,
);
}
Expand Down
95 changes: 95 additions & 0 deletions tests/Unit/Gateway/Concerns/DecodesStructuredOutputTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
<?php

use Laravel\Ai\Gateway\Concerns\DecodesStructuredOutput;

function decoderHost(): object
{
return new class
{
use DecodesStructuredOutput;

public function decode(?string $text): array
{
return $this->decodeStructuredOutput($text);
}
};
}

test('decodes plain JSON object', function () {
$host = decoderHost();

expect($host->decode('{"name":"Ada","age":36}'))
->toBe(['name' => 'Ada', 'age' => 36]);
});

test('strips ```json fence and decodes', function () {
$host = decoderHost();

$payload = "```json\n{\"ok\":true}\n```";

expect($host->decode($payload))->toBe(['ok' => true]);
});

test('strips bare ``` fence and decodes', function () {
$host = decoderHost();

$payload = "```\n{\"ok\":true}\n```";

expect($host->decode($payload))->toBe(['ok' => true]);
});

test('tolerates leading and trailing whitespace around the fence', function () {
$host = decoderHost();

$payload = " \n\n```json\n{\"value\":42}\n``` \n";

expect($host->decode($payload))->toBe(['value' => 42]);
});

test('handles uppercase JSON fence language tag', function () {
$host = decoderHost();

$payload = "```JSON\n{\"ok\":true}\n```";

expect($host->decode($payload))->toBe(['ok' => true]);
});

test('handles mixed-case json fence language tag', function () {
$host = decoderHost();

expect($host->decode("```Json\n{\"ok\":true}\n```"))->toBe(['ok' => true])
->and($host->decode("```jSoN\n{\"ok\":true}\n```"))->toBe(['ok' => true]);
});

test('returns empty array for invalid JSON', function () {
$host = decoderHost();

expect($host->decode('not json at all'))->toBe([])
->and($host->decode("```json\n{invalid}\n```"))->toBe([]);
});

test('returns empty array for null or empty input', function () {
$host = decoderHost();

expect($host->decode(null))->toBe([])
->and($host->decode(''))->toBe([])
->and($host->decode(' '))->toBe([]);
});

test('does not mangle JSON containing triple-backtick substrings inside string values', function () {
$host = decoderHost();

$payload = '{"snippet":"```js\nconsole.log(1)\n```","ok":true}';

expect($host->decode($payload))->toBe([
'snippet' => "```js\nconsole.log(1)\n```",
'ok' => true,
]);
});

test('returns empty array when JSON decodes to a non-array scalar', function () {
$host = decoderHost();

expect($host->decode('"just a string"'))->toBe([])
->and($host->decode('42'))->toBe([]);
});