Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
358d1e0
Expose the raw HTTP response on embeddings, transcription, image and …
binaryfire Nov 3, 2026
5751a50
Accept array criteria on boolean questions sent to OpenAI
binaryfire Nov 3, 2026
e81bab5
Recover from tool failures, resume faked approvals and fix streamed t…
binaryfire Nov 3, 2026
63203b7
Report the size of empty file uploads in Telescope client requests
binaryfire Nov 3, 2026
eaff9e2
Sandbox the Telescope mail preview and keep the selected tab on back …
binaryfire Nov 3, 2026
e409572
Explain why Pennant needs no queued-job flush listeners
binaryfire Nov 3, 2026
d0d6174
Convert redirects after QUERY requests to 303
binaryfire Nov 3, 2026
0aaec40
Document Inertia QUERY visits and the SSR --no-stop-before-start option
binaryfire Nov 3, 2026
598f249
Return 404 for .php files in the Nginx deployment example
binaryfire Nov 3, 2026
695bfa3
Document Redis key prefixes for several applications on one server
binaryfire Nov 3, 2026
b1640e6
Omit cache.ttl from Sentry cache.put spans for entries that never expire
binaryfire Nov 3, 2026
fa13798
Cover Guzzle 8 transfer failures in Saloon requests
binaryfire Nov 3, 2026
68e2ff0
Override vulnerable glob and minimatch versions and shorten the relea…
binaryfire Nov 3, 2026
d6697e0
Report tool failures while resuming approvals and stop on listener er…
binaryfire Nov 3, 2026
f70031b
Accept lists in classification question types
binaryfire Nov 3, 2026
3d724a7
Hide tool exception details outside debug mode and report recovered f…
binaryfire Nov 3, 2026
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
34 changes: 21 additions & 13 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

10 changes: 7 additions & 3 deletions pnpm-workspace.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -16,12 +16,16 @@ allowBuilds:
overrides:
'vite@*': npm:@voidzero-dev/vite-plus-core@1.4.0
'vitest@*': 5.0.5
# js-beautify, used by @vue/test-utils, still requires glob and minimatch
# versions affected by GHSA-3g3m-2fjr-6pw4 and GHSA-g3hv-75wg-m249.
glob: ^14.0.2
minimatch: ^11.0.1

# Supply-chain defense: require packages to be at least 7 days old before
# Supply-chain defense: require packages to be at least 3 days old before
# install. Compromised packages typically get yanked within hours, so this
# buffer dramatically reduces the chance of pulling a malicious release.
# Value is in minutes (7 days × 24h × 60min = 10080).
minimumReleaseAge: 10080
# Value is in minutes (3 days × 24h × 60min = 4320).
minimumReleaseAge: 4320
catalog:
'@popperjs/core': ^2.11.8
'@tailwindcss/vite': ^4.3.3
Expand Down
2 changes: 2 additions & 0 deletions src/ai/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@ Skill discovery is cached for the fixed paths in `ai.skills.cached_paths`. [Relo

The conversation migrations use Hypervel's column types, indexes, and approval claim fields. Existing Laravel conversation data requires a [schema migration](https://hypervel.org/docs/ai-sdk#migrating-conversation-data) before use.

Tool, sub-agent, and file storage tool failures include the exception message in the model's tool result only when `app.debug` is enabled. Otherwise the model receives a generic result such as `The tool call failed.`, because exception messages can carry query bindings, server paths, and other private details to the provider, the conversation, and the client. Exceptions recovered this way are reported in both modes. Return a string from the tool when the model should know why an expected failure occurred. See [validating tool arguments](https://hypervel.org/docs/ai-sdk#validating-tool-arguments).

Remote downloads share the HTTP client's destination checks. The protected `UntrustedUrl::validate()`, `resolve()`, and `isBlocked()` helpers and hostname-blocklist constants are removed; `resolveUsing()` remains available. URLs containing embedded credentials are rejected. Downloads are limited to 32 MiB of decoded content by default, instead of Laravel's 100 MB; set `ai.remote_files.max_size` in kilobytes to change the limit, or to `null` to disable it. See [remote attachments](https://hypervel.org/docs/ai-sdk#remote-attachments).

## Known Issues
Expand Down
4 changes: 2 additions & 2 deletions src/ai/src/Classification/Boolean.php
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,8 @@
/**
* Create a new yes / no question whose answer is the probability of "true".
*
* @param array<string, mixed>|string $instructions
* @param null|array{true?: string, false?: string} $criteria descriptions of what a yes and a no mean
* @param array<array-key, mixed>|string $instructions
* @param null|array{true?: array<array-key, mixed>|string, false?: array<array-key, mixed>|string} $criteria descriptions of what a yes and a no mean
*
* @throws InvalidArgumentException if the criteria describe anything but the true and false cases
*/
Expand Down
4 changes: 2 additions & 2 deletions src/ai/src/Classification/Choice.php
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,8 @@
/**
* Create a new single-choice question whose answer is one of the given options.
*
* @param array<string, mixed>|string $instructions
* @param array<array-key, null|array<string, mixed>|string> $options option names mapped to an optional description
* @param array<array-key, mixed>|string $instructions
* @param array<array-key, null|array<array-key, mixed>|string> $options option names mapped to an optional description
*
* @throws InvalidArgumentException if fewer than two options are given
*/
Expand Down
6 changes: 3 additions & 3 deletions src/ai/src/Classification/CollectionChoice.php
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ final class CollectionChoice
/**
* The option names mapped to their descriptions.
*
* @var array<array-key, null|array<string, mixed>|string>
* @var array<array-key, null|array<array-key, mixed>|string>
*/
private array $options = [];

Expand Down Expand Up @@ -65,7 +65,7 @@ public function __construct(
/**
* Choose the item that best answers the question about the given text.
*
* @param array<string, mixed>|string $text
* @param array<array-key, mixed>|string $text
* @param null|float $threshold the minimum probability of the chosen option; below it, null is returned
*
* @throws InvalidArgumentException if fewer than two options are available
Expand Down Expand Up @@ -120,7 +120,7 @@ private function resolveLabel(mixed $item, Closure|string|null $by): string
* Resolve the option description for the given item.
*
* @param null|array<int, string>|Closure|string $describe
* @return null|array<string, mixed>|string
* @return null|array<array-key, mixed>|string
*/
private function resolveDescription(mixed $item, Closure|array|string|null $describe): string|array|null
{
Expand Down
4 changes: 2 additions & 2 deletions src/ai/src/Classification/Score.php
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,8 @@
/**
* Create a new ordinal question whose answer is the expected position on the given levels.
*
* @param array<string, mixed>|string $instructions
* @param list<array<string, mixed>|string> $levels level descriptions ordered from lowest to highest
* @param array<array-key, mixed>|string $instructions
* @param list<array<array-key, mixed>|string> $levels level descriptions ordered from lowest to highest
*
* @throws InvalidArgumentException if the levels are not a list of at least two entries
*/
Expand Down
21 changes: 21 additions & 0 deletions src/ai/src/Exceptions/ToolExecutionException.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
<?php

declare(strict_types=1);

namespace Hypervel\Ai\Exceptions;

use Throwable;

/**
* @internal
*/
class ToolExecutionException extends AiException
{
/**
* Create a new tool execution exception wrapping the tool's own failure.
*/
public function __construct(public readonly Throwable $exception)
{
parent::__construct($exception->getMessage(), previous: $exception);
}
}
1 change: 1 addition & 0 deletions src/ai/src/Gateway/Anthropic/AnthropicGateway.php
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,7 @@ public function generateStreamStep(
$provider,
$model,
$response->getBody(),
$this->pausedContent($messages),
);
}

Expand Down
35 changes: 35 additions & 0 deletions src/ai/src/Gateway/Anthropic/Concerns/HandlesTextStreaming.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@
namespace Hypervel\Ai\Gateway\Anthropic\Concerns;

use Generator;
use Hypervel\Ai\Messages\AssistantMessage;
use Hypervel\Ai\Messages\Message;
use Hypervel\Ai\Providers\Provider;
use Hypervel\Ai\Responses\Data\TextUsage;
use Hypervel\Ai\Responses\Data\ToolCall;
Expand All @@ -21,19 +23,40 @@
use Hypervel\Ai\Streaming\Events\TextEnd;
use Hypervel\Ai\Streaming\Events\TextStart;
use Hypervel\Ai\Streaming\Events\ToolCall as ToolCallEvent;
use Hypervel\Support\Collection;
use Hypervel\Support\Str;
use Psr\Http\Message\StreamInterface;

trait HandlesTextStreaming
{
/**
* Get the content of the paused steps the turn being continued is made of.
*
* @param array<int, Message> $messages
* @return array<int, array<string, mixed>>
*/
protected function pausedContent(array $messages): array
{
/** @var Collection<int, AssistantMessage> $paused */
$paused = collect($messages)
->reverse()
->takeWhile(fn (Message $message): bool => $message instanceof AssistantMessage)
->reverse();

return $paused->flatMap(fn (AssistantMessage $message): array => $message->replayBlocks)->all();
}

/**
* Process a single Anthropic streaming turn and yield Hypervel stream events.
*
* @param array<int, array<string, mixed>> $pausedContent
*/
protected function processTextStream(
string $invocationId,
Provider $provider,
string $model,
StreamInterface $streamBody,
array $pausedContent = [],
): Generator {
$messageId = $this->generateEventId();
$reasoningId = '';
Expand All @@ -53,6 +76,18 @@ protected function processTextStream(
$afterTool = false;
$lastTextDelta = '';

// A turn resumed after pause_turn picks up where the paused content ended...
foreach ($pausedContent as $block) {
$blockType = $block['type'] ?? '';

if ($blockType === 'text' && ($block['text'] ?? '') !== '') {
$lastTextDelta = (string) $block['text'];
$afterTool = false;
} elseif ($this->isToolBlock($blockType)) {
$afterTool = true;
}
}

$messageUsage = [];
$usage = null;
$stopReason = '';
Expand Down
5 changes: 5 additions & 0 deletions src/ai/src/Gateway/Anthropic/Concerns/ParsesTextResponses.php
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,11 @@ protected function buildStepResponse(
$finishReason = FinishReason::Stop;
}

// Claude can end a message holding tool_use blocks with end_turn, so the tool calls still need to run.
if ($finishReason === FinishReason::Stop && $realToolCalls !== []) {
$finishReason = FinishReason::ToolCalls;
}

return new StepResponse(
text: $hasStructuredToolCall && ! empty($structuredData) ? (json_encode($structuredData) ?: '') : $text,
toolCalls: $realToolCalls,
Expand Down
8 changes: 4 additions & 4 deletions src/ai/src/Gateway/AzureOpenAi/AzureOpenAiGateway.php
Original file line number Diff line number Diff line change
Expand Up @@ -81,11 +81,11 @@ public function generateEmbeddings(

$data = $response->json();

return new EmbeddingsResponse(
return (new EmbeddingsResponse(
$this->parseIndexedEmbeddings($data['data'] ?? [], count($inputs), $provider->name()),
new Usage($data['usage']['prompt_tokens'] ?? 0),
new Meta($provider->name(), $model),
);
))->withRawResponse($response);
}

/**
Expand Down Expand Up @@ -122,14 +122,14 @@ public function generateImage(

$data = $response->json();

return new ImageResponse(
return (new ImageResponse(
collect($data['data'] ?? [])->map(fn (array $image): GeneratedImage => new GeneratedImage(
$image['b64_json'] ?? '',
'image/' . ($data['output_format'] ?? 'png'),
)),
$this->extractImageUsage($data),
new Meta($provider->name(), $model),
);
))->withRawResponse($response);
}

/**
Expand Down
10 changes: 10 additions & 0 deletions src/ai/src/Gateway/Bedrock/BedrockTextGateway.php
Original file line number Diff line number Diff line change
Expand Up @@ -260,6 +260,11 @@ protected function parseTextResponse(array $result, TextProvider $provider, stri
$finishReason = FinishReason::Stop;
}

// Like the Anthropic API, Converse can end a message holding toolUse blocks with end_turn, so the tool calls still need to run.
if ($finishReason === FinishReason::Stop && $toolCalls !== []) {
$finishReason = FinishReason::ToolCalls;
}

return new StepResponse(
text: $structuredOutput ?? $output,
toolCalls: $toolCalls,
Expand Down Expand Up @@ -564,6 +569,11 @@ protected function processTextStream(
$finishReason = FinishReason::Stop;
}

// Like the Anthropic API, Converse can end a message holding toolUse blocks with end_turn, so the tool calls still need to run.
if ($finishReason === FinishReason::Stop && $toolCalls !== []) {
$finishReason = FinishReason::ToolCalls;
}

$replayBlocks = array_values($responseContent);

if (! $hasReasoningBlocks) {
Expand Down
8 changes: 4 additions & 4 deletions src/ai/src/Gateway/CohereGateway.php
Original file line number Diff line number Diff line change
Expand Up @@ -138,11 +138,11 @@ public function generateEmbeddings(

$data = $response->json();

return new EmbeddingsResponse(
return (new EmbeddingsResponse(
$this->parseCohereEmbeddings($data['embeddings'] ?? []),
new Usage($data['meta']['billed_units']['input_tokens'] ?? 0),
new Meta($provider->name(), $model),
);
))->withRawResponse($response);
}

/**
Expand Down Expand Up @@ -179,14 +179,14 @@ public function rerank(
score: $result['relevance_score'],
))->all();

return new RerankingResponse(
return (new RerankingResponse(
$results,
new RerankingUsage(
inputTokens: $data['meta']['billed_units']['input_tokens'] ?? 0,
searchUnits: $data['meta']['billed_units']['search_units'] ?? null,
),
new Meta($provider->name(), $model),
);
))->withRawResponse($response);
}

/**
Expand Down
Loading
Loading