Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
43 commits
Select commit Hold shift + click to select a range
ef58cdc
Identify Scout in Turbopuffer requests
binaryfire Oct 19, 2026
05f0af9
Identify Scout in Meilisearch client requests
binaryfire Oct 19, 2026
58bafd1
Update the Meilisearch Scout documentation
binaryfire Oct 19, 2026
779ec3c
Complete the Sentry AI monitoring documentation
binaryfire Oct 19, 2026
852e42f
Assert memoization before forgetting tagged cache keys
binaryfire Oct 19, 2026
649f1cc
Keep route key validation for non-incrementing integer keys
binaryfire Oct 19, 2026
920485b
Release owned unique-until-processing locks when jobs fail
binaryfire Oct 19, 2026
0f69db4
Port QueueFake delayed job tests
binaryfire Oct 19, 2026
bfea333
Respect descending order for callable sortByDesc comparisons
binaryfire Oct 19, 2026
977b0e1
Port inline style width tests
binaryfire Oct 19, 2026
110da8b
Expose the Testbench package manifest path
binaryfire Oct 19, 2026
3fef87a
Add the WithCachedViews Testbench attribute
binaryfire Oct 19, 2026
b30287c
Record the excluded mutable-date Testbench test
binaryfire Oct 19, 2026
f4b120f
Add the Testbench PHPUnit Assert class
binaryfire Oct 19, 2026
bd1441e
Add the in_parallel_testing() Testbench helper
binaryfire Oct 19, 2026
cf73df8
Support PHPUnit 13.4
binaryfire Oct 19, 2026
3c6bdf6
Adapt server tests to current Swoole behavior
binaryfire Oct 19, 2026
d6bc6e1
Update Horizon's Vue and source-map-js dependencies
binaryfire Oct 19, 2026
7d3b951
Test client whispers while another application has connections
binaryfire Oct 19, 2026
b90fa63
Accept MCP 2025-03-26 clients in the legacy handshake
binaryfire Oct 19, 2026
b6caf78
Keep the CopyFile tool from copying directories
binaryfire Oct 19, 2026
0c9b7e3
Add Stop middleware to end agent runs early
binaryfire Oct 19, 2026
735c029
Decode structured output with unescaped quotes in object keys
binaryfire Oct 19, 2026
fa2b9ee
Map incomplete Responses API streams by their reason
binaryfire Oct 19, 2026
83376a3
Use OpenRouter's images endpoint for image generation
binaryfire Oct 19, 2026
aaf84d2
Keep falsy vector store names
binaryfire Oct 19, 2026
899f575
Use Ollama's native embedding dimensions by default
binaryfire Oct 19, 2026
4bf5ee4
Accept millisecond closures in retry()'s sleep docblock
binaryfire Oct 19, 2026
6f5eed3
Retry once after a short Retry-After before failing over
binaryfire Oct 19, 2026
bfd55d9
Run structured sub-agents unstreamed when the parent streams
binaryfire Oct 19, 2026
c6f1437
Add classification to the OpenAI-compatible provider
binaryfire Oct 19, 2026
d84ce41
Document AI SDK stopping, Retry-After retries and classification
binaryfire Oct 19, 2026
43e0549
Exempt all test methods from method title docblocks
binaryfire Oct 19, 2026
7e2f16b
Merge 0.4 into upstream-sync-2026-10-19
binaryfire Oct 19, 2026
63dbf96
Serialize Stop middleware conditions for queued agents
binaryfire Oct 19, 2026
677f0ee
Resolve OpenAI-compatible classification image options by provider
binaryfire Oct 19, 2026
fc350fc
Report rejected file tool paths to the model
binaryfire Oct 19, 2026
550b474
Reverse descending comparator results without overflow
binaryfire Oct 19, 2026
3fac85f
Detect parallel testing without an application
binaryfire Oct 19, 2026
aef4506
Keep PHPUnit on 13.4 until 13.5 is verified
binaryfire Oct 19, 2026
d64151c
Stop negotiating MCP protocol revision 2025-03-26
binaryfire Oct 19, 2026
f6f3d42
Correct AI streaming and Sentry tool documentation
binaryfire Oct 19, 2026
7778e15
Tighten ported test assertions and add missing method titles
binaryfire Oct 19, 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
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -191,7 +191,7 @@ Build complete, long-term solutions, not MVPs or local workarounds. A broad chan
- **Group traits in `Concerns/`** — follow the package's existing convention if it already has a `Concerns/` or `Traits/` directory; never mix both in one package. New Hypervel-original packages always use `Concerns/`; a newly ported package keeps its upstream directory name.
- **Use Laravel observer conventions** — place Eloquent observers in a top-level `Observers/` directory. Register model-specific observers with `#[ObservedBy(...)]`; use `observe()` only for dynamic registration or observers supplied automatically by a reusable concern.
- **Use attributed local scopes** — define local Eloquent query scopes as protected methods marked with `#[Scope]`, rather than legacy public `scopeFoo()` methods. Use separate scope classes only for genuine global scopes.
- **No class docblocks unless warranted** — only add a class-level docblock if something unusual or complex needs explanation: purpose, architectural role, usage patterns. Never write one that inventories the class — trait lists, method summaries, "registers X, configures Y" — that duplicates the members' own docblocks and goes stale. Method docblocks (title only, Laravel-style, imperative mood: "Return", not "Returns") are always added, except on `test*` methods in test classes. A body can accompany the title for complex methods that need further explanation.
- **No class docblocks unless warranted** — only add a class-level docblock if something unusual or complex needs explanation: purpose, architectural role, usage patterns. Never write one that inventories the class — trait lists, method summaries, "registers X, configures Y" — that duplicates the members' own docblocks and goes stale. Method docblocks (title only, Laravel-style, imperative mood: "Return", not "Returns") are always added, except on test methods, whether identified by a test name prefix or PHPUnit’s `#[Test]` attribute. A body can accompany the title for complex methods that need further explanation.
- **Add comments only where they're genuinely useful** (required Laravel-style method title docblocks and config section comment blocks still apply) — a short WHY for logic that isn't obvious from reading the code, the reason behind a bug fix, or logic that's coupled to code in other files and hard to understand in isolation. Avoid unnecessary code comments; don't comment what the code does, and don't annotate framework divergences, routine casts, or type normalizations.
- **Don't make classes final by default** — keep classes open; add `final` only when it protects a real invariant or avoids a concrete framework/API problem, e.g. immutability, coroutine-safety, or a security guarantee.
- **Place methods logically, not at the end** — group new methods with related ones (getters with getters, setters with setters). Two exceptions: preserve upstream order when merging ported code (see Porting rules), and `flushState()` has its own placement rule (see Static state and test cleanup).
Expand Down
4 changes: 2 additions & 2 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -119,7 +119,7 @@
"require-dev": {
"ably/ably-php": "^1.1.10",
"algolia/algoliasearch-client-php": "^4.49.0",
"brianium/paratest": "^7.24",
"brianium/paratest": "^7.27.2",
"composer/composer": "^2.10.3",
"composer/semver": "^3.4",
"fakerphp/faker": "^1.24",
Expand All @@ -131,7 +131,7 @@
"opis/json-schema": "^2.6.0",
"pda/pheanstalk": "^8.0.2",
"phpstan/phpstan": "^2.3",
"phpunit/phpunit": "13.3.*",
"phpunit/phpunit": "~13.4.3",
"pusher/pusher-php-server": "^7.2",
"resend/resend-php": "^1.0",
"spomky-labs/cbor-php": "^3.4.1",
Expand Down
5 changes: 2 additions & 3 deletions docs/todo.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,10 +29,9 @@
## Testing

- Adopt a Swoole release fixing the intermittent AIO shutdown hang in [swoole-src#6297](https://github.com/swoole/swoole-src/issues/6297). On 6.3.0RC1, ordinary coroutine scheduler teardown can wait forever for an idle native thread that missed its shutdown notification, including between tests. Verify the released fix with the standalone reproduction and affected suites; do not add a Hypervel workaround or a timing-dependent regression test.
- Add the required title docblocks to the untitled non-test methods in `tests/`: about 8,400 methods in 1,600 files, mostly helpers, fixtures, and methods of anonymous classes in older tests. `AGENTS.md` requires a title on every method except `test*` methods, but most existing tests omit them, and agents follow the surrounding code over the written rule, so new tests keep reproducing the gap until review catches it. Make the change as one approved sweep (short imperative titles only), then add an automated check, such as a PHPStan rule, so the code and the rule cannot drift apart again. Decide whether the check also covers `src/`.
- Add the required title docblocks to the untitled non-test methods in `tests/`: about 7,600 methods in 1,500 files, mostly helpers, fixtures, and methods of anonymous classes in older tests. `AGENTS.md` requires a title on every method except test methods, but most existing tests omit them, and agents follow the surrounding code over the written rule, so new tests keep reproducing the gap until review catches it. Make the change as one approved sweep (short imperative titles only), then add an automated check, such as a PHPStan rule, so the code and the rule cannot drift apart again. Decide whether the check also covers `src/`.
- Add native return types to the `test*` methods that lack them: about 5,600 methods in 490 files. `AGENTS.md` requires `: void` on test methods; a test that returns a value for `#[Depends]` takes its actual return type instead. As with method titles, untyped methods in existing files lead new tests to copy them, so make the change as one approved sweep and enforce it with the same automated check.
- Integrate the resolution of [PHPUnit #7039](https://github.com/sebastianbergmann/phpunit/issues/7039) into `RunTestsInCoroutine` once available; [PR #7040](https://github.com/sebastianbergmann/phpunit/pull/7040) proposes suspend/resume output buffering. Follow the accepted upstream design, including calling `parent::invokeTestMethod()` if capture is handled there. Add regression coverage for output expectations, flushed output and buffer cleanup; update the testing guidance and reassess output-related coroutine opt-outs.
- Lift the PHPUnit `13.3.*` pin in the root, Testbench and dogfood package manifests, and in the `hypervel/hypervel` application skeleton, once ParaTest supports PHPUnit 13.4. PHPUnit 13.4.0 made the internal `PhpHandler` constructor require an event emitter, and ParaTest 7.25.0 and Hypervel's `RunsInParallel` still construct it without one, so parallel runs fail before any test starts. When lifting the pin, pass the emitter to `PhpHandler` in `RunsInParallel`, as PHPUnit 13.4's own `Application` does.
- Integrate the resolution of [PHPUnit #7039](https://github.com/sebastianbergmann/phpunit/issues/7039) into `RunTestsInCoroutine` once PHPUnit 13.5 is released; it adds `TestCase::suspendOutputBuffering()` and `resumeOutputBuffering()` ([PR #7040](https://github.com/sebastianbergmann/phpunit/pull/7040)). Follow the merged upstream design, including calling `parent::invokeTestMethod()` if capture is handled there. Add regression coverage for output expectations, flushed output and buffer cleanup; update the testing guidance and reassess output-related coroutine opt-outs. Lift the `~13.4.3` PHPUnit constraints in the root, Testbench and dogfood manifests, the testing package's suggestion and the `hypervel/hypervel` skeleton once Testbench's PHPUnit integration is verified on 13.5.

## Filesystem

Expand Down
2 changes: 1 addition & 1 deletion dogfood/ai/composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@
}
},
"require-dev": {
"phpunit/phpunit": "13.3.*",
"phpunit/phpunit": "~13.4.3",
"mockery/mockery": "^1.6.15",
"fakerphp/faker": "^1.24"
},
Expand Down
4 changes: 2 additions & 2 deletions dogfood/testbench-package/composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -30,12 +30,12 @@
"hypervel/support": "0.4.x-dev"
},
"require-dev": {
"brianium/paratest": "^7.24",
"brianium/paratest": "^7.27.2",
"fakerphp/faker": "^1.24",
"hypervel/components": "0.4.x-dev",
"hypervel/testbench": "0.4.x-dev",
"mockery/mockery": "1.6.x-dev",
"phpunit/phpunit": "13.3.*",
"phpunit/phpunit": "~13.4.3",
"symfony/yaml": "^8.1"
},
"extra": {
Expand Down
47 changes: 47 additions & 0 deletions src/ai/src/Gateway/Concerns/DecodesStructuredOutput.php
Original file line number Diff line number Diff line change
Expand Up @@ -23,9 +23,56 @@ protected function decodeStructuredOutput(?string $text): array
$decoded = json_decode($this->extractJsonCodeFence($trimmed) ?? '', true);
}

if (! is_array($decoded)) {
$decoded = json_decode($this->escapeQuotesInObjectKeys($this->extractJsonCodeFence($trimmed) ?? $trimmed), true);
}

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

/**
* Escape unescaped double quotes in object keys, where only a quote followed by a colon closes the key.
*/
private function escapeQuotesInObjectKeys(string $json): string
{
$result = '';
$containers = [];
$offset = 0;

while (preg_match('/\G([^"]*)"/', $json, $match, offset: $offset) === 1) {
$structure = $match[1];

preg_match_all('/[{}\[\]]/', $structure, $brackets);

foreach ($brackets[0] as $bracket) {
if ($bracket === '{' || $bracket === '[') {
$containers[] = $bracket;
} else {
array_pop($containers);
}
}

$isKey = end($containers) === '{' && preg_match('/[{,]\s*$/', $structure) === 1;

$pattern = $isKey
? '/\G"((?:\\\.|[^\\\])*?)"(?=\s*:)/s'
: '/\G"((?:\\\.|[^"\\\])*)"/s';

if (preg_match($pattern, $json, $string, offset: $offset + strlen($structure)) !== 1) {
break;
}

$contents = $isKey
? preg_replace_callback('/\\\.|"/s', fn (array $token): string => $token[0] === '"' ? '\"' : $token[0], $string[1])
: $string[1];

$result .= $structure . '"' . $contents . '"';
$offset += strlen($structure) + strlen($string[0]);
}

return $result . substr($json, $offset);
}

/**
* Extract the contents of a markdown code fence from the payload, if present.
*/
Expand Down
28 changes: 27 additions & 1 deletion src/ai/src/Gateway/Concerns/HandlesFailoverErrors.php
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
use Hypervel\Ai\Exceptions\RateLimitedException;
use Hypervel\Http\Client\ConnectionException;
use Hypervel\Http\Client\RequestException;
use Throwable;

trait HandlesFailoverErrors
{
Expand All @@ -25,7 +26,12 @@ trait HandlesFailoverErrors
protected function withErrorHandling(string $providerName, Closure $callback): mixed
{
try {
return $callback();
return retry(
2,
$callback,
fn (int $attempt, Throwable $exception): int => $this->retryAfterSeconds($exception) * 1000,
fn (Throwable $exception): bool => $this->retryAfterSeconds($exception) !== null,
);
} catch (ConnectionException $connectionException) {
throw ProviderConnectionException::forProvider(
$providerName,
Expand Down Expand Up @@ -77,6 +83,26 @@ protected function withErrorHandling(string $providerName, Closure $callback): m
}
}

/**
* Get the short Retry-After delay of a rate limited or overloaded response, if it has one.
*/
protected function retryAfterSeconds(Throwable $exception): ?int
{
if (! $exception instanceof RequestException) {
return null;
}

$status = $exception->response->status();

if ($status !== 429 && ! in_array($status, $this->overloadedStatusCodes(), true)) {
return null;
}

$retryAfter = $exception->response->header('Retry-After');

return ctype_digit($retryAfter) && (int) $retryAfter <= 10 ? (int) $retryAfter : null;
}

/**
* Get the status codes that indicate a provider is transiently unavailable and the request should fail over.
*
Expand Down
8 changes: 5 additions & 3 deletions src/ai/src/Gateway/OpenAi/Concerns/ParsesTextResponses.php
Original file line number Diff line number Diff line change
Expand Up @@ -206,9 +206,11 @@ protected function extractFinishReason(array $data): FinishReason
{
// Completed output items do not make an interrupted response complete.
if (($data['status'] ?? null) === 'incomplete') {
return ($data['incomplete_details']['reason'] ?? null) === 'content_filter'
? FinishReason::ContentFilter
: FinishReason::Length;
return match ($data['incomplete_details']['reason'] ?? null) {
'max_output_tokens' => FinishReason::Length,
'content_filter' => FinishReason::ContentFilter,
default => FinishReason::Unknown,
};
}

$lastOutput = last($data['output'] ?? []);
Expand Down
10 changes: 9 additions & 1 deletion src/ai/src/Gateway/OpenAi/OpenAiClassificationGateway.php
Original file line number Diff line number Diff line change
Expand Up @@ -130,12 +130,20 @@ protected function mapImage(File|UploadedFile $image): array
throw new InvalidArgumentException("OpenAI decisions only accept JPEG, PNG, GIF, or WebP images; [{$mime}] given.");
}

return array_merge($image->providerOptions(Lab::OpenAI), [
return array_merge($image->providerOptions($this->providerOptionsKey()), [
'type' => 'input_image',
'image_url' => 'data:' . $mime . ';base64,' . base64_encode($content),
]);
}

/**
* Get the provider key used to resolve image attachment options.
*/
protected function providerOptionsKey(): Lab
{
return Lab::OpenAI;
}

/**
* Map a question to the OpenAI decisions wire format.
*/
Expand Down
18 changes: 10 additions & 8 deletions src/ai/src/Gateway/OpenAi/OpenAiStoreGateway.php
Original file line number Diff line number Diff line change
Expand Up @@ -62,15 +62,17 @@ public function createStore(
$response = $this->withErrorHandling(
$provider->name(),
fn () => $this->client($provider)
->post('vector_stores', array_filter([
->post('vector_stores', [
'name' => $name,
'metadata' => filled($description) ? ['description' => $description] : null,
'file_ids' => $fileIds->values()->all(),
'expires_after' => $expiresWhenIdleFor instanceof DateInterval ? [
'anchor' => 'last_active_at',
'days' => $this->intervalToDays($expiresWhenIdleFor),
] : null,
]))
...array_filter([
'metadata' => filled($description) ? ['description' => $description] : null,
'file_ids' => $fileIds->values()->all(),
'expires_after' => $expiresWhenIdleFor instanceof DateInterval ? [
'anchor' => 'last_active_at',
'days' => $this->intervalToDays($expiresWhenIdleFor),
] : null,
]),
])
);

return $this->getStore($provider, $response->json('id'));
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
<?php

declare(strict_types=1);

namespace Hypervel\Ai\Gateway\OpenAiCompatible;

use Hypervel\Ai\Enums\Lab;
use Hypervel\Ai\Gateway\OpenAi\OpenAiClassificationGateway;
use Hypervel\Ai\Gateway\OpenAiCompatible\Concerns\CreatesOpenAiCompatibleClient;
use Override;

class OpenAiCompatibleClassificationGateway extends OpenAiClassificationGateway
{
use CreatesOpenAiCompatibleClient;
Comment thread
greptile-apps[bot] marked this conversation as resolved.

/**
* Get the provider key used to resolve image attachment options.
*/
#[Override]
protected function providerOptionsKey(): Lab
{
return Lab::OpenAICompatible;
}
}
61 changes: 15 additions & 46 deletions src/ai/src/Gateway/OpenRouter/OpenRouterGateway.php
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,6 @@
use Hypervel\Ai\Contracts\Providers\SupportsWebFetch;
use Hypervel\Ai\Contracts\Providers\SupportsWebSearch;
use Hypervel\Ai\Contracts\Providers\TranscriptionProvider;
use Hypervel\Ai\Files\Image;
use Hypervel\Ai\Gateway\Concerns\HandlesFailoverErrors;
use Hypervel\Ai\Gateway\Concerns\ParsesServerSentEvents;
use Hypervel\Ai\Gateway\Concerns\WrapsPcmAudio;
Expand Down Expand Up @@ -127,66 +126,36 @@ public function generateImage(
array $providerOptions = [],
): ImageResponse {
/** @var ImageProvider&Provider $provider */
$imageOptions = $provider->defaultImageOptions($size, $quality);

$imageConfig = array_filter([
'aspect_ratio' => $imageOptions['aspect_ratio'] ?? null,
'image_size' => $imageOptions['image_size'] ?? null,
]);
$imageConfig = Arr::pull($providerOptions, 'image_config', []);

$response = $this->withErrorHandling(
$provider->name(),
fn () => $this->client($provider, $timeout ?? 120)
->post('chat/completions', array_merge($providerOptions, array_filter([
->post('images', array_merge($providerOptions, array_filter([
'aspect_ratio' => $imageConfig['aspect_ratio'] ?? null,
'resolution' => $imageConfig['image_size'] ?? null,
]), array_filter([
'model' => $model,
'messages' => $this->buildImageMessages($prompt, $attachments),
'modalities' => ['image'],
'image_config' => $imageConfig ?: null,
])))
'prompt' => $prompt,
...$provider->defaultImageOptions($size, $quality),
'input_references' => $this->mapAttachments(collect($attachments)) ?: null,
], fn (mixed $value): bool => $value !== null)))
);

$data = $response->json();

$this->validateTextResponse($data);

$message = $data['choices'][0]['message'] ?? [];

$images = collect($message['images'] ?? [])->map(function (array $image): ?GeneratedImage {
$url = $image['image_url']['url'] ?? '';

if (preg_match('/^data:(image\/[\w+.-]+);base64,(.+)$/', $url, $matches)) {
return new GeneratedImage($matches[2], $matches[1]);
}

return null;
})->filter()->values();

$usage = $data['usage'] ?? [];

return new ImageResponse(
$images,
new ImageUsage($usage['prompt_tokens'] ?? 0, $usage['completion_tokens'] ?? 0),
new Meta($provider->name(), $data['model'] ?? $model),
collect($data['data'] ?? [])->map(fn (array $image): GeneratedImage => new GeneratedImage(
$image['b64_json'] ?? '',
$image['media_type'] ?? 'image/png',
)),
new ImageUsage($data['usage']['prompt_tokens'] ?? 0, $data['usage']['completion_tokens'] ?? 0),
new Meta($provider->name(), $model),
);
}

/**
* Build the messages array for an image generation request.
*
* @param array<Image> $attachments
*/
protected function buildImageMessages(string $prompt, array $attachments): array
{
if ($attachments === []) {
return [['role' => 'user', 'content' => $prompt]];
}

return [['role' => 'user', 'content' => array_merge(
[['type' => 'text', 'text' => $prompt]],
$this->mapAttachments(collect($attachments)),
)]];
}

/**
* Generate audio from the given text.
*
Expand Down
Loading
Loading