diff --git a/.cursor/commands/fix-stan.md b/.cursor/commands/fix-stan.md deleted file mode 100644 index 78b3d84..0000000 --- a/.cursor/commands/fix-stan.md +++ /dev/null @@ -1 +0,0 @@ -Use `composer stan --no-progress` to see the PHPStan failures, and then fix them. diff --git a/.editorconfig b/.editorconfig index 6105737..95092e3 100644 --- a/.editorconfig +++ b/.editorconfig @@ -11,6 +11,6 @@ trim_trailing_whitespace = true [*.md] trim_trailing_whitespace = false -[*.yml,*.yaml,*.neon] +[*.yml,*.yaml,*.neon,*.tsx,*.ts] indent_style = space indent_size = 2 diff --git a/.gitattributes b/.gitattributes index 2e91244..59fd085 100644 --- a/.gitattributes +++ b/.gitattributes @@ -8,6 +8,13 @@ /README.md export-ignore /testbench.yaml export-ignore /ecs.php export-ignore +/rector.php export-ignore /tests export-ignore /phpstan.neon export-ignore /workbench export-ignore +/scratchpad.php export-ignore +/phpstan.dist.neon export-ignore +/package.json export-ignore +/vite.config.ts export-ignore +/tsconfig.json export-ignore +/eslint.config.js export-ignore diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 189ffd8..9856038 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -7,7 +7,7 @@ updates: labels: - "dependencies" - "composer" - versioning-strategy: "widen" + versioning-strategy: "increase-if-necessary" open-pull-requests-limit: 10 - package-ecosystem: "github-actions" diff --git a/.github/workflows/run-tests.yml b/.github/workflows/run-tests.yml index 4a603f9..11e0d5f 100644 --- a/.github/workflows/run-tests.yml +++ b/.github/workflows/run-tests.yml @@ -1,5 +1,9 @@ name: Tests +permissions: + contents: read + pull-requests: write + on: [push] concurrency: diff --git a/.github/workflows/static-analysis.yml b/.github/workflows/static-analysis.yml index ee0f8c3..3c83f94 100644 --- a/.github/workflows/static-analysis.yml +++ b/.github/workflows/static-analysis.yml @@ -1,5 +1,9 @@ name: Static Analysis +permissions: + contents: read + pull-requests: write + on: [push] concurrency: @@ -65,5 +69,8 @@ jobs: restore-keys: | ecs-cache- - - name: Run format checks - run: composer format --no-progress-bar + - name: Run Rector + run: ./vendor/bin/rector process --dry-run --no-progress-bar --no-diffs --output-format=github + + - name: Run ECS + run: ./vendor/bin/ecs check --no-progress-bar --no-diffs --output-format=checkstyle diff --git a/.gitignore b/.gitignore index ac4ef68..4cb990f 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,5 @@ composer.lock .env .DS_Store .phpstan-cache +node_modules +public diff --git a/composer.json b/composer.json index f92a4ef..e2356c0 100644 --- a/composer.json +++ b/composer.json @@ -17,24 +17,31 @@ ], "require": { "php": "^8.4", - "adhocore/json-fixer": "^1.0", - "cortexphp/json-schema": "dev-main", + "cortexphp/json-repair": "^0.4", + "cortexphp/json-schema": "^1.0", "cortexphp/model-info": "^0.3", - "illuminate/collections": "^12.0", + "guzzlehttp/psr7": "^2.8", + "illuminate/collections": "^12.49", "laravel/prompts": "^0.3.8", - "mozex/anthropic-php": "^1.1", "openai-php/client": "^0.18", "php-mcp/client": "^1.0", "psr-discovery/cache-implementations": "^1.2", "psr-discovery/event-dispatcher-implementations": "^1.1", "react/async": "^4.3", - "spatie/laravel-package-tools": "^1.17" + "saloonphp/cache-plugin": "^3.0", + "saloonphp/saloon": "^3.14", + "spatie/laravel-package-tools": "^1.17", + "spatie/yaml-front-matter": "^2.1" }, "require-dev": { + "beyondcode/laravel-dump-server": "^2.1", "guzzlehttp/guzzle": "^7.9", "hkulekci/qdrant": "^0.5.8", + "inertiajs/inertia-laravel": "^2.0", + "laravel/wayfinder": "^0.1.13", "league/event": "^3.0", "mockery/mockery": "^1.6", + "monolog/monolog": "^3.10", "orchestra/testbench": "^10.6", "pestphp/pest": "^4.0", "pestphp/pest-plugin-type-coverage": "^4.0", @@ -60,7 +67,7 @@ } }, "scripts": { - "test": "pest", + "test": "pest --parallel", "ecs": "ecs check --fix", "rector": "rector process", "stan": "phpstan analyse", @@ -86,6 +93,10 @@ "Composer\\Config::disableProcessTimeout", "@build", "@php vendor/bin/testbench serve --ansi" + ], + "dev": [ + "Composer\\Config::disableProcessTimeout", + "npx concurrently 'composer serve' 'npm run dev' -c '#a78bfa,#34d399' --names=server,vite --kill-others" ] }, "config": { diff --git a/config/cortex.php b/config/cortex.php index 6f38310..ba77e2a 100644 --- a/config/cortex.php +++ b/config/cortex.php @@ -3,8 +3,8 @@ declare(strict_types=1); use Cortex\LLM\Enums\LLMDriver; +use Cortex\LLM\Enums\StreamingProtocol; use Cortex\Agents\Prebuilt\WeatherAgent; -use Cortex\ModelInfo\Enums\ModelProvider; use Cortex\ModelInfo\Providers\OllamaModelInfoProvider; use Cortex\ModelInfo\Providers\LiteLLMModelInfoProvider; use Cortex\ModelInfo\Providers\LMStudioModelInfoProvider; @@ -23,33 +23,23 @@ 'llm' => [ 'default' => env('CORTEX_DEFAULT_LLM', 'openai'), - 'openai' => [ - 'driver' => LLMDriver::OpenAIChat, - 'options' => [ - 'api_key' => env('OPENAI_API_KEY', ''), - 'base_uri' => env('OPENAI_BASE_URI'), - 'organization' => env('OPENAI_ORGANIZATION'), - ], - 'default_model' => 'gpt-4.1-mini', - 'default_parameters' => [ - 'temperature' => null, - 'max_tokens' => 1024, - 'top_p' => null, - ], + 'cache' => [ + 'enabled' => env('CORTEX_LLM_CACHE_ENABLED', false), + 'store' => env('CORTEX_LLM_CACHE_STORE'), + 'ttl' => env('CORTEX_LLM_CACHE_TTL', 3600), ], - 'openai_responses' => [ + 'openai' => [ 'driver' => LLMDriver::OpenAIResponses, - 'model_provider' => ModelProvider::OpenAI, 'options' => [ 'api_key' => env('OPENAI_API_KEY', ''), 'base_uri' => env('OPENAI_BASE_URI'), 'organization' => env('OPENAI_ORGANIZATION'), ], - 'default_model' => 'gpt-5-mini', + 'default_model' => 'gpt-4.1-mini', 'default_parameters' => [ 'temperature' => null, - 'max_tokens' => null, + 'max_output_tokens' => 1024, 'top_p' => null, ], ], @@ -68,41 +58,42 @@ ], ], - 'groq' => [ + 'ollama' => [ 'driver' => LLMDriver::OpenAIChat, + // 'driver' => LLMDriver::Anthropic, 'options' => [ - 'api_key' => env('GROQ_API_KEY', ''), - 'base_uri' => env('GROQ_BASE_URI', 'https://api.groq.com/openai/v1'), + 'api_key' => 'ollama', + 'base_uri' => env('OLLAMA_BASE_URI', 'http://localhost:11434/v1'), ], - 'default_model' => 'llama-3.1-8b-instant', + 'default_model' => 'gpt-oss:20b', 'default_parameters' => [ 'temperature' => null, - 'max_tokens' => null, + 'max_tokens' => 1024, 'top_p' => null, ], ], - 'ollama' => [ - 'driver' => LLMDriver::OpenAIChat, + 'lmstudio' => [ + 'driver' => LLMDriver::OpenAIResponses, 'options' => [ - 'api_key' => 'ollama', - 'base_uri' => env('OLLAMA_BASE_URI', 'http://localhost:11434/v1'), + 'api_key' => 'lmstudio', + 'base_uri' => env('LMSTUDIO_BASE_URI', 'http://localhost:1234/v1'), ], - 'default_model' => 'gemma3:12b', + 'default_model' => 'openai/gpt-oss-20b', 'default_parameters' => [ 'temperature' => null, - 'max_tokens' => null, + 'max_tokens' => 1024, 'top_p' => null, ], ], - 'lmstudio' => [ + 'groq' => [ 'driver' => LLMDriver::OpenAIChat, 'options' => [ - 'api_key' => 'lmstudio', - 'base_uri' => env('LMSTUDIO_BASE_URI', 'http://localhost:1234/v1'), + 'api_key' => env('GROQ_API_KEY', ''), + 'base_uri' => env('GROQ_BASE_URI', 'https://api.groq.com/openai/v1'), ], - 'default_model' => 'qwen2.5-14b-instruct-mlx', + 'default_model' => 'llama-3.1-8b-instant', 'default_parameters' => [ 'temperature' => null, 'max_tokens' => null, @@ -365,4 +356,16 @@ 'agents' => [ WeatherAgent::class, ], + + /* + |-------------------------------------------------------------------------- + | Default Streaming Protocol + |-------------------------------------------------------------------------- + | + | The default streaming protocol to use for streaming responses. + | + | Supported protocols: "raw", "agui", "vercel", "text" + | + */ + 'default_streaming_protocol' => StreamingProtocol::Vercel, ]; diff --git a/docs/assets/favicon.svg b/docs/assets/favicon.svg new file mode 100644 index 0000000..3d52675 --- /dev/null +++ b/docs/assets/favicon.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/docs/assets/logo-dark.svg b/docs/assets/logo-dark.svg new file mode 100644 index 0000000..ff1b3a8 --- /dev/null +++ b/docs/assets/logo-dark.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/docs/assets/logo-light.svg b/docs/assets/logo-light.svg new file mode 100644 index 0000000..01e533e --- /dev/null +++ b/docs/assets/logo-light.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/docs/assets/providers/openai.svg b/docs/assets/providers/openai.svg new file mode 100644 index 0000000..338e1de --- /dev/null +++ b/docs/assets/providers/openai.svg @@ -0,0 +1,3 @@ + + + diff --git a/docs/cortex/agents/agent-builder.mdx b/docs/cortex/agents/agent-builder.mdx new file mode 100644 index 0000000..dced36f --- /dev/null +++ b/docs/cortex/agents/agent-builder.mdx @@ -0,0 +1,164 @@ +--- +title: Agent Builder +description: 'Configure agents inline with the fluent GenericAgentBuilder API.' +icon: 'bot-message-square' +--- + +`Cortex::agent()` with no arguments returns a `GenericAgentBuilder` — a fluent builder for constructing agents inline without defining a class. + +## Basic Usage + +```php +use Cortex\Cortex; +use Cortex\JsonSchema\Schema; +use Cortex\Tools\Prebuilt\GetCurrentWeatherTool; + +$result = Cortex::agent() + ->withId('weather_agent') + ->withPrompt('You are a weather agent. You tell the weather in {location}.') + ->withLLM('openai', 'gpt-4.1-mini') + ->withTools([GetCurrentWeatherTool::class]) + ->withOutput([ + Schema::string('location')->required(), + Schema::string('summary')->required(), + ]) + ->withMaxSteps(3) + ->withStrict(true) + ->invoke(input: ['location' => 'London']); + +$data = $result->parsedOutput(); +// ['location' => 'London', 'summary' => '...'] +``` + +## Builder Methods + +### `withId(string)` + +Sets the agent's identifier. Used when registering the agent in the registry. + +```php +->withId('my_agent') +``` + +### `withPrompt(string|ChatPromptBuilder|ChatPromptTemplate)` + +Sets the system prompt. Accepts a plain string, a `ChatPromptBuilder`, or a pre-built `ChatPromptTemplate`: + +```php +->withPrompt('You are a helpful assistant.') + +// Or a full prompt builder +->withPrompt( + Cortex::prompt([ + new SystemMessage('You are an expert at {domain}.'), + ]) +) +``` + +### `withLLM(string|LLM)` + +Sets the LLM provider and optionally the model. Accepts a provider name string, a shortcut string, or an LLM instance: + +```php +->withLLM('openai') +->withLLM('anthropic', 'claude-3-7-sonnet-20250219') +->withLLM('lmstudio/openai/gpt-oss-20b') +->withLLM(Cortex::llm('groq')->withTemperature(0.3)) +``` + +### `withTools(array)` + +Attaches tools the agent can call. Accepts class names, instances, or closures: + +```php +->withTools([ + GetCurrentWeatherTool::class, + FetchUrlTool::class, + function (string $query): string { + return search($query); + }, +]) +``` + +### `withOutput(array|ObjectSchema|string)` + +Defines the structured output schema. The agent will parse and validate the LLM response against this schema: + +```php +// Array of JsonSchema properties (wrapped in ObjectSchema automatically) +->withOutput([ + Schema::string('name')->required(), + Schema::integer('age')->required(), +]) + +// Full ObjectSchema +->withOutput(Schema::object()->properties( + Schema::string('name')->required(), +)) + +// PHP class +->withOutput(MyResponseClass::class) +``` + +### `withMaxSteps(int)` + +Maximum number of tool-call iterations before the agent stops. Default: `5`. + +```php +->withMaxSteps(10) +``` + +### `withStrict(bool)` + +When `true`, the agent enforces the input schema and structured output schema strictly. Default: `true`. + +```php +->withStrict(false) +``` + +### `withMiddleware(array)` + +Attach custom middleware to the agent pipeline. See [Middleware](/cortex/agents/middleware). + +```php +->withMiddleware([new MyLoggingMiddleware()]) +``` + +### `withInitialPromptVariables(array)` + +Pre-fill prompt variables that are fixed for the lifetime of the agent: + +```php +->withInitialPromptVariables(['language' => 'French']) +``` + +## Invoking the Built Agent + +The builder exposes `invoke()` and `stream()` directly, which internally call `build()->invoke()`: + +```php +// Blocking +$result = Cortex::agent() + ->withPrompt('...') + ->invoke(input: ['key' => 'value']); + +// Streaming +$stream = Cortex::agent() + ->withPrompt('...') + ->stream(input: ['key' => 'value']); +``` + +## Getting the Agent Instance + +If you need the `Agent` object itself (e.g. to register it or attach event listeners), call `build()`: + +```php +$agent = Cortex::agent() + ->withId('my_agent') + ->withPrompt('...') + ->build(); + +$agent->onEnd(fn($e) => logger()->info('Done')); + +$result = $agent->invoke(input: [...]); +``` diff --git a/docs/cortex/agents/custom-agents.mdx b/docs/cortex/agents/custom-agents.mdx new file mode 100644 index 0000000..449c253 --- /dev/null +++ b/docs/cortex/agents/custom-agents.mdx @@ -0,0 +1,161 @@ +--- +title: Custom Agents +description: 'Define reusable, registerable agents as classes with AbstractAgentBuilder.' +icon: 'brain-cog' +--- + +For agents you use repeatedly across your application, define them as a class by extending `AbstractAgentBuilder`. This makes them registerable, injectable, and easy to test. + +## Creating an Agent Class + +Extend `AbstractAgentBuilder` and implement `id()` and `prompt()`. Override any other methods you need: + +```php +use Cortex\Cortex; +use Cortex\Agents\AbstractAgentBuilder; +use Cortex\LLM\Contracts\LLM; +use Cortex\LLM\Data\Messages\SystemMessage; +use Cortex\Prompts\Builders\ChatPromptBuilder; +use Cortex\Prompts\Templates\ChatPromptTemplate; +use Cortex\Tools\Prebuilt\GetCurrentWeatherTool; + +class WeatherAgent extends AbstractAgentBuilder +{ + public static function id(): string + { + return 'weather'; + } + + public function name(): ?string + { + return 'Weather Assistant'; + } + + public function description(): ?string + { + return 'Provides accurate weather information for any location.'; + } + + public function prompt(): ChatPromptTemplate|ChatPromptBuilder|string + { + return Cortex::prompt([ + new SystemMessage( + 'You are a helpful weather assistant. Use the get_weather tool to fetch current weather data.' + ), + ]); + } + + public function llm(): LLM|string|null + { + return Cortex::llm('openai', 'gpt-4.1-mini'); + } + + public function tools(): array + { + return [ + GetCurrentWeatherTool::class, + ]; + } +} +``` + +## Methods to Override + +| Method | Return Type | Default | Description | +|--------|-------------|---------|-------------| +| `id()` | `string` | — | **Required.** Unique identifier for the agent | +| `prompt()` | `string\|ChatPromptBuilder\|ChatPromptTemplate` | — | **Required.** The system prompt | +| `name()` | `?string` | `null` | Human-readable name | +| `description()` | `?string` | `null` | Description (also used when agent is used as a tool) | +| `llm()` | `LLM\|string\|null` | `null` (uses default) | The LLM to use | +| `tools()` | `array\|ToolKit` | `[]` | Tools available to the agent | +| `toolChoice()` | `ToolChoice` | `ToolChoice::Auto` | How the model selects tools | +| `output()` | `ObjectSchema\|array\|string\|null` | `null` | Structured output schema | +| `outputMode()` | `StructuredOutputMode` | `Auto` | Structured output mode | +| `memoryStore()` | `?Store` | `null` (in-memory) | Custom memory store | +| `maxSteps()` | `int` | `5` | Max tool-call iterations | +| `strict()` | `bool` | `true` | Enforce input/output schemas | +| `initialPromptVariables()` | `array` | `[]` | Pre-filled prompt variables | +| `middleware()` | `array` | `[]` | Custom middleware | + +## Instantiating and Invoking + +### `make()` — Build and return an `Agent` + +```php +$agent = WeatherAgent::make(); +$result = $agent->invoke(input: ['location' => 'Paris']); +``` + +### `build()` — Build via the instance + +```php +$builder = new WeatherAgent(); +$agent = $builder->build(); +``` + +### `invoke()` / `stream()` — Shortcut on the builder + +```php +$result = WeatherAgent::make()->invoke(input: ['location' => 'Paris']); + +// Or via the builder instance +$result = (new WeatherAgent())->invoke(input: ['location' => 'Paris']); +``` + +## Registering Agents + +### Via config + +Add the class to the `agents` array in `config/cortex.php`: + +```php +'agents' => [ + WeatherAgent::class, + App\Agents\SupportAgent::class, +], +``` + +### Via service provider + +```php +use Cortex\Cortex; + +public function boot(): void +{ + Cortex::registerAgent(WeatherAgent::class); + + // Or register with a name override + Cortex::registerAgent(WeatherAgent::class, 'my_weather_agent'); +} +``` + +### Retrieving from the registry + +```php +$agent = Cortex::agent('weather'); // Uses the id() from the class +$result = $agent->invoke(input: ['location' => 'London']); +``` + +## Dependency Injection + +`AbstractAgentBuilder::make()` resolves the class through the Laravel service container, so constructor dependencies are injected automatically: + +```php +class SupportAgent extends AbstractAgentBuilder +{ + public function __construct( + private readonly KnowledgeBaseService $kb, + ) {} + + public function tools(): array + { + return [ + new SearchKnowledgeBaseTool($this->kb), + ]; + } +} + +// Container resolves KnowledgeBaseService automatically +$agent = SupportAgent::make(); +``` diff --git a/docs/cortex/agents/middleware.mdx b/docs/cortex/agents/middleware.mdx new file mode 100644 index 0000000..6afe1fd --- /dev/null +++ b/docs/cortex/agents/middleware.mdx @@ -0,0 +1,146 @@ +--- +title: Middleware +description: 'Hook into the agent pipeline with before/after middleware.' +icon: 'layers' +--- + +Agent middleware lets you intercept and modify the agent pipeline at three points: before the prompt is formatted, before the LLM is called, and after the LLM responds. + +## Middleware Types + +| Interface | Fires | Use For | +|-----------|-------|---------| +| `BeforePromptMiddleware` | Before the prompt template is rendered | Injecting context, modifying variables | +| `BeforeModelMiddleware` | After the prompt is rendered, before the LLM call | Logging requests, modifying messages | +| `AfterModelMiddleware` | After the LLM responds | Logging responses, post-processing output | + +## Creating Middleware + +Implement the relevant interface and define the `handle()` method: + +```php +use Cortex\Agents\Contracts\BeforeModelMiddleware; +use Cortex\LLM\Data\Messages\MessageCollection; +use Cortex\Pipeline\RuntimeConfig; + +class LogRequestMiddleware implements BeforeModelMiddleware +{ + public function handle(MessageCollection $messages, RuntimeConfig $config, callable $next): mixed + { + logger()->info('LLM request', [ + 'message_count' => $messages->count(), + 'thread_id' => $config->threadId, + ]); + + return $next($messages, $config); + } +} +``` + +```php +use Cortex\Agents\Contracts\AfterModelMiddleware; +use Cortex\LLM\Data\ChatResult; +use Cortex\Pipeline\RuntimeConfig; + +class LogResponseMiddleware implements AfterModelMiddleware +{ + public function handle(ChatResult $result, RuntimeConfig $config, callable $next): mixed + { + logger()->info('LLM response', [ + 'content' => $result->content(), + 'tokens' => $result->usage()?->totalTokens, + ]); + + return $next($result, $config); + } +} +``` + +## Attaching Middleware + +### On an agent class + +Override `middleware()` in your `AbstractAgentBuilder` subclass: + +```php +public function middleware(): array +{ + return [ + new LogRequestMiddleware(), + new LogResponseMiddleware(), + ]; +} +``` + +### On the builder + +```php +Cortex::agent() + ->withPrompt('...') + ->withMiddleware([ + new LogRequestMiddleware(), + new LogResponseMiddleware(), + ]) + ->invoke(input: [...]); +``` + +### On an Agent instance + +Pass middleware to the `Agent` constructor: + +```php +$agent = new Agent( + id: 'my_agent', + prompt: '...', + middleware: [ + new LogRequestMiddleware(), + ], +); +``` + +## Default Middleware + +Every agent runs two built-in middleware automatically: + +| Middleware | Description | +|------------|-------------| +| `AppendUsageMiddleware` | Accumulates token usage across all steps | +| `AddMessageToMemoryMiddleware` | Saves each LLM response to the agent's memory | + +Custom middleware is appended after these defaults. + +## Skill Middleware + +`SkillMiddleware` is a special built-in middleware used by `SkillsAgent`. It injects skill instructions into the prompt before the model is called, enabling the agent to dynamically load and use skills from the skill registry. + +```php +use Cortex\Agents\Middleware\SkillMiddleware; + +$agent = new Agent( + id: 'skills_agent', + prompt: 'You are a helpful assistant with access to skills.', + middleware: [new SkillMiddleware()], +); +``` + +## Using AbstractMiddleware + +For convenience, extend `AbstractMiddleware` which provides a `$next` helper and default pass-through behaviour: + +```php +use Cortex\Agents\Middleware\AbstractMiddleware; + +class TimingMiddleware extends AbstractMiddleware implements BeforeModelMiddleware +{ + public function handle(mixed $payload, RuntimeConfig $config, callable $next): mixed + { + $start = microtime(true); + $result = $next($payload, $config); + $elapsed = microtime(true) - $start; + + logger()->info('LLM call took', ['ms' => round($elapsed * 1000)]); + + return $result; + } +} +``` diff --git a/docs/cortex/agents/overview.mdx b/docs/cortex/agents/overview.mdx new file mode 100644 index 0000000..b7cf9a4 --- /dev/null +++ b/docs/cortex/agents/overview.mdx @@ -0,0 +1,129 @@ +--- +title: Overview +description: 'Build autonomous agents that reason, use tools, and maintain memory.' +icon: 'bot' +--- + +An agent in Cortex is a combination of a **prompt**, an **LLM**, and optional **tools**. When invoked, the agent runs a pipeline that handles the full tool-call loop: calling the LLM, executing any requested tools, feeding results back, and repeating until the model produces a final response or the step limit is reached. + +## Creating an Agent + +The simplest way to create an agent is with the `Agent` class directly: + +```php +use Cortex\Agents\Agent; + +$agent = new Agent( + id: 'joke_generator', + prompt: 'You are a joke generator. You generate jokes about {topic}.', + llm: 'openai', +); + +$result = $agent->invoke(input: ['topic' => 'programming']); + +echo $result->content(); +``` + +## Invoking an Agent + +Agents have two invocation methods: + +```php +// Blocking — waits for the full response +$result = $agent->invoke(input: ['topic' => 'programming']); + +// Streaming — returns a ChatStreamResult +$stream = $agent->stream(input: ['topic' => 'programming']); +foreach ($stream as $chunk) { + echo $chunk->content(); +} +``` + +Both methods accept: + +| Parameter | Type | Description | +|-----------|------|-------------| +| `messages` | `array\|MessageCollection\|string` | Additional messages to prepend to the conversation | +| `input` | `array` | Variables to substitute into the prompt template | +| `config` | `RuntimeConfig` | Optional runtime config (thread ID, state, etc.) | + +## Memory + +Every agent maintains a `ChatMemory` instance that persists messages across steps within a single invocation. By default, memory uses an in-memory store (not persisted between requests). + +To persist memory across requests, use the `CacheStore`: + +```php +use Cortex\Agents\Agent; +use Cortex\Memory\Stores\CacheStore; + +$agent = new Agent( + id: 'assistant', + prompt: 'You are a helpful assistant.', + memoryStore: new CacheStore(threadId: $request->session()->getId()), +); +``` + +The agent will automatically recall previous messages for the same `threadId`. + +## Steps and Usage + +After invocation, you can inspect what happened: + +```php +$result = $agent->invoke(input: ['location' => 'Paris']); + +// All steps taken (including tool call steps) +$steps = $agent->getSteps(); + +// Total token usage across all steps +$usage = $agent->getTotalUsage(); +echo $usage->totalTokens; + +// The parsed structured output (if output schema was set) +$parsed = $agent->getParsedOutput(); +``` + +## Lifecycle Events + +Register callbacks to react to agent lifecycle events: + +```php +$agent->onStart(fn($event) => logger()->info('Agent started', ['id' => $event->agent->getId()])); +$agent->onEnd(fn($event) => logger()->info('Agent finished')); +$agent->onStepStart(fn($event) => logger()->info('Step started')); +$agent->onStepEnd(fn($event) => logger()->info('Step finished')); +$agent->onStepError(fn($event) => logger()->error('Step error', ['e' => $event->exception])); +$agent->onChunk(fn($event) => /* handle stream chunk */); +``` + +## Agent as a Tool + +An agent can be wrapped as a tool and used by another agent: + +```php +$subAgent = new Agent(id: 'researcher', prompt: 'Research {topic} thoroughly.'); + +$parentAgent = new Agent( + id: 'coordinator', + prompt: 'You coordinate research tasks.', + tools: [$subAgent->asTool(name: 'research', description: 'Research a topic')], +); +``` + +## Next Steps + + + + Use the fluent builder API to configure agents inline. + + + Define reusable agents as classes with AbstractAgentBuilder. + + + Hook into the agent pipeline with custom middleware. + + + Give your agents tools to call external APIs and functions. + + diff --git a/docs/cortex/configuration.mdx b/docs/cortex/configuration.mdx new file mode 100644 index 0000000..517776c --- /dev/null +++ b/docs/cortex/configuration.mdx @@ -0,0 +1,221 @@ +--- +title: Configuration +description: 'A reference for all options in config/cortex.php.' +icon: 'settings' +--- + +Publish the config file if you haven't already: + +```bash +php artisan vendor:publish --tag=cortex-config +``` + +This creates `config/cortex.php` with the following sections. + +--- + +## LLM Providers + +```php config/cortex.php +'llm' => [ + 'default' => env('CORTEX_DEFAULT_LLM', 'openai'), + ... +] +``` + +The `default` key controls which provider is used when you call `Cortex::llm()` with no arguments. + +### Cache + +```php +'cache' => [ + 'enabled' => env('CORTEX_LLM_CACHE_ENABLED', false), + 'store' => env('CORTEX_LLM_CACHE_STORE'), + 'ttl' => env('CORTEX_LLM_CACHE_TTL', 3600), +], +``` + +When enabled, identical LLM requests are cached. Useful for development and testing. + +### Provider Configuration + +Each provider entry follows the same shape: + +```php +'openai' => [ + 'driver' => LLMDriver::OpenAIResponses, + 'options' => [ + 'api_key' => env('OPENAI_API_KEY', ''), + 'base_uri' => env('OPENAI_BASE_URI'), + 'organization' => env('OPENAI_ORGANIZATION'), + ], + 'default_model' => 'gpt-4.1-mini', + 'default_parameters' => [ + 'temperature' => null, + 'max_output_tokens' => 1024, + 'top_p' => null, + ], +], +``` + +| Key | Description | +|-----|-------------| +| `driver` | The underlying driver: `openai_chat`, `openai_responses`, or `anthropic` | +| `options` | Provider-specific connection options (API key, base URI, etc.) | +| `default_model` | The model used when none is specified | +| `default_parameters` | Default generation parameters sent with every request | + +### Built-in Providers + +| Provider | Driver | Env Var | +|----------|--------|---------| +| `openai` | `openai_responses` | `OPENAI_API_KEY` | +| `anthropic` | `anthropic` | `ANTHROPIC_API_KEY` | +| `ollama` | `openai_chat` | `OLLAMA_BASE_URI` | +| `lmstudio` | `openai_responses` | `LMSTUDIO_BASE_URI` | +| `groq` | `openai_chat` | `GROQ_API_KEY` | +| `xai` | `openai_chat` | `XAI_API_KEY` | +| `gemini` | `openai_chat` | `GEMINI_API_KEY` | +| `mistral` | `openai_chat` | `MISTRAL_API_KEY` | +| `together` | `openai_chat` | `TOGETHER_API_KEY` | +| `openrouter` | `openai_chat` | `OPENROUTER_API_KEY` | +| `deepseek` | `openai_chat` | `DEEPSEEK_API_KEY` | +| `github` | `openai_chat` | `GITHUB_API_KEY` | + +--- + +## MCP Servers + +```php +'mcp_servers' => [ + 'default' => env('CORTEX_DEFAULT_MCP_SERVER', 'local_http'), + + 'local_http' => [ + 'transport' => 'http', + 'url' => 'http://localhost:3001/sse', + ], + + 'tavily' => [ + 'transport' => 'http', + 'url' => 'https://mcp.tavily.com/mcp/?tavilyApiKey=' . env('TAVILY_API_KEY'), + ], +], +``` + +MCP servers can be used as tool sources or prompt factories. See [MCP Tools](/cortex/tools/mcp-tools) for usage. + +--- + +## Prompt Factories + +```php +'prompt_factory' => [ + 'default' => env('CORTEX_DEFAULT_PROMPT_FACTORY', 'langfuse'), + ... +] +``` + +### Langfuse + +```php +'langfuse' => [ + 'username' => env('LANGFUSE_USERNAME', ''), + 'password' => env('LANGFUSE_PASSWORD', ''), + 'base_uri' => env('LANGFUSE_BASE_URI', 'https://cloud.langfuse.com'), + 'cache' => [ + 'enabled' => env('CORTEX_PROMPT_CACHE_ENABLED', false), + 'store' => env('CORTEX_PROMPT_CACHE_STORE'), + 'ttl' => env('CORTEX_PROMPT_CACHE_TTL', 3600), + ], +], +``` + +### Blade + +```php +'blade' => [ + 'path' => 'resources/views/prompts', +], +``` + +### MCP + +```php +'mcp' => [ + 'server' => env('CORTEX_MCP_PROMPT_SERVER', 'local_http'), +], +``` + +See [Prompt Factories](/cortex/prompts/prompt-factories) for full usage details. + +--- + +## Embeddings + +```php +'embedding' => [ + 'default' => env('CORTEX_DEFAULT_EMBEDDING_DRIVER', 'openai'), + + 'openai' => [ + 'driver' => 'openai', + 'options' => ['api_key' => env('OPENAI_API_KEY', '')], + 'default_model' => 'text-embedding-3-small', + 'default_dimensions' => 1536, + ], + + 'ollama' => [ + 'driver' => 'ollama', + 'options' => ['base_url' => env('OLLAMA_BASE_URI', 'http://localhost:1234')], + 'default_model' => 'nomic-embed-text', + 'default_dimensions' => 768, + ], +], +``` + +--- + +## Model Info + +```php +'model_info' => [ + 'ignore_features' => env('CORTEX_MODEL_INFO_IGNORE_FEATURES', true), + 'providers' => [ + OllamaModelInfoProvider::class => ['host' => env('OLLAMA_BASE_URI')], + LMStudioModelInfoProvider::class => ['host' => env('LMSTUDIO_BASE_URI')], + LiteLLMModelInfoProvider::class => ['host' => env('LITELLM_BASE_URI'), 'apiKey' => env('LITELLM_API_KEY')], + ], +], +``` + +When `ignore_features` is `false`, Cortex checks whether a model supports a requested feature (e.g. structured output, tool use) before attempting to use it. Set to `true` to skip these checks. + +--- + +## Agents + +```php +'agents' => [ + WeatherAgent::class, +], +``` + +Classes listed here are automatically registered in the agent registry on boot. You can also register agents manually in a service provider: + +```php +use Cortex\Cortex; + +public function boot(): void +{ + Cortex::registerAgent(WeatherAgent::class); +} +``` + +--- + +## Default Streaming Protocol + +```php +'default_streaming_protocol' => StreamingProtocol::Vercel, +``` + +Controls the default wire format for streaming responses. Options: `Vercel`, `AGUI`, `Raw`, `Text`. See [Streaming](/cortex/llm/streaming) for details. diff --git a/docs/cortex/embeddings.mdx b/docs/cortex/embeddings.mdx new file mode 100644 index 0000000..1c06fc8 --- /dev/null +++ b/docs/cortex/embeddings.mdx @@ -0,0 +1,114 @@ +--- +title: Embeddings +description: 'Generate vector embeddings for semantic search and RAG pipelines.' +icon: 'database' +--- + +Cortex provides a unified API for generating text embeddings via OpenAI or Ollama. Embeddings are numerical vector representations of text that capture semantic meaning, enabling similarity search and retrieval-augmented generation (RAG). + +## Basic Usage + +```php +use Cortex\Cortex; + +$result = Cortex::embeddings()->invoke('Lorem ipsum dolor sit amet'); + +$vector = $result->embeddings; // float[] +$dimensions = count($vector); // e.g. 1536 for text-embedding-3-small +``` + +## Specifying a Driver + +Pass the driver name as the first argument: + +```php +$result = Cortex::embeddings('openai')->invoke('Some text'); +$result = Cortex::embeddings('ollama')->invoke('Some text'); +``` + +## Specifying a Model + +Pass the model as the second argument: + +```php +$result = Cortex::embeddings('openai', 'text-embedding-3-large')->invoke('Some text'); +$result = Cortex::embeddings('ollama', 'mxbai-embed-large')->invoke('Some text'); +``` + +## Embedding Multiple Documents + +Pass an array of strings to embed multiple documents at once: + +```php +$results = Cortex::embeddings()->invoke([ + 'The quick brown fox', + 'Lorem ipsum dolor sit amet', + 'Cortex is an AI framework for Laravel', +]); + +foreach ($results as $result) { + $vector = $result->embeddings; +} +``` + +## Configuration + +Configure embedding providers in `config/cortex.php`: + +```php +'embedding' => [ + 'default' => env('CORTEX_DEFAULT_EMBEDDING_DRIVER', 'openai'), + + 'openai' => [ + 'driver' => 'openai', + 'options' => [ + 'api_key' => env('OPENAI_API_KEY', ''), + 'base_uri' => env('OPENAI_BASE_URI'), + ], + 'default_model' => 'text-embedding-3-small', + 'default_dimensions' => 1536, + ], + + 'ollama' => [ + 'driver' => 'ollama', + 'options' => [ + 'base_url' => env('OLLAMA_BASE_URI', 'http://localhost:1234'), + ], + 'default_model' => 'nomic-embed-text', + 'default_dimensions' => 768, + ], +], +``` + +Set the default driver in `.env`: + +```bash +CORTEX_DEFAULT_EMBEDDING_DRIVER=openai +``` + +## Using the Embeddings Facade + +```php +use Cortex\Facades\Embeddings; + +$result = Embeddings::driver('openai')->invoke('Some text'); +``` + +## EmbeddingsResult + +The `invoke()` method returns an `EmbeddingsResult` (or a collection of them for batch input): + +```php +$result = Cortex::embeddings()->invoke('Some text'); + +$result->embeddings; // float[] — the embedding vector +$result->model; // string — the model used +$result->document; // Document — the original input +``` + +## Supported Providers + +| Provider | Driver | Default Model | Dimensions | +|----------|--------|---------------|------------| +| OpenAI | `openai` | `text-embedding-3-small` | 1536 | +| Ollama | `ollama` | `nomic-embed-text` | 768 | diff --git a/docs/cortex/installation.mdx b/docs/cortex/installation.mdx new file mode 100644 index 0000000..81d2d11 --- /dev/null +++ b/docs/cortex/installation.mdx @@ -0,0 +1,73 @@ +--- +title: Installation +description: 'Install and set up the Cortex package.' +icon: 'terminal' +--- + +## Requirements + +- PHP 8.4+ +- Laravel 12+ + +## Setup + + + + ```bash + composer require cortexphp/cortex + ``` + + + ```bash + php artisan vendor:publish --tag=cortex-config + ``` + + This creates `config/cortex.php` where you configure LLM providers, prompt factories, embeddings, and more. See [Configuration](/cortex/configuration) for a full reference. + + + Cortex defaults to OpenAI. Add your key to `.env`: + + ```bash + OPENAI_API_KEY=sk-... + ``` + + To use a different provider by default: + + ```bash + CORTEX_DEFAULT_LLM=anthropic + ANTHROPIC_API_KEY=sk-ant-... + ``` + + + +You're ready to go. Head to the [Quick Start](/cortex/quickstart) to make your first call. + +--- + +## Package Development + +If you're contributing to Cortex or want to run the test suite: + + + + ```bash + git clone https://github.com/cortexphp/cortex.git + cd cortex + ``` + + + ```bash + composer install + ``` + + + ```bash + composer test + ``` + + + ```bash + composer check + ``` + + diff --git a/docs/cortex/introduction.mdx b/docs/cortex/introduction.mdx new file mode 100644 index 0000000..3d2a4db --- /dev/null +++ b/docs/cortex/introduction.mdx @@ -0,0 +1,84 @@ +--- +title: Introduction +description: 'Cortex is an AI framework that brings advanced artificial intelligence capabilities to your Laravel projects.' +icon: 'book-open' +--- + +Cortex is a Laravel package that provides a unified, expressive API for working with large language models (LLMs), building AI agents, managing prompts, and generating embeddings. It abstracts away the differences between AI providers so you can focus on building features rather than integrating APIs. + +## Key Features + + + + Connect to OpenAI, Anthropic, Ollama, Groq, Gemini, Mistral, and more through a single consistent API. + + + Build autonomous agents that can reason, use tools, and complete multi-step tasks with memory and streaming support. + + + Manage prompts with a fluent builder API. Load prompts from Langfuse, Blade templates, or MCP servers. + + + Give agents the ability to call functions, fetch URLs, read files, or connect to any MCP server. + + + Extract typed, validated data from LLM responses using JSON schemas, PHP classes, or enums. + + + Stream responses in real-time using Vercel AI SDK, AG-UI, or raw SSE protocols. + + + Generate vector embeddings with OpenAI or Ollama for semantic search and RAG pipelines. + + + Compose LLMs, prompts, agents, and output parsers into reusable, chainable pipelines. + + + +## How It Works + +At its core, Cortex provides a `Cortex` facade with entry points for each major feature: + +```php +use Cortex\Cortex; + +// Call an LLM directly +$result = Cortex::llm('openai')->invoke([ + new UserMessage('What is the capital of France?'), +]); + +// Use a prompt with variables +$result = Cortex::prompt('What is the capital of {country}?') + ->llm() + ->invoke(['country' => 'France']); + +// Run an agent with tools +$result = Cortex::agent('weather_agent')->invoke( + input: ['location' => 'Paris'], +); + +// Generate embeddings +$result = Cortex::embeddings('openai')->invoke('Some text to embed'); +``` + +## Architecture + +``` +Cortex::prompt() → PromptBuilder / PromptFactory +Cortex::llm() → LLM Driver (OpenAI, Anthropic, ...) +Cortex::agent() → Agent (prompt + LLM + tools + memory) +Cortex::embeddings() → Embeddings Driver +``` + +Agents are built from three components: a **prompt** (the system instructions), an **LLM** (the model to call), and optional **tools** (functions the model can invoke). The agent runs a pipeline that handles tool call loops, memory, structured output, and streaming automatically. + +## Next Steps + + + + Install Cortex and configure your first LLM provider. + + + Build your first agent in under five minutes. + + diff --git a/docs/cortex/llm/overview.mdx b/docs/cortex/llm/overview.mdx new file mode 100644 index 0000000..c04c5ea --- /dev/null +++ b/docs/cortex/llm/overview.mdx @@ -0,0 +1,110 @@ +--- +title: Overview +description: 'Call any LLM provider with a single, consistent API.' +icon: 'cpu' +--- + +`Cortex::llm()` returns an LLM instance that you can configure and invoke directly, or pipe through a prompt or pipeline. + +## Basic Usage + +```php +use Cortex\Cortex; +use Cortex\LLM\Data\Messages\SystemMessage; +use Cortex\LLM\Data\Messages\UserMessage; + +$result = Cortex::llm()->invoke([ + new SystemMessage('You are a helpful assistant.'), + new UserMessage('What is the capital of France?'), +]); + +echo $result->content(); // "The capital of France is Paris." +``` + +## Specifying a Provider + +Pass a provider name (matching a key in `config/cortex.php`) as the first argument: + +```php +$result = Cortex::llm('anthropic')->invoke([...]); +$result = Cortex::llm('ollama')->invoke([...]); +$result = Cortex::llm('groq')->invoke([...]); +``` + +## Specifying a Model + +Pass the model name as the second argument: + +```php +$result = Cortex::llm('anthropic', 'claude-3-7-sonnet-20250219')->invoke([...]); +$result = Cortex::llm('openai', 'gpt-4o')->invoke([...]); +``` + +You can also use the shortcut string syntax `provider/model`: + +```php +$result = Cortex::llm('anthropic/claude-3-7-sonnet-20250219')->invoke([...]); +$result = Cortex::llm('ollama/llama3.2')->invoke([...]); +$result = Cortex::llm('lmstudio/openai/gpt-oss-20b')->invoke([...]); +``` + +## Setting Parameters + +Chain methods to configure the request: + +```php +$result = Cortex::llm('anthropic', 'claude-3-7-sonnet-20250219') + ->withTemperature(0.7) + ->withMaxTokens(2048) + ->invoke([ + new SystemMessage('You are a helpful assistant.'), + new UserMessage('Write a short poem about Paris.'), + ]); +``` + +| Method | Description | +|--------|-------------| +| `withTemperature(float)` | Controls randomness (0.0–1.0) | +| `withMaxTokens(int)` | Maximum tokens in the response | +| `withModel(string)` | Override the model | +| `withStreaming(bool)` | Enable streaming mode | +| `withTools(array)` | Attach tools for function calling | +| `withStructuredOutput(schema)` | Request a structured response | + +## Working with Results + +The `invoke()` method returns a `ChatResult`: + +```php +$result = Cortex::llm()->invoke([new UserMessage('Hello')]); + +$result->content(); // string — the text response +$result->message(); // AssistantMessage instance +$result->usage(); // Usage (promptTokens, completionTokens, totalTokens) +$result->finishReason(); // 'stop', 'tool_calls', 'length', etc. +``` + +## Using the LLM Facade + +You can also use the `LLM` facade directly: + +```php +use Cortex\Facades\LLM; + +$llm = LLM::provider('openai'); +$llm->withModel('gpt-4o'); +``` + +## Next Steps + + + + See all supported providers and how to configure them. + + + Extract typed data from LLM responses. + + + Stream responses in real-time. + + diff --git a/docs/cortex/llm/providers/anthropic.mdx b/docs/cortex/llm/providers/anthropic.mdx new file mode 100644 index 0000000..6c00ab6 --- /dev/null +++ b/docs/cortex/llm/providers/anthropic.mdx @@ -0,0 +1,84 @@ +--- +title: Anthropic +description: 'Configure and use Anthropic Claude models including extended thinking.' +icon: 'https://models.dev/logos/anthropic.svg' +--- + +Cortex supports Anthropic via a native driver that uses the [Anthropic Messages API](https://docs.anthropic.com/en/api/messages). + +## Configuration + +```php +// config/cortex.php +'anthropic' => [ + 'driver' => LLMDriver::Anthropic, + 'options' => [ + 'api_key' => env('ANTHROPIC_API_KEY', ''), + 'headers' => [], + ], + 'default_model' => 'claude-3-7-sonnet-20250219', + 'default_parameters' => [ + 'temperature' => null, + 'max_tokens' => 1024, + 'top_p' => null, + ], +], +``` + +Add your API key to `.env`: + +```bash +ANTHROPIC_API_KEY=sk-ant-... +``` + +## Usage + +```php +use Cortex\Cortex; + +// Use the default Anthropic model +$result = Cortex::llm('anthropic')->invoke([...]); + +// Specify a model +$result = Cortex::llm('anthropic', 'claude-3-5-haiku-20241022')->invoke([...]); + +// Shortcut syntax +$result = Cortex::llm('anthropic/claude-opus-4-5')->invoke([...]); +``` + +## Available Models + +| Model | Description | +|-------|-------------| +| `claude-3-7-sonnet-20250219` | Latest Sonnet — best balance of speed and intelligence (default) | +| `claude-opus-4-5` | Most capable Claude model | +| `claude-3-5-haiku-20241022` | Fastest and most compact Claude model | + +## Custom Headers + +Pass additional headers to every request via the `headers` option — useful for Anthropic's beta features: + +```php +'options' => [ + 'api_key' => env('ANTHROPIC_API_KEY', ''), + 'headers' => [ + 'anthropic-beta' => 'interleaved-thinking-2025-05-14', + ], +], +``` + +## Using via Amazon Bedrock + +To use Claude through Amazon Bedrock, add a separate provider entry: + +```php +'bedrock' => [ + 'driver' => LLMDriver::Anthropic, + 'options' => [ + 'api_key' => env('AWS_ACCESS_KEY_ID', ''), + 'base_uri' => env('BEDROCK_BASE_URI'), // e.g. https://bedrock-runtime.us-east-1.amazonaws.com + ], + 'default_model' => 'anthropic.claude-3-7-sonnet-20250219-v1:0', + 'default_parameters' => ['max_tokens' => 1024], +], +``` diff --git a/docs/cortex/llm/providers/openai.mdx b/docs/cortex/llm/providers/openai.mdx new file mode 100644 index 0000000..250f7b0 --- /dev/null +++ b/docs/cortex/llm/providers/openai.mdx @@ -0,0 +1,101 @@ +--- +title: OpenAI +description: 'Configure and use OpenAI models including GPT-4o and reasoning models.' +icon: '/assets/providers/openai.svg' +--- + +Cortex supports OpenAI via two drivers: **Responses API** (default, recommended) and **Chat Completions API**. + +## Configuration + +```php +// config/cortex.php +'openai' => [ + 'driver' => LLMDriver::OpenAIResponses, + 'options' => [ + 'api_key' => env('OPENAI_API_KEY', ''), + 'base_uri' => env('OPENAI_BASE_URI'), + 'organization' => env('OPENAI_ORGANIZATION'), + ], + 'default_model' => 'gpt-4.1-mini', + 'default_parameters' => [ + 'temperature' => null, + 'max_output_tokens' => 1024, + 'top_p' => null, + ], +], +``` + +Add your API key to `.env`: + +```bash +OPENAI_API_KEY=sk-... +``` + +## Usage + +```php +use Cortex\Cortex; + +// Use the default OpenAI model +$result = Cortex::llm('openai')->invoke([...]); + +// Specify a model +$result = Cortex::llm('openai', 'gpt-4o')->invoke([...]); + +// Shortcut syntax +$result = Cortex::llm('openai/gpt-4o')->invoke([...]); +``` + +## Drivers + +### `openai_responses` (Recommended) + +Uses the [OpenAI Responses API](https://platform.openai.com/docs/api-reference/responses). This is the default driver and supports the full range of OpenAI features: + +- Reasoning models (`o1`, `o3`, `o4-mini`) +- Structured output with JSON schema enforcement +- Tool use and parallel tool calls +- Streaming + +```php +use Cortex\LLM\Enums\LLMDriver; + +'driver' => LLMDriver::OpenAIResponses, +``` + +### `openai_chat` + +Uses the [Chat Completions API](https://platform.openai.com/docs/api-reference/chat). Use this if you need compatibility with older models or third-party OpenAI-compatible endpoints. + +```php +'driver' => LLMDriver::OpenAIChat, +``` + +## Reasoning Models + +Reasoning models (`o1`, `o3`, `o4-mini`) work with the `openai_responses` driver: + +```php +$result = Cortex::llm('openai', 'o4-mini')->invoke([...]); +``` + + +Reasoning models do not support `temperature`. Leave it as `null` in `default_parameters`. + + +## Azure OpenAI + +To use Azure OpenAI, add a new provider entry pointing to your Azure endpoint: + +```php +'azure' => [ + 'driver' => LLMDriver::OpenAIChat, + 'options' => [ + 'api_key' => env('AZURE_OPENAI_API_KEY', ''), + 'base_uri' => env('AZURE_OPENAI_BASE_URI'), // e.g. https://my-resource.openai.azure.com/openai/deployments/my-deployment + ], + 'default_model' => 'gpt-4o', + 'default_parameters' => ['max_tokens' => 1024], +], +``` diff --git a/docs/cortex/llm/providers/other.mdx b/docs/cortex/llm/providers/other.mdx new file mode 100644 index 0000000..eae5ee9 --- /dev/null +++ b/docs/cortex/llm/providers/other.mdx @@ -0,0 +1,168 @@ +--- +title: Other Providers +description: 'Ollama, LM Studio, Groq, Gemini, Mistral, and any OpenAI-compatible API.' +icon: 'server' +--- + +All providers other than OpenAI and Anthropic use the `openai_chat` driver, which is compatible with any OpenAI Chat Completions-compatible API. + +## Local Providers + +### Ollama + +Run open-source models locally with [Ollama](https://ollama.com): + +```bash +OLLAMA_BASE_URI=http://localhost:11434/v1 +``` + +```php +// config/cortex.php +'ollama' => [ + 'driver' => LLMDriver::OpenAIChat, + 'options' => [ + 'api_key' => 'ollama', + 'base_uri' => env('OLLAMA_BASE_URI', 'http://localhost:11434/v1'), + ], + 'default_model' => 'llama3.2', + 'default_parameters' => ['max_tokens' => 1024], +], +``` + +```php +$result = Cortex::llm('ollama', 'llama3.2')->invoke([...]); +``` + +### LM Studio + +Run models locally with [LM Studio](https://lmstudio.ai): + +```bash +LMSTUDIO_BASE_URI=http://localhost:1234/v1 +``` + +```php +$result = Cortex::llm('lmstudio', 'openai/gpt-oss-20b')->invoke([...]); +``` + +## Cloud Providers + +### Groq + +[Groq](https://groq.com) provides fast inference for open-source models: + +```bash +GROQ_API_KEY=gsk_... +``` + +```php +$result = Cortex::llm('groq', 'llama-3.1-70b-versatile')->invoke([...]); +``` + +### xAI (Grok) + +[xAI](https://x.ai) Grok models: + +```bash +XAI_API_KEY=xai-... +``` + +```php +$result = Cortex::llm('xai', 'grok-2-1212')->invoke([...]); +``` + +### Google Gemini + +[Google Gemini](https://ai.google.dev) via the OpenAI-compatible endpoint: + +```bash +GEMINI_API_KEY=AIza... +``` + +```php +$result = Cortex::llm('gemini', 'gemini-1.5-pro')->invoke([...]); +``` + +### Mistral + +[Mistral AI](https://mistral.ai) models: + +```bash +MISTRAL_API_KEY=... +``` + +```php +$result = Cortex::llm('mistral', 'mistral-large-latest')->invoke([...]); +``` + +### Together AI + +[Together AI](https://www.together.ai) for open-source model hosting: + +```bash +TOGETHER_API_KEY=... +``` + +```php +$result = Cortex::llm('together', 'meta-llama/Meta-Llama-3.1-70B-Instruct-Turbo')->invoke([...]); +``` + +### OpenRouter + +[OpenRouter](https://openrouter.ai) as a unified gateway to many providers: + +```bash +OPENROUTER_API_KEY=sk-or-... +``` + +```php +$result = Cortex::llm('openrouter', 'anthropic/claude-3.5-sonnet')->invoke([...]); +``` + +### DeepSeek + +[DeepSeek](https://deepseek.com) models: + +```bash +DEEPSEEK_API_KEY=sk-... +``` + +```php +$result = Cortex::llm('deepseek', 'deepseek-chat')->invoke([...]); +``` + +### GitHub Models + +[GitHub Models](https://github.com/marketplace/models) for Azure-hosted models: + +```bash +GITHUB_API_KEY=ghp_... +``` + +```php +$result = Cortex::llm('github', 'openai/gpt-4.1-mini')->invoke([...]); +``` + +## Adding a Custom Provider + +Any OpenAI-compatible API can be added as a custom provider: + +```php +// config/cortex.php +'my_provider' => [ + 'driver' => LLMDriver::OpenAIChat, + 'options' => [ + 'api_key' => env('MY_PROVIDER_API_KEY', ''), + 'base_uri' => env('MY_PROVIDER_BASE_URI', 'https://api.myprovider.com/v1'), + ], + 'default_model' => 'my-model-name', + 'default_parameters' => [ + 'temperature' => null, + 'max_tokens' => 1024, + ], +], +``` + +```php +$result = Cortex::llm('my_provider')->invoke([...]); +``` diff --git a/docs/cortex/llm/providers/overview.mdx b/docs/cortex/llm/providers/overview.mdx new file mode 100644 index 0000000..2851292 --- /dev/null +++ b/docs/cortex/llm/providers/overview.mdx @@ -0,0 +1,56 @@ +--- +title: Overview +description: 'All supported LLM providers and how to configure them.' +icon: 'server' +--- + +Cortex ships with support for the following providers out of the box. All are configured in `config/cortex.php` under the `llm` key. + +## Supported Providers + +| Provider | Config Key | Driver | API Key Env Var | +|----------|------------|--------|-----------------| +| OpenAI | `openai` | `openai_responses` | `OPENAI_API_KEY` | +| Anthropic | `anthropic` | `anthropic` | `ANTHROPIC_API_KEY` | +| Ollama | `ollama` | `openai_chat` | — (local) | +| LM Studio | `lmstudio` | `openai_responses` | — (local) | +| Groq | `groq` | `openai_chat` | `GROQ_API_KEY` | +| xAI (Grok) | `xai` | `openai_chat` | `XAI_API_KEY` | +| Google Gemini | `gemini` | `openai_chat` | `GEMINI_API_KEY` | +| Mistral | `mistral` | `openai_chat` | `MISTRAL_API_KEY` | +| Together AI | `together` | `openai_chat` | `TOGETHER_API_KEY` | +| OpenRouter | `openrouter` | `openai_chat` | `OPENROUTER_API_KEY` | +| DeepSeek | `deepseek` | `openai_chat` | `DEEPSEEK_API_KEY` | +| GitHub Models | `github` | `openai_chat` | `GITHUB_API_KEY` | + +## Drivers + +There are three underlying drivers: + +- **`openai_responses`** — Uses the OpenAI Responses API. Supports the latest OpenAI features including reasoning models. +- **`openai_chat`** — Uses the OpenAI Chat Completions API. Compatible with any OpenAI-compatible endpoint (Ollama, Groq, Gemini, etc.). +- **`anthropic`** — Uses the Anthropic Messages API natively. + +## Changing the Default Provider + +Set the default provider in your `.env`: + +```bash +CORTEX_DEFAULT_LLM=anthropic +``` + +Now `Cortex::llm()` with no arguments will use Anthropic. + +## Provider Guides + + + + OpenAI Chat and Responses API, including reasoning models. + + + Anthropic Claude models with extended thinking support. + + + Ollama, LM Studio, Groq, Gemini, Mistral, and more. + + diff --git a/docs/cortex/llm/streaming.mdx b/docs/cortex/llm/streaming.mdx new file mode 100644 index 0000000..ca645df --- /dev/null +++ b/docs/cortex/llm/streaming.mdx @@ -0,0 +1,105 @@ +--- +title: Streaming +description: 'Stream LLM responses in real-time using multiple wire protocols.' +icon: 'zap' +--- + +Cortex supports streaming responses from LLMs and agents. You can stream to the browser using standard protocols like Vercel AI SDK data streams or AG-UI, or consume the stream server-side. + +## Streaming from an LLM + +Call `withStreaming()` before `invoke()`, or use `stream()` directly, then iterate over the result: + +```php +use Cortex\Cortex; +use Cortex\LLM\Data\Messages\UserMessage; + +$stream = Cortex::llm()->stream([ + new UserMessage('Tell me a story'), +]); + +foreach ($stream as $chunk) { + echo $chunk->content(); +} +``` + +## Streaming from an Agent + +Call `stream()` instead of `invoke()`: + +```php +$stream = Cortex::agent('weather_agent')->stream( + input: ['location' => 'Paris'], +); + +foreach ($stream as $chunk) { + echo $chunk->content(); +} +``` + +## Streaming to the Browser + +Use `streamResponse()` to return a `StreamedResponse` from a controller. Pass the desired protocol: + +```php +use Cortex\Cortex; +use Cortex\LLM\Enums\StreamingProtocol; +use Illuminate\Http\Request; + +class ChatController extends Controller +{ + public function __invoke(Request $request): StreamedResponse + { + return Cortex::agent('my_agent') + ->stream(messages: [new UserMessage($request->input('message'))]) + ->streamResponse(StreamingProtocol::Vercel); + } +} +``` + +## Streaming Protocols + +| Protocol | Enum | Description | +|----------|------|-------------| +| Vercel | `StreamingProtocol::Vercel` | Compatible with the [Vercel AI SDK](https://sdk.vercel.ai). The default. | +| AG-UI | `StreamingProtocol::AGUI` | Compatible with the [AG-UI protocol](https://ag-ui.com) for agent UIs. | +| Raw | `StreamingProtocol::Raw` | Plain SSE with raw chunks. | +| Text | `StreamingProtocol::Text` | Plain text stream, no SSE framing. | + +Set the default in `config/cortex.php`: + +```php +'default_streaming_protocol' => StreamingProtocol::Vercel, +``` + +Or override per-request: + +```php +->streamResponse(StreamingProtocol::AGUI) +``` + +## Listening to Stream Events + +You can register callbacks on an agent to react to stream lifecycle events: + +```php +$agent = Cortex::agent('weather_agent'); + +$agent->onStart(fn($event) => logger()->info('Agent started')); +$agent->onEnd(fn($event) => logger()->info('Agent finished')); +$agent->onStepStart(fn($event) => logger()->info('Step started')); +$agent->onStepEnd(fn($event) => logger()->info('Step finished')); +$agent->onChunk(fn($event) => /* handle each chunk */); +``` + +## AG-UI HTTP Endpoint + +Cortex includes a built-in controller for serving AG-UI compatible streams. Register the route in your application: + +```php +use Cortex\Http\Controllers\AGUIController; + +Route::post('/api/agent', AGUIController::class); +``` + +The controller reads `messages`, `thread_id`, `run_id`, and `state` from the request and streams the response in AG-UI format. diff --git a/docs/cortex/llm/structured-output.mdx b/docs/cortex/llm/structured-output.mdx new file mode 100644 index 0000000..95d5baa --- /dev/null +++ b/docs/cortex/llm/structured-output.mdx @@ -0,0 +1,153 @@ +--- +title: Structured Output +description: 'Extract typed, validated data from LLM responses.' +icon: 'layers' +--- + +Structured output instructs the LLM to return a response that conforms to a specific schema. Cortex validates and parses the response automatically. + +## Using a Schema + +Define your schema with `Schema::object()` and pass it to `withStructuredOutput()`: + +```php +use Cortex\Cortex; +use Cortex\JsonSchema\Schema; +use Cortex\LLM\Data\Messages\UserMessage; + +$schema = Schema::object()->properties( + Schema::string('capital')->required(), + Schema::string('country')->required(), +); + +$result = Cortex::llm() + ->withStructuredOutput($schema) + ->invoke([ + new UserMessage('What is the capital of France?'), + ]); + +$data = $result->parsedOutput; +// ['capital' => 'Paris', 'country' => 'France'] +``` + + +For more information about the JSON Schema capabilities of Cortex, see the [JSON Schema documentation](/json-schema). + + +## Schema Types + +`Schema` provides factory methods for all JSON Schema types: + +```php +Schema::string('name')->required()->description('The person\'s name'); +Schema::integer('age')->minimum(0)->maximum(150); +Schema::number('score')->required(); +Schema::boolean('active'); +Schema::array('tags')->items(Schema::string()); +Schema::object('address')->properties( + Schema::string('street')->required(), + Schema::string('city')->required(), +); +Schema::enum('status', ['active', 'inactive', 'pending']); +``` + +## Using a PHP Class + +You can use a PHP class or DTO as the output schema. Cortex reflects the class properties to build the schema automatically: + +```php +class CapitalResponse +{ + public string $capital; + public string $country; +} + +$result = Cortex::llm() + ->withStructuredOutput(CapitalResponse::class) + ->invoke([new UserMessage('What is the capital of France?')]); + +$output = $result->parsedOutput; // CapitalResponse instance +echo $output->capital; // "Paris" +``` + +## Using an Enum + +Pass a backed enum class to extract a single enum value: + +```php +enum Sentiment: string +{ + case Positive = 'positive'; + case Negative = 'negative'; + case Neutral = 'neutral'; +} + +$result = Cortex::llm() + ->withStructuredOutput(Sentiment::class) + ->invoke([new UserMessage('I love this product!')]); + +$sentiment = $result->parsedOutput; // Sentiment::Positive +``` + +## Structured Output on Prompts + +You can also specify structured output on a prompt builder via `metadata()`: + +```php +use Cortex\Cortex; +use Cortex\JsonSchema\Schema; + +$prompt = Cortex::prompt('What is the capital of {country}?') + ->metadata( + provider: 'anthropic', + model: 'claude-3-5-sonnet-20240620', + structuredOutput: Schema::object()->properties( + Schema::string('capital'), + ), + ); + +$result = $prompt->llm()->invoke(['country' => 'France']); +``` + +## Structured Output on Agents + +Pass an output schema to an agent via `withOutput()` or the `output` constructor parameter: + +```php +use Cortex\Cortex; +use Cortex\JsonSchema\Schema; + +$agent = Cortex::agent() + ->withPrompt('You are a geography expert.') + ->withOutput([ + Schema::string('capital')->required(), + Schema::string('population')->required(), + ]); + +$result = $agent->invoke(input: ['country' => 'France']); +$data = $result->parsedOutput; +// ['capital' => 'Paris', 'population' => '...'] +``` + + +When passing an array to `withOutput()`, each element must be a `JsonSchema` instance. Cortex wraps them in an `ObjectSchema` automatically. + + +## Output Modes + +Cortex supports three structured output modes, controlled by `StructuredOutputMode`: + +| Mode | Behaviour | +|------|-----------| +| `Auto` | Cortex picks the best mode for the model (default) | +| `JsonSchema` | Uses the provider's native JSON schema enforcement | +| `ToolCall` | Uses a hidden tool call to extract structured data | + +```php +use Cortex\LLM\Enums\StructuredOutputMode; + +Cortex::llm()->withStructuredOutput( + $schema, + outputMode: StructuredOutputMode::ToolCall, +); +``` diff --git a/docs/cortex/pipelines.mdx b/docs/cortex/pipelines.mdx new file mode 100644 index 0000000..718f4a5 --- /dev/null +++ b/docs/cortex/pipelines.mdx @@ -0,0 +1,166 @@ +--- +title: Pipelines +description: 'Compose LLMs, prompts, agents, and parsers into chainable pipelines.' +icon: 'workflow' +--- + +The `Pipeline` class is the execution engine behind Cortex. It chains together `Pipeable` stages — prompts, LLMs, agents, output parsers, or custom closures — passing the output of each stage as the input to the next. + +Agents use pipelines internally, but you can also build pipelines directly for more complex workflows. + +## Basic Usage + +```php +use Cortex\Pipeline; +use Cortex\Cortex; +use Cortex\LLM\Data\Messages\UserMessage; + +$result = (new Pipeline()) + ->pipe(Cortex::llm('openai')) + ->invoke([new UserMessage('What is the capital of France?')]); + +echo $result->content(); // "The capital of France is Paris." +``` + +## Chaining Stages + +Pipe a prompt template into an LLM, then into an output parser: + +```php +use Cortex\Pipeline; +use Cortex\Cortex; +use Cortex\OutputParsers\JsonOutputParser; + +$result = (new Pipeline()) + ->pipe(Cortex::prompt('List 3 capitals as a JSON array.')->build()) + ->pipe(Cortex::llm()) + ->pipe(new JsonOutputParser()) + ->invoke(); + +// $result is a decoded PHP array +``` + +## Closure Stages + +Any callable that accepts `($payload, RuntimeConfig $config, callable $next)` can be a stage: + +```php +use Cortex\Pipeline\RuntimeConfig; + +$result = (new Pipeline()) + ->pipe(Cortex::llm()) + ->pipe(function (mixed $payload, RuntimeConfig $config, callable $next): mixed { + logger()->info('LLM responded', ['content' => $payload->content()]); + return $next($payload, $config); + }) + ->invoke([new UserMessage('Hello')]); +``` + +## Streaming + +Enable streaming for all LLM stages in the pipeline: + +```php +$stream = (new Pipeline()) + ->pipe(Cortex::llm()) + ->stream([new UserMessage('Tell me a story.')]); + +foreach ($stream as $chunk) { + echo $chunk->content(); +} +``` + +## Parallel Stages + +Pass an array of stages to `pipe()` to execute them in parallel (powered by amphp): + +```php +$result = (new Pipeline()) + ->pipe([ + Cortex::llm('openai'), + Cortex::llm('anthropic'), + ]) + ->invoke([new UserMessage('Summarise AI in one sentence.')]); +``` + +The results of parallel stages are merged into a single payload. + +## Output Parsers + +Use `->output()` as a shorthand for piping an output parser: + +```php +use Cortex\OutputParsers\JsonOutputParser; +use Cortex\OutputParsers\XmlOutputParser; +use Cortex\OutputParsers\StringOutputParser; + +$pipeline = (new Pipeline()) + ->pipe(Cortex::llm()) + ->output(new JsonOutputParser()); +``` + +Available output parsers: + +| Parser | Description | +|--------|-------------| +| `JsonOutputParser` | Parses JSON from the response | +| `XmlOutputParser` | Parses XML from the response | +| `XmlTagOutputParser` | Extracts content from a specific XML tag | +| `StringOutputParser` | Returns the raw string content | +| `ClassOutputParser` | Maps JSON to a PHP class | +| `EnumOutputParser` | Maps the response to a backed enum | +| `StructuredOutputParser` | Parses structured output from tool calls | + +## Lifecycle Events + +Register callbacks for pipeline lifecycle events: + +```php +$pipeline = (new Pipeline()) + ->pipe(Cortex::llm()) + ->onStart(fn($event) => logger()->info('Pipeline started')) + ->onEnd(fn($event) => logger()->info('Pipeline finished')) + ->onError(fn($event) => logger()->error('Pipeline error', ['e' => $event->exception])) + ->onStageStart(fn($event) => logger()->debug('Stage started')) + ->onStageEnd(fn($event) => logger()->debug('Stage finished')); +``` + +## Error Handling + +Use `catch()` to handle exceptions without re-throwing, and `finally()` to run cleanup logic: + +```php +$pipeline = (new Pipeline()) + ->pipe(Cortex::llm()) + ->catch(function (Throwable $e, RuntimeConfig $config): void { + logger()->error('Pipeline failed', ['error' => $e->getMessage()]); + }) + ->finally(function (RuntimeConfig $config, mixed $payload, Pipeline $pipeline): void { + // Always runs, even on error + Cache::forget('pipeline-lock'); + }); +``` + +## RuntimeConfig + +`RuntimeConfig` is passed through every stage and carries request-level state: + +```php +use Cortex\Pipeline\RuntimeConfig; +use Cortex\Pipeline\State; + +$config = new RuntimeConfig( + threadId: 'user-123-session', + runId: 'run-abc', + state: new State(['locale' => 'en', 'user_id' => 42]), +); + +$result = $pipeline->invoke($payload, $config); +``` + +Access state inside a tool or middleware: + +```php +$locale = $config->context->get('locale'); +$userId = $config->context->get('user_id'); +``` diff --git a/docs/cortex/prompts/factories/blade.mdx b/docs/cortex/prompts/factories/blade.mdx new file mode 100644 index 0000000..53c2c93 --- /dev/null +++ b/docs/cortex/prompts/factories/blade.mdx @@ -0,0 +1,86 @@ +--- +title: Blade +description: 'Load prompts from Blade template files with full template syntax support.' +icon: 'file-code' +--- + +The Blade factory loads prompts from Laravel Blade view files. This is useful for prompts that need conditional logic, loops, includes, or any other Blade feature. + +## Configuration + +```php +// config/cortex.php +'prompt_factory' => [ + 'blade' => [ + 'path' => 'resources/views/prompts', + ], +], +``` + +The `path` is relative to `base_path()`. All `.blade.php` files in this directory are available as prompts. + +## Creating a Blade Prompt + +Create a Blade file at the configured path. All variables passed to `invoke()` are available in the template: + +```blade +{{-- resources/views/prompts/geography.blade.php --}} +You are an expert geographer. + +Answer the following question about {{ $country }}: +{{ $question }} + +@if($include_population ?? false) +Also include the current population. +@endif +``` + +## Usage + +The prompt name is the filename without `.blade.php`: + +```php +use Cortex\Prompts\Prompt; + +$prompt = Prompt::factory('blade')->make('geography'); + +$result = $prompt->llm()->invoke([ + 'country' => 'France', + 'question' => 'What is the capital?', + 'include_population' => true, +]); +``` + +## Using Blade Features + +Since these are standard Blade views, you can use the full Blade feature set: + +```blade +{{-- resources/views/prompts/support-agent.blade.php --}} +You are a support agent for {{ $product_name }}. + +@if(!empty($user_name)) +You are speaking with {{ $user_name }}. +@endif + +Your responsibilities: +@foreach($responsibilities as $item) +- {{ $item }} +@endforeach + +@include('prompts.partials.tone-guidelines') +``` + +```php +$prompt = Prompt::factory('blade')->make('support-agent'); + +$result = $prompt->llm()->invoke([ + 'product_name' => 'Acme SaaS', + 'user_name' => 'Alice', + 'responsibilities' => ['Answer billing questions', 'Escalate technical issues'], +]); +``` + + +Blade prompts are always compiled as text prompts. For chat-style prompts with system and user messages, use the Langfuse or MCP factory, or define prompts inline with the builder. + diff --git a/docs/cortex/prompts/factories/langfuse.mdx b/docs/cortex/prompts/factories/langfuse.mdx new file mode 100644 index 0000000..6396061 --- /dev/null +++ b/docs/cortex/prompts/factories/langfuse.mdx @@ -0,0 +1,76 @@ +--- +title: Langfuse +description: 'Load prompts from Langfuse with optional caching.' +icon: 'cloud' +--- + +[Langfuse](https://langfuse.com) is a prompt management and observability platform. Cortex fetches prompts from the Langfuse API by name and returns them as a ready-to-invoke `PromptTemplate`. + +## Configuration + +```php +// config/cortex.php +'prompt_factory' => [ + 'default' => env('CORTEX_DEFAULT_PROMPT_FACTORY', 'langfuse'), + + 'langfuse' => [ + 'username' => env('LANGFUSE_USERNAME', ''), + 'password' => env('LANGFUSE_PASSWORD', ''), + 'base_uri' => env('LANGFUSE_BASE_URI', 'https://cloud.langfuse.com'), + 'cache' => [ + 'enabled' => env('CORTEX_PROMPT_CACHE_ENABLED', false), + 'store' => env('CORTEX_PROMPT_CACHE_STORE'), + 'ttl' => env('CORTEX_PROMPT_CACHE_TTL', 3600), + ], + ], +], +``` + +Add your credentials to `.env`: + +```bash +LANGFUSE_USERNAME=pk-lf-... +LANGFUSE_PASSWORD=sk-lf-... +LANGFUSE_BASE_URI=https://cloud.langfuse.com +``` + +## Usage + +```php +use Cortex\Prompts\Prompt; + +$prompt = Prompt::factory('langfuse')->make('geography-expert'); + +$result = $prompt->llm()->invoke(['country' => 'France']); +``` + +The prompt name must match the **slug** of the prompt in your Langfuse project. Langfuse prompts can be text or chat type — Cortex handles both automatically. + +When Langfuse is set as the default factory, you can omit the driver name: + +```php +$prompt = Prompt::factory()->make('geography-expert'); +``` + +Or via the `Cortex` facade: + +```php +use Cortex\Cortex; + +$prompt = Cortex::prompt()->factory('langfuse')->make('geography-expert'); +$result = $prompt->llm()->invoke(['country' => 'France']); +``` + +## Caching + +Enable caching to avoid hitting the Langfuse API on every request: + +```bash +CORTEX_PROMPT_CACHE_ENABLED=true +CORTEX_PROMPT_CACHE_STORE=redis +CORTEX_PROMPT_CACHE_TTL=3600 +``` + + +Caching is strongly recommended in production. Without it, every invocation makes an HTTP request to Langfuse. + diff --git a/docs/cortex/prompts/factories/mcp.mdx b/docs/cortex/prompts/factories/mcp.mdx new file mode 100644 index 0000000..c0c3df0 --- /dev/null +++ b/docs/cortex/prompts/factories/mcp.mdx @@ -0,0 +1,55 @@ +--- +title: MCP +description: 'Load prompts from a Model Context Protocol server.' +icon: 'plug' +--- + +The MCP factory fetches prompts from an [MCP (Model Context Protocol)](https://modelcontextprotocol.io) server. This is useful when your prompts are managed by an MCP-compatible tool or service. + +## Configuration + +```php +// config/cortex.php +'prompt_factory' => [ + 'mcp' => [ + 'server' => env('CORTEX_MCP_PROMPT_SERVER', 'local_http'), + ], +], +``` + +The `server` key references an entry in the `mcp_servers` section of the config: + +```php +'mcp_servers' => [ + 'local_http' => [ + 'transport' => 'http', + 'url' => 'http://localhost:3001/sse', + ], +], +``` + +## Usage + +```php +use Cortex\Prompts\Prompt; + +$prompt = Prompt::factory('mcp')->make('my-mcp-prompt'); + +$result = $prompt->llm()->invoke(['variable' => 'value']); +``` + +The prompt name must match the prompt identifier exposed by the MCP server. + +## Configuring the Server + +Set the MCP server to use for prompts in `.env`: + +```bash +CORTEX_MCP_PROMPT_SERVER=local_http +``` + +To use a different server, update the config or set the env var to point to any server defined in `mcp_servers`. + + +MCP prompt servers must implement the `prompts/get` capability of the MCP specification. Not all MCP servers expose prompts — check your server's documentation. + diff --git a/docs/cortex/prompts/factories/overview.mdx b/docs/cortex/prompts/factories/overview.mdx new file mode 100644 index 0000000..e969823 --- /dev/null +++ b/docs/cortex/prompts/factories/overview.mdx @@ -0,0 +1,54 @@ +--- +title: Overview +description: 'Load prompts from external sources — Langfuse, Blade templates, or MCP servers.' +icon: 'factory' +--- + +Prompt factories let you manage prompts outside your codebase and load them by name at runtime. Cortex ships with three factory drivers. + +## Basic Usage + +```php +use Cortex\Prompts\Prompt; + +// Using the default factory driver +$prompt = Prompt::factory()->make('my-prompt-name'); + +// Using a specific driver +$prompt = Prompt::factory('langfuse')->make('my-prompt-name'); +$prompt = Prompt::factory('blade')->make('geography'); +$prompt = Prompt::factory('mcp')->make('my-mcp-prompt'); +``` + +Via the `Cortex` facade: + +```php +use Cortex\Cortex; + +$prompt = Cortex::prompt()->factory('langfuse')->make('test-prompt'); +$result = $prompt->llm()->invoke(['country' => 'France']); +``` + +The returned `$prompt` is a `PromptTemplate` that you can invoke like any other prompt. + +## Setting the Default Factory + +```bash +CORTEX_DEFAULT_PROMPT_FACTORY=langfuse +``` + +Then `Prompt::factory()` with no argument uses Langfuse. + +## Drivers + + + + Fetch prompts from Langfuse with caching support. + + + Load prompts from Blade template files. + + + Fetch prompts from an MCP server. + + diff --git a/docs/cortex/prompts/overview.mdx b/docs/cortex/prompts/overview.mdx new file mode 100644 index 0000000..e055133 --- /dev/null +++ b/docs/cortex/prompts/overview.mdx @@ -0,0 +1,77 @@ +--- +title: Overview +description: 'Manage and compose prompts with a fluent, typed API.' +icon: 'file-text' +--- + +Cortex provides a first-class prompt system for building reusable, parameterised prompts. Prompts can be defined inline, loaded from external sources (Langfuse, Blade, MCP), and piped directly to an LLM or agent. + +## The Three Ways to Create a Prompt + +### 1. Inline text prompt + +Pass a string to `Cortex::prompt()` to create a text prompt with `{variable}` interpolation: + +```php +use Cortex\Cortex; + +$result = Cortex::prompt('What is the capital of {country}?') + ->llm() + ->invoke(['country' => 'France']); +``` + +### 2. Inline chat prompt + +Pass an array of message objects to create a multi-turn chat prompt: + +```php +use Cortex\Cortex; +use Cortex\LLM\Data\Messages\SystemMessage; +use Cortex\LLM\Data\Messages\UserMessage; + +$result = Cortex::prompt([ + new SystemMessage('You are an expert at geography.'), + new UserMessage('What is the capital of {country}?'), +]) +->llm() +->invoke(['country' => 'France']); +``` + +### 3. From a factory (external source) + +Load a prompt by name from Langfuse, a Blade view, or an MCP server: + +```php +$prompt = Cortex::prompt()->factory('langfuse')->make('geography-prompt'); +$result = $prompt->llm()->invoke(['country' => 'France']); +``` + +## Variable Interpolation + +Variables are denoted with `{curly_braces}` in prompt text. They are replaced at invocation time: + +```php +Cortex::prompt('Hello, {name}! You are {age} years old.') + ->llm() + ->invoke(['name' => 'Alice', 'age' => 30]); +``` + +This works in both text and chat prompts, including inside `SystemMessage` and `UserMessage` content. + +## Prompt Types + +| Type | Class | Use When | +|------|-------|----------| +| Text | `TextPromptBuilder` | Single-string prompts, simple completions | +| Chat | `ChatPromptBuilder` | Multi-turn conversations, system + user messages | + +## Next Steps + + + + The full builder API: metadata, schemas, piping to LLMs and agents. + + + Load prompts from Langfuse, Blade templates, or MCP servers. + + diff --git a/docs/cortex/prompts/prompt-builders.mdx b/docs/cortex/prompts/prompt-builders.mdx new file mode 100644 index 0000000..3226fde --- /dev/null +++ b/docs/cortex/prompts/prompt-builders.mdx @@ -0,0 +1,185 @@ +--- +title: Prompt Builders +description: 'Build typed, reusable prompts with the fluent builder API.' +icon: 'sparkles' +--- + +Prompt builders let you construct prompts programmatically with full type safety. Both `TextPromptBuilder` and `ChatPromptBuilder` share a common API via the `BuildsPrompts` trait. + +## Text Prompt Builder + +Use `Prompt::builder('text')` (or `Cortex::prompt('string')` as a shortcut) for single-string prompts: + +```php +use Cortex\Prompts\Prompt; + +$builder = Prompt::builder('text') + ->text('What is the capital of {country}?'); + +// Render the prompt with variables +$formatted = $builder->format(['country' => 'France']); +// "What is the capital of France?" + +// Pipe directly to an LLM +$result = $builder->llm()->invoke(['country' => 'France']); +``` + +## Chat Prompt Builder + +Use `Prompt::builder('chat')` (or `Cortex::prompt([...])` as a shortcut) for multi-turn chat prompts: + +```php +use Cortex\Prompts\Prompt; +use Cortex\LLM\Data\Messages\SystemMessage; +use Cortex\LLM\Data\Messages\UserMessage; + +$builder = Prompt::builder('chat') + ->messages([ + new SystemMessage('You are an expert at geography.'), + new UserMessage('What is the capital of {country}?'), + ]); + +$result = $builder->llm()->invoke(['country' => 'France']); +``` + +## Shared Builder API + +Both builder types share the following methods from `BuildsPrompts`: + +### `metadata()` + +Attach LLM configuration directly to the prompt. This is useful when you want a prompt to carry its own model settings: + +```php +use Cortex\JsonSchema\Schema; + +$builder = Prompt::builder('text') + ->text('What is the capital of {country}?') + ->metadata( + provider: 'anthropic', + model: 'claude-3-5-sonnet-20240620', + parameters: ['temperature' => 0.5], + structuredOutput: Schema::object()->properties( + Schema::string('capital'), + ), + ); + +// The prompt now carries its own LLM config — no need to pass provider/model at invocation +$result = $builder->llm()->invoke(['country' => 'France']); +``` + +### `initialVariables()` + +Set default variable values that are pre-filled before invocation: + +```php +$builder = Prompt::builder('text') + ->text('Translate "{text}" to {language}.') + ->initialVariables(['language' => 'French']); + +// Only need to supply 'text' at invocation time +$result = $builder->llm()->invoke(['text' => 'Hello, world!']); +``` + +### `inputSchema()` and `inputSchemaProperties()` + +Define a schema for the variables your prompt expects. In strict mode, Cortex will throw if required variables are missing: + +```php +use Cortex\JsonSchema\Schema; + +$builder = Prompt::builder('text') + ->text('What is the capital of {country}?') + ->inputSchemaProperties( + Schema::string('country')->description('The country name'), + ); +``` + +Or pass a full `ObjectSchema`: + +```php +$builder->inputSchema( + Schema::object()->properties( + Schema::string('country')->required(), + ) +); +``` + +### `strict()` + +Controls whether missing required input variables throw an exception (default: `true`): + +```php +$builder->strict(false); // Silently allow missing variables +``` + +### `build()` + +Compile the builder into a `PromptTemplate` instance: + +```php +$template = $builder->build(); +// Returns ChatPromptTemplate or TextPromptTemplate +``` + +## Convenience Methods + +### `->llm()` — Pipe to an LLM + +Build the prompt and return a `Pipeline` that feeds into an LLM: + +```php +$result = Prompt::builder('text') + ->text('What is the capital of {country}?') + ->llm('openai', 'gpt-4o') + ->invoke(['country' => 'France']); +``` + +### `->pipe()` — Pipe to any Pipeable + +Feed the built prompt into any `Pipeable` (another LLM, an output parser, etc.): + +```php +use Cortex\OutputParsers\JsonOutputParser; + +$result = Prompt::builder('text') + ->text('List 3 capitals as JSON.') + ->pipe(Cortex::llm()) + ->pipe(new JsonOutputParser()) + ->invoke(); +``` + +### `->agent()` — Create an Agent (chat only) + +`ChatPromptBuilder` has an additional `->agent()` method that returns a `GenericAgentBuilder` pre-configured with the prompt: + +```php +use Cortex\Prompts\Prompt; +use Cortex\LLM\Data\Messages\SystemMessage; +use Cortex\LLM\Data\Messages\UserMessage; + +$result = Prompt::builder('chat') + ->messages([ + new SystemMessage('You are an expert at geography.'), + new UserMessage('What is the capital of {country}?'), + ]) + ->agent() + ->invoke(input: ['country' => 'France']); +``` + +### `->format()` — Render the template + +Render the prompt with variables without invoking an LLM: + +```php +// Text prompt — returns a string +$text = Prompt::builder('text') + ->text('Hello, {name}!') + ->format(['name' => 'Alice']); +// "Hello, Alice!" + +// Chat prompt — returns a MessageCollection +$messages = Prompt::builder('chat') + ->messages([new UserMessage('Hello, {name}!')]) + ->format(['name' => 'Alice']); +``` diff --git a/docs/cortex/quickstart.mdx b/docs/cortex/quickstart.mdx new file mode 100644 index 0000000..70cf26b --- /dev/null +++ b/docs/cortex/quickstart.mdx @@ -0,0 +1,112 @@ +--- +title: Quick Start +description: 'Make your first LLM call, use a prompt, and build an agent.' +icon: 'rocket' +--- + + + Make sure you've completed [Installation](/cortex/installation) before continuing. + + +## Call an LLM + +The simplest way to use Cortex is to call an LLM directly: + +```php +use Cortex\Cortex; +use Cortex\LLM\Data\Messages\UserMessage; + +$result = Cortex::llm()->invoke([ + new UserMessage('What is the capital of France?'), +]); + +echo $result->content(); // "The capital of France is Paris." +``` + +## Use a Prompt with Variables + +Prompts let you define reusable templates with `{variable}` placeholders: + +```php +use Cortex\Cortex; + +$result = Cortex::prompt('What is the capital of {country}?') + ->llm() + ->invoke(['country' => 'France']); +``` + +For multi-message (chat) prompts: + +```php +use Cortex\Cortex; +use Cortex\LLM\Data\Messages\SystemMessage; +use Cortex\LLM\Data\Messages\UserMessage; + +$result = Cortex::prompt([ + new SystemMessage('You are an expert at geography.'), + new UserMessage('What is the capital of {country}?'), +]) +->llm() +->invoke(['country' => 'France']); +``` + +## Build an Agent + +Agents combine a prompt, an LLM, and tools to complete multi-step tasks: + +```php +use Cortex\Cortex; +use Cortex\JsonSchema\Schema; +use Cortex\Tools\Prebuilt\GetCurrentWeatherTool; + +$result = Cortex::agent() + ->withPrompt('You are a weather assistant. Tell the user about the weather in {location}.') + ->withTools([GetCurrentWeatherTool::class]) + ->withOutput([ + Schema::string('location')->required(), + Schema::string('summary')->required(), + ]) + ->invoke(input: ['location' => 'London']); + +$parsed = $result->parsedOutput(); // ['location' => 'London', 'summary' => '...'] +``` + +## Register and Reuse Agents + +For agents you use repeatedly, define them as a class and register them: + +```php +// In a service provider +use Cortex\Cortex; +use App\Agents\WeatherAgent; + +public function boot(): void +{ + Cortex::registerAgent(WeatherAgent::class); +} +``` + +Then invoke by ID anywhere in your application: + +```php +$result = Cortex::agent('weather')->invoke( + input: ['location' => 'Paris'], +); +``` + +## Next Steps + + + + Configure providers, set parameters, and use structured output. + + + Build reusable, typed prompts with metadata and variable schemas. + + + Understand how agents work with memory, tools, and multi-step reasoning. + + + Give your agents custom capabilities by building your own tools. + + diff --git a/docs/cortex/tools/creating-tools.mdx b/docs/cortex/tools/creating-tools.mdx new file mode 100644 index 0000000..62d5485 --- /dev/null +++ b/docs/cortex/tools/creating-tools.mdx @@ -0,0 +1,156 @@ +--- +title: Creating Tools +description: 'Build custom tools as classes or closures.' +icon: 'wrench' +--- + +## Class-Based Tools + +Extend `AbstractTool` for reusable tools with complex logic. You must implement `name()`, `description()`, `schema()`, and `invoke()`: + +```php +use Cortex\JsonSchema\Schema; +use Cortex\LLM\Data\ToolCall; +use Cortex\Tools\AbstractTool; +use Cortex\Pipeline\RuntimeConfig; +use Cortex\JsonSchema\Types\ObjectSchema; + +class SearchDatabaseTool extends AbstractTool +{ + public function name(): string + { + return 'search_database'; + } + + public function description(): string + { + return 'Search the product database for items matching a query.'; + } + + public function schema(): ObjectSchema + { + return Schema::object()->properties( + Schema::string('query')->required()->description('The search query'), + Schema::integer('limit')->default(10)->description('Max results to return'), + ); + } + + public function invoke(ToolCall|array $toolCall = [], ?RuntimeConfig $config = null): mixed + { + $args = $this->getArguments($toolCall); + + $this->schema()->validate($args); + + return Product::search($args['query']) + ->take($args['limit'] ?? 10) + ->get() + ->toArray(); + } +} +``` + +Then use it in an agent: + +```php +$result = Cortex::agent() + ->withPrompt('You are a product search assistant.') + ->withTools([SearchDatabaseTool::class]) + ->invoke(input: [...]); +``` + +## Accessing Runtime Context + +The `RuntimeConfig` passed to `invoke()` carries request-level context. You can read values from `$config->context`: + +```php +public function invoke(ToolCall|array $toolCall = [], ?RuntimeConfig $config = null): mixed +{ + $args = $this->getArguments($toolCall); + $locale = $config?->context?->get('locale') ?? 'en'; + + return $this->search($args['query'], $locale); +} +``` + +## Closure Tools + +For simple, inline tools, pass a closure directly to `withTools()`. Cortex wraps it in a `ClosureTool` automatically: + +```php +$result = Cortex::agent() + ->withPrompt('You are a calculator assistant.') + ->withTools([ + /** + * Add two numbers together. + */ + function (float $a, float $b): float { + return $a + $b; + }, + ]) + ->invoke(input: [...]); +``` + +Cortex uses reflection to build the tool schema from the closure's parameter types and docblock. + +### The `#[Tool]` Attribute + +Use the `#[Tool]` attribute to explicitly set the name and description: + +```php +use Cortex\Attributes\Tool; + +$result = Cortex::agent() + ->withTools([ + #[Tool(name: 'add_numbers', description: 'Add two numbers together')] + function (float $a, float $b): float { + return $a + $b; + }, + ]) + ->invoke(messages: [new UserMessage('Add 2 and 3')]); +``` + +### Schema from Closure + +You can also generate a schema from a closure explicitly using `Schema::fromClosure()`: + +```php +use Cortex\JsonSchema\Schema; + +$schema = Schema::fromClosure(function (string $query, int $limit = 10): void {}); +// ObjectSchema with 'query' (required string) and 'limit' (integer, default 10) +``` + +## Building a ClosureTool Explicitly + +If you need more control, instantiate `ClosureTool` directly: + +```php +use Cortex\Tools\ClosureTool; + +$tool = new ClosureTool( + closure: fn(string $city): array => $this->getWeather($city), + name: 'get_weather', + description: 'Get the current weather for a city.', +); +``` + +## Returning Tool Results + +Tool `invoke()` can return any value. Cortex serialises it for the LLM: + +- **string** — returned as-is +- **array** — JSON-encoded +- **object** — JSON-encoded via `json_encode()` +- **null** — sent as an empty string + +```php +// Return a string +return 'The weather in Paris is sunny, 22°C.'; + +// Return structured data (will be JSON-encoded) +return [ + 'temperature' => 22, + 'conditions' => 'Sunny', + 'city' => 'Paris', +]; +``` diff --git a/docs/cortex/tools/mcp-tools.mdx b/docs/cortex/tools/mcp-tools.mdx new file mode 100644 index 0000000..8931559 --- /dev/null +++ b/docs/cortex/tools/mcp-tools.mdx @@ -0,0 +1,101 @@ +--- +title: MCP Tools +description: 'Use tools from Model Context Protocol servers in your agents.' +icon: 'plug' +--- + +[Model Context Protocol (MCP)](https://modelcontextprotocol.io) is an open standard for connecting AI models to external tools and data sources. Cortex can connect to any MCP server and expose its tools to your agents. + +## Configuring MCP Servers + +Define your MCP servers in `config/cortex.php`: + +```php +'mcp_servers' => [ + 'default' => env('CORTEX_DEFAULT_MCP_SERVER', 'local_http'), + + 'local_http' => [ + 'transport' => 'http', + 'url' => 'http://localhost:3001/sse', + ], + + 'tavily' => [ + 'transport' => 'http', + 'url' => 'https://mcp.tavily.com/mcp/?tavilyApiKey=' . env('TAVILY_API_KEY'), + ], +], +``` + +## Using an MCP Tool + +Create an `McpTool` by specifying the tool name and optionally the server: + +```php +use Cortex\Tools\McpTool; +use Cortex\Cortex; + +$result = Cortex::agent() + ->withPrompt('You are a research assistant. Use the search tool to find information.') + ->withTools([ + new McpTool(name: 'search', server: 'tavily'), + ]) + ->invoke([new UserMessage('Latest AI news')]); +``` + +If `server` is omitted, the default MCP server from config is used. + +## McpToolKit + +To expose all tools from an MCP server at once, use `McpToolKit`: + +```php +use Cortex\Tools\ToolKits\McpToolKit; +use Cortex\Cortex; + +$result = Cortex::agent() + ->withPrompt('You have access to a suite of tools.') + ->withTools(new McpToolKit(server: 'local_http')) + ->invoke(input: [...]); +``` + +`McpToolKit` connects to the server, fetches the list of available tools, and makes them all available to the agent. + +## Using the McpServer Facade + +You can interact with MCP servers directly via the `McpServer` facade: + +```php +use Cortex\Facades\McpServer; + +// Get a client for a specific server +$client = McpServer::driver('tavily'); + +// List available tools +$tools = $client->listTools(); + +// Call a tool directly +$result = $client->callTool('search', ['query' => 'AI news']); +``` + +## MCP Prompts + +MCP servers can also serve prompts. Use the `mcp` prompt factory to load them: + +```php +use Cortex\Prompts\Prompt; + +$prompt = Prompt::factory('mcp')->make('my-mcp-prompt'); +$result = $prompt->llm()->invoke(['variable' => 'value']); +``` + +Configure which MCP server to use for prompts in `config/cortex.php`: + +```php +'prompt_factory' => [ + 'mcp' => [ + 'server' => env('CORTEX_MCP_PROMPT_SERVER', 'local_http'), + ], +], +``` + +See [Prompt Factories](/cortex/prompts/prompt-factories) for more details. diff --git a/docs/cortex/tools/overview.mdx b/docs/cortex/tools/overview.mdx new file mode 100644 index 0000000..949039f --- /dev/null +++ b/docs/cortex/tools/overview.mdx @@ -0,0 +1,116 @@ +--- +title: Overview +description: 'Give agents the ability to call functions, APIs, and external services.' +icon: 'wrench' +--- + +Tools are functions that an agent can call during its reasoning loop. When the LLM decides to use a tool, Cortex executes it and feeds the result back into the conversation automatically. + +## The Tool Contract + +Every tool implements the `Tool` interface with three required methods: + +```php +interface Tool +{ + public function name(): string; // The function name the LLM sees + public function description(): string; // What the tool does (shown to the LLM) + public function schema(): ObjectSchema; // The input parameters schema + public function invoke(ToolCall|array $toolCall, ?RuntimeConfig $config): mixed; +} +``` + +## Tool Types + +| Type | Class | Use When | +|------|-------|----------| +| Class tool | `AbstractTool` | Reusable tools with complex logic | +| Closure tool | `ClosureTool` | Quick inline tools | +| Agent tool | `AgentTool` | Wrapping an agent as a tool | +| MCP tool | `McpTool` | Tools from an MCP server | + +## Prebuilt Tools + +Cortex ships with several ready-to-use tools: + +| Tool | Description | +|------|-------------| +| `GetCurrentWeatherTool` | Fetches current weather for a location via Open-Meteo | +| `FetchUrlTool` | Fetches the content of a URL | +| `ReadFileTool` | Reads a file from the filesystem | +| `ListDirectoryTool` | Lists files in a directory | +| `ListSkillsTool` | Lists available skills from the skill registry | +| `ReadSkillTool` | Reads the content of a skill | +| `UseSkillTool` | Executes a skill | + +## Attaching Tools to an Agent + +Pass tool class names, instances, or closures to the agent: + +```php +use Cortex\Cortex; +use Cortex\Tools\Prebuilt\GetCurrentWeatherTool; +use Cortex\Tools\Prebuilt\FetchUrlTool; + +$result = Cortex::agent() + ->withPrompt('You are a research assistant.') + ->withTools([ + GetCurrentWeatherTool::class, // class name (resolved via container) + new FetchUrlTool(), // instance + function (string $query): string { // closure + return search($query); + }, + ]) + ->invoke(input: [...]); +``` + +## ToolKit + +A `ToolKit` is a collection of tools that can be attached to an agent as a group. Implement the `ToolKit` interface: + +```php +use Cortex\Contracts\ToolKit; + +class ResearchToolKit implements ToolKit +{ + public function getTools(): array + { + return [ + new FetchUrlTool(), + new ReadFileTool(), + ]; + } +} + +$agent = Cortex::agent() + ->withTools(new ResearchToolKit()) + ->invoke(input: [...]); +``` + +## Tool Choice + +Control how the LLM selects tools with `ToolChoice`: + +```php +use Cortex\LLM\Enums\ToolChoice; + +// Auto — model decides when to use tools (default) +->withTools([...], ToolChoice::Auto) + +// Required — model must use a tool +->withTools([...], ToolChoice::Required) + +// None — model cannot use tools +->withTools([...], ToolChoice::None) +``` + +## Next Steps + + + + Build class-based and closure tools with custom schemas. + + + Connect to MCP servers and use their tools in your agents. + + diff --git a/docs/docs.json b/docs/docs.json new file mode 100644 index 0000000..2543690 --- /dev/null +++ b/docs/docs.json @@ -0,0 +1,129 @@ +{ + "$schema": "https://mintlify.com/docs.json", + "theme": "aspen", + "name": "CortexPHP", + "colors": { + "primary": "#1C85FE", + "light": "#1C85FE", + "dark": "#1C85FE" + }, + "favicon": "/assets/favicon.svg", + "icons": { + "library": "lucide" + }, + "navbar": { + "links": [ + { + "label": "GitHub", + "icon": "github", + "href": "https://github.com/cortexphp" + } + ] + }, + "navigation": { + "products": [ + { + "product": "Cortex", + "icon": "brain", + "groups": [ + { + "group": "Get Started", + "pages": [ + "cortex/introduction", + "cortex/installation", + "cortex/quickstart", + "cortex/configuration" + ] + }, + { + "group": "LLM", + "pages": [ + "cortex/llm/overview", + { + "group": "Providers", + "icon": "server", + "expanded": false, + "root": "cortex/llm/providers/overview", + "pages": [ + "cortex/llm/providers/openai", + "cortex/llm/providers/anthropic", + "cortex/llm/providers/other" + ] + }, + "cortex/llm/structured-output", + "cortex/llm/streaming" + ] + }, + { + "group": "Prompts", + "pages": [ + "cortex/prompts/overview", + "cortex/prompts/prompt-builders", + { + "group": "Prompt Factories", + "icon": "factory", + "expanded": false, + "root": "cortex/prompts/factories/overview", + "pages": [ + "cortex/prompts/factories/blade", + "cortex/prompts/factories/mcp", + "cortex/prompts/factories/langfuse" + ] + } + ] + }, + { + "group": "Agents", + "pages": [ + "cortex/agents/overview", + "cortex/agents/agent-builder", + "cortex/agents/custom-agents", + "cortex/agents/middleware" + ] + }, + { + "group": "Tools", + "pages": [ + "cortex/tools/overview", + "cortex/tools/creating-tools", + "cortex/tools/mcp-tools" + ] + }, + { + "group": "More", + "pages": [ + "cortex/embeddings", + "cortex/pipelines" + ] + } + ] + } + ] + }, + "logo": { + "light": "/assets/logo-light.svg", + "dark": "/assets/logo-dark.svg" + }, + "styling": { + "codeblocks": { + "theme": { + "light": "github-light-high-contrast", + "dark": "github-dark" + } + } + }, + "contextual": { + "options": [ + "copy", + "view", + "chatgpt", + "claude" + ] + }, + "footer": { + "socials": { + "github": "https://github.com/cortexphp", + "x": "https://twitter.com/cortexphp" + } + } +} diff --git a/eslint.config.js b/eslint.config.js new file mode 100644 index 0000000..a136d22 --- /dev/null +++ b/eslint.config.js @@ -0,0 +1,44 @@ +import js from '@eslint/js'; +import prettier from 'eslint-config-prettier'; +import react from 'eslint-plugin-react'; +import reactHooks from 'eslint-plugin-react-hooks'; +import globals from 'globals'; +import typescript from 'typescript-eslint'; + +/** @type {import('eslint').Linter.Config[]} */ +export default [ + js.configs.recommended, + ...typescript.configs.recommended, + { + ...react.configs.flat.recommended, + ...react.configs.flat['jsx-runtime'], // Required for React 17+ + languageOptions: { + globals: { + ...globals.browser, + }, + }, + rules: { + 'react/react-in-jsx-scope': 'off', + 'react/prop-types': 'off', + 'react/no-unescaped-entities': 'off', + }, + settings: { + react: { + version: 'detect', + }, + }, + }, + { + plugins: { + 'react-hooks': reactHooks, + }, + rules: { + 'react-hooks/rules-of-hooks': 'error', + 'react-hooks/exhaustive-deps': 'warn', + }, + }, + { + ignores: ['vendor', 'node_modules', 'public', 'bootstrap/ssr', 'tailwind.config.js'], + }, + prettier, // Turn off all rules that might conflict with Prettier +]; diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..f4481e2 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,10794 @@ +{ + "name": "cortex", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "dependencies": { + "@assistant-ui/react": "^0.11.55", + "@assistant-ui/react-ai-sdk": "^1.1.20", + "@assistant-ui/react-markdown": "^0.11.9", + "@headlessui/react": "^2.2.0", + "@inertiajs/react": "^2.3.7", + "@radix-ui/react-avatar": "^1.1.11", + "@radix-ui/react-checkbox": "^1.1.4", + "@radix-ui/react-collapsible": "^1.1.3", + "@radix-ui/react-dialog": "^1.1.15", + "@radix-ui/react-dropdown-menu": "^2.1.6", + "@radix-ui/react-label": "^2.1.2", + "@radix-ui/react-navigation-menu": "^1.2.5", + "@radix-ui/react-select": "^2.1.6", + "@radix-ui/react-separator": "^1.1.2", + "@radix-ui/react-slot": "^1.2.4", + "@radix-ui/react-toggle": "^1.1.2", + "@radix-ui/react-toggle-group": "^1.1.2", + "@radix-ui/react-tooltip": "^1.2.8", + "@tailwindcss/vite": "^4.1.11", + "@types/react": "^19.2.0", + "@types/react-dom": "^19.2.0", + "@vitejs/plugin-react": "^5.0.0", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "concurrently": "^9.0.1", + "framer-motion": "^12.26.2", + "globals": "^15.14.0", + "input-otp": "^1.4.2", + "laravel-vite-plugin": "^2.0", + "lucide-react": "^0.475.0", + "motion": "^12.26.2", + "react": "^19.2.0", + "react-dom": "^19.2.0", + "react-shiki": "^0.9.1", + "remark-gfm": "^4.0.1", + "tailwind-merge": "^3.4.0", + "tailwindcss": "^4.0.0", + "typescript": "^5.7.2", + "vite": "^7.0.4", + "zustand": "^5.0.10" + }, + "devDependencies": { + "@eslint/js": "^9.19.0", + "@laravel/vite-plugin-wayfinder": "^0.1.3", + "@types/node": "^22.13.5", + "babel-plugin-react-compiler": "^1.0.0", + "eslint": "^9.17.0", + "eslint-config-prettier": "^10.0.1", + "eslint-plugin-react": "^7.37.3", + "eslint-plugin-react-hooks": "^7.0.0", + "prettier": "^3.4.2", + "prettier-plugin-organize-imports": "^4.1.0", + "prettier-plugin-tailwindcss": "^0.6.11", + "tw-animate-css": "^1.4.0", + "typescript-eslint": "^8.23.0" + }, + "optionalDependencies": { + "@rollup/rollup-linux-x64-gnu": "4.9.5", + "@rollup/rollup-win32-x64-msvc": "4.9.5", + "@tailwindcss/oxide-linux-x64-gnu": "^4.0.1", + "@tailwindcss/oxide-win32-x64-msvc": "^4.0.1", + "lightningcss-linux-x64-gnu": "^1.29.1", + "lightningcss-win32-x64-msvc": "^1.29.1" + } + }, + "node_modules/@ai-sdk/gateway": { + "version": "2.0.27", + "resolved": "https://registry.npmjs.org/@ai-sdk/gateway/-/gateway-2.0.27.tgz", + "integrity": "sha512-8hbezMsGa0crSt7/DKjkYL1UbbJJW/UFxTfhmf5qcIeYeeWG4dTNmm+DWbUdIsTaWvp59KC4eeC9gYXBbTHd7w==", + "license": "Apache-2.0", + "dependencies": { + "@ai-sdk/provider": "2.0.1", + "@ai-sdk/provider-utils": "3.0.20", + "@vercel/oidc": "3.1.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "zod": "^3.25.76 || ^4.1.8" + } + }, + "node_modules/@ai-sdk/provider": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@ai-sdk/provider/-/provider-2.0.1.tgz", + "integrity": "sha512-KCUwswvsC5VsW2PWFqF8eJgSCu5Ysj7m1TxiHTVA6g7k360bk0RNQENT8KTMAYEs+8fWPD3Uu4dEmzGHc+jGng==", + "license": "Apache-2.0", + "dependencies": { + "json-schema": "^0.4.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@ai-sdk/provider-utils": { + "version": "3.0.20", + "resolved": "https://registry.npmjs.org/@ai-sdk/provider-utils/-/provider-utils-3.0.20.tgz", + "integrity": "sha512-iXHVe0apM2zUEzauqJwqmpC37A5rihrStAih5Ks+JE32iTe4LZ58y17UGBjpQQTCRw9YxMeo2UFLxLpBluyvLQ==", + "license": "Apache-2.0", + "dependencies": { + "@ai-sdk/provider": "2.0.1", + "@standard-schema/spec": "^1.0.0", + "eventsource-parser": "^3.0.6" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "zod": "^3.25.76 || ^4.1.8" + } + }, + "node_modules/@ai-sdk/react": { + "version": "2.0.123", + "resolved": "https://registry.npmjs.org/@ai-sdk/react/-/react-2.0.123.tgz", + "integrity": "sha512-exaEvHAsDdR0wgzF3l0BmC9U1nPLnkPK2CCnX3BP4RDj/PySZvPXjry3AOz1Ayb8KSPZgWklVRzxsQxrOYQJxA==", + "license": "Apache-2.0", + "dependencies": { + "@ai-sdk/provider-utils": "3.0.20", + "ai": "5.0.121", + "swr": "^2.2.5", + "throttleit": "2.1.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "react": "^18 || ~19.0.1 || ~19.1.2 || ^19.2.1", + "zod": "^3.25.76 || ^4.1.8" + }, + "peerDependenciesMeta": { + "zod": { + "optional": true + } + } + }, + "node_modules/@assistant-ui/react": { + "version": "0.11.57", + "resolved": "https://registry.npmjs.org/@assistant-ui/react/-/react-0.11.57.tgz", + "integrity": "sha512-Kj+BuF9N2YAgW4jCZws3wYeX98xkOO3RgTjkWkfpUihAtJVKxxe+mP+OZcK9eTA4bo/W5HFC6PUsV0L0/OxsgQ==", + "license": "MIT", + "dependencies": { + "@assistant-ui/tap": "^0.3.5", + "@radix-ui/primitive": "^1.1.3", + "@radix-ui/react-compose-refs": "^1.1.2", + "@radix-ui/react-context": "^1.1.3", + "@radix-ui/react-dropdown-menu": "^2.1.16", + "@radix-ui/react-popover": "^1.1.15", + "@radix-ui/react-primitive": "^2.1.4", + "@radix-ui/react-slot": "^1.2.4", + "@radix-ui/react-use-callback-ref": "^1.1.1", + "@radix-ui/react-use-escape-keydown": "^1.1.1", + "assistant-cloud": "^0.1.12", + "assistant-stream": "^0.2.46", + "nanoid": "^5.1.6", + "react-textarea-autosize": "^8.5.9", + "zod": "^4.3.5", + "zustand": "^5.0.9" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^18 || ^19", + "react-dom": "^18 || ^19" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@assistant-ui/react-ai-sdk": { + "version": "1.1.21", + "resolved": "https://registry.npmjs.org/@assistant-ui/react-ai-sdk/-/react-ai-sdk-1.1.21.tgz", + "integrity": "sha512-TRJx6fDoIqUpkl1LuRZB2QHGZ7pXAc4qtX05h+z0yYIMY4Y2/XzwxlitpcS+NSo1PS8YCgZaM9HO1y66H/il2Q==", + "license": "MIT", + "dependencies": { + "@ai-sdk/react": "^2.0.118", + "ai": "^5.0.116", + "zod": "^4.3.5" + }, + "peerDependencies": { + "@assistant-ui/react": "^0.11.56", + "@types/react": "*", + "assistant-cloud": "*", + "react": "^18 || ^19" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "assistant-cloud": { + "optional": true + } + } + }, + "node_modules/@assistant-ui/react-markdown": { + "version": "0.11.9", + "resolved": "https://registry.npmjs.org/@assistant-ui/react-markdown/-/react-markdown-0.11.9.tgz", + "integrity": "sha512-zR0Ty4ID5htJgm4g1TVAbTsyfJZ8XHccDQ0sMODsq/PWAM75l7EmAbxdSKPbvCqny1A/FxvAB4dz1LA17ZgoWg==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "^2.1.4", + "@radix-ui/react-use-callback-ref": "^1.1.1", + "classnames": "^2.5.1", + "react-markdown": "^10.1.0" + }, + "peerDependencies": { + "@assistant-ui/react": "^0.11.53", + "@types/react": "*", + "react": "^18 || ^19" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@assistant-ui/tap": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/@assistant-ui/tap/-/tap-0.3.5.tgz", + "integrity": "sha512-aI7lOKglkVYy17GrS9EdjSrOmEBmofWPBZ4F5wb96yqEynXflXY3qUAFCgmUwaP/TVkog72+o1ePyvsGphSmJQ==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^18 || ^19" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "react": { + "optional": true + } + } + }, + "node_modules/@babel/code-frame": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.28.6.tgz", + "integrity": "sha512-JYgintcMjRiCvS8mMECzaEn+m3PfoQiyqukOMCCVQtoJGYJw8j/8LBJEiqkHLkfwCcs74E3pbAUFNg7d9VNJ+Q==", + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.28.5", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.28.6.tgz", + "integrity": "sha512-2lfu57JtzctfIrcGMz992hyLlByuzgIk58+hhGCxjKZ3rWI82NnVLjXcaTqkI2NvlcvOskZaiZ5kjUALo3Lpxg==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.6.tgz", + "integrity": "sha512-H3mcG6ZDLTlYfaSNi0iOKkigqMFvkTKlGUYlD8GW7nNOYRrevuA46iTypPyv+06V3fEmvvazfntkBU34L0azAw==", + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.28.6", + "@babel/generator": "^7.28.6", + "@babel/helper-compilation-targets": "^7.28.6", + "@babel/helper-module-transforms": "^7.28.6", + "@babel/helpers": "^7.28.6", + "@babel/parser": "^7.28.6", + "@babel/template": "^7.28.6", + "@babel/traverse": "^7.28.6", + "@babel/types": "^7.28.6", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.28.6.tgz", + "integrity": "sha512-lOoVRwADj8hjf7al89tvQ2a1lf53Z+7tiXMgpZJL3maQPDxh0DgLMN62B2MKUOFcoodBHLMbDM6WAbKgNy5Suw==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.28.6", + "@babel/types": "^7.28.6", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz", + "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==", + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.28.6", + "@babel/helper-validator-option": "^7.27.1", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", + "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", + "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==", + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz", + "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==", + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.28.6", + "@babel/helper-validator-identifier": "^7.28.5", + "@babel/traverse": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.28.6.tgz", + "integrity": "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", + "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.6.tgz", + "integrity": "sha512-xOBvwq86HHdB7WUDTfKfT/Vuxh7gElQ+Sfti2Cy6yIWNW05P8iUslOVcZ4/sKbE+/jQaukQAdz/gf3724kYdqw==", + "license": "MIT", + "dependencies": { + "@babel/template": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.6.tgz", + "integrity": "sha512-TeR9zWR18BvbfPmGbLampPMW+uW1NZnJlRuuHso8i87QZNq2JRF9i6RgxRqtEq+wQGsS19NNTWr2duhnE49mfQ==", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.6" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.27.1.tgz", + "integrity": "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.27.1.tgz", + "integrity": "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/runtime": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.6.tgz", + "integrity": "sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/template": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", + "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.28.6", + "@babel/parser": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.28.6.tgz", + "integrity": "sha512-fgWX62k02qtjqdSNTAGxmKYY/7FSL9WAS1o2Hu5+I5m9T0yxZzr4cnrfXQ/MX0rIifthCSs6FKTlzYbJcPtMNg==", + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.28.6", + "@babel/generator": "^7.28.6", + "@babel/helper-globals": "^7.28.0", + "@babel/parser": "^7.28.6", + "@babel/template": "^7.28.6", + "@babel/types": "^7.28.6", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.6.tgz", + "integrity": "sha512-0ZrskXVEHSWIqZM/sQZ4EV3jZJXRkio/WCxaqKZP1g//CEWEPSfeZFcms4XeKBCHU0ZKnIkdJeU/kF+eRp5lBg==", + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.2.tgz", + "integrity": "sha512-GZMB+a0mOMZs4MpDbj8RJp4cw+w1WV5NYD6xzgvzUJ5Ek2jerwfO2eADyI6ExDSUED+1X8aMbegahsJi+8mgpw==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.2.tgz", + "integrity": "sha512-DVNI8jlPa7Ujbr1yjU2PfUSRtAUZPG9I1RwW4F4xFB1Imiu2on0ADiI/c3td+KmDtVKNbi+nffGDQMfcIMkwIA==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.2.tgz", + "integrity": "sha512-pvz8ZZ7ot/RBphf8fv60ljmaoydPU12VuXHImtAs0XhLLw+EXBi2BLe3OYSBslR4rryHvweW5gmkKFwTiFy6KA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.2.tgz", + "integrity": "sha512-z8Ank4Byh4TJJOh4wpz8g2vDy75zFL0TlZlkUkEwYXuPSgX8yzep596n6mT7905kA9uHZsf/o2OJZubl2l3M7A==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.2.tgz", + "integrity": "sha512-davCD2Zc80nzDVRwXTcQP/28fiJbcOwvdolL0sOiOsbwBa72kegmVU0Wrh1MYrbuCL98Omp5dVhQFWRKR2ZAlg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.2.tgz", + "integrity": "sha512-ZxtijOmlQCBWGwbVmwOF/UCzuGIbUkqB1faQRf5akQmxRJ1ujusWsb3CVfk/9iZKr2L5SMU5wPBi1UWbvL+VQA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.2.tgz", + "integrity": "sha512-lS/9CN+rgqQ9czogxlMcBMGd+l8Q3Nj1MFQwBZJyoEKI50XGxwuzznYdwcav6lpOGv5BqaZXqvBSiB/kJ5op+g==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.2.tgz", + "integrity": "sha512-tAfqtNYb4YgPnJlEFu4c212HYjQWSO/w/h/lQaBK7RbwGIkBOuNKQI9tqWzx7Wtp7bTPaGC6MJvWI608P3wXYA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.2.tgz", + "integrity": "sha512-vWfq4GaIMP9AIe4yj1ZUW18RDhx6EPQKjwe7n8BbIecFtCQG4CfHGaHuh7fdfq+y3LIA2vGS/o9ZBGVxIDi9hw==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.2.tgz", + "integrity": "sha512-hYxN8pr66NsCCiRFkHUAsxylNOcAQaxSSkHMMjcpx0si13t1LHFphxJZUiGwojB1a/Hd5OiPIqDdXONia6bhTw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.2.tgz", + "integrity": "sha512-MJt5BRRSScPDwG2hLelYhAAKh9imjHK5+NE/tvnRLbIqUWa+0E9N4WNMjmp/kXXPHZGqPLxggwVhz7QP8CTR8w==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.2.tgz", + "integrity": "sha512-lugyF1atnAT463aO6KPshVCJK5NgRnU4yb3FUumyVz+cGvZbontBgzeGFO1nF+dPueHD367a2ZXe1NtUkAjOtg==", + "cpu": [ + "loong64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.2.tgz", + "integrity": "sha512-nlP2I6ArEBewvJ2gjrrkESEZkB5mIoaTswuqNFRv/WYd+ATtUpe9Y09RnJvgvdag7he0OWgEZWhviS1OTOKixw==", + "cpu": [ + "mips64el" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.2.tgz", + "integrity": "sha512-C92gnpey7tUQONqg1n6dKVbx3vphKtTHJaNG2Ok9lGwbZil6DrfyecMsp9CrmXGQJmZ7iiVXvvZH6Ml5hL6XdQ==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.2.tgz", + "integrity": "sha512-B5BOmojNtUyN8AXlK0QJyvjEZkWwy/FKvakkTDCziX95AowLZKR6aCDhG7LeF7uMCXEJqwa8Bejz5LTPYm8AvA==", + "cpu": [ + "riscv64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.2.tgz", + "integrity": "sha512-p4bm9+wsPwup5Z8f4EpfN63qNagQ47Ua2znaqGH6bqLlmJ4bx97Y9JdqxgGZ6Y8xVTixUnEkoKSHcpRlDnNr5w==", + "cpu": [ + "s390x" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.2.tgz", + "integrity": "sha512-uwp2Tip5aPmH+NRUwTcfLb+W32WXjpFejTIOWZFw/v7/KnpCDKG66u4DLcurQpiYTiYwQ9B7KOeMJvLCu/OvbA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.2.tgz", + "integrity": "sha512-Kj6DiBlwXrPsCRDeRvGAUb/LNrBASrfqAIok+xB0LxK8CHqxZ037viF13ugfsIpePH93mX7xfJp97cyDuTZ3cw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.2.tgz", + "integrity": "sha512-HwGDZ0VLVBY3Y+Nw0JexZy9o/nUAWq9MlV7cahpaXKW6TOzfVno3y3/M8Ga8u8Yr7GldLOov27xiCnqRZf0tCA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.2.tgz", + "integrity": "sha512-DNIHH2BPQ5551A7oSHD0CKbwIA/Ox7+78/AWkbS5QoRzaqlev2uFayfSxq68EkonB+IKjiuxBFoV8ESJy8bOHA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.2.tgz", + "integrity": "sha512-/it7w9Nb7+0KFIzjalNJVR5bOzA9Vay+yIPLVHfIQYG/j+j9VTH84aNB8ExGKPU4AzfaEvN9/V4HV+F+vo8OEg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.2.tgz", + "integrity": "sha512-LRBbCmiU51IXfeXk59csuX/aSaToeG7w48nMwA6049Y4J4+VbWALAuXcs+qcD04rHDuSCSRKdmY63sruDS5qag==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.2.tgz", + "integrity": "sha512-kMtx1yqJHTmqaqHPAzKCAkDaKsffmXkPHThSfRwZGyuqyIeBvf08KSsYXl+abf5HDAPMJIPnbBfXvP2ZC2TfHg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.2.tgz", + "integrity": "sha512-Yaf78O/B3Kkh+nKABUF++bvJv5Ijoy9AN1ww904rOXZFLWVc5OLOfL56W+C8F9xn5JQZa3UX6m+IktJnIb1Jjg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.2.tgz", + "integrity": "sha512-Iuws0kxo4yusk7sw70Xa2E2imZU5HoixzxfGCdxwBdhiDgt9vX9VUCBhqcwY7/uh//78A1hMkkROMJq9l27oLQ==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.2.tgz", + "integrity": "sha512-sRdU18mcKf7F+YgheI/zGf5alZatMUTKj/jNS6l744f9u3WFu4v7twcUI9vu4mknF4Y9aDlblIie0IM+5xxaqQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.9.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", + "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.21.1", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.1.tgz", + "integrity": "sha512-aw1gNayWpdI/jSYVgzN5pL0cfzU02GT3NBpeT/DXbx1/1x7ZKxFPd9bwrzygx/qiwIQiJ1sw/zD8qY/kRvlGHA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^2.1.7", + "debug": "^4.3.1", + "minimatch": "^3.1.2" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/config-helpers": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.2.tgz", + "integrity": "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/core": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.17.0.tgz", + "integrity": "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/eslintrc": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.3.tgz", + "integrity": "sha512-Kr+LPIUVKz2qkx1HAMH8q1q6azbqBAsXJUxBl/ODDuVPX45Z9DfwB8tPjTi6nNZ8BuM3nbJxC5zCAg5elnBUTQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^6.12.4", + "debug": "^4.3.2", + "espree": "^10.0.1", + "globals": "^14.0.0", + "ignore": "^5.2.0", + "import-fresh": "^3.2.1", + "js-yaml": "^4.1.1", + "minimatch": "^3.1.2", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint/eslintrc/node_modules/globals": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", + "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@eslint/js": { + "version": "9.39.2", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.2.tgz", + "integrity": "sha512-q1mjIoW1VX4IvSocvM/vbTiveKC4k9eLrajNEuSsmjymSDEbpGddtpfOoN7YGAqBK3NG+uqo8ia4PDTt8buCYA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + } + }, + "node_modules/@eslint/object-schema": { + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.7.tgz", + "integrity": "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.4.1.tgz", + "integrity": "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0", + "levn": "^0.4.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@floating-ui/core": { + "version": "1.7.3", + "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.3.tgz", + "integrity": "sha512-sGnvb5dmrJaKEZ+LDIpguvdX3bDlEllmv4/ClQ9awcmCZrlx5jQyyMWFM5kBI+EyNOCDDiKk8il0zeuX3Zlg/w==", + "license": "MIT", + "dependencies": { + "@floating-ui/utils": "^0.2.10" + } + }, + "node_modules/@floating-ui/dom": { + "version": "1.7.4", + "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.4.tgz", + "integrity": "sha512-OOchDgh4F2CchOX94cRVqhvy7b3AFb+/rQXyswmzmGakRfkMgoWVjfnLWkRirfLEfuD4ysVW16eXzwt3jHIzKA==", + "license": "MIT", + "dependencies": { + "@floating-ui/core": "^1.7.3", + "@floating-ui/utils": "^0.2.10" + } + }, + "node_modules/@floating-ui/react": { + "version": "0.26.28", + "resolved": "https://registry.npmjs.org/@floating-ui/react/-/react-0.26.28.tgz", + "integrity": "sha512-yORQuuAtVpiRjpMhdc0wJj06b9JFjrYF4qp96j++v2NBpbi6SEGF7donUJ3TMieerQ6qVkAv1tgr7L4r5roTqw==", + "license": "MIT", + "dependencies": { + "@floating-ui/react-dom": "^2.1.2", + "@floating-ui/utils": "^0.2.8", + "tabbable": "^6.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@floating-ui/react-dom": { + "version": "2.1.6", + "resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.6.tgz", + "integrity": "sha512-4JX6rEatQEvlmgU80wZyq9RT96HZJa88q8hp0pBd+LrczeDI4o6uA2M+uvxngVHo4Ihr8uibXxH6+70zhAFrVw==", + "license": "MIT", + "dependencies": { + "@floating-ui/dom": "^1.7.4" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@floating-ui/utils": { + "version": "0.2.10", + "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.10.tgz", + "integrity": "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==", + "license": "MIT" + }, + "node_modules/@headlessui/react": { + "version": "2.2.9", + "resolved": "https://registry.npmjs.org/@headlessui/react/-/react-2.2.9.tgz", + "integrity": "sha512-Mb+Un58gwBn0/yWZfyrCh0TJyurtT+dETj7YHleylHk5od3dv2XqETPGWMyQ5/7sYN7oWdyM1u9MvC0OC8UmzQ==", + "license": "MIT", + "dependencies": { + "@floating-ui/react": "^0.26.16", + "@react-aria/focus": "^3.20.2", + "@react-aria/interactions": "^3.25.0", + "@tanstack/react-virtual": "^3.13.9", + "use-sync-external-store": "^1.5.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "react": "^18 || ^19 || ^19.0.0-rc", + "react-dom": "^18 || ^19 || ^19.0.0-rc" + } + }, + "node_modules/@humanfs/core": { + "version": "0.19.1", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", + "integrity": "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.7", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.7.tgz", + "integrity": "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.1", + "@humanwhocodes/retry": "^0.4.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@inertiajs/core": { + "version": "2.3.10", + "resolved": "https://registry.npmjs.org/@inertiajs/core/-/core-2.3.10.tgz", + "integrity": "sha512-shoDHBtbFngFnHomGcLEhIEnPr+IjqxFo3PnWxurKNE5zUfViEyMYC0ZK36OVI6AeYKtHdrdyRGVeRWlOoTXHQ==", + "license": "MIT", + "dependencies": { + "@types/lodash-es": "^4.17.12", + "axios": "^1.13.2", + "laravel-precognition": "^1.0.0", + "lodash-es": "^4.17.21", + "qs": "^6.14.1" + } + }, + "node_modules/@inertiajs/react": { + "version": "2.3.10", + "resolved": "https://registry.npmjs.org/@inertiajs/react/-/react-2.3.10.tgz", + "integrity": "sha512-CrKFMldKbbAb4JaZFo8ew+9HmIVNm0pd+vwCSHpL/WOhm98rtCnlO1034icZbwfljDWgyByLPzIE4Ru5vr2Opw==", + "license": "MIT", + "dependencies": { + "@inertiajs/core": "2.3.10", + "@types/lodash-es": "^4.17.12", + "laravel-precognition": "^1.0.0", + "lodash-es": "^4.17.21" + }, + "peerDependencies": { + "react": "^16.9.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.9.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@laravel/vite-plugin-wayfinder": { + "version": "0.1.7", + "resolved": "https://registry.npmjs.org/@laravel/vite-plugin-wayfinder/-/vite-plugin-wayfinder-0.1.7.tgz", + "integrity": "sha512-yZYIr1iwuCQ7LFI+GsJk9vacw1HWMp3ZlDlW0pdfz3zXyKeu4US7oH79KmQQ031L0cYaSyaUMo/Ha1D4BosKqw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@opentelemetry/api": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz", + "integrity": "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==", + "license": "Apache-2.0", + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/@radix-ui/number": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/number/-/number-1.1.1.tgz", + "integrity": "sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g==", + "license": "MIT" + }, + "node_modules/@radix-ui/primitive": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.3.tgz", + "integrity": "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==", + "license": "MIT" + }, + "node_modules/@radix-ui/react-arrow": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.1.7.tgz", + "integrity": "sha512-F+M1tLhO+mlQaOWspE8Wstg+z6PwxwRd8oQ8IXceWz92kfAmalTRf0EjrouQeo7QssEPfCn05B4Ihs1K9WQ/7w==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-arrow/node_modules/@radix-ui/react-primitive": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-arrow/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-avatar": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/@radix-ui/react-avatar/-/react-avatar-1.1.11.tgz", + "integrity": "sha512-0Qk603AHGV28BOBO34p7IgD5m+V5Sg/YovfayABkoDDBM5d3NCx0Mp4gGrjzLGes1jV5eNOE1r3itqOR33VC6Q==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-context": "1.1.3", + "@radix-ui/react-primitive": "2.1.4", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-is-hydrated": "0.1.0", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-checkbox": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-checkbox/-/react-checkbox-1.3.3.tgz", + "integrity": "sha512-wBbpv+NQftHDdG86Qc0pIyXk5IR3tM8Vd0nWLKDcX8nNn4nXFOFwsKuqw2okA/1D/mpaAkmuyndrPJTYDNZtFw==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-use-previous": "1.1.1", + "@radix-ui/react-use-size": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-checkbox/node_modules/@radix-ui/react-context": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz", + "integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-checkbox/node_modules/@radix-ui/react-primitive": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-checkbox/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-collapsible": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/@radix-ui/react-collapsible/-/react-collapsible-1.1.12.tgz", + "integrity": "sha512-Uu+mSh4agx2ib1uIGPP4/CKNULyajb3p92LsVXmH2EHVMTfZWpll88XJ0j4W0z3f8NK1eYl1+Mf/szHPmcHzyA==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-collapsible/node_modules/@radix-ui/react-context": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz", + "integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-collapsible/node_modules/@radix-ui/react-primitive": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-collapsible/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-collection": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.7.tgz", + "integrity": "sha512-Fh9rGN0MoI4ZFUNyfFVNU4y9LUz93u9/0K+yLgA2bwRojxM8JU1DyvvMBabnZPBgMWREAJvU2jjVzq+LrFUglw==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-collection/node_modules/@radix-ui/react-context": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz", + "integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-collection/node_modules/@radix-ui/react-primitive": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-collection/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-compose-refs": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.2.tgz", + "integrity": "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-context": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.3.tgz", + "integrity": "sha512-ieIFACdMpYfMEjF0rEf5KLvfVyIkOz6PDGyNnP+u+4xQ6jny3VCgA4OgXOwNx2aUkxn8zx9fiVcM8CfFYv9Lxw==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dialog": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dialog/-/react-dialog-1.1.15.tgz", + "integrity": "sha512-TCglVRtzlffRNxRMEyR36DGBLJpeusFcgMVD9PZEzAKnUs1lKCgX5u9BmC2Yg+LL9MgZDugFFs1Vl+Jp4t/PGw==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-dismissable-layer": "1.1.11", + "@radix-ui/react-focus-guards": "1.1.3", + "@radix-ui/react-focus-scope": "1.1.7", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-portal": "1.1.9", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-slot": "1.2.3", + "@radix-ui/react-use-controllable-state": "1.2.2", + "aria-hidden": "^1.2.4", + "react-remove-scroll": "^2.6.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-context": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz", + "integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-primitive": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-direction": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.1.1.tgz", + "integrity": "sha512-1UEWRX6jnOA2y4H5WczZ44gOOjTEmlqv1uNW4GAJEO5+bauCBhv8snY65Iw5/VOS/ghKN9gr2KjnLKxrsvoMVw==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dismissable-layer": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.11.tgz", + "integrity": "sha512-Nqcp+t5cTB8BinFkZgXiMJniQH0PsUt2k51FUhbdfeKvc4ACcG2uQniY/8+h1Yv6Kza4Q7lD7PQV0z0oicE0Mg==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-escape-keydown": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dismissable-layer/node_modules/@radix-ui/react-primitive": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dismissable-layer/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dropdown-menu": { + "version": "2.1.16", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dropdown-menu/-/react-dropdown-menu-2.1.16.tgz", + "integrity": "sha512-1PLGQEynI/3OX/ftV54COn+3Sud/Mn8vALg2rWnBLnRaGtJDduNW/22XjlGgPdpcIbiQxjKtb7BkcjP00nqfJw==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-menu": "2.1.16", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-controllable-state": "1.2.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dropdown-menu/node_modules/@radix-ui/react-context": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz", + "integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dropdown-menu/node_modules/@radix-ui/react-primitive": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dropdown-menu/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-focus-guards": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-guards/-/react-focus-guards-1.1.3.tgz", + "integrity": "sha512-0rFg/Rj2Q62NCm62jZw0QX7a3sz6QCQU0LpZdNrJX8byRGaGVTqbrW9jAoIAHyMQqsNpeZ81YgSizOt5WXq0Pw==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-focus-scope": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-scope/-/react-focus-scope-1.1.7.tgz", + "integrity": "sha512-t2ODlkXBQyn7jkl6TNaw/MtVEVvIGelJDCG41Okq/KwUsJBwQ4XVZsHAVUkK4mBv3ewiAS3PGuUWuY2BoK4ZUw==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-focus-scope/node_modules/@radix-ui/react-primitive": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-focus-scope/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-id": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.1.1.tgz", + "integrity": "sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-label": { + "version": "2.1.8", + "resolved": "https://registry.npmjs.org/@radix-ui/react-label/-/react-label-2.1.8.tgz", + "integrity": "sha512-FmXs37I6hSBVDlO4y764TNz1rLgKwjJMQ0EGte6F3Cb3f4bIuHB/iLa/8I9VKkmOy+gNHq8rql3j686ACVV21A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.4" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-menu": { + "version": "2.1.16", + "resolved": "https://registry.npmjs.org/@radix-ui/react-menu/-/react-menu-2.1.16.tgz", + "integrity": "sha512-72F2T+PLlphrqLcAotYPp0uJMr5SjP5SL01wfEspJbru5Zs5vQaSHb4VB3ZMJPimgHHCHG7gMOeOB9H3Hdmtxg==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-collection": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-dismissable-layer": "1.1.11", + "@radix-ui/react-focus-guards": "1.1.3", + "@radix-ui/react-focus-scope": "1.1.7", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-popper": "1.2.8", + "@radix-ui/react-portal": "1.1.9", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-roving-focus": "1.1.11", + "@radix-ui/react-slot": "1.2.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "aria-hidden": "^1.2.4", + "react-remove-scroll": "^2.6.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-menu/node_modules/@radix-ui/react-context": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz", + "integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-menu/node_modules/@radix-ui/react-primitive": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-menu/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-navigation-menu": { + "version": "1.2.14", + "resolved": "https://registry.npmjs.org/@radix-ui/react-navigation-menu/-/react-navigation-menu-1.2.14.tgz", + "integrity": "sha512-YB9mTFQvCOAQMHU+C/jVl96WmuWeltyUEpRJJky51huhds5W2FQr1J8D/16sQlf0ozxkPK8uF3niQMdUwZPv5w==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-collection": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-dismissable-layer": "1.1.11", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-use-layout-effect": "1.1.1", + "@radix-ui/react-use-previous": "1.1.1", + "@radix-ui/react-visually-hidden": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-navigation-menu/node_modules/@radix-ui/react-context": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz", + "integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-navigation-menu/node_modules/@radix-ui/react-primitive": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-navigation-menu/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-popover": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/@radix-ui/react-popover/-/react-popover-1.1.15.tgz", + "integrity": "sha512-kr0X2+6Yy/vJzLYJUPCZEc8SfQcf+1COFoAqauJm74umQhta9M7lNJHP7QQS3vkvcGLQUbWpMzwrXYwrYztHKA==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-dismissable-layer": "1.1.11", + "@radix-ui/react-focus-guards": "1.1.3", + "@radix-ui/react-focus-scope": "1.1.7", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-popper": "1.2.8", + "@radix-ui/react-portal": "1.1.9", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-slot": "1.2.3", + "@radix-ui/react-use-controllable-state": "1.2.2", + "aria-hidden": "^1.2.4", + "react-remove-scroll": "^2.6.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-popover/node_modules/@radix-ui/react-context": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz", + "integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-popover/node_modules/@radix-ui/react-primitive": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-popover/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-popper": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.2.8.tgz", + "integrity": "sha512-0NJQ4LFFUuWkE7Oxf0htBKS6zLkkjBH+hM1uk7Ng705ReR8m/uelduy1DBo0PyBXPKVnBA6YBlU94MBGXrSBCw==", + "license": "MIT", + "dependencies": { + "@floating-ui/react-dom": "^2.0.0", + "@radix-ui/react-arrow": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-layout-effect": "1.1.1", + "@radix-ui/react-use-rect": "1.1.1", + "@radix-ui/react-use-size": "1.1.1", + "@radix-ui/rect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-popper/node_modules/@radix-ui/react-context": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz", + "integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-popper/node_modules/@radix-ui/react-primitive": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-popper/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-portal": { + "version": "1.1.9", + "resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.9.tgz", + "integrity": "sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-portal/node_modules/@radix-ui/react-primitive": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-portal/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-presence": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.5.tgz", + "integrity": "sha512-/jfEwNDdQVBCNvjkGit4h6pMOzq8bHkopq458dPt2lMjx+eBQUohZNG9A7DtO/O5ukSbxuaNGXMjHicgwy6rQQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-primitive": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.4.tgz", + "integrity": "sha512-9hQc4+GNVtJAIEPEqlYqW5RiYdrr8ea5XQ0ZOnD6fgru+83kqT15mq2OCcbe8KnjRZl5vF3ks69AKz3kh1jrhg==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.2.4" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-roving-focus": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/@radix-ui/react-roving-focus/-/react-roving-focus-1.1.11.tgz", + "integrity": "sha512-7A6S9jSgm/S+7MdtNDSb+IU859vQqJ/QAtcYQcfFC6W8RS4IxIZDldLR0xqCFZ6DCyrQLjLPsxtTNch5jVA4lA==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-collection": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-controllable-state": "1.2.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-roving-focus/node_modules/@radix-ui/react-context": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz", + "integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-roving-focus/node_modules/@radix-ui/react-primitive": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-roving-focus/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-select": { + "version": "2.2.6", + "resolved": "https://registry.npmjs.org/@radix-ui/react-select/-/react-select-2.2.6.tgz", + "integrity": "sha512-I30RydO+bnn2PQztvo25tswPH+wFBjehVGtmagkU78yMdwTwVf12wnAOF+AeP8S2N8xD+5UPbGhkUfPyvT+mwQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/number": "1.1.1", + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-collection": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-dismissable-layer": "1.1.11", + "@radix-ui/react-focus-guards": "1.1.3", + "@radix-ui/react-focus-scope": "1.1.7", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-popper": "1.2.8", + "@radix-ui/react-portal": "1.1.9", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-slot": "1.2.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-use-layout-effect": "1.1.1", + "@radix-ui/react-use-previous": "1.1.1", + "@radix-ui/react-visually-hidden": "1.2.3", + "aria-hidden": "^1.2.4", + "react-remove-scroll": "^2.6.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-select/node_modules/@radix-ui/react-context": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz", + "integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-select/node_modules/@radix-ui/react-primitive": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-select/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-separator": { + "version": "1.1.8", + "resolved": "https://registry.npmjs.org/@radix-ui/react-separator/-/react-separator-1.1.8.tgz", + "integrity": "sha512-sDvqVY4itsKwwSMEe0jtKgfTh+72Sy3gPmQpjqcQneqQ4PFmr/1I0YA+2/puilhggCe2gJcx5EBAYFkWkdpa5g==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.4" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-slot": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.4.tgz", + "integrity": "sha512-Jl+bCv8HxKnlTLVrcDE8zTMJ09R9/ukw4qBs/oZClOfoQk/cOTbDn+NceXfV7j09YPVQUryJPHurafcSg6EVKA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-toggle": { + "version": "1.1.10", + "resolved": "https://registry.npmjs.org/@radix-ui/react-toggle/-/react-toggle-1.1.10.tgz", + "integrity": "sha512-lS1odchhFTeZv3xwHH31YPObmJn8gOg7Lq12inrr0+BH/l3Tsq32VfjqH1oh80ARM3mlkfMic15n0kg4sD1poQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-controllable-state": "1.2.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-toggle-group": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/@radix-ui/react-toggle-group/-/react-toggle-group-1.1.11.tgz", + "integrity": "sha512-5umnS0T8JQzQT6HbPyO7Hh9dgd82NmS36DQr+X/YJ9ctFNCiiQd6IJAYYZ33LUwm8M+taCz5t2ui29fHZc4Y6Q==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-roving-focus": "1.1.11", + "@radix-ui/react-toggle": "1.1.10", + "@radix-ui/react-use-controllable-state": "1.2.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-toggle-group/node_modules/@radix-ui/react-context": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz", + "integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-toggle-group/node_modules/@radix-ui/react-primitive": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-toggle-group/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-toggle/node_modules/@radix-ui/react-primitive": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-toggle/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-tooltip": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@radix-ui/react-tooltip/-/react-tooltip-1.2.8.tgz", + "integrity": "sha512-tY7sVt1yL9ozIxvmbtN5qtmH2krXcBCfjEiCgKGLqunJHvgvZG2Pcl2oQ3kbcZARb1BGEHdkLzcYGO8ynVlieg==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-dismissable-layer": "1.1.11", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-popper": "1.2.8", + "@radix-ui/react-portal": "1.1.9", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-slot": "1.2.3", + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-visually-hidden": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-tooltip/node_modules/@radix-ui/react-context": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz", + "integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-tooltip/node_modules/@radix-ui/react-primitive": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-tooltip/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-callback-ref": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.1.tgz", + "integrity": "sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-controllable-state": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.2.2.tgz", + "integrity": "sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-effect-event": "0.0.2", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-effect-event": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-effect-event/-/react-use-effect-event-0.0.2.tgz", + "integrity": "sha512-Qp8WbZOBe+blgpuUT+lw2xheLP8q0oatc9UpmiemEICxGvFLYmHm9QowVZGHtJlGbS6A6yJ3iViad/2cVjnOiA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-escape-keydown": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-escape-keydown/-/react-use-escape-keydown-1.1.1.tgz", + "integrity": "sha512-Il0+boE7w/XebUHyBjroE+DbByORGR9KKmITzbR7MyQ4akpORYP/ZmbhAr0DG7RmmBqoOnZdy2QlvajJ2QA59g==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-callback-ref": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-is-hydrated": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-is-hydrated/-/react-use-is-hydrated-0.1.0.tgz", + "integrity": "sha512-U+UORVEq+cTnRIaostJv9AGdV3G6Y+zbVd+12e18jQ5A3c0xL03IhnHuiU4UV69wolOQp5GfR58NW/EgdQhwOA==", + "license": "MIT", + "dependencies": { + "use-sync-external-store": "^1.5.0" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-layout-effect": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.1.tgz", + "integrity": "sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-previous": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-previous/-/react-use-previous-1.1.1.tgz", + "integrity": "sha512-2dHfToCj/pzca2Ck724OZ5L0EVrr3eHRNsG/b3xQJLA2hZpVCS99bLAX+hm1IHXDEnzU6by5z/5MIY794/a8NQ==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-rect": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-rect/-/react-use-rect-1.1.1.tgz", + "integrity": "sha512-QTYuDesS0VtuHNNvMh+CjlKJ4LJickCMUAqjlE3+j8w+RlRpwyX3apEQKGFzbZGdo7XNG1tXa+bQqIE7HIXT2w==", + "license": "MIT", + "dependencies": { + "@radix-ui/rect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-size": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-size/-/react-use-size-1.1.1.tgz", + "integrity": "sha512-ewrXRDTAqAXlkl6t/fkXWNAhFX9I+CkKlw6zjEwk86RSPKwZr3xpBRso655aqYafwtnbpHLj6toFzmd6xdVptQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-visually-hidden": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-visually-hidden/-/react-visually-hidden-1.2.3.tgz", + "integrity": "sha512-pzJq12tEaaIhqjbzpCuv/OypJY/BPavOofm+dbab+MHLajy277+1lLm6JFcGgF5eskJ6mquGirhXY2GD/8u8Ug==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-visually-hidden/node_modules/@radix-ui/react-primitive": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-visually-hidden/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/rect": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/rect/-/rect-1.1.1.tgz", + "integrity": "sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==", + "license": "MIT" + }, + "node_modules/@react-aria/focus": { + "version": "3.21.3", + "resolved": "https://registry.npmjs.org/@react-aria/focus/-/focus-3.21.3.tgz", + "integrity": "sha512-FsquWvjSCwC2/sBk4b+OqJyONETUIXQ2vM0YdPAuC+QFQh2DT6TIBo6dOZVSezlhudDla69xFBd6JvCFq1AbUw==", + "license": "Apache-2.0", + "dependencies": { + "@react-aria/interactions": "^3.26.0", + "@react-aria/utils": "^3.32.0", + "@react-types/shared": "^3.32.1", + "@swc/helpers": "^0.5.0", + "clsx": "^2.0.0" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1", + "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" + } + }, + "node_modules/@react-aria/interactions": { + "version": "3.26.0", + "resolved": "https://registry.npmjs.org/@react-aria/interactions/-/interactions-3.26.0.tgz", + "integrity": "sha512-AAEcHiltjfbmP1i9iaVw34Mb7kbkiHpYdqieWufldh4aplWgsF11YQZOfaCJW4QoR2ML4Zzoa9nfFwLXA52R7Q==", + "license": "Apache-2.0", + "dependencies": { + "@react-aria/ssr": "^3.9.10", + "@react-aria/utils": "^3.32.0", + "@react-stately/flags": "^3.1.2", + "@react-types/shared": "^3.32.1", + "@swc/helpers": "^0.5.0" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1", + "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" + } + }, + "node_modules/@react-aria/ssr": { + "version": "3.9.10", + "resolved": "https://registry.npmjs.org/@react-aria/ssr/-/ssr-3.9.10.tgz", + "integrity": "sha512-hvTm77Pf+pMBhuBm760Li0BVIO38jv1IBws1xFm1NoL26PU+fe+FMW5+VZWyANR6nYL65joaJKZqOdTQMkO9IQ==", + "license": "Apache-2.0", + "dependencies": { + "@swc/helpers": "^0.5.0" + }, + "engines": { + "node": ">= 12" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" + } + }, + "node_modules/@react-aria/utils": { + "version": "3.32.0", + "resolved": "https://registry.npmjs.org/@react-aria/utils/-/utils-3.32.0.tgz", + "integrity": "sha512-/7Rud06+HVBIlTwmwmJa2W8xVtgxgzm0+kLbuFooZRzKDON6hhozS1dOMR/YLMxyJOaYOTpImcP4vRR9gL1hEg==", + "license": "Apache-2.0", + "dependencies": { + "@react-aria/ssr": "^3.9.10", + "@react-stately/flags": "^3.1.2", + "@react-stately/utils": "^3.11.0", + "@react-types/shared": "^3.32.1", + "@swc/helpers": "^0.5.0", + "clsx": "^2.0.0" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1", + "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" + } + }, + "node_modules/@react-stately/flags": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@react-stately/flags/-/flags-3.1.2.tgz", + "integrity": "sha512-2HjFcZx1MyQXoPqcBGALwWWmgFVUk2TuKVIQxCbRq7fPyWXIl6VHcakCLurdtYC2Iks7zizvz0Idv48MQ38DWg==", + "license": "Apache-2.0", + "dependencies": { + "@swc/helpers": "^0.5.0" + } + }, + "node_modules/@react-stately/utils": { + "version": "3.11.0", + "resolved": "https://registry.npmjs.org/@react-stately/utils/-/utils-3.11.0.tgz", + "integrity": "sha512-8LZpYowJ9eZmmYLpudbo/eclIRnbhWIJZ994ncmlKlouNzKohtM8qTC6B1w1pwUbiwGdUoyzLuQbeaIor5Dvcw==", + "license": "Apache-2.0", + "dependencies": { + "@swc/helpers": "^0.5.0" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" + } + }, + "node_modules/@react-types/shared": { + "version": "3.32.1", + "resolved": "https://registry.npmjs.org/@react-types/shared/-/shared-3.32.1.tgz", + "integrity": "sha512-famxyD5emrGGpFuUlgOP6fVW2h/ZaF405G5KDi3zPHzyjAWys/8W6NAVJtNbkCkhedmvL0xOhvt8feGXyXaw5w==", + "license": "Apache-2.0", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-beta.53", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.53.tgz", + "integrity": "sha512-vENRlFU4YbrwVqNDZ7fLvy+JR1CRkyr01jhSiDpE1u6py3OMzQfztQU2jxykW3ALNxO4kSlqIDeYyD0Y9RcQeQ==", + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.55.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.55.2.tgz", + "integrity": "sha512-21J6xzayjy3O6NdnlO6aXi/urvSRjm6nCI6+nF6ra2YofKruGixN9kfT+dt55HVNwfDmpDHJcaS3JuP/boNnlA==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.55.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.55.2.tgz", + "integrity": "sha512-eXBg7ibkNUZ+sTwbFiDKou0BAckeV6kIigK7y5Ko4mB/5A1KLhuzEKovsmfvsL8mQorkoincMFGnQuIT92SKqA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.55.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.55.2.tgz", + "integrity": "sha512-UCbaTklREjrc5U47ypLulAgg4njaqfOVLU18VrCrI+6E5MQjuG0lSWaqLlAJwsD7NpFV249XgB0Bi37Zh5Sz4g==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.55.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.55.2.tgz", + "integrity": "sha512-dP67MA0cCMHFT2g5XyjtpVOtp7y4UyUxN3dhLdt11at5cPKnSm4lY+EhwNvDXIMzAMIo2KU+mc9wxaAQJTn7sQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.55.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.55.2.tgz", + "integrity": "sha512-WDUPLUwfYV9G1yxNRJdXcvISW15mpvod1Wv3ok+Ws93w1HjIVmCIFxsG2DquO+3usMNCpJQ0wqO+3GhFdl6Fow==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.55.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.55.2.tgz", + "integrity": "sha512-Ng95wtHVEulRwn7R0tMrlUuiLVL/HXA8Lt/MYVpy88+s5ikpntzZba1qEulTuPnPIZuOPcW9wNEiqvZxZmgmqQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.55.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.55.2.tgz", + "integrity": "sha512-AEXMESUDWWGqD6LwO/HkqCZgUE1VCJ1OhbvYGsfqX2Y6w5quSXuyoy/Fg3nRqiwro+cJYFxiw5v4kB2ZDLhxrw==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.55.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.55.2.tgz", + "integrity": "sha512-ZV7EljjBDwBBBSv570VWj0hiNTdHt9uGznDtznBB4Caj3ch5rgD4I2K1GQrtbvJ/QiB+663lLgOdcADMNVC29Q==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.55.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.55.2.tgz", + "integrity": "sha512-uvjwc8NtQVPAJtq4Tt7Q49FOodjfbf6NpqXyW/rjXoV+iZ3EJAHLNAnKT5UJBc6ffQVgmXTUL2ifYiLABlGFqA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.55.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.55.2.tgz", + "integrity": "sha512-s3KoWVNnye9mm/2WpOZ3JeUiediUVw6AvY/H7jNA6qgKA2V2aM25lMkVarTDfiicn/DLq3O0a81jncXszoyCFA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.55.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.55.2.tgz", + "integrity": "sha512-gi21faacK+J8aVSyAUptML9VQN26JRxe484IbF+h3hpG+sNVoMXPduhREz2CcYr5my0NE3MjVvQ5bMKX71pfVA==", + "cpu": [ + "loong64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.55.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.55.2.tgz", + "integrity": "sha512-qSlWiXnVaS/ceqXNfnoFZh4IiCA0EwvCivivTGbEu1qv2o+WTHpn1zNmCTAoOG5QaVr2/yhCoLScQtc/7RxshA==", + "cpu": [ + "loong64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.55.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.55.2.tgz", + "integrity": "sha512-rPyuLFNoF1B0+wolH277E780NUKf+KoEDb3OyoLbAO18BbeKi++YN6gC/zuJoPPDlQRL3fIxHxCxVEWiem2yXw==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.55.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.55.2.tgz", + "integrity": "sha512-g+0ZLMook31iWV4PvqKU0i9E78gaZgYpSrYPed/4Bu+nGTgfOPtfs1h11tSSRPXSjC5EzLTjV/1A7L2Vr8pJoQ==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.55.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.55.2.tgz", + "integrity": "sha512-i+sGeRGsjKZcQRh3BRfpLsM3LX3bi4AoEVqmGDyc50L6KfYsN45wVCSz70iQMwPWr3E5opSiLOwsC9WB4/1pqg==", + "cpu": [ + "riscv64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.55.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.55.2.tgz", + "integrity": "sha512-C1vLcKc4MfFV6I0aWsC7B2Y9QcsiEcvKkfxprwkPfLaN8hQf0/fKHwSF2lcYzA9g4imqnhic729VB9Fo70HO3Q==", + "cpu": [ + "riscv64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.55.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.55.2.tgz", + "integrity": "sha512-68gHUK/howpQjh7g7hlD9DvTTt4sNLp1Bb+Yzw2Ki0xvscm2cOdCLZNJNhd2jW8lsTPrHAHuF751BygifW4bkQ==", + "cpu": [ + "s390x" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.9.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.9.5.tgz", + "integrity": "sha512-Dq1bqBdLaZ1Gb/l2e5/+o3B18+8TI9ANlA1SkejZqDgdU/jK/ThYaMPMJpVMMXy2uRHvGKbkz9vheVGdq3cJfA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.55.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.55.2.tgz", + "integrity": "sha512-4BJucJBGbuGnH6q7kpPqGJGzZnYrpAzRd60HQSt3OpX/6/YVgSsJnNzR8Ot74io50SeVT4CtCWe/RYIAymFPwA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.55.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.55.2.tgz", + "integrity": "sha512-cT2MmXySMo58ENv8p6/O6wI/h/gLnD3D6JoajwXFZH6X9jz4hARqUhWpGuQhOgLNXscfZYRQMJvZDtWNzMAIDw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.55.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.55.2.tgz", + "integrity": "sha512-sZnyUgGkuzIXaK3jNMPmUIyJrxu/PjmATQrocpGA1WbCPX8H5tfGgRSuYtqBYAvLuIGp8SPRb1O4d1Fkb5fXaQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.55.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.55.2.tgz", + "integrity": "sha512-sDpFbenhmWjNcEbBcoTV0PWvW5rPJFvu+P7XoTY0YLGRupgLbFY0XPfwIbJOObzO7QgkRDANh65RjhPmgSaAjQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.55.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.55.2.tgz", + "integrity": "sha512-GvJ03TqqaweWCigtKQVBErw2bEhu1tyfNQbarwr94wCGnczA9HF8wqEe3U/Lfu6EdeNP0p6R+APeHVwEqVxpUQ==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.55.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.55.2.tgz", + "integrity": "sha512-KvXsBvp13oZz9JGe5NYS7FNizLe99Ny+W8ETsuCyjXiKdiGrcz2/J/N8qxZ/RSwivqjQguug07NLHqrIHrqfYw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.9.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.9.5.tgz", + "integrity": "sha512-1q+mykKE3Vot1kaFJIDoUFv5TuW+QQVaf2FmTT9krg86pQrGStOSJJ0Zil7CFagyxDuouTepzt5Y5TVzyajOdQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@shikijs/core": { + "version": "3.21.0", + "resolved": "https://registry.npmjs.org/@shikijs/core/-/core-3.21.0.tgz", + "integrity": "sha512-AXSQu/2n1UIQekY8euBJlvFYZIw0PHY63jUzGbrOma4wPxzznJXTXkri+QcHeBNaFxiiOljKxxJkVSoB3PjbyA==", + "license": "MIT", + "dependencies": { + "@shikijs/types": "3.21.0", + "@shikijs/vscode-textmate": "^10.0.2", + "@types/hast": "^3.0.4", + "hast-util-to-html": "^9.0.5" + } + }, + "node_modules/@shikijs/engine-javascript": { + "version": "3.21.0", + "resolved": "https://registry.npmjs.org/@shikijs/engine-javascript/-/engine-javascript-3.21.0.tgz", + "integrity": "sha512-ATwv86xlbmfD9n9gKRiwuPpWgPENAWCLwYCGz9ugTJlsO2kOzhOkvoyV/UD+tJ0uT7YRyD530x6ugNSffmvIiQ==", + "license": "MIT", + "dependencies": { + "@shikijs/types": "3.21.0", + "@shikijs/vscode-textmate": "^10.0.2", + "oniguruma-to-es": "^4.3.4" + } + }, + "node_modules/@shikijs/engine-oniguruma": { + "version": "3.21.0", + "resolved": "https://registry.npmjs.org/@shikijs/engine-oniguruma/-/engine-oniguruma-3.21.0.tgz", + "integrity": "sha512-OYknTCct6qiwpQDqDdf3iedRdzj6hFlOPv5hMvI+hkWfCKs5mlJ4TXziBG9nyabLwGulrUjHiCq3xCspSzErYQ==", + "license": "MIT", + "dependencies": { + "@shikijs/types": "3.21.0", + "@shikijs/vscode-textmate": "^10.0.2" + } + }, + "node_modules/@shikijs/langs": { + "version": "3.21.0", + "resolved": "https://registry.npmjs.org/@shikijs/langs/-/langs-3.21.0.tgz", + "integrity": "sha512-g6mn5m+Y6GBJ4wxmBYqalK9Sp0CFkUqfNzUy2pJglUginz6ZpWbaWjDB4fbQ/8SHzFjYbtU6Ddlp1pc+PPNDVA==", + "license": "MIT", + "dependencies": { + "@shikijs/types": "3.21.0" + } + }, + "node_modules/@shikijs/themes": { + "version": "3.21.0", + "resolved": "https://registry.npmjs.org/@shikijs/themes/-/themes-3.21.0.tgz", + "integrity": "sha512-BAE4cr9EDiZyYzwIHEk7JTBJ9CzlPuM4PchfcA5ao1dWXb25nv6hYsoDiBq2aZK9E3dlt3WB78uI96UESD+8Mw==", + "license": "MIT", + "dependencies": { + "@shikijs/types": "3.21.0" + } + }, + "node_modules/@shikijs/types": { + "version": "3.21.0", + "resolved": "https://registry.npmjs.org/@shikijs/types/-/types-3.21.0.tgz", + "integrity": "sha512-zGrWOxZ0/+0ovPY7PvBU2gIS9tmhSUUt30jAcNV0Bq0gb2S98gwfjIs1vxlmH5zM7/4YxLamT6ChlqqAJmPPjA==", + "license": "MIT", + "dependencies": { + "@shikijs/vscode-textmate": "^10.0.2", + "@types/hast": "^3.0.4" + } + }, + "node_modules/@shikijs/vscode-textmate": { + "version": "10.0.2", + "resolved": "https://registry.npmjs.org/@shikijs/vscode-textmate/-/vscode-textmate-10.0.2.tgz", + "integrity": "sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==", + "license": "MIT" + }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "license": "MIT" + }, + "node_modules/@swc/helpers": { + "version": "0.5.18", + "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.18.tgz", + "integrity": "sha512-TXTnIcNJQEKwThMMqBXsZ4VGAza6bvN4pa41Rkqoio6QBKMvo+5lexeTMScGCIxtzgQJzElcvIltani+adC5PQ==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.8.0" + } + }, + "node_modules/@tailwindcss/node": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.1.18.tgz", + "integrity": "sha512-DoR7U1P7iYhw16qJ49fgXUlry1t4CpXeErJHnQ44JgTSKMaZUdf17cfn5mHchfJ4KRBZRFA/Coo+MUF5+gOaCQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.4", + "enhanced-resolve": "^5.18.3", + "jiti": "^2.6.1", + "lightningcss": "1.30.2", + "magic-string": "^0.30.21", + "source-map-js": "^1.2.1", + "tailwindcss": "4.1.18" + } + }, + "node_modules/@tailwindcss/oxide": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.1.18.tgz", + "integrity": "sha512-EgCR5tTS5bUSKQgzeMClT6iCY3ToqE1y+ZB0AKldj809QXk1Y+3jB0upOYZrn9aGIzPtUsP7sX4QQ4XtjBB95A==", + "license": "MIT", + "engines": { + "node": ">= 10" + }, + "optionalDependencies": { + "@tailwindcss/oxide-android-arm64": "4.1.18", + "@tailwindcss/oxide-darwin-arm64": "4.1.18", + "@tailwindcss/oxide-darwin-x64": "4.1.18", + "@tailwindcss/oxide-freebsd-x64": "4.1.18", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.1.18", + "@tailwindcss/oxide-linux-arm64-gnu": "4.1.18", + "@tailwindcss/oxide-linux-arm64-musl": "4.1.18", + "@tailwindcss/oxide-linux-x64-gnu": "4.1.18", + "@tailwindcss/oxide-linux-x64-musl": "4.1.18", + "@tailwindcss/oxide-wasm32-wasi": "4.1.18", + "@tailwindcss/oxide-win32-arm64-msvc": "4.1.18", + "@tailwindcss/oxide-win32-x64-msvc": "4.1.18" + } + }, + "node_modules/@tailwindcss/oxide-android-arm64": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.1.18.tgz", + "integrity": "sha512-dJHz7+Ugr9U/diKJA0W6N/6/cjI+ZTAoxPf9Iz9BFRF2GzEX8IvXxFIi/dZBloVJX/MZGvRuFA9rqwdiIEZQ0Q==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-darwin-arm64": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.1.18.tgz", + "integrity": "sha512-Gc2q4Qhs660bhjyBSKgq6BYvwDz4G+BuyJ5H1xfhmDR3D8HnHCmT/BSkvSL0vQLy/nkMLY20PQ2OoYMO15Jd0A==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-darwin-x64": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.1.18.tgz", + "integrity": "sha512-FL5oxr2xQsFrc3X9o1fjHKBYBMD1QZNyc1Xzw/h5Qu4XnEBi3dZn96HcHm41c/euGV+GRiXFfh2hUCyKi/e+yw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-freebsd-x64": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.1.18.tgz", + "integrity": "sha512-Fj+RHgu5bDodmV1dM9yAxlfJwkkWvLiRjbhuO2LEtwtlYlBgiAT4x/j5wQr1tC3SANAgD+0YcmWVrj8R9trVMA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.1.18.tgz", + "integrity": "sha512-Fp+Wzk/Ws4dZn+LV2Nqx3IilnhH51YZoRaYHQsVq3RQvEl+71VGKFpkfHrLM/Li+kt5c0DJe/bHXK1eHgDmdiA==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.1.18.tgz", + "integrity": "sha512-S0n3jboLysNbh55Vrt7pk9wgpyTTPD0fdQeh7wQfMqLPM/Hrxi+dVsLsPrycQjGKEQk85Kgbx+6+QnYNiHalnw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-musl": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.1.18.tgz", + "integrity": "sha512-1px92582HkPQlaaCkdRcio71p8bc8i/ap5807tPRDK/uw953cauQBT8c5tVGkOwrHMfc2Yh6UuxaH4vtTjGvHg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-gnu": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.1.18.tgz", + "integrity": "sha512-v3gyT0ivkfBLoZGF9LyHmts0Isc8jHZyVcbzio6Wpzifg/+5ZJpDiRiUhDLkcr7f/r38SWNe7ucxmGW3j3Kb/g==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-musl": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.1.18.tgz", + "integrity": "sha512-bhJ2y2OQNlcRwwgOAGMY0xTFStt4/wyU6pvI6LSuZpRgKQwxTec0/3Scu91O8ir7qCR3AuepQKLU/kX99FouqQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.1.18.tgz", + "integrity": "sha512-LffYTvPjODiP6PT16oNeUQJzNVyJl1cjIebq/rWWBF+3eDst5JGEFSc5cWxyRCJ0Mxl+KyIkqRxk1XPEs9x8TA==", + "bundleDependencies": [ + "@napi-rs/wasm-runtime", + "@emnapi/core", + "@emnapi/runtime", + "@tybys/wasm-util", + "@emnapi/wasi-threads", + "tslib" + ], + "cpu": [ + "wasm32" + ], + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1", + "@emnapi/wasi-threads": "^1.1.0", + "@napi-rs/wasm-runtime": "^1.1.0", + "@tybys/wasm-util": "^0.10.1", + "tslib": "^2.4.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.1.18.tgz", + "integrity": "sha512-HjSA7mr9HmC8fu6bdsZvZ+dhjyGCLdotjVOgLA2vEqxEBZaQo9YTX4kwgEvPCpRh8o4uWc4J/wEoFzhEmjvPbA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-win32-x64-msvc": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.1.18.tgz", + "integrity": "sha512-bJWbyYpUlqamC8dpR7pfjA0I7vdF6t5VpUGMWRkXVE3AXgIZjYUYAK7II1GNaxR8J1SSrSrppRar8G++JekE3Q==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/vite": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.1.18.tgz", + "integrity": "sha512-jVA+/UpKL1vRLg6Hkao5jldawNmRo7mQYrZtNHMIVpLfLhDml5nMRUo/8MwoX2vNXvnaXNNMedrMfMugAVX1nA==", + "license": "MIT", + "dependencies": { + "@tailwindcss/node": "4.1.18", + "@tailwindcss/oxide": "4.1.18", + "tailwindcss": "4.1.18" + }, + "peerDependencies": { + "vite": "^5.2.0 || ^6 || ^7" + } + }, + "node_modules/@tanstack/react-virtual": { + "version": "3.13.18", + "resolved": "https://registry.npmjs.org/@tanstack/react-virtual/-/react-virtual-3.13.18.tgz", + "integrity": "sha512-dZkhyfahpvlaV0rIKnvQiVoWPyURppl6w4m9IwMDpuIjcJ1sD9YGWrt0wISvgU7ewACXx2Ct46WPgI6qAD4v6A==", + "license": "MIT", + "dependencies": { + "@tanstack/virtual-core": "3.13.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/@tanstack/virtual-core": { + "version": "3.13.18", + "resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.13.18.tgz", + "integrity": "sha512-Mx86Hqu1k39icq2Zusq+Ey2J6dDWTjDvEv43PJtRCoEYTLyfaPnxIQ6iy7YAOK0NV/qOEmZQ/uCufrppZxTgcg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/debug": { + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.12.tgz", + "integrity": "sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==", + "license": "MIT", + "dependencies": { + "@types/ms": "*" + } + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "license": "MIT" + }, + "node_modules/@types/estree-jsx": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@types/estree-jsx/-/estree-jsx-1.0.5.tgz", + "integrity": "sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg==", + "license": "MIT", + "dependencies": { + "@types/estree": "*" + } + }, + "node_modules/@types/hast": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.4.tgz", + "integrity": "sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "*" + } + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/lodash": { + "version": "4.17.23", + "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.23.tgz", + "integrity": "sha512-RDvF6wTulMPjrNdCoYRC8gNR880JNGT8uB+REUpC2Ns4pRqQJhGz90wh7rgdXDPpCczF3VGktDuFGVnz8zP7HA==", + "license": "MIT" + }, + "node_modules/@types/lodash-es": { + "version": "4.17.12", + "resolved": "https://registry.npmjs.org/@types/lodash-es/-/lodash-es-4.17.12.tgz", + "integrity": "sha512-0NgftHUcV4v34VhXm8QBSftKVXtbkBG3ViCjs6+eJ5a6y6Mi/jiFGPc1sC7QK+9BFhWrURE3EOggmWaSxL9OzQ==", + "license": "MIT", + "dependencies": { + "@types/lodash": "*" + } + }, + "node_modules/@types/mdast": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-4.0.4.tgz", + "integrity": "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==", + "license": "MIT", + "dependencies": { + "@types/unist": "*" + } + }, + "node_modules/@types/ms": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", + "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==", + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "22.19.7", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.7.tgz", + "integrity": "sha512-MciR4AKGHWl7xwxkBa6xUGxQJ4VBOmPTF7sL+iGzuahOFaO0jHCsuEfS80pan1ef4gWId1oWOweIhrDEYLuaOw==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/react": { + "version": "19.2.8", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.8.tgz", + "integrity": "sha512-3MbSL37jEchWZz2p2mjntRZtPt837ij10ApxKfgmXCTuHWagYg7iA5bqPw6C8BMPfwidlvfPI/fxOc42HLhcyg==", + "license": "MIT", + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.2.3", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", + "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.2.0" + } + }, + "node_modules/@types/unist": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz", + "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==", + "license": "MIT" + }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.53.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.53.1.tgz", + "integrity": "sha512-cFYYFZ+oQFi6hUnBTbLRXfTJiaQtYE3t4O692agbBl+2Zy+eqSKWtPjhPXJu1G7j4RLjKgeJPDdq3EqOwmX5Ag==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.12.2", + "@typescript-eslint/scope-manager": "8.53.1", + "@typescript-eslint/type-utils": "8.53.1", + "@typescript-eslint/utils": "8.53.1", + "@typescript-eslint/visitor-keys": "8.53.1", + "ignore": "^7.0.5", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.4.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^8.53.1", + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", + "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "8.53.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.53.1.tgz", + "integrity": "sha512-nm3cvFN9SqZGXjmw5bZ6cGmvJSyJPn0wU9gHAZZHDnZl2wF9PhHv78Xf06E0MaNk4zLVHL8hb2/c32XvyJOLQg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/scope-manager": "8.53.1", + "@typescript-eslint/types": "8.53.1", + "@typescript-eslint/typescript-estree": "8.53.1", + "@typescript-eslint/visitor-keys": "8.53.1", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/project-service": { + "version": "8.53.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.53.1.tgz", + "integrity": "sha512-WYC4FB5Ra0xidsmlPb+1SsnaSKPmS3gsjIARwbEkHkoWloQmuzcfypljaJcR78uyLA1h8sHdWWPHSLDI+MtNog==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.53.1", + "@typescript-eslint/types": "^8.53.1", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.53.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.53.1.tgz", + "integrity": "sha512-Lu23yw1uJMFY8cUeq7JlrizAgeQvWugNQzJp8C3x8Eo5Jw5Q2ykMdiiTB9vBVOOUBysMzmRRmUfwFrZuI2C4SQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.53.1", + "@typescript-eslint/visitor-keys": "8.53.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.53.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.53.1.tgz", + "integrity": "sha512-qfvLXS6F6b1y43pnf0pPbXJ+YoXIC7HKg0UGZ27uMIemKMKA6XH2DTxsEDdpdN29D+vHV07x/pnlPNVLhdhWiA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "8.53.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.53.1.tgz", + "integrity": "sha512-MOrdtNvyhy0rHyv0ENzub1d4wQYKb2NmIqG7qEqPWFW7Mpy2jzFC3pQ2yKDvirZB7jypm5uGjF2Qqs6OIqu47w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.53.1", + "@typescript-eslint/typescript-estree": "8.53.1", + "@typescript-eslint/utils": "8.53.1", + "debug": "^4.4.3", + "ts-api-utils": "^2.4.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/types": { + "version": "8.53.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.53.1.tgz", + "integrity": "sha512-jr/swrr2aRmUAUjW5/zQHbMaui//vQlsZcJKijZf3M26bnmLj8LyZUpj8/Rd6uzaek06OWsqdofN/Thenm5O8A==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "8.53.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.53.1.tgz", + "integrity": "sha512-RGlVipGhQAG4GxV1s34O91cxQ/vWiHJTDHbXRr0li2q/BGg3RR/7NM8QDWgkEgrwQYCvmJV9ichIwyoKCQ+DTg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.53.1", + "@typescript-eslint/tsconfig-utils": "8.53.1", + "@typescript-eslint/types": "8.53.1", + "@typescript-eslint/visitor-keys": "8.53.1", + "debug": "^4.4.3", + "minimatch": "^9.0.5", + "semver": "^7.7.3", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.4.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", + "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/semver": { + "version": "7.7.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", + "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "8.53.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.53.1.tgz", + "integrity": "sha512-c4bMvGVWW4hv6JmDUEG7fSYlWOl3II2I4ylt0NM+seinYQlZMQIaKaXIIVJWt9Ofh6whrpM+EdDQXKXjNovvrg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/scope-manager": "8.53.1", + "@typescript-eslint/types": "8.53.1", + "@typescript-eslint/typescript-estree": "8.53.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.53.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.53.1.tgz", + "integrity": "sha512-oy+wV7xDKFPRyNggmXuZQSBzvoLnpmJs+GhzRhPjrxl2b/jIlyjVokzm47CZCDUdXKr2zd7ZLodPfOBpOPyPlg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.53.1", + "eslint-visitor-keys": "^4.2.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@ungap/structured-clone": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.0.tgz", + "integrity": "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==", + "license": "ISC" + }, + "node_modules/@vercel/oidc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@vercel/oidc/-/oidc-3.1.0.tgz", + "integrity": "sha512-Fw28YZpRnA3cAHHDlkt7xQHiJ0fcL+NRcIqsocZQUSmbzeIKRpwttJjik5ZGanXP+vlA4SbTg+AbA3bP363l+w==", + "license": "Apache-2.0", + "engines": { + "node": ">= 20" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-5.1.2.tgz", + "integrity": "sha512-EcA07pHJouywpzsoTUqNh5NwGayl2PPVEJKUSinGGSxFGYn+shYbqMGBg6FXDqgXum9Ou/ecb+411ssw8HImJQ==", + "license": "MIT", + "dependencies": { + "@babel/core": "^7.28.5", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-beta.53", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.18.0" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" + } + }, + "node_modules/acorn": { + "version": "8.15.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", + "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/ai": { + "version": "5.0.121", + "resolved": "https://registry.npmjs.org/ai/-/ai-5.0.121.tgz", + "integrity": "sha512-3iYPdARKGLryC/7OA9RgBUaym1gynvWS7UPy8NwoRNCKP52lshldtHB5xcEfVviw7liWH2zJlW9yEzsDglcIEQ==", + "license": "Apache-2.0", + "dependencies": { + "@ai-sdk/gateway": "2.0.27", + "@ai-sdk/provider": "2.0.1", + "@ai-sdk/provider-utils": "3.0.20", + "@opentelemetry/api": "1.9.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "zod": "^3.25.76 || ^4.1.8" + } + }, + "node_modules/ajv": { + "version": "6.12.6", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", + "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0" + }, + "node_modules/aria-hidden": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/aria-hidden/-/aria-hidden-1.2.6.tgz", + "integrity": "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/array-buffer-byte-length": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/array-buffer-byte-length/-/array-buffer-byte-length-1.0.2.tgz", + "integrity": "sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "is-array-buffer": "^3.0.5" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array-includes": { + "version": "3.1.9", + "resolved": "https://registry.npmjs.org/array-includes/-/array-includes-3.1.9.tgz", + "integrity": "sha512-FmeCCAenzH0KH381SPT5FZmiA/TmpndpcaShhfgEN9eCVjnFBqq3l1xrI42y8+PPLI6hypzou4GXw00WHmPBLQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "define-properties": "^1.2.1", + "es-abstract": "^1.24.0", + "es-object-atoms": "^1.1.1", + "get-intrinsic": "^1.3.0", + "is-string": "^1.1.1", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.findlast": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/array.prototype.findlast/-/array.prototype.findlast-1.2.5.tgz", + "integrity": "sha512-CVvd6FHg1Z3POpBLxO6E6zr+rSKEQ9L6rZHAaY7lLfhKsWYUBBOuMs0e9o24oopj6H+geRCX0YJ+TJLBK2eHyQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.2", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0", + "es-shim-unscopables": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.flat": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/array.prototype.flat/-/array.prototype.flat-1.3.3.tgz", + "integrity": "sha512-rwG/ja1neyLqCuGZ5YYrznA62D4mZXg0i1cIskIUKSiqF3Cje9/wXAls9B9s1Wa2fomMsIv8czB8jZcPmxCXFg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-shim-unscopables": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.flatmap": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/array.prototype.flatmap/-/array.prototype.flatmap-1.3.3.tgz", + "integrity": "sha512-Y7Wt51eKJSyi80hFrJCePGGNo5ktJCslFuboqJsbf57CCPcm5zztluPlc4/aD8sWsKvlwatezpV4U1efk8kpjg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-shim-unscopables": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.tosorted": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/array.prototype.tosorted/-/array.prototype.tosorted-1.1.4.tgz", + "integrity": "sha512-p6Fx8B7b7ZhL/gmUsAy0D15WhvDccw3mnGNbZpi3pmeJdxtWsj2jEaI4Y6oo3XiHfzuSgPwKc04MYt6KgvC/wA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.3", + "es-errors": "^1.3.0", + "es-shim-unscopables": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/arraybuffer.prototype.slice": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/arraybuffer.prototype.slice/-/arraybuffer.prototype.slice-1.0.4.tgz", + "integrity": "sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-buffer-byte-length": "^1.0.1", + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "is-array-buffer": "^3.0.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/assistant-cloud": { + "version": "0.1.12", + "resolved": "https://registry.npmjs.org/assistant-cloud/-/assistant-cloud-0.1.12.tgz", + "integrity": "sha512-A2tY6QIdP9+RkE8Mmpm4kAoO0NyKsKpJKYebbYFZ3bAnQKyB15Bw/PS9AovpdeziGU9At97TyiMrT36pDjCD7A==", + "license": "MIT", + "dependencies": { + "assistant-stream": "^0.2.46" + } + }, + "node_modules/assistant-stream": { + "version": "0.2.46", + "resolved": "https://registry.npmjs.org/assistant-stream/-/assistant-stream-0.2.46.tgz", + "integrity": "sha512-smcC4sqOcTrUO01YpiHPgdG3Wc57kmQlCIEdMXSNuWMgcDvo60hnRY3rPDhZQBJHZOXQ9Q1wLR8ugKDjxi72GQ==", + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "nanoid": "5.1.6", + "secure-json-parse": "^4.1.0" + } + }, + "node_modules/async-function": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/async-function/-/async-function-1.0.0.tgz", + "integrity": "sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, + "node_modules/available-typed-arrays": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", + "integrity": "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "possible-typed-array-names": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/axios": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.2.tgz", + "integrity": "sha512-VPk9ebNqPcy5lRGuSlKx752IlDatOjT9paPlm8A7yOuW2Fbvp4X3JznJtT4f0GzGLLiWE9W8onz51SqLYwzGaA==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.15.6", + "form-data": "^4.0.4", + "proxy-from-env": "^1.1.0" + } + }, + "node_modules/babel-plugin-react-compiler": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/babel-plugin-react-compiler/-/babel-plugin-react-compiler-1.0.0.tgz", + "integrity": "sha512-Ixm8tFfoKKIPYdCCKYTsqv+Fd4IJ0DQqMyEimo+pxUOMUR9cVPlwTrFt9Avu+3cb6Zp3mAzl+t1MrG2fxxKsxw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.26.0" + } + }, + "node_modules/bail": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/bail/-/bail-2.0.2.tgz", + "integrity": "sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/baseline-browser-mapping": { + "version": "2.9.15", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.15.tgz", + "integrity": "sha512-kX8h7K2srmDyYnXRIppo4AH/wYgzWVCs+eKr3RusRSQ5PvRYoEFmR/I0PbdTjKFAoKqp5+kbxnNTFO9jOfSVJg==", + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.js" + } + }, + "node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/browserslist": { + "version": "4.28.1", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz", + "integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.9.0", + "caniuse-lite": "^1.0.30001759", + "electron-to-chromium": "^1.5.263", + "node-releases": "^2.0.27", + "update-browserslist-db": "^1.2.0" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/call-bind": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.8.tgz", + "integrity": "sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.0", + "es-define-property": "^1.0.0", + "get-intrinsic": "^1.2.4", + "set-function-length": "^1.2.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001765", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001765.tgz", + "integrity": "sha512-LWcNtSyZrakjECqmpP4qdg0MMGdN368D7X8XvvAqOcqMv0RxnlqVKZl2V6/mBR68oYMxOZPLw/gO7DuisMHUvQ==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/ccount": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/ccount/-/ccount-2.0.1.tgz", + "integrity": "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/chalk/node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/character-entities": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/character-entities/-/character-entities-2.0.2.tgz", + "integrity": "sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-entities-html4": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/character-entities-html4/-/character-entities-html4-2.1.0.tgz", + "integrity": "sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-entities-legacy": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/character-entities-legacy/-/character-entities-legacy-3.0.0.tgz", + "integrity": "sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-reference-invalid": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/character-reference-invalid/-/character-reference-invalid-2.0.1.tgz", + "integrity": "sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/class-variance-authority": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/class-variance-authority/-/class-variance-authority-0.7.1.tgz", + "integrity": "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==", + "license": "Apache-2.0", + "dependencies": { + "clsx": "^2.1.1" + }, + "funding": { + "url": "https://polar.sh/cva" + } + }, + "node_modules/classnames": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/classnames/-/classnames-2.5.1.tgz", + "integrity": "sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow==", + "license": "MIT" + }, + "node_modules/cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "license": "MIT" + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/comma-separated-tokens": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/comma-separated-tokens/-/comma-separated-tokens-2.0.3.tgz", + "integrity": "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true, + "license": "MIT" + }, + "node_modules/concurrently": { + "version": "9.2.1", + "resolved": "https://registry.npmjs.org/concurrently/-/concurrently-9.2.1.tgz", + "integrity": "sha512-fsfrO0MxV64Znoy8/l1vVIjjHa29SZyyqPgQBwhiDcaW8wJc2W3XWVOGx4M3oJBnv/zdUZIIp1gDeS98GzP8Ng==", + "license": "MIT", + "dependencies": { + "chalk": "4.1.2", + "rxjs": "7.8.2", + "shell-quote": "1.8.3", + "supports-color": "8.1.1", + "tree-kill": "1.2.2", + "yargs": "17.7.2" + }, + "bin": { + "conc": "dist/bin/concurrently.js", + "concurrently": "dist/bin/concurrently.js" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/open-cli-tools/concurrently?sponsor=1" + } + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "license": "MIT" + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "license": "MIT" + }, + "node_modules/data-view-buffer": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/data-view-buffer/-/data-view-buffer-1.0.2.tgz", + "integrity": "sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/data-view-byte-length": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/data-view-byte-length/-/data-view-byte-length-1.0.2.tgz", + "integrity": "sha512-tuhGbE6CfTM9+5ANGf+oQb72Ky/0+s3xKUpHvShfiz2RxMFgFPjsXuRLBVMtvMs15awe45SRb83D6wH4ew6wlQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/inspect-js" + } + }, + "node_modules/data-view-byte-offset": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/data-view-byte-offset/-/data-view-byte-offset-1.0.1.tgz", + "integrity": "sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decode-named-character-reference": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.3.0.tgz", + "integrity": "sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q==", + "license": "MIT", + "dependencies": { + "character-entities": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/define-data-property": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", + "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0", + "es-errors": "^1.3.0", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/define-properties": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz", + "integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.0.1", + "has-property-descriptors": "^1.0.0", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/detect-node-es": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/detect-node-es/-/detect-node-es-1.1.0.tgz", + "integrity": "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==", + "license": "MIT" + }, + "node_modules/devlop": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/devlop/-/devlop-1.1.0.tgz", + "integrity": "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==", + "license": "MIT", + "dependencies": { + "dequal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/doctrine": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz", + "integrity": "sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "esutils": "^2.0.2" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.267", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.267.tgz", + "integrity": "sha512-0Drusm6MVRXSOJpGbaSVgcQsuB4hEkMpHXaVstcPmhu5LIedxs1xNK/nIxmQIU/RPC0+1/o0AVZfBTkTNJOdUw==", + "license": "ISC" + }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT" + }, + "node_modules/enhanced-resolve": { + "version": "5.18.4", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.18.4.tgz", + "integrity": "sha512-LgQMM4WXU3QI+SYgEc2liRgznaD5ojbmY3sb8LxyguVkIg5FxdpTkvk72te2R38/TGKxH634oLxXRGY6d7AP+Q==", + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.2.0" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/es-abstract": { + "version": "1.24.1", + "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.24.1.tgz", + "integrity": "sha512-zHXBLhP+QehSSbsS9Pt23Gg964240DPd6QCf8WpkqEXxQ7fhdZzYsocOr5u7apWonsS5EjZDmTF+/slGMyasvw==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-buffer-byte-length": "^1.0.2", + "arraybuffer.prototype.slice": "^1.0.4", + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "data-view-buffer": "^1.0.2", + "data-view-byte-length": "^1.0.2", + "data-view-byte-offset": "^1.0.1", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "es-set-tostringtag": "^2.1.0", + "es-to-primitive": "^1.3.0", + "function.prototype.name": "^1.1.8", + "get-intrinsic": "^1.3.0", + "get-proto": "^1.0.1", + "get-symbol-description": "^1.1.0", + "globalthis": "^1.0.4", + "gopd": "^1.2.0", + "has-property-descriptors": "^1.0.2", + "has-proto": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "internal-slot": "^1.1.0", + "is-array-buffer": "^3.0.5", + "is-callable": "^1.2.7", + "is-data-view": "^1.0.2", + "is-negative-zero": "^2.0.3", + "is-regex": "^1.2.1", + "is-set": "^2.0.3", + "is-shared-array-buffer": "^1.0.4", + "is-string": "^1.1.1", + "is-typed-array": "^1.1.15", + "is-weakref": "^1.1.1", + "math-intrinsics": "^1.1.0", + "object-inspect": "^1.13.4", + "object-keys": "^1.1.1", + "object.assign": "^4.1.7", + "own-keys": "^1.0.1", + "regexp.prototype.flags": "^1.5.4", + "safe-array-concat": "^1.1.3", + "safe-push-apply": "^1.0.0", + "safe-regex-test": "^1.1.0", + "set-proto": "^1.0.0", + "stop-iteration-iterator": "^1.1.0", + "string.prototype.trim": "^1.2.10", + "string.prototype.trimend": "^1.0.9", + "string.prototype.trimstart": "^1.0.8", + "typed-array-buffer": "^1.0.3", + "typed-array-byte-length": "^1.0.3", + "typed-array-byte-offset": "^1.0.4", + "typed-array-length": "^1.0.7", + "unbox-primitive": "^1.1.0", + "which-typed-array": "^1.1.19" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-iterator-helpers": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/es-iterator-helpers/-/es-iterator-helpers-1.2.2.tgz", + "integrity": "sha512-BrUQ0cPTB/IwXj23HtwHjS9n7O4h9FX94b4xc5zlTHxeLgTAdzYUDyy6KdExAl9lbN5rtfe44xpjpmj9grxs5w==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "define-properties": "^1.2.1", + "es-abstract": "^1.24.1", + "es-errors": "^1.3.0", + "es-set-tostringtag": "^2.1.0", + "function-bind": "^1.1.2", + "get-intrinsic": "^1.3.0", + "globalthis": "^1.0.4", + "gopd": "^1.2.0", + "has-property-descriptors": "^1.0.2", + "has-proto": "^1.2.0", + "has-symbols": "^1.1.0", + "internal-slot": "^1.1.0", + "iterator.prototype": "^1.1.5", + "safe-array-concat": "^1.1.3" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-shim-unscopables": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/es-shim-unscopables/-/es-shim-unscopables-1.1.0.tgz", + "integrity": "sha512-d9T8ucsEhh8Bi1woXCf+TIKDIROLG5WCkxg8geBCbvk22kzwC5G2OnXVMO6FUsvQlgUUXQ2itephWDLqDzbeCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-to-primitive": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-to-primitive/-/es-to-primitive-1.3.0.tgz", + "integrity": "sha512-w+5mJ3GuFL+NjVtJlvydShqE1eN3h3PbI7/5LAsYJP/2qtuMXjfL2LpHSRqo4b4eSF5K/DH1JXKUAHSB2UW50g==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-callable": "^1.2.7", + "is-date-object": "^1.0.5", + "is-symbol": "^1.0.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/esbuild": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.2.tgz", + "integrity": "sha512-HyNQImnsOC7X9PMNaCIeAm4ISCQXs5a5YasTXVliKv4uuBo1dKrG0A+uQS8M5eXjVMnLg3WgXaKvprHlFJQffw==", + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.2", + "@esbuild/android-arm": "0.27.2", + "@esbuild/android-arm64": "0.27.2", + "@esbuild/android-x64": "0.27.2", + "@esbuild/darwin-arm64": "0.27.2", + "@esbuild/darwin-x64": "0.27.2", + "@esbuild/freebsd-arm64": "0.27.2", + "@esbuild/freebsd-x64": "0.27.2", + "@esbuild/linux-arm": "0.27.2", + "@esbuild/linux-arm64": "0.27.2", + "@esbuild/linux-ia32": "0.27.2", + "@esbuild/linux-loong64": "0.27.2", + "@esbuild/linux-mips64el": "0.27.2", + "@esbuild/linux-ppc64": "0.27.2", + "@esbuild/linux-riscv64": "0.27.2", + "@esbuild/linux-s390x": "0.27.2", + "@esbuild/linux-x64": "0.27.2", + "@esbuild/netbsd-arm64": "0.27.2", + "@esbuild/netbsd-x64": "0.27.2", + "@esbuild/openbsd-arm64": "0.27.2", + "@esbuild/openbsd-x64": "0.27.2", + "@esbuild/openharmony-arm64": "0.27.2", + "@esbuild/sunos-x64": "0.27.2", + "@esbuild/win32-arm64": "0.27.2", + "@esbuild/win32-ia32": "0.27.2", + "@esbuild/win32-x64": "0.27.2" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "9.39.2", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.2.tgz", + "integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.1", + "@eslint/config-array": "^0.21.1", + "@eslint/config-helpers": "^0.4.2", + "@eslint/core": "^0.17.0", + "@eslint/eslintrc": "^3.3.1", + "@eslint/js": "9.39.2", + "@eslint/plugin-kit": "^0.4.1", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "ajv": "^6.12.4", + "chalk": "^4.0.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^8.4.0", + "eslint-visitor-keys": "^4.2.1", + "espree": "^10.4.0", + "esquery": "^1.5.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^8.0.0", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "lodash.merge": "^4.6.2", + "minimatch": "^3.1.2", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-config-prettier": { + "version": "10.1.8", + "resolved": "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-10.1.8.tgz", + "integrity": "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==", + "dev": true, + "license": "MIT", + "bin": { + "eslint-config-prettier": "bin/cli.js" + }, + "funding": { + "url": "https://opencollective.com/eslint-config-prettier" + }, + "peerDependencies": { + "eslint": ">=7.0.0" + } + }, + "node_modules/eslint-plugin-react": { + "version": "7.37.5", + "resolved": "https://registry.npmjs.org/eslint-plugin-react/-/eslint-plugin-react-7.37.5.tgz", + "integrity": "sha512-Qteup0SqU15kdocexFNAJMvCJEfa2xUKNV4CC1xsVMrIIqEy3SQ/rqyxCWNzfrd3/ldy6HMlD2e0JDVpDg2qIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-includes": "^3.1.8", + "array.prototype.findlast": "^1.2.5", + "array.prototype.flatmap": "^1.3.3", + "array.prototype.tosorted": "^1.1.4", + "doctrine": "^2.1.0", + "es-iterator-helpers": "^1.2.1", + "estraverse": "^5.3.0", + "hasown": "^2.0.2", + "jsx-ast-utils": "^2.4.1 || ^3.0.0", + "minimatch": "^3.1.2", + "object.entries": "^1.1.9", + "object.fromentries": "^2.0.8", + "object.values": "^1.2.1", + "prop-types": "^15.8.1", + "resolve": "^2.0.0-next.5", + "semver": "^6.3.1", + "string.prototype.matchall": "^4.0.12", + "string.prototype.repeat": "^1.0.0" + }, + "engines": { + "node": ">=4" + }, + "peerDependencies": { + "eslint": "^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9.7" + } + }, + "node_modules/eslint-plugin-react-hooks": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-7.0.1.tgz", + "integrity": "sha512-O0d0m04evaNzEPoSW+59Mezf8Qt0InfgGIBJnpC0h3NH/WjUAR7BIKUfysC6todmtiZ/A0oUVS8Gce0WhBrHsA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.24.4", + "@babel/parser": "^7.24.4", + "hermes-parser": "^0.25.1", + "zod": "^3.25.0 || ^4.0.0", + "zod-validation-error": "^3.5.0 || ^4.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0" + } + }, + "node_modules/eslint-scope": { + "version": "8.4.0", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", + "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/espree": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", + "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.15.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^4.2.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esquery": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estree-util-is-identifier-name": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/estree-util-is-identifier-name/-/estree-util-is-identifier-name-3.0.0.tgz", + "integrity": "sha512-hFtqIDZTIUZ9BXLb8y4pYGyk6+wekIivNVTcmvk8NoOh+VeRn5y6cEHzbURrWbfp1fIqdVipilzj+lfaadNZmg==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/eventsource-parser": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.0.6.tgz", + "integrity": "sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/extend": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", + "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", + "license": "MIT" + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/file-entry-cache": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^4.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/flatted": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz", + "integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==", + "dev": true, + "license": "ISC" + }, + "node_modules/follow-redirects": { + "version": "1.15.11", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz", + "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/for-each": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz", + "integrity": "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-callable": "^1.2.7" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/form-data": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", + "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/framer-motion": { + "version": "12.27.1", + "resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.27.1.tgz", + "integrity": "sha512-cEAqO69kcZt3gL0TGua8WTgRQfv4J57nqt1zxHtLKwYhAwA0x9kDS/JbMa1hJbwkGY74AGJKvZ9pX/IqWZtZWQ==", + "license": "MIT", + "dependencies": { + "motion-dom": "^12.27.1", + "motion-utils": "^12.24.10", + "tslib": "^2.4.0" + }, + "peerDependencies": { + "@emotion/is-prop-valid": "*", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@emotion/is-prop-valid": { + "optional": true + }, + "react": { + "optional": true + }, + "react-dom": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/function.prototype.name": { + "version": "1.1.8", + "resolved": "https://registry.npmjs.org/function.prototype.name/-/function.prototype.name-1.1.8.tgz", + "integrity": "sha512-e5iwyodOHhbMr/yNrc7fDYG4qlbIvI5gajyzPnb5TCwyhjApznQh1BMFou9b30SevY43gCJKXycoCBjMbsuW0Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "functions-have-names": "^1.2.3", + "hasown": "^2.0.2", + "is-callable": "^1.2.7" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/functions-have-names": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/functions-have-names/-/functions-have-names-1.2.3.tgz", + "integrity": "sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/generator-function": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/generator-function/-/generator-function-2.0.1.tgz", + "integrity": "sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-nonce": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-nonce/-/get-nonce-1.0.1.tgz", + "integrity": "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/get-symbol-description": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/get-symbol-description/-/get-symbol-description-1.1.0.tgz", + "integrity": "sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/globals": { + "version": "15.15.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-15.15.0.tgz", + "integrity": "sha512-7ACyT3wmyp3I61S4fG682L0VA2RGD9otkqGJIwNUMF1SWUombIIk+af1unuDYgMm082aHYwD+mzJvv9Iu8dsgg==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/globalthis": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/globalthis/-/globalthis-1.0.4.tgz", + "integrity": "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-properties": "^1.2.1", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "license": "ISC" + }, + "node_modules/has-bigints": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.1.0.tgz", + "integrity": "sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/has-property-descriptors": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", + "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-proto": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/has-proto/-/has-proto-1.2.0.tgz", + "integrity": "sha512-KIL7eQPfHQRC8+XluaIw7BHUwwqL19bQn4hzNgdr+1wXoU0KKj6rufu47lhY7KbJR2C6T6+PfyN0Ea7wkSS+qQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/hast-util-to-html": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/hast-util-to-html/-/hast-util-to-html-9.0.5.tgz", + "integrity": "sha512-OguPdidb+fbHQSU4Q4ZiLKnzWo8Wwsf5bZfbvu7//a9oTYoqD/fWpe96NuHkoS9h0ccGOTe0C4NGXdtS0iObOw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/unist": "^3.0.0", + "ccount": "^2.0.0", + "comma-separated-tokens": "^2.0.0", + "hast-util-whitespace": "^3.0.0", + "html-void-elements": "^3.0.0", + "mdast-util-to-hast": "^13.0.0", + "property-information": "^7.0.0", + "space-separated-tokens": "^2.0.0", + "stringify-entities": "^4.0.0", + "zwitch": "^2.0.4" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-to-jsx-runtime": { + "version": "2.3.6", + "resolved": "https://registry.npmjs.org/hast-util-to-jsx-runtime/-/hast-util-to-jsx-runtime-2.3.6.tgz", + "integrity": "sha512-zl6s8LwNyo1P9uw+XJGvZtdFF1GdAkOg8ujOw+4Pyb76874fLps4ueHXDhXWdk6YHQ6OgUtinliG7RsYvCbbBg==", + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/unist": "^3.0.0", + "comma-separated-tokens": "^2.0.0", + "devlop": "^1.0.0", + "estree-util-is-identifier-name": "^3.0.0", + "hast-util-whitespace": "^3.0.0", + "mdast-util-mdx-expression": "^2.0.0", + "mdast-util-mdx-jsx": "^3.0.0", + "mdast-util-mdxjs-esm": "^2.0.0", + "property-information": "^7.0.0", + "space-separated-tokens": "^2.0.0", + "style-to-js": "^1.0.0", + "unist-util-position": "^5.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-whitespace": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/hast-util-whitespace/-/hast-util-whitespace-3.0.0.tgz", + "integrity": "sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hermes-estree": { + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.25.1.tgz", + "integrity": "sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw==", + "dev": true, + "license": "MIT" + }, + "node_modules/hermes-parser": { + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/hermes-parser/-/hermes-parser-0.25.1.tgz", + "integrity": "sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "hermes-estree": "0.25.1" + } + }, + "node_modules/html-url-attributes": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/html-url-attributes/-/html-url-attributes-3.0.1.tgz", + "integrity": "sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/html-void-elements": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/html-void-elements/-/html-void-elements-3.0.0.tgz", + "integrity": "sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/import-fresh": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/inline-style-parser": { + "version": "0.2.7", + "resolved": "https://registry.npmjs.org/inline-style-parser/-/inline-style-parser-0.2.7.tgz", + "integrity": "sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==", + "license": "MIT" + }, + "node_modules/input-otp": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/input-otp/-/input-otp-1.4.2.tgz", + "integrity": "sha512-l3jWwYNvrEa6NTCt7BECfCm48GvwuZzkoeG3gBL2w4CHeOXW3eKFmf9UNYkNfYc3mxMrthMnxjIE07MT0zLBQA==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc" + } + }, + "node_modules/internal-slot": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz", + "integrity": "sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "hasown": "^2.0.2", + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/is-alphabetical": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-alphabetical/-/is-alphabetical-2.0.1.tgz", + "integrity": "sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-alphanumerical": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-alphanumerical/-/is-alphanumerical-2.0.1.tgz", + "integrity": "sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw==", + "license": "MIT", + "dependencies": { + "is-alphabetical": "^2.0.0", + "is-decimal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-array-buffer": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz", + "integrity": "sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-async-function": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-async-function/-/is-async-function-2.1.1.tgz", + "integrity": "sha512-9dgM/cZBnNvjzaMYHVoxxfPj2QXt22Ev7SuuPrs+xav0ukGB0S6d4ydZdEiM48kLx5kDV+QBPrpVnFyefL8kkQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "async-function": "^1.0.0", + "call-bound": "^1.0.3", + "get-proto": "^1.0.1", + "has-tostringtag": "^1.0.2", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-bigint": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-bigint/-/is-bigint-1.1.0.tgz", + "integrity": "sha512-n4ZT37wG78iz03xPRKJrHTdZbe3IicyucEtdRsV5yglwc3GyUfbAfpSeD0FJ41NbUNSt5wbhqfp1fS+BgnvDFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-bigints": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-boolean-object": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/is-boolean-object/-/is-boolean-object-1.2.2.tgz", + "integrity": "sha512-wa56o2/ElJMYqjCjGkXri7it5FbebW5usLw/nPmCMs5DeZ7eziSYZhSmPRn0txqeW4LnAmQQU7FgqLpsEFKM4A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-callable": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz", + "integrity": "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-core-module": { + "version": "2.16.1", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", + "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==", + "dev": true, + "license": "MIT", + "dependencies": { + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-data-view": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-data-view/-/is-data-view-1.0.2.tgz", + "integrity": "sha512-RKtWF8pGmS87i2D6gqQu/l7EYRlVdfzemCJN/P3UOs//x1QE7mfhvzHIApBTRf7axvT6DMGwSwBXYCT0nfB9xw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "get-intrinsic": "^1.2.6", + "is-typed-array": "^1.1.13" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-date-object": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-date-object/-/is-date-object-1.1.0.tgz", + "integrity": "sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-decimal": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-decimal/-/is-decimal-2.0.1.tgz", + "integrity": "sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-finalizationregistry": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-finalizationregistry/-/is-finalizationregistry-1.1.1.tgz", + "integrity": "sha512-1pC6N8qWJbWoPtEjgcL2xyhQOP491EQjeUo3qTKcmV8YSDDJrOepfG8pcC7h/QgnQHYSv0mJ3Z/ZWxmatVrysg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-generator-function": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.1.2.tgz", + "integrity": "sha512-upqt1SkGkODW9tsGNG5mtXTXtECizwtS2kA161M+gJPc1xdb/Ax629af6YrTwcOeQHbewrPNlE5Dx7kzvXTizA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.4", + "generator-function": "^2.0.0", + "get-proto": "^1.0.1", + "has-tostringtag": "^1.0.2", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-hexadecimal": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-hexadecimal/-/is-hexadecimal-2.0.1.tgz", + "integrity": "sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-map": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-map/-/is-map-2.0.3.tgz", + "integrity": "sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-negative-zero": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-negative-zero/-/is-negative-zero-2.0.3.tgz", + "integrity": "sha512-5KoIu2Ngpyek75jXodFvnafB6DJgr3u8uuK0LEZJjrU19DrMD3EVERaR8sjz8CCGgpZvxPl9SuE1GMVPFHx1mw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-number-object": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-number-object/-/is-number-object-1.1.1.tgz", + "integrity": "sha512-lZhclumE1G6VYD8VHe35wFaIif+CTy5SJIi5+3y4psDgWu4wPDoBhF8NxUOinEc7pHgiTsT6MaBb92rKhhD+Xw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-plain-obj": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-4.1.0.tgz", + "integrity": "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-regex": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz", + "integrity": "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "gopd": "^1.2.0", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-set": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-set/-/is-set-2.0.3.tgz", + "integrity": "sha512-iPAjerrse27/ygGLxw+EBR9agv9Y6uLeYVJMu+QNCoouJ1/1ri0mGrcWpfCqFZuzzx3WjtwxG098X+n4OuRkPg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-shared-array-buffer": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/is-shared-array-buffer/-/is-shared-array-buffer-1.0.4.tgz", + "integrity": "sha512-ISWac8drv4ZGfwKl5slpHG9OwPNty4jOWPRIhBpxOoD+hqITiwuipOQ2bNthAzwA3B4fIjO4Nln74N0S9byq8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-string": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-string/-/is-string-1.1.1.tgz", + "integrity": "sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-symbol": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-symbol/-/is-symbol-1.1.1.tgz", + "integrity": "sha512-9gGx6GTtCQM73BgmHQXfDmLtfjjTUDSyoxTCbp5WtoixAhfgsDirWIcVQ/IHpvI5Vgd5i/J5F7B9cN/WlVbC/w==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "has-symbols": "^1.1.0", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-typed-array": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.15.tgz", + "integrity": "sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "which-typed-array": "^1.1.16" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-weakmap": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/is-weakmap/-/is-weakmap-2.0.2.tgz", + "integrity": "sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-weakref": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-weakref/-/is-weakref-1.1.1.tgz", + "integrity": "sha512-6i9mGWSlqzNMEqpCp93KwRS1uUOodk2OJ6b+sq7ZPDSy2WuI5NFIxp/254TytR8ftefexkWn5xNiHUNpPOfSew==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-weakset": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/is-weakset/-/is-weakset-2.0.4.tgz", + "integrity": "sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/isarray": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", + "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==", + "dev": true, + "license": "MIT" + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/iterator.prototype": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/iterator.prototype/-/iterator.prototype-1.1.5.tgz", + "integrity": "sha512-H0dkQoCa3b2VEeKQBOxFph+JAbcrQdE7KC0UkqwpLmv2EC4P41QXP+rqo9wYodACiG5/WM5s9oDApTU8utwj9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "es-object-atoms": "^1.0.0", + "get-intrinsic": "^1.2.6", + "get-proto": "^1.0.0", + "has-symbols": "^1.1.0", + "set-function-name": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/jiti": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz", + "integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==", + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "license": "MIT" + }, + "node_modules/js-yaml": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", + "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "dev": true, + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/json-schema/-/json-schema-0.4.0.tgz", + "integrity": "sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA==", + "license": "(AFL-2.1 OR BSD-3-Clause)" + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/jsx-ast-utils": { + "version": "3.3.5", + "resolved": "https://registry.npmjs.org/jsx-ast-utils/-/jsx-ast-utils-3.3.5.tgz", + "integrity": "sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-includes": "^3.1.6", + "array.prototype.flat": "^1.3.1", + "object.assign": "^4.1.4", + "object.values": "^1.1.6" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/laravel-precognition": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/laravel-precognition/-/laravel-precognition-1.0.0.tgz", + "integrity": "sha512-hvXPT7dayCQAidxnsY0hab9Q+Y2rsh7xRpH9uiFtXN8Dekc3tIZt+NrxrOZ9N5SwHBmRBze/Bv+ElfXac0kD6g==", + "license": "MIT", + "dependencies": { + "axios": "^1.4.0", + "lodash-es": "^4.17.21" + } + }, + "node_modules/laravel-vite-plugin": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/laravel-vite-plugin/-/laravel-vite-plugin-2.0.1.tgz", + "integrity": "sha512-zQuvzWfUKQu9oNVi1o0RZAJCwhGsdhx4NEOyrVQwJHaWDseGP9tl7XUPLY2T8Cj6+IrZ6lmyxlR1KC8unf3RLA==", + "license": "MIT", + "dependencies": { + "picocolors": "^1.0.0", + "vite-plugin-full-reload": "^1.1.0" + }, + "bin": { + "clean-orphaned-assets": "bin/clean.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "vite": "^7.0.0" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/lightningcss": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.30.2.tgz", + "integrity": "sha512-utfs7Pr5uJyyvDETitgsaqSyjCb2qNRAtuqUeWIAKztsOYdcACf2KtARYXg2pSvhkt+9NfoaNY7fxjl6nuMjIQ==", + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.30.2", + "lightningcss-darwin-arm64": "1.30.2", + "lightningcss-darwin-x64": "1.30.2", + "lightningcss-freebsd-x64": "1.30.2", + "lightningcss-linux-arm-gnueabihf": "1.30.2", + "lightningcss-linux-arm64-gnu": "1.30.2", + "lightningcss-linux-arm64-musl": "1.30.2", + "lightningcss-linux-x64-gnu": "1.30.2", + "lightningcss-linux-x64-musl": "1.30.2", + "lightningcss-win32-arm64-msvc": "1.30.2", + "lightningcss-win32-x64-msvc": "1.30.2" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.30.2.tgz", + "integrity": "sha512-BH9sEdOCahSgmkVhBLeU7Hc9DWeZ1Eb6wNS6Da8igvUwAe0sqROHddIlvU06q3WyXVEOYDZ6ykBZQnjTbmo4+A==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.30.2.tgz", + "integrity": "sha512-ylTcDJBN3Hp21TdhRT5zBOIi73P6/W0qwvlFEk22fkdXchtNTOU4Qc37SkzV+EKYxLouZ6M4LG9NfZ1qkhhBWA==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.30.2.tgz", + "integrity": "sha512-oBZgKchomuDYxr7ilwLcyms6BCyLn0z8J0+ZZmfpjwg9fRVZIR5/GMXd7r9RH94iDhld3UmSjBM6nXWM2TfZTQ==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.30.2.tgz", + "integrity": "sha512-c2bH6xTrf4BDpK8MoGG4Bd6zAMZDAXS569UxCAGcA7IKbHNMlhGQ89eRmvpIUGfKWNVdbhSbkQaWhEoMGmGslA==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.30.2.tgz", + "integrity": "sha512-eVdpxh4wYcm0PofJIZVuYuLiqBIakQ9uFZmipf6LF/HRj5Bgm0eb3qL/mr1smyXIS1twwOxNWndd8z0E374hiA==", + "cpu": [ + "arm" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.30.2.tgz", + "integrity": "sha512-UK65WJAbwIJbiBFXpxrbTNArtfuznvxAJw4Q2ZGlU8kPeDIWEX1dg3rn2veBVUylA2Ezg89ktszWbaQnxD/e3A==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.30.2.tgz", + "integrity": "sha512-5Vh9dGeblpTxWHpOx8iauV02popZDsCYMPIgiuw97OJ5uaDsL86cnqSFs5LZkG3ghHoX5isLgWzMs+eD1YzrnA==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.31.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.31.0.tgz", + "integrity": "sha512-enNePbgDKmJybVz90/8dAGTOulvpn0IwxamHHnIj32gmdbuSPJ9mk+Nob4UmiqLMAdHlH+0c+lpsZkv4TSxi3w==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.30.2.tgz", + "integrity": "sha512-XJaLUUFXb6/QG2lGIW6aIk6jKdtjtcffUT0NKvIqhSBY3hh9Ch+1LCeH80dR9q9LBjG3ewbDjnumefsLsP6aiA==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.30.2.tgz", + "integrity": "sha512-FZn+vaj7zLv//D/192WFFVA0RgHawIcHqLX9xuWiQt7P0PtdFEVaxgF9rjM/IRYHQXNnk61/H/gb2Ei+kUQ4xQ==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.31.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.31.0.tgz", + "integrity": "sha512-7V6CPCLNO1Pv5gPPvXWst7V8cvZjbRKgwht1qd4/OH7yacV/kMV5VDq/RDnmdQpXUTnn4ye+vZkU8REXU46iZA==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss/node_modules/lightningcss-linux-x64-gnu": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.30.2.tgz", + "integrity": "sha512-Cfd46gdmj1vQ+lR6VRTTadNHu6ALuw2pKR9lYq4FnhvgBc4zWY1EtZcAc6EffShbb1MFrIPfLDXD6Xprbnni4w==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss/node_modules/lightningcss-win32-x64-msvc": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.30.2.tgz", + "integrity": "sha512-5g1yc73p+iAkid5phb4oVFMB45417DkRevRbt/El/gKXJk4jid+vPFF/AXbxn05Aky8PapwzZrdJShv5C0avjw==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lodash-es": { + "version": "4.17.22", + "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.17.22.tgz", + "integrity": "sha512-XEawp1t0gxSi9x01glktRZ5HDy0HXqrM0x5pXQM98EaI0NxO6jVM7omDOxsuEo5UIASAnm2bRp1Jt/e0a2XU8Q==", + "license": "MIT" + }, + "node_modules/lodash.merge": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/longest-streak": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/longest-streak/-/longest-streak-3.1.0.tgz", + "integrity": "sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/lucide-react": { + "version": "0.475.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.475.0.tgz", + "integrity": "sha512-NJzvVu1HwFVeZ+Gwq2q00KygM1aBhy/ZrhY9FsAgJtpB+E4R7uxRk9M2iKvHa6/vNxZydIB59htha4c2vvwvVg==", + "license": "ISC", + "peerDependencies": { + "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/markdown-table": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/markdown-table/-/markdown-table-3.0.4.tgz", + "integrity": "sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/mdast-util-find-and-replace": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mdast-util-find-and-replace/-/mdast-util-find-and-replace-3.0.2.tgz", + "integrity": "sha512-Tmd1Vg/m3Xz43afeNxDIhWRtFZgM2VLyaf4vSTYwudTyeuTneoL3qtWMA5jeLyz/O1vDJmmV4QuScFCA2tBPwg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "escape-string-regexp": "^5.0.0", + "unist-util-is": "^6.0.0", + "unist-util-visit-parents": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-find-and-replace/node_modules/escape-string-regexp": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-5.0.0.tgz", + "integrity": "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/mdast-util-from-markdown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/mdast-util-from-markdown/-/mdast-util-from-markdown-2.0.2.tgz", + "integrity": "sha512-uZhTV/8NBuw0WHkPTrCqDOl0zVe1BIng5ZtHoDk49ME1qqcjYmmLmOf0gELgcRMxN4w2iuIeVso5/6QymSrgmA==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "mdast-util-to-string": "^4.0.0", + "micromark": "^4.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-decode-string": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0", + "unist-util-stringify-position": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm/-/mdast-util-gfm-3.1.0.tgz", + "integrity": "sha512-0ulfdQOM3ysHhCJ1p06l0b0VKlhU0wuQs3thxZQagjcjPrlFRqY215uZGHHJan9GEAXd9MbfPjFJz+qMkVR6zQ==", + "license": "MIT", + "dependencies": { + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-gfm-autolink-literal": "^2.0.0", + "mdast-util-gfm-footnote": "^2.0.0", + "mdast-util-gfm-strikethrough": "^2.0.0", + "mdast-util-gfm-table": "^2.0.0", + "mdast-util-gfm-task-list-item": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-autolink-literal": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-autolink-literal/-/mdast-util-gfm-autolink-literal-2.0.1.tgz", + "integrity": "sha512-5HVP2MKaP6L+G6YaxPNjuL0BPrq9orG3TsrZ9YXbA3vDw/ACI4MEsnoDpn6ZNm7GnZgtAcONJyPhOP8tNJQavQ==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "ccount": "^2.0.0", + "devlop": "^1.0.0", + "mdast-util-find-and-replace": "^3.0.0", + "micromark-util-character": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-footnote": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-footnote/-/mdast-util-gfm-footnote-2.1.0.tgz", + "integrity": "sha512-sqpDWlsHn7Ac9GNZQMeUzPQSMzR6Wv0WKRNvQRg0KqHh02fpTz69Qc1QSseNX29bhz1ROIyNyxExfawVKTm1GQ==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "devlop": "^1.1.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-strikethrough": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-strikethrough/-/mdast-util-gfm-strikethrough-2.0.0.tgz", + "integrity": "sha512-mKKb915TF+OC5ptj5bJ7WFRPdYtuHv0yTRxK2tJvi+BDqbkiG7h7u/9SI89nRAYcmap2xHQL9D+QG/6wSrTtXg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-table": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-table/-/mdast-util-gfm-table-2.0.0.tgz", + "integrity": "sha512-78UEvebzz/rJIxLvE7ZtDd/vIQ0RHv+3Mh5DR96p7cS7HsBhYIICDBCu8csTNWNO6tBWfqXPWekRuj2FNOGOZg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "markdown-table": "^3.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-task-list-item": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-task-list-item/-/mdast-util-gfm-task-list-item-2.0.0.tgz", + "integrity": "sha512-IrtvNvjxC1o06taBAVJznEnkiHxLFTzgonUdy8hzFVeDun0uTjxxrRGVaNFqkU1wJR3RBPEfsxmU6jDWPofrTQ==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-mdx-expression": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mdast-util-mdx-expression/-/mdast-util-mdx-expression-2.0.1.tgz", + "integrity": "sha512-J6f+9hUp+ldTZqKRSg7Vw5V6MqjATc+3E4gf3CFNcuZNWD8XdyI6zQ8GqH7f8169MM6P7hMBRDVGnn7oHB9kXQ==", + "license": "MIT", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-mdx-jsx": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/mdast-util-mdx-jsx/-/mdast-util-mdx-jsx-3.2.0.tgz", + "integrity": "sha512-lj/z8v0r6ZtsN/cGNNtemmmfoLAFZnjMbNyLzBafjzikOM+glrjNHPlf6lQDOTccj9n5b0PPihEBbhneMyGs1Q==", + "license": "MIT", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "ccount": "^2.0.0", + "devlop": "^1.1.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0", + "parse-entities": "^4.0.0", + "stringify-entities": "^4.0.0", + "unist-util-stringify-position": "^4.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-mdxjs-esm": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mdast-util-mdxjs-esm/-/mdast-util-mdxjs-esm-2.0.1.tgz", + "integrity": "sha512-EcmOpxsZ96CvlP03NghtH1EsLtr0n9Tm4lPUJUBccV9RwUOneqSycg19n5HGzCf+10LozMRSObtVr3ee1WoHtg==", + "license": "MIT", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-phrasing": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/mdast-util-phrasing/-/mdast-util-phrasing-4.1.0.tgz", + "integrity": "sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "unist-util-is": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-hast": { + "version": "13.2.1", + "resolved": "https://registry.npmjs.org/mdast-util-to-hast/-/mdast-util-to-hast-13.2.1.tgz", + "integrity": "sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "@ungap/structured-clone": "^1.0.0", + "devlop": "^1.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "trim-lines": "^3.0.0", + "unist-util-position": "^5.0.0", + "unist-util-visit": "^5.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-markdown": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/mdast-util-to-markdown/-/mdast-util-to-markdown-2.1.2.tgz", + "integrity": "sha512-xj68wMTvGXVOKonmog6LwyJKrYXZPvlwabaryTjLh9LuvovB/KAH+kvi8Gjj+7rJjsFi23nkUxRQv1KqSroMqA==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "longest-streak": "^3.0.0", + "mdast-util-phrasing": "^4.0.0", + "mdast-util-to-string": "^4.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-decode-string": "^2.0.0", + "unist-util-visit": "^5.0.0", + "zwitch": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-to-string/-/mdast-util-to-string-4.0.0.tgz", + "integrity": "sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/micromark/-/micromark-4.0.2.tgz", + "integrity": "sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "@types/debug": "^4.0.0", + "debug": "^4.0.0", + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "micromark-core-commonmark": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-combine-extensions": "^2.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-encode": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-subtokenize": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-core-commonmark": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/micromark-core-commonmark/-/micromark-core-commonmark-2.0.3.tgz", + "integrity": "sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "micromark-factory-destination": "^2.0.0", + "micromark-factory-label": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-factory-title": "^2.0.0", + "micromark-factory-whitespace": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-html-tag-name": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-subtokenize": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-extension-gfm": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm/-/micromark-extension-gfm-3.0.0.tgz", + "integrity": "sha512-vsKArQsicm7t0z2GugkCKtZehqUm31oeGBV/KVSorWSy8ZlNAv7ytjFhvaryUiCUJYqs+NoE6AFhpQvBTM6Q4w==", + "license": "MIT", + "dependencies": { + "micromark-extension-gfm-autolink-literal": "^2.0.0", + "micromark-extension-gfm-footnote": "^2.0.0", + "micromark-extension-gfm-strikethrough": "^2.0.0", + "micromark-extension-gfm-table": "^2.0.0", + "micromark-extension-gfm-tagfilter": "^2.0.0", + "micromark-extension-gfm-task-list-item": "^2.0.0", + "micromark-util-combine-extensions": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-autolink-literal": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-autolink-literal/-/micromark-extension-gfm-autolink-literal-2.1.0.tgz", + "integrity": "sha512-oOg7knzhicgQ3t4QCjCWgTmfNhvQbDDnJeVu9v81r7NltNCVmhPy1fJRX27pISafdjL+SVc4d3l48Gb6pbRypw==", + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-footnote": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-footnote/-/micromark-extension-gfm-footnote-2.1.0.tgz", + "integrity": "sha512-/yPhxI1ntnDNsiHtzLKYnE3vf9JZ6cAisqVDauhp4CEHxlb4uoOTxOCJ+9s51bIB8U1N1FJ1RXOKTIlD5B/gqw==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-core-commonmark": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-strikethrough": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-strikethrough/-/micromark-extension-gfm-strikethrough-2.1.0.tgz", + "integrity": "sha512-ADVjpOOkjz1hhkZLlBiYA9cR2Anf8F4HqZUO6e5eDcPQd0Txw5fxLzzxnEkSkfnD0wziSGiv7sYhk/ktvbf1uw==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-table": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-table/-/micromark-extension-gfm-table-2.1.1.tgz", + "integrity": "sha512-t2OU/dXXioARrC6yWfJ4hqB7rct14e8f7m0cbI5hUmDyyIlwv5vEtooptH8INkbLzOatzKuVbQmAYcbWoyz6Dg==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-tagfilter": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-tagfilter/-/micromark-extension-gfm-tagfilter-2.0.0.tgz", + "integrity": "sha512-xHlTOmuCSotIA8TW1mDIM6X2O1SiX5P9IuDtqGonFhEK0qgRI4yeC6vMxEV2dgyr2TiD+2PQ10o+cOhdVAcwfg==", + "license": "MIT", + "dependencies": { + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-task-list-item": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-task-list-item/-/micromark-extension-gfm-task-list-item-2.1.0.tgz", + "integrity": "sha512-qIBZhqxqI6fjLDYFTBIa4eivDMnP+OZqsNwmQ3xNLE4Cxwc+zfQEfbs6tzAo2Hjq+bh6q5F+Z8/cksrLFYWQQw==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-factory-destination": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-destination/-/micromark-factory-destination-2.0.1.tgz", + "integrity": "sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-label": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-label/-/micromark-factory-label-2.0.1.tgz", + "integrity": "sha512-VFMekyQExqIW7xIChcXn4ok29YE3rnuyveW3wZQWWqF4Nv9Wk5rgJ99KzPvHjkmPXF93FXIbBp6YdW3t71/7Vg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-space": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-space/-/micromark-factory-space-2.0.1.tgz", + "integrity": "sha512-zRkxjtBxxLd2Sc0d+fbnEunsTj46SWXgXciZmHq0kDYGnck/ZSGj9/wULTV95uoeYiK5hRXP2mJ98Uo4cq/LQg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-title": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-title/-/micromark-factory-title-2.0.1.tgz", + "integrity": "sha512-5bZ+3CjhAd9eChYTHsjy6TGxpOFSKgKKJPJxr293jTbfry2KDoWkhBb6TcPVB4NmzaPhMs1Frm9AZH7OD4Cjzw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-whitespace": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-whitespace/-/micromark-factory-whitespace-2.0.1.tgz", + "integrity": "sha512-Ob0nuZ3PKt/n0hORHyvoD9uZhr+Za8sFoP+OnMcnWK5lngSzALgQYKMr9RJVOWLqQYuyn6ulqGWSXdwf6F80lQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-character": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/micromark-util-character/-/micromark-util-character-2.1.1.tgz", + "integrity": "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-chunked": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-chunked/-/micromark-util-chunked-2.0.1.tgz", + "integrity": "sha512-QUNFEOPELfmvv+4xiNg2sRYeS/P84pTW0TCgP5zc9FpXetHY0ab7SxKyAQCNCc1eK0459uoLI1y5oO5Vc1dbhA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-classify-character": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-classify-character/-/micromark-util-classify-character-2.0.1.tgz", + "integrity": "sha512-K0kHzM6afW/MbeWYWLjoHQv1sgg2Q9EccHEDzSkxiP/EaagNzCm7T/WMKZ3rjMbvIpvBiZgwR3dKMygtA4mG1Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-combine-extensions": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-combine-extensions/-/micromark-util-combine-extensions-2.0.1.tgz", + "integrity": "sha512-OnAnH8Ujmy59JcyZw8JSbK9cGpdVY44NKgSM7E9Eh7DiLS2E9RNQf0dONaGDzEG9yjEl5hcqeIsj4hfRkLH/Bg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-chunked": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-decode-numeric-character-reference": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/micromark-util-decode-numeric-character-reference/-/micromark-util-decode-numeric-character-reference-2.0.2.tgz", + "integrity": "sha512-ccUbYk6CwVdkmCQMyr64dXz42EfHGkPQlBj5p7YVGzq8I7CtjXZJrubAYezf7Rp+bjPseiROqe7G6foFd+lEuw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-decode-string": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-decode-string/-/micromark-util-decode-string-2.0.1.tgz", + "integrity": "sha512-nDV/77Fj6eH1ynwscYTOsbK7rR//Uj0bZXBwJZRfaLEJ1iGBR6kIfNmlNqaqJf649EP0F3NWNdeJi03elllNUQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "decode-named-character-reference": "^1.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-encode": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-encode/-/micromark-util-encode-2.0.1.tgz", + "integrity": "sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-html-tag-name": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-html-tag-name/-/micromark-util-html-tag-name-2.0.1.tgz", + "integrity": "sha512-2cNEiYDhCWKI+Gs9T0Tiysk136SnR13hhO8yW6BGNyhOC4qYFnwF1nKfD3HFAIXA5c45RrIG1ub11GiXeYd1xA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-normalize-identifier": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-normalize-identifier/-/micromark-util-normalize-identifier-2.0.1.tgz", + "integrity": "sha512-sxPqmo70LyARJs0w2UclACPUUEqltCkJ6PhKdMIDuJ3gSf/Q+/GIe3WKl0Ijb/GyH9lOpUkRAO2wp0GVkLvS9Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-resolve-all": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-resolve-all/-/micromark-util-resolve-all-2.0.1.tgz", + "integrity": "sha512-VdQyxFWFT2/FGJgwQnJYbe1jjQoNTS4RjglmSjTUlpUMa95Htx9NHeYW4rGDJzbjvCsl9eLjMQwGeElsqmzcHg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-sanitize-uri": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-sanitize-uri/-/micromark-util-sanitize-uri-2.0.1.tgz", + "integrity": "sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-encode": "^2.0.0", + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-subtokenize": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-util-subtokenize/-/micromark-util-subtokenize-2.1.0.tgz", + "integrity": "sha512-XQLu552iSctvnEcgXw6+Sx75GflAPNED1qx7eBJ+wydBb2KCbRZe+NwvIEEMM83uml1+2WSXpBAcp9IUCgCYWA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-symbol": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-symbol/-/micromark-util-symbol-2.0.1.tgz", + "integrity": "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-types": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/micromark-util-types/-/micromark-util-types-2.0.2.tgz", + "integrity": "sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/motion": { + "version": "12.27.1", + "resolved": "https://registry.npmjs.org/motion/-/motion-12.27.1.tgz", + "integrity": "sha512-FAZTPDm1LccBdWSL46WLnEdTSHmdVx+fdWK8f61qBQn67MmFefXLXlrwy94rK2DDsd9A50Gj8H+LYCgQ/cQlFg==", + "license": "MIT", + "dependencies": { + "framer-motion": "^12.27.1", + "tslib": "^2.4.0" + }, + "peerDependencies": { + "@emotion/is-prop-valid": "*", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@emotion/is-prop-valid": { + "optional": true + }, + "react": { + "optional": true + }, + "react-dom": { + "optional": true + } + } + }, + "node_modules/motion-dom": { + "version": "12.27.1", + "resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.27.1.tgz", + "integrity": "sha512-V/53DA2nBqKl9O2PMJleSUb/G0dsMMeZplZwgIQf5+X0bxIu7Q1cTv6DrjvTTGYRm3+7Y5wMlRZ1wT61boU/bQ==", + "license": "MIT", + "dependencies": { + "motion-utils": "^12.24.10" + } + }, + "node_modules/motion-utils": { + "version": "12.24.10", + "resolved": "https://registry.npmjs.org/motion-utils/-/motion-utils-12.24.10.tgz", + "integrity": "sha512-x5TFgkCIP4pPsRLpKoI86jv/q8t8FQOiM/0E8QKBzfMozWHfkKap2gA1hOki+B5g3IsBNpxbUnfOum1+dgvYww==", + "license": "MIT" + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "5.1.6", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-5.1.6.tgz", + "integrity": "sha512-c7+7RQ+dMB5dPwwCp4ee1/iV/q2P6aK1mTZcfr1BTuVlyW9hJYiMPybJCcnBlQtuSmTIWNeazm/zqNoZSSElBg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.js" + }, + "engines": { + "node": "^18 || >=20" + } + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, + "node_modules/node-releases": { + "version": "2.0.27", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz", + "integrity": "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==", + "license": "MIT" + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object-keys": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", + "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/object.assign": { + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/object.assign/-/object.assign-4.1.7.tgz", + "integrity": "sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0", + "has-symbols": "^1.1.0", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object.entries": { + "version": "1.1.9", + "resolved": "https://registry.npmjs.org/object.entries/-/object.entries-1.1.9.tgz", + "integrity": "sha512-8u/hfXFRBD1O0hPUjioLhoWFHRmt6tKA4/vZPyckBr18l1KE9uHrFaFaUi8MDRTpi4uak2goyPTSNJLXX2k2Hw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/object.fromentries": { + "version": "2.0.8", + "resolved": "https://registry.npmjs.org/object.fromentries/-/object.fromentries-2.0.8.tgz", + "integrity": "sha512-k6E21FzySsSK5a21KRADBd/NGneRegFO5pLHfdQLpRDETUNJueLXs3WCzyQ3tFRDYgbq3KHGXfTbi2bs8WQ6rQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.2", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object.values": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/object.values/-/object.values-1.2.1.tgz", + "integrity": "sha512-gXah6aZrcUxjWg2zR2MwouP2eHlCBzdV4pygudehaKXSGW4v2AsRQUK+lwwXhii6KFZcunEnmSUoYp5CXibxtA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/oniguruma-parser": { + "version": "0.12.1", + "resolved": "https://registry.npmjs.org/oniguruma-parser/-/oniguruma-parser-0.12.1.tgz", + "integrity": "sha512-8Unqkvk1RYc6yq2WBYRj4hdnsAxVze8i7iPfQr8e4uSP3tRv0rpZcbGUDvxfQQcdwHt/e9PrMvGCsa8OqG9X3w==", + "license": "MIT" + }, + "node_modules/oniguruma-to-es": { + "version": "4.3.4", + "resolved": "https://registry.npmjs.org/oniguruma-to-es/-/oniguruma-to-es-4.3.4.tgz", + "integrity": "sha512-3VhUGN3w2eYxnTzHn+ikMI+fp/96KoRSVK9/kMTcFqj1NRDh2IhQCKvYxDnWePKRXY/AqH+Fuiyb7VHSzBjHfA==", + "license": "MIT", + "dependencies": { + "oniguruma-parser": "^0.12.1", + "regex": "^6.0.1", + "regex-recursion": "^6.0.2" + } + }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/own-keys": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/own-keys/-/own-keys-1.0.1.tgz", + "integrity": "sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "get-intrinsic": "^1.2.6", + "object-keys": "^1.1.1", + "safe-push-apply": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/parse-entities": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/parse-entities/-/parse-entities-4.0.2.tgz", + "integrity": "sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw==", + "license": "MIT", + "dependencies": { + "@types/unist": "^2.0.0", + "character-entities-legacy": "^3.0.0", + "character-reference-invalid": "^2.0.0", + "decode-named-character-reference": "^1.0.0", + "is-alphanumerical": "^2.0.0", + "is-decimal": "^2.0.0", + "is-hexadecimal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/parse-entities/node_modules/@types/unist": { + "version": "2.0.11", + "resolved": "https://registry.npmjs.org/@types/unist/-/unist-2.0.11.tgz", + "integrity": "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==", + "license": "MIT" + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/possible-typed-array-names": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", + "integrity": "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/postcss": { + "version": "8.5.6", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", + "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/postcss/node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/prettier": { + "version": "3.8.0", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.0.tgz", + "integrity": "sha512-yEPsovQfpxYfgWNhCfECjG5AQaO+K3dp6XERmOepyPDVqcJm+bjyCVO3pmU+nAPe0N5dDvekfGezt/EIiRe1TA==", + "dev": true, + "license": "MIT", + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, + "node_modules/prettier-plugin-organize-imports": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/prettier-plugin-organize-imports/-/prettier-plugin-organize-imports-4.3.0.tgz", + "integrity": "sha512-FxFz0qFhyBsGdIsb697f/EkvHzi5SZOhWAjxcx2dLt+Q532bAlhswcXGYB1yzjZ69kW8UoadFBw7TyNwlq96Iw==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "prettier": ">=2.0", + "typescript": ">=2.9", + "vue-tsc": "^2.1.0 || 3" + }, + "peerDependenciesMeta": { + "vue-tsc": { + "optional": true + } + } + }, + "node_modules/prettier-plugin-tailwindcss": { + "version": "0.6.14", + "resolved": "https://registry.npmjs.org/prettier-plugin-tailwindcss/-/prettier-plugin-tailwindcss-0.6.14.tgz", + "integrity": "sha512-pi2e/+ZygeIqntN+vC573BcW5Cve8zUB0SSAGxqpB4f96boZF4M3phPVoOFCeypwkpRYdi7+jQ5YJJUwrkGUAg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.21.3" + }, + "peerDependencies": { + "@ianvs/prettier-plugin-sort-imports": "*", + "@prettier/plugin-hermes": "*", + "@prettier/plugin-oxc": "*", + "@prettier/plugin-pug": "*", + "@shopify/prettier-plugin-liquid": "*", + "@trivago/prettier-plugin-sort-imports": "*", + "@zackad/prettier-plugin-twig": "*", + "prettier": "^3.0", + "prettier-plugin-astro": "*", + "prettier-plugin-css-order": "*", + "prettier-plugin-import-sort": "*", + "prettier-plugin-jsdoc": "*", + "prettier-plugin-marko": "*", + "prettier-plugin-multiline-arrays": "*", + "prettier-plugin-organize-attributes": "*", + "prettier-plugin-organize-imports": "*", + "prettier-plugin-sort-imports": "*", + "prettier-plugin-style-order": "*", + "prettier-plugin-svelte": "*" + }, + "peerDependenciesMeta": { + "@ianvs/prettier-plugin-sort-imports": { + "optional": true + }, + "@prettier/plugin-hermes": { + "optional": true + }, + "@prettier/plugin-oxc": { + "optional": true + }, + "@prettier/plugin-pug": { + "optional": true + }, + "@shopify/prettier-plugin-liquid": { + "optional": true + }, + "@trivago/prettier-plugin-sort-imports": { + "optional": true + }, + "@zackad/prettier-plugin-twig": { + "optional": true + }, + "prettier-plugin-astro": { + "optional": true + }, + "prettier-plugin-css-order": { + "optional": true + }, + "prettier-plugin-import-sort": { + "optional": true + }, + "prettier-plugin-jsdoc": { + "optional": true + }, + "prettier-plugin-marko": { + "optional": true + }, + "prettier-plugin-multiline-arrays": { + "optional": true + }, + "prettier-plugin-organize-attributes": { + "optional": true + }, + "prettier-plugin-organize-imports": { + "optional": true + }, + "prettier-plugin-sort-imports": { + "optional": true + }, + "prettier-plugin-style-order": { + "optional": true + }, + "prettier-plugin-svelte": { + "optional": true + } + } + }, + "node_modules/prop-types": { + "version": "15.8.1", + "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", + "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", + "dev": true, + "license": "MIT", + "dependencies": { + "loose-envify": "^1.4.0", + "object-assign": "^4.1.1", + "react-is": "^16.13.1" + } + }, + "node_modules/property-information": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/property-information/-/property-information-7.1.0.tgz", + "integrity": "sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/proxy-from-env": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", + "license": "MIT" + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/qs": { + "version": "6.14.1", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.1.tgz", + "integrity": "sha512-4EK3+xJl8Ts67nLYNwqw/dsFVnCf+qR7RgXSK9jEEm9unao3njwMDdmsdvoKBKHzxd7tCYz5e5M+SnMjdtXGQQ==", + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/react": { + "version": "19.2.3", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.3.tgz", + "integrity": "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.2.3", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.3.tgz", + "integrity": "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.3" + } + }, + "node_modules/react-is": { + "version": "16.13.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/react-markdown": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/react-markdown/-/react-markdown-10.1.0.tgz", + "integrity": "sha512-qKxVopLT/TyA6BX3Ue5NwabOsAzm0Q7kAPwq6L+wWDwisYs7R8vZ0nRXqq6rkueboxpkjvLGU9fWifiX/ZZFxQ==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "hast-util-to-jsx-runtime": "^2.0.0", + "html-url-attributes": "^3.0.0", + "mdast-util-to-hast": "^13.0.0", + "remark-parse": "^11.0.0", + "remark-rehype": "^11.0.0", + "unified": "^11.0.0", + "unist-util-visit": "^5.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + }, + "peerDependencies": { + "@types/react": ">=18", + "react": ">=18" + } + }, + "node_modules/react-refresh": { + "version": "0.18.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.18.0.tgz", + "integrity": "sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-remove-scroll": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.7.2.tgz", + "integrity": "sha512-Iqb9NjCCTt6Hf+vOdNIZGdTiH1QSqr27H/Ek9sv/a97gfueI/5h1s3yRi1nngzMUaOOToin5dI1dXKdXiF+u0Q==", + "license": "MIT", + "dependencies": { + "react-remove-scroll-bar": "^2.3.7", + "react-style-singleton": "^2.2.3", + "tslib": "^2.1.0", + "use-callback-ref": "^1.3.3", + "use-sidecar": "^1.1.3" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/react-remove-scroll-bar": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/react-remove-scroll-bar/-/react-remove-scroll-bar-2.3.8.tgz", + "integrity": "sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==", + "license": "MIT", + "dependencies": { + "react-style-singleton": "^2.2.2", + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/react-shiki": { + "version": "0.9.1", + "resolved": "https://registry.npmjs.org/react-shiki/-/react-shiki-0.9.1.tgz", + "integrity": "sha512-Ln1PnISi7WaSlheSBRdxVruVbU1zMUkCmxe+vmbIvZSsHdfvOF5NBOgf1h4cCr6OjdR0dLAxmPKcx3tobdyxVA==", + "license": "MIT", + "dependencies": { + "clsx": "^2.1.1", + "dequal": "^2.0.3", + "hast-util-to-jsx-runtime": "^2.3.6", + "shiki": "^3.11.0", + "unist-util-visit": "^5.0.0" + }, + "peerDependencies": { + "@types/react": ">=16.8.0", + "@types/react-dom": ">=16.8.0", + "react": ">= 16.8.0", + "react-dom": ">= 16.8.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/react-style-singleton": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/react-style-singleton/-/react-style-singleton-2.2.3.tgz", + "integrity": "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==", + "license": "MIT", + "dependencies": { + "get-nonce": "^1.0.0", + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/react-textarea-autosize": { + "version": "8.5.9", + "resolved": "https://registry.npmjs.org/react-textarea-autosize/-/react-textarea-autosize-8.5.9.tgz", + "integrity": "sha512-U1DGlIQN5AwgjTyOEnI1oCcMuEr1pv1qOtklB2l4nyMGbHzWrI0eFsYK0zos2YWqAolJyG0IWJaqWmWj5ETh0A==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.20.13", + "use-composed-ref": "^1.3.0", + "use-latest": "^1.2.1" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/reflect.getprototypeof": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz", + "integrity": "sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.9", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0", + "get-intrinsic": "^1.2.7", + "get-proto": "^1.0.1", + "which-builtin-type": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/regex": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/regex/-/regex-6.1.0.tgz", + "integrity": "sha512-6VwtthbV4o/7+OaAF9I5L5V3llLEsoPyq9P1JVXkedTP33c7MfCG0/5NOPcSJn0TzXcG9YUrR0gQSWioew3LDg==", + "license": "MIT", + "dependencies": { + "regex-utilities": "^2.3.0" + } + }, + "node_modules/regex-recursion": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/regex-recursion/-/regex-recursion-6.0.2.tgz", + "integrity": "sha512-0YCaSCq2VRIebiaUviZNs0cBz1kg5kVS2UKUfNIx8YVs1cN3AV7NTctO5FOKBA+UT2BPJIWZauYHPqJODG50cg==", + "license": "MIT", + "dependencies": { + "regex-utilities": "^2.3.0" + } + }, + "node_modules/regex-utilities": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/regex-utilities/-/regex-utilities-2.3.0.tgz", + "integrity": "sha512-8VhliFJAWRaUiVvREIiW2NXXTmHs4vMNnSzuJVhscgmGav3g9VDxLrQndI3dZZVVdp0ZO/5v0xmX516/7M9cng==", + "license": "MIT" + }, + "node_modules/regexp.prototype.flags": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.4.tgz", + "integrity": "sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-errors": "^1.3.0", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "set-function-name": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/remark-gfm": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/remark-gfm/-/remark-gfm-4.0.1.tgz", + "integrity": "sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-gfm": "^3.0.0", + "micromark-extension-gfm": "^3.0.0", + "remark-parse": "^11.0.0", + "remark-stringify": "^11.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-parse": { + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/remark-parse/-/remark-parse-11.0.0.tgz", + "integrity": "sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-from-markdown": "^2.0.0", + "micromark-util-types": "^2.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-rehype": { + "version": "11.1.2", + "resolved": "https://registry.npmjs.org/remark-rehype/-/remark-rehype-11.1.2.tgz", + "integrity": "sha512-Dh7l57ianaEoIpzbp0PC9UKAdCSVklD8E5Rpw7ETfbTl3FqcOOgq5q2LVDhgGCkaBv7p24JXikPdvhhmHvKMsw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "mdast-util-to-hast": "^13.0.0", + "unified": "^11.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-stringify": { + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/remark-stringify/-/remark-stringify-11.0.0.tgz", + "integrity": "sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-to-markdown": "^2.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/resolve": { + "version": "2.0.0-next.5", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-2.0.0-next.5.tgz", + "integrity": "sha512-U7WjGVG9sH8tvjW5SmGbQuui75FiyjAX72HX15DwBBwF9dNiQZRQAg9nnPhYy+TUnE0+VcrttuvNI8oSxZcocA==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-core-module": "^2.13.0", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/rollup": { + "version": "4.55.2", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.55.2.tgz", + "integrity": "sha512-PggGy4dhwx5qaW+CKBilA/98Ql9keyfnb7lh4SR6shQ91QQQi1ORJ1v4UinkdP2i87OBs9AQFooQylcrrRfIcg==", + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.55.2", + "@rollup/rollup-android-arm64": "4.55.2", + "@rollup/rollup-darwin-arm64": "4.55.2", + "@rollup/rollup-darwin-x64": "4.55.2", + "@rollup/rollup-freebsd-arm64": "4.55.2", + "@rollup/rollup-freebsd-x64": "4.55.2", + "@rollup/rollup-linux-arm-gnueabihf": "4.55.2", + "@rollup/rollup-linux-arm-musleabihf": "4.55.2", + "@rollup/rollup-linux-arm64-gnu": "4.55.2", + "@rollup/rollup-linux-arm64-musl": "4.55.2", + "@rollup/rollup-linux-loong64-gnu": "4.55.2", + "@rollup/rollup-linux-loong64-musl": "4.55.2", + "@rollup/rollup-linux-ppc64-gnu": "4.55.2", + "@rollup/rollup-linux-ppc64-musl": "4.55.2", + "@rollup/rollup-linux-riscv64-gnu": "4.55.2", + "@rollup/rollup-linux-riscv64-musl": "4.55.2", + "@rollup/rollup-linux-s390x-gnu": "4.55.2", + "@rollup/rollup-linux-x64-gnu": "4.55.2", + "@rollup/rollup-linux-x64-musl": "4.55.2", + "@rollup/rollup-openbsd-x64": "4.55.2", + "@rollup/rollup-openharmony-arm64": "4.55.2", + "@rollup/rollup-win32-arm64-msvc": "4.55.2", + "@rollup/rollup-win32-ia32-msvc": "4.55.2", + "@rollup/rollup-win32-x64-gnu": "4.55.2", + "@rollup/rollup-win32-x64-msvc": "4.55.2", + "fsevents": "~2.3.2" + } + }, + "node_modules/rollup/node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.55.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.55.2.tgz", + "integrity": "sha512-1e30XAuaBP1MAizaOBApsgeGZge2/Byd6wV4a8oa6jPdHELbRHBiw7wvo4dp7Ie2PE8TZT4pj9RLGZv9N4qwlw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/rollup/node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.55.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.55.2.tgz", + "integrity": "sha512-xNO+fksQhsAckRtDSPWaMeT1uIM+JrDRXlerpnWNXhn1TdB3YZ6uKBMBTKP0eX9XtYEP978hHk1f8332i2AW8Q==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/rxjs": { + "version": "7.8.2", + "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz", + "integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.1.0" + } + }, + "node_modules/safe-array-concat": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/safe-array-concat/-/safe-array-concat-1.1.3.tgz", + "integrity": "sha512-AURm5f0jYEOydBj7VQlVvDrjeFgthDdEF5H1dP+6mNpoXOMo1quQqJ4wvJDyRZ9+pO3kGWoOdmV08cSv2aJV6Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.2", + "get-intrinsic": "^1.2.6", + "has-symbols": "^1.1.0", + "isarray": "^2.0.5" + }, + "engines": { + "node": ">=0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/safe-push-apply": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/safe-push-apply/-/safe-push-apply-1.0.0.tgz", + "integrity": "sha512-iKE9w/Z7xCzUMIZqdBsp6pEQvwuEebH4vdpjcDWnyzaI6yl6O9FHvVpmGelvEHNsoY6wGblkxR6Zty/h00WiSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "isarray": "^2.0.5" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/safe-regex-test": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/safe-regex-test/-/safe-regex-test-1.1.0.tgz", + "integrity": "sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "is-regex": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "license": "MIT" + }, + "node_modules/secure-json-parse": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/secure-json-parse/-/secure-json-parse-4.1.0.tgz", + "integrity": "sha512-l4KnYfEyqYJxDwlNVyRfO2E4NTHfMKAWdUuA8J0yve2Dz/E/PdBepY03RvyJpssIpRFwJoCD55wA+mEDs6ByWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/set-function-length": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", + "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "function-bind": "^1.1.2", + "get-intrinsic": "^1.2.4", + "gopd": "^1.0.1", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/set-function-name": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/set-function-name/-/set-function-name-2.0.2.tgz", + "integrity": "sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "functions-have-names": "^1.2.3", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/set-proto": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/set-proto/-/set-proto-1.0.0.tgz", + "integrity": "sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/shell-quote": { + "version": "1.8.3", + "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.3.tgz", + "integrity": "sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/shiki": { + "version": "3.21.0", + "resolved": "https://registry.npmjs.org/shiki/-/shiki-3.21.0.tgz", + "integrity": "sha512-N65B/3bqL/TI2crrXr+4UivctrAGEjmsib5rPMMPpFp1xAx/w03v8WZ9RDDFYteXoEgY7qZ4HGgl5KBIu1153w==", + "license": "MIT", + "dependencies": { + "@shikijs/core": "3.21.0", + "@shikijs/engine-javascript": "3.21.0", + "@shikijs/engine-oniguruma": "3.21.0", + "@shikijs/langs": "3.21.0", + "@shikijs/themes": "3.21.0", + "@shikijs/types": "3.21.0", + "@shikijs/vscode-textmate": "^10.0.2", + "@types/hast": "^3.0.4" + } + }, + "node_modules/side-channel": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", + "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/space-separated-tokens": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/space-separated-tokens/-/space-separated-tokens-2.0.2.tgz", + "integrity": "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/stop-iteration-iterator": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz", + "integrity": "sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "internal-slot": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/string.prototype.matchall": { + "version": "4.0.12", + "resolved": "https://registry.npmjs.org/string.prototype.matchall/-/string.prototype.matchall-4.0.12.tgz", + "integrity": "sha512-6CC9uyBL+/48dYizRf7H7VAYCMCNTBeM78x/VTUe9bFEaxBepPJDa1Ow99LqI/1yF7kuy7Q3cQsYMrcjGUcskA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.6", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0", + "get-intrinsic": "^1.2.6", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "internal-slot": "^1.1.0", + "regexp.prototype.flags": "^1.5.3", + "set-function-name": "^2.0.2", + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/string.prototype.repeat": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/string.prototype.repeat/-/string.prototype.repeat-1.0.0.tgz", + "integrity": "sha512-0u/TldDbKD8bFCQ/4f5+mNRrXwZ8hg2w7ZR8wa16e8z9XpePWl3eGEcUD0OXpEH/VJH/2G3gjUtR3ZOiBe2S/w==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-properties": "^1.1.3", + "es-abstract": "^1.17.5" + } + }, + "node_modules/string.prototype.trim": { + "version": "1.2.10", + "resolved": "https://registry.npmjs.org/string.prototype.trim/-/string.prototype.trim-1.2.10.tgz", + "integrity": "sha512-Rs66F0P/1kedk5lyYyH9uBzuiI/kNRmwJAR9quK6VOtIpZ2G+hMZd+HQbbv25MgCA6gEffoMZYxlTod4WcdrKA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.2", + "define-data-property": "^1.1.4", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-object-atoms": "^1.0.0", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/string.prototype.trimend": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/string.prototype.trimend/-/string.prototype.trimend-1.0.9.tgz", + "integrity": "sha512-G7Ok5C6E/j4SGfyLCloXTrngQIQU3PWtXGst3yM7Bea9FRURf1S42ZHlZZtsNque2FN2PoUhfZXYLNWwEr4dLQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.2", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/string.prototype.trimstart": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/string.prototype.trimstart/-/string.prototype.trimstart-1.0.8.tgz", + "integrity": "sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/stringify-entities": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/stringify-entities/-/stringify-entities-4.0.4.tgz", + "integrity": "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==", + "license": "MIT", + "dependencies": { + "character-entities-html4": "^2.0.0", + "character-entities-legacy": "^3.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/style-to-js": { + "version": "1.1.21", + "resolved": "https://registry.npmjs.org/style-to-js/-/style-to-js-1.1.21.tgz", + "integrity": "sha512-RjQetxJrrUJLQPHbLku6U/ocGtzyjbJMP9lCNK7Ag0CNh690nSH8woqWH9u16nMjYBAok+i7JO1NP2pOy8IsPQ==", + "license": "MIT", + "dependencies": { + "style-to-object": "1.0.14" + } + }, + "node_modules/style-to-object": { + "version": "1.0.14", + "resolved": "https://registry.npmjs.org/style-to-object/-/style-to-object-1.0.14.tgz", + "integrity": "sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw==", + "license": "MIT", + "dependencies": { + "inline-style-parser": "0.2.7" + } + }, + "node_modules/supports-color": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", + "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/swr": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/swr/-/swr-2.3.8.tgz", + "integrity": "sha512-gaCPRVoMq8WGDcWj9p4YWzCMPHzE0WNl6W8ADIx9c3JBEIdMkJGMzW+uzXvxHMltwcYACr9jP+32H8/hgwMR7w==", + "license": "MIT", + "dependencies": { + "dequal": "^2.0.3", + "use-sync-external-store": "^1.6.0" + }, + "peerDependencies": { + "react": "^16.11.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/tabbable": { + "version": "6.4.0", + "resolved": "https://registry.npmjs.org/tabbable/-/tabbable-6.4.0.tgz", + "integrity": "sha512-05PUHKSNE8ou2dwIxTngl4EzcnsCDZGJ/iCLtDflR/SHB/ny14rXc+qU5P4mG9JkusiV7EivzY9Mhm55AzAvCg==", + "license": "MIT" + }, + "node_modules/tailwind-merge": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.4.0.tgz", + "integrity": "sha512-uSaO4gnW+b3Y2aWoWfFpX62vn2sR3skfhbjsEnaBI81WD1wBLlHZe5sWf0AqjksNdYTbGBEd0UasQMT3SNV15g==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/dcastil" + } + }, + "node_modules/tailwindcss": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.18.tgz", + "integrity": "sha512-4+Z+0yiYyEtUVCScyfHCxOYP06L5Ne+JiHhY2IjR2KWMIWhJOYZKLSGZaP5HkZ8+bY0cxfzwDE5uOmzFXyIwxw==", + "license": "MIT" + }, + "node_modules/tapable": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.0.tgz", + "integrity": "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==", + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/throttleit": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/throttleit/-/throttleit-2.1.0.tgz", + "integrity": "sha512-nt6AMGKW1p/70DF/hGBdJB57B8Tspmbp5gfJ8ilhLnt7kkr2ye7hzD6NVG8GGErk2HWF34igrL2CXmNIkzKqKw==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tree-kill": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz", + "integrity": "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==", + "license": "MIT", + "bin": { + "tree-kill": "cli.js" + } + }, + "node_modules/trim-lines": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/trim-lines/-/trim-lines-3.0.1.tgz", + "integrity": "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/trough": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/trough/-/trough-2.2.0.tgz", + "integrity": "sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/ts-api-utils": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.4.0.tgz", + "integrity": "sha512-3TaVTaAv2gTiMB35i3FiGJaRfwb3Pyn/j3m/bfAvGe8FB7CF6u+LMYqYlDh7reQf7UNvoTvdfAqHGmPGOSsPmA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.12" + }, + "peerDependencies": { + "typescript": ">=4.8.4" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/tw-animate-css": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/tw-animate-css/-/tw-animate-css-1.4.0.tgz", + "integrity": "sha512-7bziOlRqH0hJx80h/3mbicLW7o8qLsH5+RaLR2t+OHM3D0JlWGODQKQ4cxbK7WlvmUxpcj6Kgu6EKqjrGFe3QQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Wombosvideo" + } + }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/typed-array-buffer": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/typed-array-buffer/-/typed-array-buffer-1.0.3.tgz", + "integrity": "sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-typed-array": "^1.1.14" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/typed-array-byte-length": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/typed-array-byte-length/-/typed-array-byte-length-1.0.3.tgz", + "integrity": "sha512-BaXgOuIxz8n8pIq3e7Atg/7s+DpiYrxn4vdot3w9KbnBhcRQq6o3xemQdIfynqSeXeDrF32x+WvfzmOjPiY9lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "for-each": "^0.3.3", + "gopd": "^1.2.0", + "has-proto": "^1.2.0", + "is-typed-array": "^1.1.14" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/typed-array-byte-offset": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/typed-array-byte-offset/-/typed-array-byte-offset-1.0.4.tgz", + "integrity": "sha512-bTlAFB/FBYMcuX81gbL4OcpH5PmlFHqlCCpAl8AlEzMz5k53oNDvN8p1PNOWLEmI2x4orp3raOFB51tv9X+MFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.8", + "for-each": "^0.3.3", + "gopd": "^1.2.0", + "has-proto": "^1.2.0", + "is-typed-array": "^1.1.15", + "reflect.getprototypeof": "^1.0.9" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/typed-array-length": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/typed-array-length/-/typed-array-length-1.0.7.tgz", + "integrity": "sha512-3KS2b+kL7fsuk/eJZ7EQdnEmQoaho/r6KUef7hxvltNA5DR8NAUM+8wJMbJyZ4G9/7i3v5zPBIMN5aybAh2/Jg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "for-each": "^0.3.3", + "gopd": "^1.0.1", + "is-typed-array": "^1.1.13", + "possible-typed-array-names": "^1.0.0", + "reflect.getprototypeof": "^1.0.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/typescript-eslint": { + "version": "8.53.1", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.53.1.tgz", + "integrity": "sha512-gB+EVQfP5RDElh9ittfXlhZJdjSU4jUSTyE2+ia8CYyNvet4ElfaLlAIqDvQV9JPknKx0jQH1racTYe/4LaLSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/eslint-plugin": "8.53.1", + "@typescript-eslint/parser": "8.53.1", + "@typescript-eslint/typescript-estree": "8.53.1", + "@typescript-eslint/utils": "8.53.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/unbox-primitive": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.1.0.tgz", + "integrity": "sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-bigints": "^1.0.2", + "has-symbols": "^1.1.0", + "which-boxed-primitive": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/unified": { + "version": "11.0.5", + "resolved": "https://registry.npmjs.org/unified/-/unified-11.0.5.tgz", + "integrity": "sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "bail": "^2.0.0", + "devlop": "^1.0.0", + "extend": "^3.0.0", + "is-plain-obj": "^4.0.0", + "trough": "^2.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-is": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/unist-util-is/-/unist-util-is-6.0.1.tgz", + "integrity": "sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-position": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/unist-util-position/-/unist-util-position-5.0.0.tgz", + "integrity": "sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-stringify-position": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/unist-util-stringify-position/-/unist-util-stringify-position-4.0.0.tgz", + "integrity": "sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-visit": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/unist-util-visit/-/unist-util-visit-5.0.0.tgz", + "integrity": "sha512-MR04uvD+07cwl/yhVuVWAtw+3GOR/knlL55Nd/wAdblk27GCVt3lqpTivy/tkJcZoNPzTwS1Y+KMojlLDhoTzg==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0", + "unist-util-visit-parents": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-visit-parents": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/unist-util-visit-parents/-/unist-util-visit-parents-6.0.2.tgz", + "integrity": "sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/use-callback-ref": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/use-callback-ref/-/use-callback-ref-1.3.3.tgz", + "integrity": "sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/use-composed-ref": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/use-composed-ref/-/use-composed-ref-1.4.0.tgz", + "integrity": "sha512-djviaxuOOh7wkj0paeO1Q/4wMZ8Zrnag5H6yBvzN7AKKe8beOaED9SF5/ByLqsku8NP4zQqsvM2u3ew/tJK8/w==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/use-isomorphic-layout-effect": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/use-isomorphic-layout-effect/-/use-isomorphic-layout-effect-1.2.1.tgz", + "integrity": "sha512-tpZZ+EX0gaghDAiFR37hj5MgY6ZN55kLiPkJsKxBMZ6GZdOSPJXiOzPM984oPYZ5AnehYx5WQp1+ME8I/P/pRA==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/use-latest": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/use-latest/-/use-latest-1.3.0.tgz", + "integrity": "sha512-mhg3xdm9NaM8q+gLT8KryJPnRFOz1/5XPBhmDEVZK1webPzDjrPk7f/mbpeLqTgB9msytYWANxgALOCJKnLvcQ==", + "license": "MIT", + "dependencies": { + "use-isomorphic-layout-effect": "^1.1.1" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/use-sidecar": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/use-sidecar/-/use-sidecar-1.1.3.tgz", + "integrity": "sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ==", + "license": "MIT", + "dependencies": { + "detect-node-es": "^1.1.0", + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/use-sync-external-store": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", + "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/vfile": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/vfile/-/vfile-6.0.3.tgz", + "integrity": "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/vfile-message": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/vfile-message/-/vfile-message-4.0.3.tgz", + "integrity": "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-stringify-position": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/vite": { + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz", + "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", + "license": "MIT", + "dependencies": { + "esbuild": "^0.27.0", + "fdir": "^6.5.0", + "picomatch": "^4.0.3", + "postcss": "^8.5.6", + "rollup": "^4.43.0", + "tinyglobby": "^0.2.15" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "lightningcss": "^1.21.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vite-plugin-full-reload": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/vite-plugin-full-reload/-/vite-plugin-full-reload-1.2.0.tgz", + "integrity": "sha512-kz18NW79x0IHbxRSHm0jttP4zoO9P9gXh+n6UTwlNKnviTTEpOlum6oS9SmecrTtSr+muHEn5TUuC75UovQzcA==", + "license": "MIT", + "dependencies": { + "picocolors": "^1.0.0", + "picomatch": "^2.3.1" + } + }, + "node_modules/vite-plugin-full-reload/node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/which-boxed-primitive": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/which-boxed-primitive/-/which-boxed-primitive-1.1.1.tgz", + "integrity": "sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-bigint": "^1.1.0", + "is-boolean-object": "^1.2.1", + "is-number-object": "^1.1.1", + "is-string": "^1.1.1", + "is-symbol": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-builtin-type": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/which-builtin-type/-/which-builtin-type-1.2.1.tgz", + "integrity": "sha512-6iBczoX+kDQ7a3+YJBnh3T+KZRxM/iYNPXicqk66/Qfm1b93iu+yOImkg0zHbj5LNOcNv1TEADiZ0xa34B4q6Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "function.prototype.name": "^1.1.6", + "has-tostringtag": "^1.0.2", + "is-async-function": "^2.0.0", + "is-date-object": "^1.1.0", + "is-finalizationregistry": "^1.1.0", + "is-generator-function": "^1.0.10", + "is-regex": "^1.2.1", + "is-weakref": "^1.0.2", + "isarray": "^2.0.5", + "which-boxed-primitive": "^1.1.0", + "which-collection": "^1.0.2", + "which-typed-array": "^1.1.16" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-collection": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/which-collection/-/which-collection-1.0.2.tgz", + "integrity": "sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-map": "^2.0.3", + "is-set": "^2.0.3", + "is-weakmap": "^2.0.2", + "is-weakset": "^2.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-typed-array": { + "version": "1.1.20", + "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.20.tgz", + "integrity": "sha512-LYfpUkmqwl0h9A2HL09Mms427Q1RZWuOHsukfVcKRq9q95iQxdw0ix1JQrqbcDR9PH1QDwf5Qo8OZb5lksZ8Xg==", + "dev": true, + "license": "MIT", + "dependencies": { + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "for-each": "^0.3.5", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "license": "ISC" + }, + "node_modules/yargs": { + "version": "17.7.2", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", + "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", + "license": "MIT", + "dependencies": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/zod": { + "version": "4.3.5", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.5.tgz", + "integrity": "sha512-k7Nwx6vuWx1IJ9Bjuf4Zt1PEllcwe7cls3VNzm4CQ1/hgtFUK2bRNG3rvnpPUhFjmqJKAKtjV576KnUkHocg/g==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/zod-validation-error": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/zod-validation-error/-/zod-validation-error-4.0.2.tgz", + "integrity": "sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "zod": "^3.25.0 || ^4.0.0" + } + }, + "node_modules/zustand": { + "version": "5.0.10", + "resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.10.tgz", + "integrity": "sha512-U1AiltS1O9hSy3rul+Ub82ut2fqIAefiSuwECWt6jlMVUGejvf+5omLcRBSzqbRagSM3hQZbtzdeRc6QVScXTg==", + "license": "MIT", + "engines": { + "node": ">=12.20.0" + }, + "peerDependencies": { + "@types/react": ">=18.0.0", + "immer": ">=9.0.6", + "react": ">=18.0.0", + "use-sync-external-store": ">=1.2.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "immer": { + "optional": true + }, + "react": { + "optional": true + }, + "use-sync-external-store": { + "optional": true + } + } + }, + "node_modules/zwitch": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz", + "integrity": "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..bd74a33 --- /dev/null +++ b/package.json @@ -0,0 +1,79 @@ +{ + "$schema": "https://www.schemastore.org/package.json", + "private": true, + "type": "module", + "scripts": { + "build": "vite build", + "build:ssr": "vite build && vite build --ssr", + "dev": "vite", + "format": "prettier --write resources/", + "format:check": "prettier --check resources/", + "lint": "eslint . --fix", + "types": "tsc --noEmit" + }, + "devDependencies": { + "@eslint/js": "^9.19.0", + "@laravel/vite-plugin-wayfinder": "^0.1.3", + "@types/node": "^22.13.5", + "babel-plugin-react-compiler": "^1.0.0", + "eslint": "^9.17.0", + "eslint-config-prettier": "^10.0.1", + "eslint-plugin-react": "^7.37.3", + "eslint-plugin-react-hooks": "^7.0.0", + "prettier": "^3.4.2", + "prettier-plugin-organize-imports": "^4.1.0", + "prettier-plugin-tailwindcss": "^0.6.11", + "tw-animate-css": "^1.4.0", + "typescript-eslint": "^8.23.0" + }, + "dependencies": { + "@assistant-ui/react": "^0.11.55", + "@assistant-ui/react-ai-sdk": "^1.1.20", + "@assistant-ui/react-markdown": "^0.11.9", + "@headlessui/react": "^2.2.0", + "@inertiajs/react": "^2.3.7", + "@radix-ui/react-avatar": "^1.1.11", + "@radix-ui/react-checkbox": "^1.1.4", + "@radix-ui/react-collapsible": "^1.1.3", + "@radix-ui/react-dialog": "^1.1.15", + "@radix-ui/react-dropdown-menu": "^2.1.6", + "@radix-ui/react-label": "^2.1.2", + "@radix-ui/react-navigation-menu": "^1.2.5", + "@radix-ui/react-select": "^2.1.6", + "@radix-ui/react-separator": "^1.1.2", + "@radix-ui/react-slot": "^1.2.4", + "@radix-ui/react-toggle": "^1.1.2", + "@radix-ui/react-toggle-group": "^1.1.2", + "@radix-ui/react-tooltip": "^1.2.8", + "@tailwindcss/vite": "^4.1.11", + "@types/react": "^19.2.0", + "@types/react-dom": "^19.2.0", + "@vitejs/plugin-react": "^5.0.0", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "concurrently": "^9.0.1", + "framer-motion": "^12.26.2", + "globals": "^15.14.0", + "input-otp": "^1.4.2", + "laravel-vite-plugin": "^2.0", + "lucide-react": "^0.475.0", + "motion": "^12.26.2", + "react": "^19.2.0", + "react-dom": "^19.2.0", + "react-shiki": "^0.9.1", + "remark-gfm": "^4.0.1", + "tailwind-merge": "^3.4.0", + "tailwindcss": "^4.0.0", + "typescript": "^5.7.2", + "vite": "^7.0.4", + "zustand": "^5.0.10" + }, + "optionalDependencies": { + "@rollup/rollup-linux-x64-gnu": "4.9.5", + "@rollup/rollup-win32-x64-msvc": "4.9.5", + "@tailwindcss/oxide-linux-x64-gnu": "^4.0.1", + "@tailwindcss/oxide-win32-x64-msvc": "^4.0.1", + "lightningcss-linux-x64-gnu": "^1.29.1", + "lightningcss-win32-x64-msvc": "^1.29.1" + } +} diff --git a/phpstan.dist.neon b/phpstan.dist.neon index 3a4ef42..ae93ac0 100644 --- a/phpstan.dist.neon +++ b/phpstan.dist.neon @@ -3,6 +3,5 @@ parameters: paths: - src excludePaths: - - src/LLM/Drivers/Anthropic/AnthropicChat.php - src/LLM/Streaming tmpDir: .phpstan-cache diff --git a/resources/css/app.css b/resources/css/app.css new file mode 100644 index 0000000..4198305 --- /dev/null +++ b/resources/css/app.css @@ -0,0 +1,193 @@ +@import "tailwindcss"; + +@import "tw-animate-css"; + +@source '../views'; +@source '../../vendor/laravel/framework/src/Illuminate/Pagination/resources/views/*.blade.php'; + +@custom-variant dark (&:is(.dark *)); + +@theme { + --font-sans: + "Instrument Sans", ui-sans-serif, system-ui, sans-serif, + "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", + "Noto Color Emoji"; + + --radius-lg: var(--radius); + --radius-md: calc(var(--radius) - 2px); + --radius-sm: calc(var(--radius) - 4px); + + --color-background: var(--background); + --color-foreground: var(--foreground); + + --color-card: var(--card); + --color-card-foreground: var(--card-foreground); + + --color-popover: var(--popover); + --color-popover-foreground: var(--popover-foreground); + + --color-primary: var(--primary); + --color-primary-foreground: var(--primary-foreground); + + --color-secondary: var(--secondary); + --color-secondary-foreground: var(--secondary-foreground); + + --color-muted: var(--muted); + --color-muted-foreground: var(--muted-foreground); + + --color-accent: var(--accent); + --color-accent-foreground: var(--accent-foreground); + + --color-destructive: var(--destructive); + --color-destructive-foreground: var(--destructive-foreground); + + --color-border: var(--border); + --color-input: var(--input); + --color-ring: var(--ring); + + --color-chart-1: var(--chart-1); + --color-chart-2: var(--chart-2); + --color-chart-3: var(--chart-3); + --color-chart-4: var(--chart-4); + --color-chart-5: var(--chart-5); + + --color-sidebar: var(--sidebar); + --color-sidebar-foreground: var(--sidebar-foreground); + --color-sidebar-primary: var(--sidebar-primary); + --color-sidebar-primary-foreground: var(--sidebar-primary-foreground); + --color-sidebar-accent: var(--sidebar-accent); + --color-sidebar-accent-foreground: var(--sidebar-accent-foreground); + --color-sidebar-border: var(--sidebar-border); + --color-sidebar-ring: var(--sidebar-ring); +} + +:root { + --background: oklch(1 0 0); + --foreground: oklch(0.145 0 0); + --card: oklch(1 0 0); + --card-foreground: oklch(0.145 0 0); + --popover: oklch(1 0 0); + --popover-foreground: oklch(0.145 0 0); + --primary: oklch(0.205 0 0); + --primary-foreground: oklch(0.985 0 0); + --secondary: oklch(0.97 0 0); + --secondary-foreground: oklch(0.205 0 0); + --muted: oklch(0.97 0 0); + --muted-foreground: oklch(0.556 0 0); + --accent: oklch(0.97 0 0); + --accent-foreground: oklch(0.205 0 0); + --destructive: oklch(0.577 0.245 27.325); + --destructive-foreground: oklch(0.577 0.245 27.325); + --border: oklch(0.922 0 0); + --input: oklch(0.922 0 0); + --ring: oklch(0.708 0 0); + --chart-1: oklch(0.646 0.222 41.116); + --chart-2: oklch(0.6 0.118 184.704); + --chart-3: oklch(0.398 0.07 227.392); + --chart-4: oklch(0.828 0.189 84.429); + --chart-5: oklch(0.769 0.188 70.08); + --radius: 0.625rem; + --sidebar: oklch(0.985 0 0); + --sidebar-foreground: oklch(0.145 0 0); + --sidebar-primary: oklch(0.205 0 0); + --sidebar-primary-foreground: oklch(0.985 0 0); + --sidebar-accent: oklch(0.97 0 0); + --sidebar-accent-foreground: oklch(0.205 0 0); + --sidebar-border: oklch(0.922 0 0); + --sidebar-ring: oklch(0.708 0 0); +} + +.dark { + --background: oklch(0.145 0 0); + --foreground: oklch(0.985 0 0); + --card: oklch(0.205 0 0); + --card-foreground: oklch(0.985 0 0); + --popover: oklch(0.205 0 0); + --popover-foreground: oklch(0.985 0 0); + --primary: oklch(0.922 0 0); + --primary-foreground: oklch(0.205 0 0); + --secondary: oklch(0.269 0 0); + --secondary-foreground: oklch(0.985 0 0); + --muted: oklch(0.269 0 0); + --muted-foreground: oklch(0.708 0 0); + --accent: oklch(0.269 0 0); + --accent-foreground: oklch(0.985 0 0); + --destructive: oklch(0.704 0.191 22.216); + --destructive-foreground: oklch(0.637 0.237 25.331); + --border: oklch(1 0 0 / 10%); + --input: oklch(1 0 0 / 15%); + --ring: oklch(0.556 0 0); + --chart-1: oklch(0.488 0.243 264.376); + --chart-2: oklch(0.696 0.17 162.48); + --chart-3: oklch(0.769 0.188 70.08); + --chart-4: oklch(0.627 0.265 303.9); + --chart-5: oklch(0.645 0.246 16.439); + --sidebar: oklch(0.205 0 0); + --sidebar-foreground: oklch(0.985 0 0); + --sidebar-primary: oklch(0.488 0.243 264.376); + --sidebar-primary-foreground: oklch(0.985 0 0); + --sidebar-accent: oklch(0.269 0 0); + --sidebar-accent-foreground: oklch(0.985 0 0); + --sidebar-border: oklch(1 0 0 / 10%); + --sidebar-ring: oklch(0.556 0 0); +} + +@layer base { + * { + @apply border-border; + } + + body { + @apply bg-background text-foreground; + } +} + +@theme inline { + --radius-sm: calc(var(--radius) - 4px); + --radius-md: calc(var(--radius) - 2px); + --radius-lg: var(--radius); + --radius-xl: calc(var(--radius) + 4px); + --radius-2xl: calc(var(--radius) + 8px); + --radius-3xl: calc(var(--radius) + 12px); + --radius-4xl: calc(var(--radius) + 16px); + --color-background: var(--background); + --color-foreground: var(--foreground); + --color-card: var(--card); + --color-card-foreground: var(--card-foreground); + --color-popover: var(--popover); + --color-popover-foreground: var(--popover-foreground); + --color-primary: var(--primary); + --color-primary-foreground: var(--primary-foreground); + --color-secondary: var(--secondary); + --color-secondary-foreground: var(--secondary-foreground); + --color-muted: var(--muted); + --color-muted-foreground: var(--muted-foreground); + --color-accent: var(--accent); + --color-accent-foreground: var(--accent-foreground); + --color-destructive: var(--destructive); + --color-border: var(--border); + --color-input: var(--input); + --color-ring: var(--ring); + --color-chart-1: var(--chart-1); + --color-chart-2: var(--chart-2); + --color-chart-3: var(--chart-3); + --color-chart-4: var(--chart-4); + --color-chart-5: var(--chart-5); + --color-sidebar: var(--sidebar); + --color-sidebar-foreground: var(--sidebar-foreground); + --color-sidebar-primary: var(--sidebar-primary); + --color-sidebar-primary-foreground: var(--sidebar-primary-foreground); + --color-sidebar-accent: var(--sidebar-accent); + --color-sidebar-accent-foreground: var(--sidebar-accent-foreground); + --color-sidebar-border: var(--sidebar-border); + --color-sidebar-ring: var(--sidebar-ring); +} + +@layer base { + * { + @apply border-border outline-ring/50; + } + body { + @apply bg-background text-foreground; + } +} diff --git a/resources/js/actions/Cortex/Http/Controllers/AGUIController.ts b/resources/js/actions/Cortex/Http/Controllers/AGUIController.ts new file mode 100644 index 0000000..388d428 --- /dev/null +++ b/resources/js/actions/Cortex/Http/Controllers/AGUIController.ts @@ -0,0 +1,36 @@ +import { queryParams, type RouteQueryOptions, type RouteDefinition } from './../../../../wayfinder' +/** +* @see \Cortex\Http\Controllers\AGUIController::__invoke +* @see Users/sean/Code/cortexphp/cortex/src/Http/Controllers/AGUIController.php:19 +* @route '/api/agui' +*/ +const AGUIController = (options?: RouteQueryOptions): RouteDefinition<'post'> => ({ + url: AGUIController.url(options), + method: 'post', +}) + +AGUIController.definition = { + methods: ["post"], + url: '/api/agui', +} satisfies RouteDefinition<["post"]> + +/** +* @see \Cortex\Http\Controllers\AGUIController::__invoke +* @see Users/sean/Code/cortexphp/cortex/src/Http/Controllers/AGUIController.php:19 +* @route '/api/agui' +*/ +AGUIController.url = (options?: RouteQueryOptions) => { + return AGUIController.definition.url + queryParams(options) +} + +/** +* @see \Cortex\Http\Controllers\AGUIController::__invoke +* @see Users/sean/Code/cortexphp/cortex/src/Http/Controllers/AGUIController.php:19 +* @route '/api/agui' +*/ +AGUIController.post = (options?: RouteQueryOptions): RouteDefinition<'post'> => ({ + url: AGUIController.url(options), + method: 'post', +}) + +export default AGUIController \ No newline at end of file diff --git a/resources/js/actions/Cortex/Http/Controllers/AgentsController.ts b/resources/js/actions/Cortex/Http/Controllers/AgentsController.ts new file mode 100644 index 0000000..5a2d247 --- /dev/null +++ b/resources/js/actions/Cortex/Http/Controllers/AgentsController.ts @@ -0,0 +1,160 @@ +import { queryParams, type RouteQueryOptions, type RouteDefinition, applyUrlDefaults } from './../../../../wayfinder' +/** +* @see \Cortex\Http\Controllers\AgentsController::invoke +* @see Users/sean/Code/cortexphp/cortex/src/Http/Controllers/AgentsController.php:26 +* @route '/api/agents/{agent}/invoke' +*/ +export const invoke = (args: { agent: string | number | { string: string | number } } | [agent: string | number | { string: string | number } ] | string | number | { string: string | number }, options?: RouteQueryOptions): RouteDefinition<'get'> => ({ + url: invoke.url(args, options), + method: 'get', +}) + +invoke.definition = { + methods: ["get","post","head"], + url: '/api/agents/{agent}/invoke', +} satisfies RouteDefinition<["get","post","head"]> + +/** +* @see \Cortex\Http\Controllers\AgentsController::invoke +* @see Users/sean/Code/cortexphp/cortex/src/Http/Controllers/AgentsController.php:26 +* @route '/api/agents/{agent}/invoke' +*/ +invoke.url = (args: { agent: string | number | { string: string | number } } | [agent: string | number | { string: string | number } ] | string | number | { string: string | number }, options?: RouteQueryOptions) => { + if (typeof args === 'string' || typeof args === 'number') { + args = { agent: args } + } + + if (typeof args === 'object' && !Array.isArray(args) && 'string' in args) { + args = { agent: args.string } + } + + if (Array.isArray(args)) { + args = { + agent: args[0], + } + } + + args = applyUrlDefaults(args) + + const parsedArgs = { + agent: typeof args.agent === 'object' + ? args.agent.string + : args.agent, + } + + return invoke.definition.url + .replace('{agent}', parsedArgs.agent.toString()) + .replace(/\/+$/, '') + queryParams(options) +} + +/** +* @see \Cortex\Http\Controllers\AgentsController::invoke +* @see Users/sean/Code/cortexphp/cortex/src/Http/Controllers/AgentsController.php:26 +* @route '/api/agents/{agent}/invoke' +*/ +invoke.get = (args: { agent: string | number | { string: string | number } } | [agent: string | number | { string: string | number } ] | string | number | { string: string | number }, options?: RouteQueryOptions): RouteDefinition<'get'> => ({ + url: invoke.url(args, options), + method: 'get', +}) + +/** +* @see \Cortex\Http\Controllers\AgentsController::invoke +* @see Users/sean/Code/cortexphp/cortex/src/Http/Controllers/AgentsController.php:26 +* @route '/api/agents/{agent}/invoke' +*/ +invoke.post = (args: { agent: string | number | { string: string | number } } | [agent: string | number | { string: string | number } ] | string | number | { string: string | number }, options?: RouteQueryOptions): RouteDefinition<'post'> => ({ + url: invoke.url(args, options), + method: 'post', +}) + +/** +* @see \Cortex\Http\Controllers\AgentsController::invoke +* @see Users/sean/Code/cortexphp/cortex/src/Http/Controllers/AgentsController.php:26 +* @route '/api/agents/{agent}/invoke' +*/ +invoke.head = (args: { agent: string | number | { string: string | number } } | [agent: string | number | { string: string | number } ] | string | number | { string: string | number }, options?: RouteQueryOptions): RouteDefinition<'head'> => ({ + url: invoke.url(args, options), + method: 'head', +}) + +/** +* @see \Cortex\Http\Controllers\AgentsController::stream +* @see Users/sean/Code/cortexphp/cortex/src/Http/Controllers/AgentsController.php:66 +* @route '/api/agents/{agent}/stream' +*/ +export const stream = (args: { agent: string | number | { string: string | number } } | [agent: string | number | { string: string | number } ] | string | number | { string: string | number }, options?: RouteQueryOptions): RouteDefinition<'get'> => ({ + url: stream.url(args, options), + method: 'get', +}) + +stream.definition = { + methods: ["get","post","head"], + url: '/api/agents/{agent}/stream', +} satisfies RouteDefinition<["get","post","head"]> + +/** +* @see \Cortex\Http\Controllers\AgentsController::stream +* @see Users/sean/Code/cortexphp/cortex/src/Http/Controllers/AgentsController.php:66 +* @route '/api/agents/{agent}/stream' +*/ +stream.url = (args: { agent: string | number | { string: string | number } } | [agent: string | number | { string: string | number } ] | string | number | { string: string | number }, options?: RouteQueryOptions) => { + if (typeof args === 'string' || typeof args === 'number') { + args = { agent: args } + } + + if (typeof args === 'object' && !Array.isArray(args) && 'string' in args) { + args = { agent: args.string } + } + + if (Array.isArray(args)) { + args = { + agent: args[0], + } + } + + args = applyUrlDefaults(args) + + const parsedArgs = { + agent: typeof args.agent === 'object' + ? args.agent.string + : args.agent, + } + + return stream.definition.url + .replace('{agent}', parsedArgs.agent.toString()) + .replace(/\/+$/, '') + queryParams(options) +} + +/** +* @see \Cortex\Http\Controllers\AgentsController::stream +* @see Users/sean/Code/cortexphp/cortex/src/Http/Controllers/AgentsController.php:66 +* @route '/api/agents/{agent}/stream' +*/ +stream.get = (args: { agent: string | number | { string: string | number } } | [agent: string | number | { string: string | number } ] | string | number | { string: string | number }, options?: RouteQueryOptions): RouteDefinition<'get'> => ({ + url: stream.url(args, options), + method: 'get', +}) + +/** +* @see \Cortex\Http\Controllers\AgentsController::stream +* @see Users/sean/Code/cortexphp/cortex/src/Http/Controllers/AgentsController.php:66 +* @route '/api/agents/{agent}/stream' +*/ +stream.post = (args: { agent: string | number | { string: string | number } } | [agent: string | number | { string: string | number } ] | string | number | { string: string | number }, options?: RouteQueryOptions): RouteDefinition<'post'> => ({ + url: stream.url(args, options), + method: 'post', +}) + +/** +* @see \Cortex\Http\Controllers\AgentsController::stream +* @see Users/sean/Code/cortexphp/cortex/src/Http/Controllers/AgentsController.php:66 +* @route '/api/agents/{agent}/stream' +*/ +stream.head = (args: { agent: string | number | { string: string | number } } | [agent: string | number | { string: string | number } ] | string | number | { string: string | number }, options?: RouteQueryOptions): RouteDefinition<'head'> => ({ + url: stream.url(args, options), + method: 'head', +}) + +const AgentsController = { invoke, stream } + +export default AgentsController \ No newline at end of file diff --git a/resources/js/actions/Cortex/Http/Controllers/index.ts b/resources/js/actions/Cortex/Http/Controllers/index.ts new file mode 100644 index 0000000..c518e69 --- /dev/null +++ b/resources/js/actions/Cortex/Http/Controllers/index.ts @@ -0,0 +1,9 @@ +import AgentsController from './AgentsController' +import AGUIController from './AGUIController' + +const Controllers = { + AgentsController: Object.assign(AgentsController, AgentsController), + AGUIController: Object.assign(AGUIController, AGUIController), +} + +export default Controllers \ No newline at end of file diff --git a/resources/js/actions/Cortex/Http/index.ts b/resources/js/actions/Cortex/Http/index.ts new file mode 100644 index 0000000..ac9e00d --- /dev/null +++ b/resources/js/actions/Cortex/Http/index.ts @@ -0,0 +1,7 @@ +import Controllers from './Controllers' + +const Http = { + Controllers: Object.assign(Controllers, Controllers), +} + +export default Http \ No newline at end of file diff --git a/resources/js/actions/Cortex/index.ts b/resources/js/actions/Cortex/index.ts new file mode 100644 index 0000000..3e10e25 --- /dev/null +++ b/resources/js/actions/Cortex/index.ts @@ -0,0 +1,7 @@ +import Http from './Http' + +const Cortex = { + Http: Object.assign(Http, Http), +} + +export default Cortex \ No newline at end of file diff --git a/resources/js/actions/Orchestra/Workbench/Http/Controllers/WorkbenchController.ts b/resources/js/actions/Orchestra/Workbench/Http/Controllers/WorkbenchController.ts new file mode 100644 index 0000000..7db5b27 --- /dev/null +++ b/resources/js/actions/Orchestra/Workbench/Http/Controllers/WorkbenchController.ts @@ -0,0 +1,245 @@ +import { queryParams, type RouteQueryOptions, type RouteDefinition, applyUrlDefaults, validateParameters } from './../../../../../wayfinder' +/** +* @see \Orchestra\Workbench\Http\Controllers\WorkbenchController::start +* @see Users/sean/Code/cortexphp/cortex/vendor/orchestra/workbench/src/Http/Controllers/WorkbenchController.php:18 +* @route '/_workbench' +*/ +export const start = (options?: RouteQueryOptions): RouteDefinition<'get'> => ({ + url: start.url(options), + method: 'get', +}) + +start.definition = { + methods: ["get","head"], + url: '/_workbench', +} satisfies RouteDefinition<["get","head"]> + +/** +* @see \Orchestra\Workbench\Http\Controllers\WorkbenchController::start +* @see Users/sean/Code/cortexphp/cortex/vendor/orchestra/workbench/src/Http/Controllers/WorkbenchController.php:18 +* @route '/_workbench' +*/ +start.url = (options?: RouteQueryOptions) => { + return start.definition.url + queryParams(options) +} + +/** +* @see \Orchestra\Workbench\Http\Controllers\WorkbenchController::start +* @see Users/sean/Code/cortexphp/cortex/vendor/orchestra/workbench/src/Http/Controllers/WorkbenchController.php:18 +* @route '/_workbench' +*/ +start.get = (options?: RouteQueryOptions): RouteDefinition<'get'> => ({ + url: start.url(options), + method: 'get', +}) + +/** +* @see \Orchestra\Workbench\Http\Controllers\WorkbenchController::start +* @see Users/sean/Code/cortexphp/cortex/vendor/orchestra/workbench/src/Http/Controllers/WorkbenchController.php:18 +* @route '/_workbench' +*/ +start.head = (options?: RouteQueryOptions): RouteDefinition<'head'> => ({ + url: start.url(options), + method: 'head', +}) + +/** +* @see \Orchestra\Workbench\Http\Controllers\WorkbenchController::login +* @see Users/sean/Code/cortexphp/cortex/vendor/orchestra/workbench/src/Http/Controllers/WorkbenchController.php:60 +* @route '/_workbench/login/{userId}/{guard?}' +*/ +export const login = (args: { userId: string | number, guard?: string | number } | [userId: string | number, guard: string | number ], options?: RouteQueryOptions): RouteDefinition<'get'> => ({ + url: login.url(args, options), + method: 'get', +}) + +login.definition = { + methods: ["get","head"], + url: '/_workbench/login/{userId}/{guard?}', +} satisfies RouteDefinition<["get","head"]> + +/** +* @see \Orchestra\Workbench\Http\Controllers\WorkbenchController::login +* @see Users/sean/Code/cortexphp/cortex/vendor/orchestra/workbench/src/Http/Controllers/WorkbenchController.php:60 +* @route '/_workbench/login/{userId}/{guard?}' +*/ +login.url = (args: { userId: string | number, guard?: string | number } | [userId: string | number, guard: string | number ], options?: RouteQueryOptions) => { + if (Array.isArray(args)) { + args = { + userId: args[0], + guard: args[1], + } + } + + args = applyUrlDefaults(args) + + validateParameters(args, [ + "guard", + ]) + + const parsedArgs = { + userId: args.userId, + guard: args.guard, + } + + return login.definition.url + .replace('{userId}', parsedArgs.userId.toString()) + .replace('{guard?}', parsedArgs.guard?.toString() ?? '') + .replace(/\/+$/, '') + queryParams(options) +} + +/** +* @see \Orchestra\Workbench\Http\Controllers\WorkbenchController::login +* @see Users/sean/Code/cortexphp/cortex/vendor/orchestra/workbench/src/Http/Controllers/WorkbenchController.php:60 +* @route '/_workbench/login/{userId}/{guard?}' +*/ +login.get = (args: { userId: string | number, guard?: string | number } | [userId: string | number, guard: string | number ], options?: RouteQueryOptions): RouteDefinition<'get'> => ({ + url: login.url(args, options), + method: 'get', +}) + +/** +* @see \Orchestra\Workbench\Http\Controllers\WorkbenchController::login +* @see Users/sean/Code/cortexphp/cortex/vendor/orchestra/workbench/src/Http/Controllers/WorkbenchController.php:60 +* @route '/_workbench/login/{userId}/{guard?}' +*/ +login.head = (args: { userId: string | number, guard?: string | number } | [userId: string | number, guard: string | number ], options?: RouteQueryOptions): RouteDefinition<'head'> => ({ + url: login.url(args, options), + method: 'head', +}) + +/** +* @see \Orchestra\Workbench\Http\Controllers\WorkbenchController::logout +* @see Users/sean/Code/cortexphp/cortex/vendor/orchestra/workbench/src/Http/Controllers/WorkbenchController.php:84 +* @route '/_workbench/logout/{guard?}' +*/ +export const logout = (args?: { guard?: string | number } | [guard: string | number ] | string | number, options?: RouteQueryOptions): RouteDefinition<'get'> => ({ + url: logout.url(args, options), + method: 'get', +}) + +logout.definition = { + methods: ["get","head"], + url: '/_workbench/logout/{guard?}', +} satisfies RouteDefinition<["get","head"]> + +/** +* @see \Orchestra\Workbench\Http\Controllers\WorkbenchController::logout +* @see Users/sean/Code/cortexphp/cortex/vendor/orchestra/workbench/src/Http/Controllers/WorkbenchController.php:84 +* @route '/_workbench/logout/{guard?}' +*/ +logout.url = (args?: { guard?: string | number } | [guard: string | number ] | string | number, options?: RouteQueryOptions) => { + if (typeof args === 'string' || typeof args === 'number') { + args = { guard: args } + } + + if (Array.isArray(args)) { + args = { + guard: args[0], + } + } + + args = applyUrlDefaults(args) + + validateParameters(args, [ + "guard", + ]) + + const parsedArgs = { + guard: args?.guard, + } + + return logout.definition.url + .replace('{guard?}', parsedArgs.guard?.toString() ?? '') + .replace(/\/+$/, '') + queryParams(options) +} + +/** +* @see \Orchestra\Workbench\Http\Controllers\WorkbenchController::logout +* @see Users/sean/Code/cortexphp/cortex/vendor/orchestra/workbench/src/Http/Controllers/WorkbenchController.php:84 +* @route '/_workbench/logout/{guard?}' +*/ +logout.get = (args?: { guard?: string | number } | [guard: string | number ] | string | number, options?: RouteQueryOptions): RouteDefinition<'get'> => ({ + url: logout.url(args, options), + method: 'get', +}) + +/** +* @see \Orchestra\Workbench\Http\Controllers\WorkbenchController::logout +* @see Users/sean/Code/cortexphp/cortex/vendor/orchestra/workbench/src/Http/Controllers/WorkbenchController.php:84 +* @route '/_workbench/logout/{guard?}' +*/ +logout.head = (args?: { guard?: string | number } | [guard: string | number ] | string | number, options?: RouteQueryOptions): RouteDefinition<'head'> => ({ + url: logout.url(args, options), + method: 'head', +}) + +/** +* @see \Orchestra\Workbench\Http\Controllers\WorkbenchController::user +* @see Users/sean/Code/cortexphp/cortex/vendor/orchestra/workbench/src/Http/Controllers/WorkbenchController.php:39 +* @route '/_workbench/user/{guard?}' +*/ +export const user = (args?: { guard?: string | number } | [guard: string | number ] | string | number, options?: RouteQueryOptions): RouteDefinition<'get'> => ({ + url: user.url(args, options), + method: 'get', +}) + +user.definition = { + methods: ["get","head"], + url: '/_workbench/user/{guard?}', +} satisfies RouteDefinition<["get","head"]> + +/** +* @see \Orchestra\Workbench\Http\Controllers\WorkbenchController::user +* @see Users/sean/Code/cortexphp/cortex/vendor/orchestra/workbench/src/Http/Controllers/WorkbenchController.php:39 +* @route '/_workbench/user/{guard?}' +*/ +user.url = (args?: { guard?: string | number } | [guard: string | number ] | string | number, options?: RouteQueryOptions) => { + if (typeof args === 'string' || typeof args === 'number') { + args = { guard: args } + } + + if (Array.isArray(args)) { + args = { + guard: args[0], + } + } + + args = applyUrlDefaults(args) + + validateParameters(args, [ + "guard", + ]) + + const parsedArgs = { + guard: args?.guard, + } + + return user.definition.url + .replace('{guard?}', parsedArgs.guard?.toString() ?? '') + .replace(/\/+$/, '') + queryParams(options) +} + +/** +* @see \Orchestra\Workbench\Http\Controllers\WorkbenchController::user +* @see Users/sean/Code/cortexphp/cortex/vendor/orchestra/workbench/src/Http/Controllers/WorkbenchController.php:39 +* @route '/_workbench/user/{guard?}' +*/ +user.get = (args?: { guard?: string | number } | [guard: string | number ] | string | number, options?: RouteQueryOptions): RouteDefinition<'get'> => ({ + url: user.url(args, options), + method: 'get', +}) + +/** +* @see \Orchestra\Workbench\Http\Controllers\WorkbenchController::user +* @see Users/sean/Code/cortexphp/cortex/vendor/orchestra/workbench/src/Http/Controllers/WorkbenchController.php:39 +* @route '/_workbench/user/{guard?}' +*/ +user.head = (args?: { guard?: string | number } | [guard: string | number ] | string | number, options?: RouteQueryOptions): RouteDefinition<'head'> => ({ + url: user.url(args, options), + method: 'head', +}) + +const WorkbenchController = { start, login, logout, user } + +export default WorkbenchController \ No newline at end of file diff --git a/resources/js/actions/Orchestra/Workbench/Http/Controllers/index.ts b/resources/js/actions/Orchestra/Workbench/Http/Controllers/index.ts new file mode 100644 index 0000000..c779fe9 --- /dev/null +++ b/resources/js/actions/Orchestra/Workbench/Http/Controllers/index.ts @@ -0,0 +1,7 @@ +import WorkbenchController from './WorkbenchController' + +const Controllers = { + WorkbenchController: Object.assign(WorkbenchController, WorkbenchController), +} + +export default Controllers \ No newline at end of file diff --git a/resources/js/actions/Orchestra/Workbench/Http/index.ts b/resources/js/actions/Orchestra/Workbench/Http/index.ts new file mode 100644 index 0000000..ac9e00d --- /dev/null +++ b/resources/js/actions/Orchestra/Workbench/Http/index.ts @@ -0,0 +1,7 @@ +import Controllers from './Controllers' + +const Http = { + Controllers: Object.assign(Controllers, Controllers), +} + +export default Http \ No newline at end of file diff --git a/resources/js/actions/Orchestra/Workbench/index.ts b/resources/js/actions/Orchestra/Workbench/index.ts new file mode 100644 index 0000000..caaf2f7 --- /dev/null +++ b/resources/js/actions/Orchestra/Workbench/index.ts @@ -0,0 +1,7 @@ +import Http from './Http' + +const Workbench = { + Http: Object.assign(Http, Http), +} + +export default Workbench \ No newline at end of file diff --git a/resources/js/actions/Orchestra/index.ts b/resources/js/actions/Orchestra/index.ts new file mode 100644 index 0000000..88b0861 --- /dev/null +++ b/resources/js/actions/Orchestra/index.ts @@ -0,0 +1,7 @@ +import Workbench from './Workbench' + +const Orchestra = { + Workbench: Object.assign(Workbench, Workbench), +} + +export default Orchestra \ No newline at end of file diff --git a/resources/js/app.tsx b/resources/js/app.tsx new file mode 100644 index 0000000..755e00b --- /dev/null +++ b/resources/js/app.tsx @@ -0,0 +1,33 @@ +import "../css/app.css"; + +import { createInertiaApp } from "@inertiajs/react"; +import { resolvePageComponent } from "laravel-vite-plugin/inertia-helpers"; +import { StrictMode } from "react"; +import { createRoot } from "react-dom/client"; +import { initializeTheme } from "./hooks/use-appearance"; + +const appName = import.meta.env.VITE_APP_NAME || "Cortex Studio"; + +createInertiaApp({ + title: (title) => (title ? `${title} - ${appName}` : appName), + resolve: (name) => + resolvePageComponent( + `./pages/${name}.tsx`, + import.meta.glob("./pages/**/*.tsx"), + ), + setup({ el, App, props }) { + const root = createRoot(el); + + root.render( + + + , + ); + }, + progress: { + color: "#4B5563", + }, +}); + +// This will set light / dark mode on load... +initializeTheme(); diff --git a/resources/js/components/alert-error.tsx b/resources/js/components/alert-error.tsx new file mode 100644 index 0000000..d57d982 --- /dev/null +++ b/resources/js/components/alert-error.tsx @@ -0,0 +1,24 @@ +import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; +import { AlertCircleIcon } from "lucide-react"; + +export default function AlertError({ + errors, + title, +}: { + errors: string[]; + title?: string; +}) { + return ( + + + {title || "Something went wrong."} + +
    + {Array.from(new Set(errors)).map((error, index) => ( +
  • {error}
  • + ))} +
+
+
+ ); +} diff --git a/resources/js/components/app-content.tsx b/resources/js/components/app-content.tsx new file mode 100644 index 0000000..a162380 --- /dev/null +++ b/resources/js/components/app-content.tsx @@ -0,0 +1,25 @@ +import { SidebarInset } from "@/components/ui/sidebar"; +import * as React from "react"; + +interface AppContentProps extends React.ComponentProps<"main"> { + variant?: "header" | "sidebar"; +} + +export function AppContent({ + variant = "header", + children, + ...props +}: AppContentProps) { + if (variant === "sidebar") { + return {children}; + } + + return ( +
+ {children} +
+ ); +} diff --git a/resources/js/components/app-header.tsx b/resources/js/components/app-header.tsx new file mode 100644 index 0000000..ae225ec --- /dev/null +++ b/resources/js/components/app-header.tsx @@ -0,0 +1,262 @@ +import { Breadcrumbs } from "@/components/breadcrumbs"; +import { Icon } from "@/components/icon"; +import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; +import { Button } from "@/components/ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { + NavigationMenu, + NavigationMenuItem, + NavigationMenuList, + navigationMenuTriggerStyle, +} from "@/components/ui/navigation-menu"; +import { + Sheet, + SheetContent, + SheetHeader, + SheetTitle, + SheetTrigger, +} from "@/components/ui/sheet"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@/components/ui/tooltip"; +// import { UserMenuContent } from '@/components/user-menu-content'; +import { useInitials } from "@/hooks/use-initials"; +import { useActiveUrl } from "@/hooks/use-active-url"; +import { cn, toUrl } from "@/lib/utils"; +import { dashboard } from "@/routes/cortex"; +import { type BreadcrumbItem, type NavItem, type SharedData } from "@/types"; +import { Link, usePage } from "@inertiajs/react"; +import { BookOpen, Folder, LayoutGrid, Menu, Search } from "lucide-react"; +import AppLogo from "./app-logo"; +import AppLogoIcon from "./app-logo-icon"; + +const mainNavItems: NavItem[] = [ + { + title: "Dashboard", + href: dashboard(), + icon: LayoutGrid, + }, +]; + +const rightNavItems: NavItem[] = [ + { + title: "Repository", + href: "https://github.com/laravel/react-starter-kit", + icon: Folder, + }, + { + title: "Documentation", + href: "https://laravel.com/docs/starter-kits#react", + icon: BookOpen, + }, +]; + +const activeItemStyles = + "text-neutral-900 dark:bg-neutral-800 dark:text-neutral-100"; + +interface AppHeaderProps { + breadcrumbs?: BreadcrumbItem[]; +} + +export function AppHeader({ breadcrumbs = [] }: AppHeaderProps) { + const page = usePage(); + const { auth } = page.props; + const getInitials = useInitials(); + const { urlIsActive } = useActiveUrl(); + return ( + <> +
+
+ {/* Mobile Menu */} +
+ + + + + + + Navigation Menu + + + + +
+
+
+ {mainNavItems.map((item) => ( + + {item.icon && ( + + )} + {item.title} + + ))} +
+ +
+ {rightNavItems.map((item) => ( + + {item.icon && ( + + )} + {item.title} + + ))} +
+
+
+
+
+
+ + + + + + {/* Desktop Navigation */} +
+ + + {mainNavItems.map((item, index) => ( + + + {item.icon && ( + + )} + {item.title} + + {urlIsActive(item.href) && ( +
+ )} +
+ ))} +
+
+
+ +
+
+ +
+ {rightNavItems.map((item) => ( + + + + + + {item.title} + + {item.icon && ( + + )} + + + +

{item.title}

+
+
+
+ ))} +
+
+ + + + + + {/* */} + + +
+
+
+ {breadcrumbs.length > 1 && ( +
+
+ +
+
+ )} + + ); +} diff --git a/resources/js/components/app-logo-icon.tsx b/resources/js/components/app-logo-icon.tsx new file mode 100644 index 0000000..5a035d3 --- /dev/null +++ b/resources/js/components/app-logo-icon.tsx @@ -0,0 +1,9 @@ +import { SVGAttributes } from "react"; + +export default function AppLogoIcon(props: SVGAttributes) { + return ( + + + + ); +} diff --git a/resources/js/components/app-logo.tsx b/resources/js/components/app-logo.tsx new file mode 100644 index 0000000..6e1b9b9 --- /dev/null +++ b/resources/js/components/app-logo.tsx @@ -0,0 +1,16 @@ +import AppLogoIcon from "./app-logo-icon"; + +export default function AppLogo() { + return ( + <> +
+ +
+
+ + Cortex Studio + +
+ + ); +} diff --git a/resources/js/components/app-shell.tsx b/resources/js/components/app-shell.tsx new file mode 100644 index 0000000..04bc629 --- /dev/null +++ b/resources/js/components/app-shell.tsx @@ -0,0 +1,20 @@ +import { SidebarProvider } from "@/components/ui/sidebar"; +import { SharedData } from "@/types"; +import { usePage } from "@inertiajs/react"; + +interface AppShellProps { + children: React.ReactNode; + variant?: "header" | "sidebar"; +} + +export function AppShell({ children, variant = "header" }: AppShellProps) { + const isOpen = usePage().props.sidebarOpen; + + if (variant === "header") { + return ( +
{children}
+ ); + } + + return {children}; +} diff --git a/resources/js/components/app-sidebar-header.tsx b/resources/js/components/app-sidebar-header.tsx new file mode 100644 index 0000000..d4ec210 --- /dev/null +++ b/resources/js/components/app-sidebar-header.tsx @@ -0,0 +1,18 @@ +import { Breadcrumbs } from "@/components/breadcrumbs"; +import { SidebarTrigger } from "@/components/ui/sidebar"; +import { type BreadcrumbItem as BreadcrumbItemType } from "@/types"; + +export function AppSidebarHeader({ + breadcrumbs = [], +}: { + breadcrumbs?: BreadcrumbItemType[]; +}) { + return ( +
+
+ + +
+
+ ); +} diff --git a/resources/js/components/app-sidebar.tsx b/resources/js/components/app-sidebar.tsx new file mode 100644 index 0000000..f68dcf9 --- /dev/null +++ b/resources/js/components/app-sidebar.tsx @@ -0,0 +1,70 @@ +import { NavFooter } from "@/components/nav-footer"; +import { NavMain } from "@/components/nav-main"; +// import { NavUser } from '@/components/nav-user'; +import { + Sidebar, + SidebarContent, + SidebarFooter, + SidebarHeader, + SidebarMenu, + SidebarMenuButton, + SidebarMenuItem, +} from "@/components/ui/sidebar"; +import cortex from "@/routes/cortex"; +import { type NavItem } from "@/types"; +import { Link } from "@inertiajs/react"; +import { BookOpen, Folder, LayoutGrid, Users } from "lucide-react"; +import AppLogo from "./app-logo"; + +const mainNavItems: NavItem[] = [ + { + title: "Dashboard", + href: cortex.dashboard(), + icon: LayoutGrid, + }, + { + title: "Agents", + href: cortex.agents.index(), + icon: Users, + }, +]; + +const footerNavItems: NavItem[] = [ + { + title: "Repository", + href: "https://github.com/cortexphp/cortex", + icon: Folder, + }, + { + title: "Documentation", + href: "https://docs.cortexphp.com", + icon: BookOpen, + }, +]; + +export function AppSidebar() { + return ( + + + + + + + + + + + + + + + + + + + + {/* */} + + + ); +} diff --git a/resources/js/components/appearance-dropdown.tsx b/resources/js/components/appearance-dropdown.tsx new file mode 100644 index 0000000..0368ff5 --- /dev/null +++ b/resources/js/components/appearance-dropdown.tsx @@ -0,0 +1,67 @@ +import { Button } from "@/components/ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { useAppearance } from "@/hooks/use-appearance"; +import { Monitor, Moon, Sun } from "lucide-react"; +import { HTMLAttributes } from "react"; + +export default function AppearanceToggleDropdown({ + className = "", + ...props +}: HTMLAttributes) { + const { appearance, updateAppearance } = useAppearance(); + + const getCurrentIcon = () => { + switch (appearance) { + case "dark": + return ; + case "light": + return ; + default: + return ; + } + }; + + return ( +
+ + + + + + updateAppearance("light")}> + + + Light + + + updateAppearance("dark")}> + + + Dark + + + updateAppearance("system")} + > + + + System + + + + +
+ ); +} diff --git a/resources/js/components/appearance-tabs.tsx b/resources/js/components/appearance-tabs.tsx new file mode 100644 index 0000000..6c37c86 --- /dev/null +++ b/resources/js/components/appearance-tabs.tsx @@ -0,0 +1,43 @@ +import { Appearance, useAppearance } from "@/hooks/use-appearance"; +import { cn } from "@/lib/utils"; +import { LucideIcon, Monitor, Moon, Sun } from "lucide-react"; +import { HTMLAttributes } from "react"; + +export default function AppearanceToggleTab({ + className = "", + ...props +}: HTMLAttributes) { + const { appearance, updateAppearance } = useAppearance(); + + const tabs: { value: Appearance; icon: LucideIcon; label: string }[] = [ + { value: "light", icon: Sun, label: "Light" }, + { value: "dark", icon: Moon, label: "Dark" }, + { value: "system", icon: Monitor, label: "System" }, + ]; + + return ( +
+ {tabs.map(({ value, icon: Icon, label }) => ( + + ))} +
+ ); +} diff --git a/resources/js/components/assistant-ui/attachment.tsx b/resources/js/components/assistant-ui/attachment.tsx new file mode 100644 index 0000000..23f3b98 --- /dev/null +++ b/resources/js/components/assistant-ui/attachment.tsx @@ -0,0 +1,231 @@ +import { PropsWithChildren, useEffect, useState, type FC } from "react"; +import { XIcon, PlusIcon, FileText } from "lucide-react"; +import { + AttachmentPrimitive, + ComposerPrimitive, + MessagePrimitive, + useAssistantState, + useAssistantApi, +} from "@assistant-ui/react"; +import { useShallow } from "zustand/shallow"; +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@/components/ui/tooltip"; +import { + Dialog, + DialogTitle, + DialogContent, + DialogTrigger, +} from "@/components/ui/dialog"; +import { Avatar, AvatarImage, AvatarFallback } from "@/components/ui/avatar"; +import { TooltipIconButton } from "@/components/assistant-ui/tooltip-icon-button"; +import { cn } from "@/lib/utils"; + +const useFileSrc = (file: File | undefined) => { + const [src, setSrc] = useState(undefined); + + useEffect(() => { + if (!file) { + setSrc(undefined); + return; + } + + const objectUrl = URL.createObjectURL(file); + setSrc(objectUrl); + + return () => { + URL.revokeObjectURL(objectUrl); + }; + }, [file]); + + return src; +}; + +const useAttachmentSrc = () => { + const { file, src } = useAssistantState( + useShallow(({ attachment }): { file?: File; src?: string } => { + if (attachment.type !== "image") return {}; + if (attachment.file) return { file: attachment.file }; + const src = attachment.content?.filter((c) => c.type === "image")[0] + ?.image; + if (!src) return {}; + return { src }; + }), + ); + + return useFileSrc(file) ?? src; +}; + +type AttachmentPreviewProps = { + src: string; +}; + +const AttachmentPreview: FC = ({ src }) => { + const [isLoaded, setIsLoaded] = useState(false); + return ( + Image Preview setIsLoaded(true)} + /> + ); +}; + +const AttachmentPreviewDialog: FC = ({ children }) => { + const src = useAttachmentSrc(); + + if (!src) return children; + + return ( + + + {children} + + + + Image Attachment Preview + +
+ +
+
+
+ ); +}; + +const AttachmentThumb: FC = () => { + const isImage = useAssistantState( + ({ attachment }) => attachment.type === "image", + ); + const src = useAttachmentSrc(); + + return ( + + + + + + + ); +}; + +const AttachmentUI: FC = () => { + const api = useAssistantApi(); + const isComposer = api.attachment.source === "composer"; + + const isImage = useAssistantState( + ({ attachment }) => attachment.type === "image", + ); + const typeLabel = useAssistantState(({ attachment }) => { + const type = attachment.type; + switch (type) { + case "image": + return "Image"; + case "document": + return "Document"; + case "file": + return "File"; + default: + const _exhaustiveCheck: never = type; + throw new Error(`Unknown attachment type: ${_exhaustiveCheck}`); + } + }); + + return ( + + #attachment-tile]:size-24", + )} + > + + +
+ +
+
+
+ {isComposer && } +
+ + + +
+ ); +}; + +const AttachmentRemove: FC = () => { + return ( + + + + + + ); +}; + +export const UserMessageAttachments: FC = () => { + return ( +
+ +
+ ); +}; + +export const ComposerAttachments: FC = () => { + return ( +
+ +
+ ); +}; + +export const ComposerAddAttachment: FC = () => { + return ( + + + + + + ); +}; diff --git a/resources/js/components/assistant-ui/markdown-text.tsx b/resources/js/components/assistant-ui/markdown-text.tsx new file mode 100644 index 0000000..4db61fb --- /dev/null +++ b/resources/js/components/assistant-ui/markdown-text.tsx @@ -0,0 +1,243 @@ +"use client"; + +import "@assistant-ui/react-markdown/styles/dot.css"; + +import { + type CodeHeaderProps, + MarkdownTextPrimitive, + unstable_memoizeMarkdownComponents as memoizeMarkdownComponents, + useIsMarkdownCodeBlock, +} from "@assistant-ui/react-markdown"; +import remarkGfm from "remark-gfm"; +import { type FC, memo, useState } from "react"; +import { CheckIcon, CopyIcon } from "lucide-react"; + +import { TooltipIconButton } from "@/components/assistant-ui/tooltip-icon-button"; +import { cn } from "@/lib/utils"; + +import { SyntaxHighlighter } from "./shiki-highlighter"; + +const MarkdownTextImpl = () => { + return ( + + ); +}; + +export const MarkdownText = memo(MarkdownTextImpl); + +const CodeHeader: FC = ({ language, code }) => { + const { isCopied, copyToClipboard } = useCopyToClipboard(); + const onCopy = () => { + if (!code || isCopied) return; + copyToClipboard(code); + }; + + return ( +
+ + {language} + + + {!isCopied && } + {isCopied && } + +
+ ); +}; + +const useCopyToClipboard = ({ + copiedDuration = 3000, +}: { + copiedDuration?: number; +} = {}) => { + const [isCopied, setIsCopied] = useState(false); + + const copyToClipboard = (value: string) => { + if (!value) return; + + navigator.clipboard.writeText(value).then(() => { + setIsCopied(true); + setTimeout(() => setIsCopied(false), copiedDuration); + }); + }; + + return { isCopied, copyToClipboard }; +}; + +const defaultComponents = memoizeMarkdownComponents({ + SyntaxHighlighter, + h1: ({ className, ...props }) => ( +

+ ), + h2: ({ className, ...props }) => ( +

+ ), + h3: ({ className, ...props }) => ( +

+ ), + h4: ({ className, ...props }) => ( +

+ ), + h5: ({ className, ...props }) => ( +

+ ), + h6: ({ className, ...props }) => ( +
+ ), + p: ({ className, ...props }) => ( +

+ ), + a: ({ className, ...props }) => ( + + ), + blockquote: ({ className, ...props }) => ( +

+ ), + ul: ({ className, ...props }) => ( +
    li]:mt-2", + className, + )} + {...props} + /> + ), + ol: ({ className, ...props }) => ( +
      li]:mt-2", + className, + )} + {...props} + /> + ), + hr: ({ className, ...props }) => ( +
      + ), + table: ({ className, ...props }) => ( + + ), + th: ({ className, ...props }) => ( + td:first-child]:rounded-bl-lg [&:last-child>td:last-child]:rounded-br-lg", + className, + )} + {...props} + /> + ), + sup: ({ className, ...props }) => ( + a]:text-xs [&>a]:no-underline", + className, + )} + {...props} + /> + ), + pre: ({ className, ...props }) => ( +
      +    ),
      +    code: function Code({ className, ...props }) {
      +        const isCodeBlock = useIsMarkdownCodeBlock();
      +        return (
      +            
      +        );
      +    },
      +    CodeHeader,
      +});
      diff --git a/resources/js/components/assistant-ui/reasoning.tsx b/resources/js/components/assistant-ui/reasoning.tsx
      new file mode 100644
      index 0000000..ea60998
      --- /dev/null
      +++ b/resources/js/components/assistant-ui/reasoning.tsx
      @@ -0,0 +1,267 @@
      +"use client";
      +
      +import { BrainIcon, ChevronDownIcon } from "lucide-react";
      +import {
      +    memo,
      +    useCallback,
      +    useRef,
      +    useState,
      +    type FC,
      +    type PropsWithChildren,
      +} from "react";
      +
      +import {
      +    useScrollLock,
      +    useAssistantState,
      +    type ReasoningMessagePartComponent,
      +    type ReasoningGroupComponent,
      +} from "@assistant-ui/react";
      +
      +import { MarkdownText } from "@/components/assistant-ui/markdown-text";
      +import {
      +    Collapsible,
      +    CollapsibleContent,
      +    CollapsibleTrigger,
      +} from "@/components/ui/collapsible";
      +import { cn } from "@/lib/utils";
      +
      +const ANIMATION_DURATION = 200;
      +const SHIMMER_DURATION = 1000;
      +
      +/**
      + * Root collapsible container that manages open/closed state and scroll lock.
      + * Provides animation timing via CSS variable and prevents scroll jumps on collapse.
      + */
      +const ReasoningRoot: FC<
      +    PropsWithChildren<{
      +        className?: string;
      +    }>
      +> = ({ className, children }) => {
      +    const collapsibleRef = useRef(null);
      +    const [isOpen, setIsOpen] = useState(false);
      +    const lockScroll = useScrollLock(collapsibleRef, ANIMATION_DURATION);
      +
      +    const handleOpenChange = useCallback(
      +        (open: boolean) => {
      +            if (!open) {
      +                lockScroll();
      +            }
      +            setIsOpen(open);
      +        },
      +        [lockScroll],
      +    );
      +
      +    return (
      +        
      +            {children}
      +        
      +    );
      +};
      +
      +ReasoningRoot.displayName = "ReasoningRoot";
      +
      +/**
      + * Gradient overlay that softens the bottom edge during expand/collapse animations.
      + * Animation: Fades out with delay when opening and fades back in when closing.
      + */
      +const GradientFade: FC<{ className?: string }> = ({ className }) => (
      +    
      +); + +/** + * Trigger button for the Reasoning collapsible. + * Composed of icons, label, and text shimmer animation when reasoning is being streamed. + */ +const ReasoningTrigger: FC<{ active: boolean; className?: string }> = ({ + active, + className, +}) => ( + + + + Reasoning + {active ? ( + + Reasoning + + ) : null} + + + +); + +/** + * Collapsible content wrapper that handles height expand/collapse animation. + * Animation: Height animates up (collapse) and down (expand). + * Also provides group context for child animations via data-state attributes. + */ +const ReasoningContent: FC< + PropsWithChildren<{ + className?: string; + "aria-busy"?: boolean; + }> +> = ({ className, children, "aria-busy": ariaBusy }) => ( + + {children} + + +); + +ReasoningContent.displayName = "ReasoningContent"; + +/** + * Text content wrapper that animates the reasoning text visibility. + * Animation: Slides in from top + fades in when opening, reverses when closing. + * Reacts to parent ReasoningContent's data-state via Radix group selectors. + */ +const ReasoningText: FC< + PropsWithChildren<{ + className?: string; + }> +> = ({ className, children }) => ( +
      + {children} +
      +); + +ReasoningText.displayName = "ReasoningText"; + +/** + * Renders a single reasoning part's text with markdown support. + * Consecutive reasoning parts are automatically grouped by ReasoningGroup. + * + * Pass Reasoning to MessagePrimitive.Parts in thread.tsx + * + * @example: + * ```tsx + * + * ``` + */ +const ReasoningImpl: ReasoningMessagePartComponent = () => ; + +/** + * Collapsible wrapper that groups consecutive reasoning parts together. + * Includes scroll lock to prevent page jumps during collapse animation. + * + * Pass ReasoningGroup to MessagePrimitive.Parts in thread.tsx + * + * @example: + * ```tsx + * + * ``` + */ +const ReasoningGroupImpl: ReasoningGroupComponent = ({ + children, + startIndex, + endIndex, +}) => { + /** + * Detects if reasoning is currently streaming within this group's range. + */ + const isReasoningStreaming = useAssistantState(({ message }) => { + if (message.status?.type !== "running") return false; + const lastIndex = message.parts.length - 1; + if (lastIndex < 0) return false; + const lastType = message.parts[lastIndex]?.type; + if (lastType !== "reasoning") return false; + return lastIndex >= startIndex && lastIndex <= endIndex; + }); + + return ( + + + + + {children} + + + ); +}; + +export const Reasoning = memo(ReasoningImpl); +Reasoning.displayName = "Reasoning"; + +export const ReasoningGroup = memo(ReasoningGroupImpl); +ReasoningGroup.displayName = "ReasoningGroup"; diff --git a/resources/js/components/assistant-ui/shiki-highlighter.tsx b/resources/js/components/assistant-ui/shiki-highlighter.tsx new file mode 100644 index 0000000..013638c --- /dev/null +++ b/resources/js/components/assistant-ui/shiki-highlighter.tsx @@ -0,0 +1,58 @@ +"use client"; + +import { FC } from "react"; +import ShikiHighlighter, { type ShikiHighlighterProps } from "react-shiki"; +import type { SyntaxHighlighterProps as AUIProps } from "@assistant-ui/react-markdown"; +import { cn } from "@/lib/utils"; + +/** + * Props for the SyntaxHighlighter component + */ +export type HighlighterProps = Omit< + ShikiHighlighterProps, + "children" | "theme" +> & { + theme?: ShikiHighlighterProps["theme"]; +} & Pick; + +/** + * SyntaxHighlighter component, using react-shiki + * Use it by passing to `defaultComponents` in `markdown-text.tsx` + * + * @example + * const defaultComponents = memoizeMarkdownComponents({ + * SyntaxHighlighter, + * h1: //... + * //...other elements... + * }); + */ +export const SyntaxHighlighter: FC = ({ + code, + language, + theme = { dark: "kanagawa-wave", light: "kanagawa-lotus" }, + className, + addDefaultStyles = false, // assistant-ui requires custom base styles + showLanguage = false, // assistant-ui/react-markdown handles language labels + node: _node, + components: _components, + ...props +}) => { + return ( + + {code.trim()} + + ); +}; + +SyntaxHighlighter.displayName = "SyntaxHighlighter"; diff --git a/resources/js/components/assistant-ui/thread-list.tsx b/resources/js/components/assistant-ui/thread-list.tsx new file mode 100644 index 0000000..c37aad8 --- /dev/null +++ b/resources/js/components/assistant-ui/thread-list.tsx @@ -0,0 +1,95 @@ +import type { FC } from "react"; +import { + ThreadListItemPrimitive, + ThreadListPrimitive, + useAssistantState, +} from "@assistant-ui/react"; +import { ArchiveIcon, PlusIcon } from "lucide-react"; + +import { Button } from "@/components/ui/button"; +import { TooltipIconButton } from "@/components/assistant-ui/tooltip-icon-button"; +import { Skeleton } from "@/components/ui/skeleton"; + +export const ThreadList: FC = () => { + return ( + + + + + ); +}; + +const ThreadListNew: FC = () => { + return ( + + + + ); +}; + +const ThreadListItems: FC = () => { + const isLoading = useAssistantState(({ threads }) => threads.isLoading); + + if (isLoading) { + return ; + } + + return ; +}; + +const ThreadListSkeleton: FC = () => { + return ( + <> + {Array.from({ length: 5 }, (_, i) => ( +
      + +
      + ))} + + ); +}; + +const ThreadListItem: FC = () => { + return ( + + + + + + + ); +}; + +const ThreadListItemTitle: FC = () => { + return ( + + + + ); +}; + +const ThreadListItemArchive: FC = () => { + return ( + + + + + + ); +}; diff --git a/resources/js/components/assistant-ui/thread.tsx b/resources/js/components/assistant-ui/thread.tsx new file mode 100644 index 0000000..d439209 --- /dev/null +++ b/resources/js/components/assistant-ui/thread.tsx @@ -0,0 +1,399 @@ +import { + ArrowDownIcon, + ArrowUpIcon, + CheckIcon, + ChevronLeftIcon, + ChevronRightIcon, + CopyIcon, + PencilIcon, + RefreshCwIcon, + Square, +} from "lucide-react"; + +import { + ActionBarPrimitive, + BranchPickerPrimitive, + ComposerPrimitive, + ErrorPrimitive, + MessagePrimitive, + ThreadPrimitive, +} from "@assistant-ui/react"; + +import type { FC } from "react"; +import { LazyMotion, MotionConfig, domAnimation } from "motion/react"; +import * as m from "motion/react-m"; + +import { Button } from "@/components/ui/button"; +import { MarkdownText } from "@/components/assistant-ui/markdown-text"; +import { Reasoning, ReasoningGroup } from "@/components/assistant-ui/reasoning"; +import { ToolFallback } from "@/components/assistant-ui/tool-fallback"; +import { TooltipIconButton } from "@/components/assistant-ui/tooltip-icon-button"; +import { + ComposerAddAttachment, + ComposerAttachments, + UserMessageAttachments, +} from "@/components/assistant-ui/attachment"; + +import { cn } from "@/lib/utils"; + +export const Thread: FC = () => { + return ( + + + + + + + + + + + +
      + + + + + + + ); +}; + +const ThreadScrollToBottom: FC = () => { + return ( + + + + + + ); +}; + +const ThreadWelcome: FC = () => { + return ( +
      +
      +
      + + Hello there! + + + How can I help you today? + +
      +
      + +
      + ); +}; + +const ThreadSuggestions: FC = () => { + return ( +
      + {[ + { + title: "Plan my day", + label: "in San Francisco", + action: "Plan my day in San Francisco", + }, + { + title: "Explain React hooks", + label: "like useState and useEffect", + action: "Explain React hooks like useState and useEffect", + }, + { + title: "Tell me a joke", + label: "about dogs", + action: "Tell me a joke about dogs", + }, + { + title: "Create a meal plan", + label: "for healthy weight loss", + action: "Create a meal plan for healthy weight loss", + }, + ].map((suggestedAction, index) => ( + + + + + + ))} +
      + ); +}; + +const Composer: FC = () => { + return ( +
      + + + + + + + + +
      + ); +}; + +const ComposerAction: FC = () => { + return ( +
      + + + + + + + + + + + + + + + +
      + ); +}; + +const MessageError: FC = () => { + return ( + + + + + + ); +}; + +const AssistantMessage: FC = () => { + return ( + +
      +
      + + +
      + +
      + + +
      +
      +
      + ); +}; + +const AssistantActionBar: FC = () => { + return ( + + + + + + + + + + + + + + + + + + ); +}; + +const UserMessage: FC = () => { + return ( + +
      + + +
      +
      + +
      +
      + +
      +
      + + +
      +
      + ); +}; + +const UserActionBar: FC = () => { + return ( + + + + + + + + ); +}; + +const EditComposer: FC = () => { + return ( +
      + + + +
      + + + + + + +
      +
      +
      + ); +}; + +const BranchPicker: FC = ({ + className, + ...rest +}) => { + return ( + + + + + + + + /{" "} + + + + + + + + + ); +}; diff --git a/resources/js/components/assistant-ui/threadlist-sidebar.tsx b/resources/js/components/assistant-ui/threadlist-sidebar.tsx new file mode 100644 index 0000000..3cfecc5 --- /dev/null +++ b/resources/js/components/assistant-ui/threadlist-sidebar.tsx @@ -0,0 +1,73 @@ +import * as React from "react"; +import { Github, MessagesSquare } from "lucide-react"; +import { Link } from "@inertiajs/react"; +import { + Sidebar, + SidebarContent, + SidebarFooter, + SidebarHeader, + SidebarMenu, + SidebarMenuButton, + SidebarMenuItem, + SidebarRail, +} from "@/components/ui/sidebar"; +import { ThreadList } from "@/components/assistant-ui/thread-list"; + +export function ThreadListSidebar({ + ...props +}: React.ComponentProps) { + return ( + + +
      + + + + +
      + +
      +
      + + assistant-ui + +
      + +
      +
      +
      +
      +
      + + + + + + + + + +
      + +
      +
      + + GitHub + + View Source +
      + +
      +
      +
      +
      +
      + ); +} diff --git a/resources/js/components/assistant-ui/tool-fallback.tsx b/resources/js/components/assistant-ui/tool-fallback.tsx new file mode 100644 index 0000000..7be6726 --- /dev/null +++ b/resources/js/components/assistant-ui/tool-fallback.tsx @@ -0,0 +1,46 @@ +import type { ToolCallMessagePartComponent } from "@assistant-ui/react"; +import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react"; +import { useState } from "react"; +import { Button } from "@/components/ui/button"; + +export const ToolFallback: ToolCallMessagePartComponent = ({ + toolName, + argsText, + result, +}) => { + const [isCollapsed, setIsCollapsed] = useState(true); + return ( +
      +
      + +

      + Used tool: {toolName} +

      + +
      + {!isCollapsed && ( +
      +
      +
      +                            {argsText}
      +                        
      +
      + {result !== undefined && ( +
      +

      + Result: +

      +
      +                                {typeof result === "string"
      +                                    ? result
      +                                    : JSON.stringify(result, null, 2)}
      +                            
      +
      + )} +
      + )} +
      + ); +}; diff --git a/resources/js/components/assistant-ui/tooltip-icon-button.tsx b/resources/js/components/assistant-ui/tooltip-icon-button.tsx new file mode 100644 index 0000000..1d74e7c --- /dev/null +++ b/resources/js/components/assistant-ui/tooltip-icon-button.tsx @@ -0,0 +1,42 @@ +"use client"; + +import { ComponentPropsWithRef, forwardRef } from "react"; +import { Slottable } from "@radix-ui/react-slot"; + +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@/components/ui/tooltip"; +import { Button } from "@/components/ui/button"; +import { cn } from "@/lib/utils"; + +export type TooltipIconButtonProps = ComponentPropsWithRef & { + tooltip: string; + side?: "top" | "bottom" | "left" | "right"; +}; + +export const TooltipIconButton = forwardRef< + HTMLButtonElement, + TooltipIconButtonProps +>(({ children, tooltip, side = "bottom", className, ...rest }, ref) => { + return ( + + + + + {tooltip} + + ); +}); + +TooltipIconButton.displayName = "TooltipIconButton"; diff --git a/resources/js/components/assistant.tsx b/resources/js/components/assistant.tsx new file mode 100644 index 0000000..b96fc37 --- /dev/null +++ b/resources/js/components/assistant.tsx @@ -0,0 +1,43 @@ +import { AssistantRuntimeProvider } from "@assistant-ui/react"; +import { useChatRuntime, AssistantChatTransport } from "@assistant-ui/react-ai-sdk"; +import { Thread } from "@/components/assistant-ui/thread"; +import { ThreadList } from "@/components/assistant-ui/thread-list"; +import { WeatherToolUI } from "@/components/tools/weather-tool"; +import api from "@/routes/cortex/api"; +// import { Card, CardContent, CardHeader, CardTitle } from "./ui/card"; + +export const Assistant = ({ agent }: { agent: string }) => { + const runtime = useChatRuntime({ + id: crypto.randomUUID(), + transport: new AssistantChatTransport({ + api: api.agents.stream.url( + { agent }, + { mergeQuery: { protocol: "vercel" } }, + ), + }), + }); + + return ( + + +
      + {/*
      + +
      */} +
      + +
      + {/*
      + + + Agent Metadata + + +
      {JSON.stringify(agent, null, 2)}
      +
      +
      +
      */} +
      +
      + ); +}; diff --git a/resources/js/components/breadcrumbs.tsx b/resources/js/components/breadcrumbs.tsx new file mode 100644 index 0000000..e9bc006 --- /dev/null +++ b/resources/js/components/breadcrumbs.tsx @@ -0,0 +1,49 @@ +import { + Breadcrumb, + BreadcrumbItem, + BreadcrumbLink, + BreadcrumbList, + BreadcrumbPage, + BreadcrumbSeparator, +} from "@/components/ui/breadcrumb"; +import { type BreadcrumbItem as BreadcrumbItemType } from "@/types"; +import { Link } from "@inertiajs/react"; +import { Fragment } from "react"; + +export function Breadcrumbs({ + breadcrumbs, +}: { + breadcrumbs: BreadcrumbItemType[]; +}) { + return ( + <> + {breadcrumbs.length > 0 && ( + + + {breadcrumbs.map((item, index) => { + const isLast = index === breadcrumbs.length - 1; + return ( + + + {isLast ? ( + + {item.title} + + ) : ( + + + {item.title} + + + )} + + {!isLast && } + + ); + })} + + + )} + + ); +} diff --git a/resources/js/components/delete-user.tsx b/resources/js/components/delete-user.tsx new file mode 100644 index 0000000..44bff9b --- /dev/null +++ b/resources/js/components/delete-user.tsx @@ -0,0 +1,120 @@ +import ProfileController from "@/actions/App/Http/Controllers/Settings/ProfileController"; +import HeadingSmall from "@/components/heading-small"; +import InputError from "@/components/input-error"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogFooter, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Form } from "@inertiajs/react"; +import { useRef } from "react"; + +export default function DeleteUser() { + const passwordInput = useRef(null); + + return ( +
      + +
      +
      +

      Warning

      +

      + Please proceed with caution, this cannot be undone. +

      +
      + + + + + + + + Are you sure you want to delete your account? + + + Once your account is deleted, all of its resources + and data will also be permanently deleted. Please + enter your password to confirm you would like to + permanently delete your account. + + +
      passwordInput.current?.focus()} + resetOnSuccess + className="space-y-6" + > + {({ resetAndClearErrors, processing, errors }) => ( + <> +
      + + + + + +
      + + + + + + + + + + + )} + +
      +
      +
      +
      + ); +} diff --git a/resources/js/components/heading-small.tsx b/resources/js/components/heading-small.tsx new file mode 100644 index 0000000..a545f1a --- /dev/null +++ b/resources/js/components/heading-small.tsx @@ -0,0 +1,16 @@ +export default function HeadingSmall({ + title, + description, +}: { + title: string; + description?: string; +}) { + return ( +
      +

      {title}

      + {description && ( +

      {description}

      + )} +
      + ); +} diff --git a/resources/js/components/heading.tsx b/resources/js/components/heading.tsx new file mode 100644 index 0000000..a4a6e23 --- /dev/null +++ b/resources/js/components/heading.tsx @@ -0,0 +1,16 @@ +export default function Heading({ + title, + description, +}: { + title: string; + description?: string; +}) { + return ( +
      +

      {title}

      + {description && ( +

      {description}

      + )} +
      + ); +} diff --git a/resources/js/components/icon.tsx b/resources/js/components/icon.tsx new file mode 100644 index 0000000..ef5c258 --- /dev/null +++ b/resources/js/components/icon.tsx @@ -0,0 +1,15 @@ +import { cn } from "@/lib/utils"; +import { type LucideProps } from "lucide-react"; +import { type ComponentType } from "react"; + +interface IconProps extends Omit { + iconNode: ComponentType; +} + +export function Icon({ + iconNode: IconComponent, + className, + ...props +}: IconProps) { + return ; +} diff --git a/resources/js/components/input-error.tsx b/resources/js/components/input-error.tsx new file mode 100644 index 0000000..1fd93e4 --- /dev/null +++ b/resources/js/components/input-error.tsx @@ -0,0 +1,17 @@ +import { cn } from "@/lib/utils"; +import { type HTMLAttributes } from "react"; + +export default function InputError({ + message, + className = "", + ...props +}: HTMLAttributes & { message?: string }) { + return message ? ( +

      + {message} +

      + ) : null; +} diff --git a/resources/js/components/nav-footer.tsx b/resources/js/components/nav-footer.tsx new file mode 100644 index 0000000..5739485 --- /dev/null +++ b/resources/js/components/nav-footer.tsx @@ -0,0 +1,53 @@ +import { Icon } from "@/components/icon"; +import { + SidebarGroup, + SidebarGroupContent, + SidebarMenu, + SidebarMenuButton, + SidebarMenuItem, +} from "@/components/ui/sidebar"; +import { toUrl } from "@/lib/utils"; +import { type NavItem } from "@/types"; +import { type ComponentPropsWithoutRef } from "react"; + +export function NavFooter({ + items, + className, + ...props +}: ComponentPropsWithoutRef & { + items: NavItem[]; +}) { + return ( + + + + {items.map((item) => ( + + + + {item.icon && ( + + )} + {item.title} + + + + ))} + + + + ); +} diff --git a/resources/js/components/nav-main.tsx b/resources/js/components/nav-main.tsx new file mode 100644 index 0000000..2cc0398 --- /dev/null +++ b/resources/js/components/nav-main.tsx @@ -0,0 +1,36 @@ +import { + SidebarGroup, + SidebarGroupLabel, + SidebarMenu, + SidebarMenuButton, + SidebarMenuItem, +} from "@/components/ui/sidebar"; +import { useActiveUrl } from "@/hooks/use-active-url"; +import { type NavItem } from "@/types"; +import { Link } from "@inertiajs/react"; + +export function NavMain({ items = [] }: { items: NavItem[] }) { + const { urlIsActive } = useActiveUrl(); + + return ( + + Platform + + {items.map((item) => ( + + + + {item.icon && } + {item.title} + + + + ))} + + + ); +} diff --git a/resources/js/components/nav-user.tsx b/resources/js/components/nav-user.tsx new file mode 100644 index 0000000..2a17626 --- /dev/null +++ b/resources/js/components/nav-user.tsx @@ -0,0 +1,55 @@ +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { + SidebarMenu, + SidebarMenuButton, + SidebarMenuItem, + useSidebar, +} from "@/components/ui/sidebar"; +import { UserInfo } from "@/components/user-info"; +// import { UserMenuContent } from '@/components/user-menu-content'; +import { useIsMobile } from "@/hooks/use-mobile"; +import { type SharedData } from "@/types"; +import { usePage } from "@inertiajs/react"; +import { ChevronsUpDown } from "lucide-react"; + +export function NavUser() { + const { auth } = usePage().props; + const { state } = useSidebar(); + const isMobile = useIsMobile(); + + return ( + + + + + + + + + + + {/* */} + + + + + ); +} diff --git a/resources/js/components/text-link.tsx b/resources/js/components/text-link.tsx new file mode 100644 index 0000000..1488e04 --- /dev/null +++ b/resources/js/components/text-link.tsx @@ -0,0 +1,23 @@ +import { cn } from "@/lib/utils"; +import { Link } from "@inertiajs/react"; +import { ComponentProps } from "react"; + +type LinkProps = ComponentProps; + +export default function TextLink({ + className = "", + children, + ...props +}: LinkProps) { + return ( + + {children} + + ); +} diff --git a/resources/js/components/tools/weather-card.tsx b/resources/js/components/tools/weather-card.tsx new file mode 100644 index 0000000..3f85f7c --- /dev/null +++ b/resources/js/components/tools/weather-card.tsx @@ -0,0 +1,211 @@ +import { + Cloud, + Droplets, + Wind, + Thermometer, + MapPin, + Gauge, + Clock, +} from "lucide-react"; + +interface WeatherCardProps { + temperature?: number; + feelsLike?: number; + humidity?: number; + windSpeed?: number; + windGusts?: number; + conditions?: string; + location?: string; + temperatureUnit?: string; + windSpeedUnit?: string; + conditionsCode?: number; + time?: string; +} + +const formatTime = (timeString?: string): string => { + if (!timeString) return ""; + + try { + const date = new Date(timeString); + const options: Intl.DateTimeFormatOptions = { + hour: "numeric", + minute: "2-digit", + }; + return date.toLocaleTimeString("en-US", options); + } catch { + return timeString; + } +}; + +const getWeatherGradient = (code?: number): string => { + if (code === undefined) return "from-blue-500 to-blue-700"; + + // Clear/Sunny + if (code === 0 || code === 1) { + return "from-sky-400 to-blue-500"; + } + + // Partly cloudy + if (code === 2) { + return "from-slate-400 to-blue-500"; + } + + // Overcast + if (code === 3) { + return "from-gray-500 to-gray-600"; + } + + // Fog + if (code === 45 || code === 48) { + return "from-gray-400 to-gray-500"; + } + + // Drizzle + if (code >= 51 && code <= 57) { + return "from-slate-500 to-blue-600"; + } + + // Rain + if ((code >= 61 && code <= 67) || (code >= 80 && code <= 82)) { + return "from-blue-600 to-slate-700"; + } + + // Snow + if ((code >= 71 && code <= 77) || code === 85 || code === 86) { + return "from-slate-300 to-blue-400"; + } + + // Thunderstorm + if (code >= 95 && code <= 99) { + return "from-slate-700 to-purple-900"; + } + + // Default + return "from-blue-500 to-blue-700"; +}; + +export const WeatherCard = ({ + temperature, + feelsLike, + humidity, + windSpeed, + windGusts, + conditions, + location, + temperatureUnit = "celsius", + windSpeedUnit = "mph", + conditionsCode, + time, +}: WeatherCardProps) => { + const gradientColors = getWeatherGradient(conditionsCode); + const formattedTime = formatTime(time); + + return ( +
      + {/* Header with Location and Time */} +
      + {location && ( +
      + +

      {location}

      +
      + )} + {formattedTime && ( +
      + + {formattedTime} +
      + )} +
      + + {/* Main Temperature Display */} +
      +
      + {temperature !== undefined && ( +
      + {Math.round(temperature)}° +
      + )} + {conditions && ( +
      + + {conditions} +
      + )} +
      + {feelsLike !== undefined && ( +
      +
      Feels like
      +
      + {Math.round(feelsLike)}° +
      +
      + )} +
      + + {/* Weather Details Grid */} +
      + {humidity !== undefined && ( +
      +
      + +
      +
      +
      Humidity
      +
      + {Math.round(humidity)}% +
      +
      +
      + )} + + {windSpeed !== undefined && ( +
      +
      + +
      +
      +
      Wind Speed
      +
      + {Math.round(windSpeed)} {windSpeedUnit} +
      +
      +
      + )} + + {windGusts !== undefined && ( +
      +
      + +
      +
      +
      Wind Gusts
      +
      + {Math.round(windGusts)} {windSpeedUnit} +
      +
      +
      + )} + + {feelsLike !== undefined && temperature !== undefined && ( +
      +
      + +
      +
      +
      + Temperature +
      +
      + {Math.round(temperature)}° + {temperatureUnit === "celsius" ? "C" : "F"} +
      +
      +
      + )} +
      +
      + ); +}; diff --git a/resources/js/components/tools/weather-tool.tsx b/resources/js/components/tools/weather-tool.tsx new file mode 100644 index 0000000..4eb0e0b --- /dev/null +++ b/resources/js/components/tools/weather-tool.tsx @@ -0,0 +1,91 @@ +import { makeAssistantTool } from "@assistant-ui/react"; +import { WeatherCard } from "./weather-card"; + +export const WeatherToolUI = makeAssistantTool({ + toolName: "get_weather", + render: ({ args, result, status }) => { + if (status.type === "running") { + return ( +
      + + Checking weather in {args.location as string}... + +
      + ); + } + + if (status.type === "incomplete" && status.reason === "error") { + return ( +
      + Failed to get weather for {args.location as string} +
      + ); + } + + let weatherResult: Record | undefined = undefined; + + if (typeof result === "string") { + weatherResult = JSON.parse(result) as Record; + } + + return ( + + ); + }, +}); diff --git a/resources/js/components/two-factor-recovery-codes.tsx b/resources/js/components/two-factor-recovery-codes.tsx new file mode 100644 index 0000000..aeda486 --- /dev/null +++ b/resources/js/components/two-factor-recovery-codes.tsx @@ -0,0 +1,164 @@ +import { Button } from "@/components/ui/button"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { regenerateRecoveryCodes } from "@/routes/two-factor"; +import { Form } from "@inertiajs/react"; +import { Eye, EyeOff, LockKeyhole, RefreshCw } from "lucide-react"; +import { useCallback, useEffect, useRef, useState } from "react"; +import AlertError from "./alert-error"; + +interface TwoFactorRecoveryCodesProps { + recoveryCodesList: string[]; + fetchRecoveryCodes: () => Promise; + errors: string[]; +} + +export default function TwoFactorRecoveryCodes({ + recoveryCodesList, + fetchRecoveryCodes, + errors, +}: TwoFactorRecoveryCodesProps) { + const [codesAreVisible, setCodesAreVisible] = useState(false); + const codesSectionRef = useRef(null); + const canRegenerateCodes = recoveryCodesList.length > 0 && codesAreVisible; + + const toggleCodesVisibility = useCallback(async () => { + if (!codesAreVisible && !recoveryCodesList.length) { + await fetchRecoveryCodes(); + } + + setCodesAreVisible(!codesAreVisible); + + if (!codesAreVisible) { + setTimeout(() => { + codesSectionRef.current?.scrollIntoView({ + behavior: "smooth", + block: "nearest", + }); + }); + } + }, [codesAreVisible, recoveryCodesList.length, fetchRecoveryCodes]); + + useEffect(() => { + if (!recoveryCodesList.length) { + fetchRecoveryCodes(); + } + }, [recoveryCodesList.length, fetchRecoveryCodes]); + + const RecoveryCodeIconComponent = codesAreVisible ? EyeOff : Eye; + + return ( + + + + + + Recovery codes let you regain access if you lose your 2FA + device. Store them in a secure password manager. + + + +
      + + + {canRegenerateCodes && ( +
      + {({ processing }) => ( + + )} + + )} +
      +
      +
      + {errors?.length ? ( + + ) : ( + <> +
      + {recoveryCodesList.length ? ( + recoveryCodesList.map((code, index) => ( +
      + {code} +
      + )) + ) : ( +
      + {Array.from( + { length: 8 }, + (_, index) => ( + + )} +
      + +
      +

      + Each recovery code can be used once to + access your account and will be removed + after use. If you need more, click{" "} + + Regenerate Codes + {" "} + above. +

      +
      + + )} +
      +
      + + + ); +} diff --git a/resources/js/components/two-factor-setup-modal.tsx b/resources/js/components/two-factor-setup-modal.tsx new file mode 100644 index 0000000..a463a9e --- /dev/null +++ b/resources/js/components/two-factor-setup-modal.tsx @@ -0,0 +1,347 @@ +import InputError from "@/components/input-error"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { + InputOTP, + InputOTPGroup, + InputOTPSlot, +} from "@/components/ui/input-otp"; +import { useAppearance } from "@/hooks/use-appearance"; +import { useClipboard } from "@/hooks/use-clipboard"; +import { OTP_MAX_LENGTH } from "@/hooks/use-two-factor-auth"; +import { confirm } from "@/routes/two-factor"; +import { Form } from "@inertiajs/react"; +import { REGEXP_ONLY_DIGITS } from "input-otp"; +import { Check, Copy, ScanLine } from "lucide-react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import AlertError from "./alert-error"; +import { Spinner } from "./ui/spinner"; + +function GridScanIcon() { + return ( +
      +
      +
      + {Array.from({ length: 5 }, (_, i) => ( +
      + ))} +
      +
      + {Array.from({ length: 5 }, (_, i) => ( +
      + ))} +
      + +
      +
      + ); +} + +function TwoFactorSetupStep({ + qrCodeSvg, + manualSetupKey, + buttonText, + onNextStep, + errors, +}: { + qrCodeSvg: string | null; + manualSetupKey: string | null; + buttonText: string; + onNextStep: () => void; + errors: string[]; +}) { + const { resolvedAppearance } = useAppearance(); + const [copiedText, copy] = useClipboard(); + const IconComponent = copiedText === manualSetupKey ? Check : Copy; + + return ( + <> + {errors?.length ? ( + + ) : ( + <> +
      +
      +
      + {qrCodeSvg ? ( +
      + ) : ( + + )} +
      +
      +
      + +
      + +
      + +
      +
      + + or, enter the code manually + +
      + +
      +
      + {!manualSetupKey ? ( +
      + +
      + ) : ( + <> + + + + )} +
      +
      + + )} + + ); +} + +function TwoFactorVerificationStep({ + onClose, + onBack, +}: { + onClose: () => void; + onBack: () => void; +}) { + const [code, setCode] = useState(""); + const pinInputContainerRef = useRef(null); + + useEffect(() => { + setTimeout(() => { + pinInputContainerRef.current?.querySelector("input")?.focus(); + }, 0); + }, []); + + return ( +
      onClose()} + resetOnError + resetOnSuccess + > + {({ + processing, + errors, + }: { + processing: boolean; + errors?: { confirmTwoFactorAuthentication?: { code?: string } }; + }) => ( + <> +
      +
      + + + {Array.from( + { length: OTP_MAX_LENGTH }, + (_, index) => ( + + ), + )} + + + +
      + +
      + + +
      +
      + + )} + + ); +} + +interface TwoFactorSetupModalProps { + isOpen: boolean; + onClose: () => void; + requiresConfirmation: boolean; + twoFactorEnabled: boolean; + qrCodeSvg: string | null; + manualSetupKey: string | null; + clearSetupData: () => void; + fetchSetupData: () => Promise; + errors: string[]; +} + +export default function TwoFactorSetupModal({ + isOpen, + onClose, + requiresConfirmation, + twoFactorEnabled, + qrCodeSvg, + manualSetupKey, + clearSetupData, + fetchSetupData, + errors, +}: TwoFactorSetupModalProps) { + const [showVerificationStep, setShowVerificationStep] = + useState(false); + + const modalConfig = useMemo<{ + title: string; + description: string; + buttonText: string; + }>(() => { + if (twoFactorEnabled) { + return { + title: "Two-Factor Authentication Enabled", + description: + "Two-factor authentication is now enabled. Scan the QR code or enter the setup key in your authenticator app.", + buttonText: "Close", + }; + } + + if (showVerificationStep) { + return { + title: "Verify Authentication Code", + description: + "Enter the 6-digit code from your authenticator app", + buttonText: "Continue", + }; + } + + return { + title: "Enable Two-Factor Authentication", + description: + "To finish enabling two-factor authentication, scan the QR code or enter the setup key in your authenticator app", + buttonText: "Continue", + }; + }, [twoFactorEnabled, showVerificationStep]); + + const handleModalNextStep = useCallback(() => { + if (requiresConfirmation) { + setShowVerificationStep(true); + return; + } + + clearSetupData(); + onClose(); + }, [requiresConfirmation, clearSetupData, onClose]); + + const resetModalState = useCallback(() => { + setShowVerificationStep(false); + + if (twoFactorEnabled) { + clearSetupData(); + } + }, [twoFactorEnabled, clearSetupData]); + + useEffect(() => { + if (isOpen && !qrCodeSvg) { + fetchSetupData(); + } + }, [isOpen, qrCodeSvg, fetchSetupData]); + + const handleClose = useCallback(() => { + resetModalState(); + onClose(); + }, [onClose, resetModalState]); + + return ( + !open && handleClose()}> + + + + {modalConfig.title} + + {modalConfig.description} + + + +
      + {showVerificationStep ? ( + setShowVerificationStep(false)} + /> + ) : ( + + )} +
      +
      +
      + ); +} diff --git a/resources/js/components/ui/alert.tsx b/resources/js/components/ui/alert.tsx new file mode 100644 index 0000000..fd9def6 --- /dev/null +++ b/resources/js/components/ui/alert.tsx @@ -0,0 +1,66 @@ +import * as React from "react"; +import { cva, type VariantProps } from "class-variance-authority"; + +import { cn } from "@/lib/utils"; + +const alertVariants = cva( + "relative w-full rounded-lg border px-4 py-3 text-sm grid has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] grid-cols-[0_1fr] has-[>svg]:gap-x-3 gap-y-0.5 items-start [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current", + { + variants: { + variant: { + default: "bg-background text-foreground", + destructive: + "text-destructive-foreground [&>svg]:text-current *:data-[slot=alert-description]:text-destructive-foreground/80", + }, + }, + defaultVariants: { + variant: "default", + }, + }, +); + +function Alert({ + className, + variant, + ...props +}: React.ComponentProps<"div"> & VariantProps) { + return ( +
      + ); +} + +function AlertTitle({ className, ...props }: React.ComponentProps<"div">) { + return ( +
      + ); +} + +function AlertDescription({ + className, + ...props +}: React.ComponentProps<"div">) { + return ( +
      + ); +} + +export { Alert, AlertTitle, AlertDescription }; diff --git a/resources/js/components/ui/avatar.tsx b/resources/js/components/ui/avatar.tsx new file mode 100644 index 0000000..dfeb84b --- /dev/null +++ b/resources/js/components/ui/avatar.tsx @@ -0,0 +1,51 @@ +import * as React from "react"; +import * as AvatarPrimitive from "@radix-ui/react-avatar"; + +import { cn } from "@/lib/utils"; + +function Avatar({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function AvatarImage({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function AvatarFallback({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +export { Avatar, AvatarImage, AvatarFallback }; diff --git a/resources/js/components/ui/badge.tsx b/resources/js/components/ui/badge.tsx new file mode 100644 index 0000000..b2f5006 --- /dev/null +++ b/resources/js/components/ui/badge.tsx @@ -0,0 +1,46 @@ +import * as React from "react"; +import { Slot } from "@radix-ui/react-slot"; +import { cva, type VariantProps } from "class-variance-authority"; + +import { cn } from "@/lib/utils"; + +const badgeVariants = cva( + "inline-flex items-center justify-center rounded-md border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden", + { + variants: { + variant: { + default: + "border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90", + secondary: + "border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90", + destructive: + "border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60", + outline: + "text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground", + }, + }, + defaultVariants: { + variant: "default", + }, + }, +); + +function Badge({ + className, + variant, + asChild = false, + ...props +}: React.ComponentProps<"span"> & + VariantProps & { asChild?: boolean }) { + const Comp = asChild ? Slot : "span"; + + return ( + + ); +} + +export { Badge, badgeVariants }; diff --git a/resources/js/components/ui/breadcrumb.tsx b/resources/js/components/ui/breadcrumb.tsx new file mode 100644 index 0000000..dc80665 --- /dev/null +++ b/resources/js/components/ui/breadcrumb.tsx @@ -0,0 +1,109 @@ +import * as React from "react"; +import { Slot } from "@radix-ui/react-slot"; +import { ChevronRight, MoreHorizontal } from "lucide-react"; + +import { cn } from "@/lib/utils"; + +function Breadcrumb({ ...props }: React.ComponentProps<"nav">) { + return + + + + +
      +
      + {children} +
      +
      +
      +
      + ); +} diff --git a/resources/js/lib/utils.ts b/resources/js/lib/utils.ts new file mode 100644 index 0000000..ac8ebb0 --- /dev/null +++ b/resources/js/lib/utils.ts @@ -0,0 +1,11 @@ +import { InertiaLinkProps } from "@inertiajs/react"; +import { type ClassValue, clsx } from "clsx"; +import { twMerge } from "tailwind-merge"; + +export function cn(...inputs: ClassValue[]) { + return twMerge(clsx(inputs)); +} + +export function toUrl(url: NonNullable): string { + return typeof url === "string" ? url : url.url; +} diff --git a/resources/js/pages/agents/index.tsx b/resources/js/pages/agents/index.tsx new file mode 100644 index 0000000..02beb35 --- /dev/null +++ b/resources/js/pages/agents/index.tsx @@ -0,0 +1,85 @@ +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import AppLayout from "@/layouts/app-layout"; +import cortex from "@/routes/cortex"; +import { type BreadcrumbItem } from "@/types"; +import { Head, Link } from "@inertiajs/react"; +import { Bot } from "lucide-react"; +import { type Agent } from "@/types/agents"; + +const breadcrumbs: BreadcrumbItem[] = [ + { + title: "Agents", + href: cortex.agents.index.url(), + }, +]; + +export default function AgentsIndex({ agents }: { agents: Agent[] }) { + return ( + + +
      + {agents.length === 0 ? ( + + + +
      + + No agents found + + + Register agents in your application to see + them here. + +
      +
      +
      + ) : ( +
      + {agents.map((agent) => ( + + + +
      +
      +
      + +
      +
      + + {agent.name || agent.id} + + + {agent.description || + "No description available"} + +
      +
      +
      +
      +
      + + ))} +
      + )} +
      +
      + ); +} diff --git a/resources/js/pages/agents/show.tsx b/resources/js/pages/agents/show.tsx new file mode 100644 index 0000000..488b0c2 --- /dev/null +++ b/resources/js/pages/agents/show.tsx @@ -0,0 +1,28 @@ +import { Head } from "@inertiajs/react"; +import { Assistant } from "@/components/assistant"; +import AppLayout from "@/layouts/app-layout"; +import agents from "@/routes/cortex/agents"; +import { type BreadcrumbItem } from "@/types"; +import { type Agent } from "@/types/agents"; + +export default function AgentsShow({ agent }: { agent: Agent }) { + const breadcrumbs: BreadcrumbItem[] = [ + { + title: "Agents", + href: agents.index.url(), + }, + { + title: agent.name || agent.id, + href: agents.show.url({ agent: agent.id }), + }, + ]; + + return ( + + +
      + +
      +
      + ); +} diff --git a/resources/js/pages/dashboard.tsx b/resources/js/pages/dashboard.tsx new file mode 100644 index 0000000..bd263b2 --- /dev/null +++ b/resources/js/pages/dashboard.tsx @@ -0,0 +1,36 @@ +import { PlaceholderPattern } from "@/components/ui/placeholder-pattern"; +import AppLayout from "@/layouts/app-layout"; +import { dashboard } from "@/routes/cortex"; +import { type BreadcrumbItem } from "@/types"; +import { Head } from "@inertiajs/react"; + +const breadcrumbs: BreadcrumbItem[] = [ + { + title: "Dashboard", + href: dashboard().url, + }, +]; + +export default function Dashboard() { + return ( + + +
      +
      +
      + +
      +
      + +
      +
      + +
      +
      +
      + +
      +
      +
      + ); +} diff --git a/resources/js/routes/cortex/agents/index.ts b/resources/js/routes/cortex/agents/index.ts new file mode 100644 index 0000000..4bdf608 --- /dev/null +++ b/resources/js/routes/cortex/agents/index.ts @@ -0,0 +1,111 @@ +import { queryParams, type RouteQueryOptions, type RouteDefinition, applyUrlDefaults } from './../../../wayfinder' +/** +* @see Users/sean/Code/cortexphp/cortex/routes/web.php:13 +* @route '/cortex/agents' +*/ +export const index = (options?: RouteQueryOptions): RouteDefinition<'get'> => ({ + url: index.url(options), + method: 'get', +}) + +index.definition = { + methods: ["get","head"], + url: '/cortex/agents', +} satisfies RouteDefinition<["get","head"]> + +/** +* @see Users/sean/Code/cortexphp/cortex/routes/web.php:13 +* @route '/cortex/agents' +*/ +index.url = (options?: RouteQueryOptions) => { + return index.definition.url + queryParams(options) +} + +/** +* @see Users/sean/Code/cortexphp/cortex/routes/web.php:13 +* @route '/cortex/agents' +*/ +index.get = (options?: RouteQueryOptions): RouteDefinition<'get'> => ({ + url: index.url(options), + method: 'get', +}) + +/** +* @see Users/sean/Code/cortexphp/cortex/routes/web.php:13 +* @route '/cortex/agents' +*/ +index.head = (options?: RouteQueryOptions): RouteDefinition<'head'> => ({ + url: index.url(options), + method: 'head', +}) + +/** +* @see Users/sean/Code/cortexphp/cortex/routes/web.php:20 +* @route '/cortex/agents/{agent}' +*/ +export const show = (args: { agent: string | number | { string: string | number } } | [agent: string | number | { string: string | number } ] | string | number | { string: string | number }, options?: RouteQueryOptions): RouteDefinition<'get'> => ({ + url: show.url(args, options), + method: 'get', +}) + +show.definition = { + methods: ["get","head"], + url: '/cortex/agents/{agent}', +} satisfies RouteDefinition<["get","head"]> + +/** +* @see Users/sean/Code/cortexphp/cortex/routes/web.php:20 +* @route '/cortex/agents/{agent}' +*/ +show.url = (args: { agent: string | number | { string: string | number } } | [agent: string | number | { string: string | number } ] | string | number | { string: string | number }, options?: RouteQueryOptions) => { + if (typeof args === 'string' || typeof args === 'number') { + args = { agent: args } + } + + if (typeof args === 'object' && !Array.isArray(args) && 'string' in args) { + args = { agent: args.string } + } + + if (Array.isArray(args)) { + args = { + agent: args[0], + } + } + + args = applyUrlDefaults(args) + + const parsedArgs = { + agent: typeof args.agent === 'object' + ? args.agent.string + : args.agent, + } + + return show.definition.url + .replace('{agent}', parsedArgs.agent.toString()) + .replace(/\/+$/, '') + queryParams(options) +} + +/** +* @see Users/sean/Code/cortexphp/cortex/routes/web.php:20 +* @route '/cortex/agents/{agent}' +*/ +show.get = (args: { agent: string | number | { string: string | number } } | [agent: string | number | { string: string | number } ] | string | number | { string: string | number }, options?: RouteQueryOptions): RouteDefinition<'get'> => ({ + url: show.url(args, options), + method: 'get', +}) + +/** +* @see Users/sean/Code/cortexphp/cortex/routes/web.php:20 +* @route '/cortex/agents/{agent}' +*/ +show.head = (args: { agent: string | number | { string: string | number } } | [agent: string | number | { string: string | number } ] | string | number | { string: string | number }, options?: RouteQueryOptions): RouteDefinition<'head'> => ({ + url: show.url(args, options), + method: 'head', +}) + +const agents = { + index: Object.assign(index, index), + show: Object.assign(show, show), +} + +export default agents \ No newline at end of file diff --git a/resources/js/routes/cortex/api/agents/index.ts b/resources/js/routes/cortex/api/agents/index.ts new file mode 100644 index 0000000..780b8a8 --- /dev/null +++ b/resources/js/routes/cortex/api/agents/index.ts @@ -0,0 +1,163 @@ +import { queryParams, type RouteQueryOptions, type RouteDefinition, applyUrlDefaults } from './../../../../wayfinder' +/** +* @see \Cortex\Http\Controllers\AgentsController::invoke +* @see Users/sean/Code/cortexphp/cortex/src/Http/Controllers/AgentsController.php:26 +* @route '/api/agents/{agent}/invoke' +*/ +export const invoke = (args: { agent: string | number | { string: string | number } } | [agent: string | number | { string: string | number } ] | string | number | { string: string | number }, options?: RouteQueryOptions): RouteDefinition<'get'> => ({ + url: invoke.url(args, options), + method: 'get', +}) + +invoke.definition = { + methods: ["get","post","head"], + url: '/api/agents/{agent}/invoke', +} satisfies RouteDefinition<["get","post","head"]> + +/** +* @see \Cortex\Http\Controllers\AgentsController::invoke +* @see Users/sean/Code/cortexphp/cortex/src/Http/Controllers/AgentsController.php:26 +* @route '/api/agents/{agent}/invoke' +*/ +invoke.url = (args: { agent: string | number | { string: string | number } } | [agent: string | number | { string: string | number } ] | string | number | { string: string | number }, options?: RouteQueryOptions) => { + if (typeof args === 'string' || typeof args === 'number') { + args = { agent: args } + } + + if (typeof args === 'object' && !Array.isArray(args) && 'string' in args) { + args = { agent: args.string } + } + + if (Array.isArray(args)) { + args = { + agent: args[0], + } + } + + args = applyUrlDefaults(args) + + const parsedArgs = { + agent: typeof args.agent === 'object' + ? args.agent.string + : args.agent, + } + + return invoke.definition.url + .replace('{agent}', parsedArgs.agent.toString()) + .replace(/\/+$/, '') + queryParams(options) +} + +/** +* @see \Cortex\Http\Controllers\AgentsController::invoke +* @see Users/sean/Code/cortexphp/cortex/src/Http/Controllers/AgentsController.php:26 +* @route '/api/agents/{agent}/invoke' +*/ +invoke.get = (args: { agent: string | number | { string: string | number } } | [agent: string | number | { string: string | number } ] | string | number | { string: string | number }, options?: RouteQueryOptions): RouteDefinition<'get'> => ({ + url: invoke.url(args, options), + method: 'get', +}) + +/** +* @see \Cortex\Http\Controllers\AgentsController::invoke +* @see Users/sean/Code/cortexphp/cortex/src/Http/Controllers/AgentsController.php:26 +* @route '/api/agents/{agent}/invoke' +*/ +invoke.post = (args: { agent: string | number | { string: string | number } } | [agent: string | number | { string: string | number } ] | string | number | { string: string | number }, options?: RouteQueryOptions): RouteDefinition<'post'> => ({ + url: invoke.url(args, options), + method: 'post', +}) + +/** +* @see \Cortex\Http\Controllers\AgentsController::invoke +* @see Users/sean/Code/cortexphp/cortex/src/Http/Controllers/AgentsController.php:26 +* @route '/api/agents/{agent}/invoke' +*/ +invoke.head = (args: { agent: string | number | { string: string | number } } | [agent: string | number | { string: string | number } ] | string | number | { string: string | number }, options?: RouteQueryOptions): RouteDefinition<'head'> => ({ + url: invoke.url(args, options), + method: 'head', +}) + +/** +* @see \Cortex\Http\Controllers\AgentsController::stream +* @see Users/sean/Code/cortexphp/cortex/src/Http/Controllers/AgentsController.php:66 +* @route '/api/agents/{agent}/stream' +*/ +export const stream = (args: { agent: string | number | { string: string | number } } | [agent: string | number | { string: string | number } ] | string | number | { string: string | number }, options?: RouteQueryOptions): RouteDefinition<'get'> => ({ + url: stream.url(args, options), + method: 'get', +}) + +stream.definition = { + methods: ["get","post","head"], + url: '/api/agents/{agent}/stream', +} satisfies RouteDefinition<["get","post","head"]> + +/** +* @see \Cortex\Http\Controllers\AgentsController::stream +* @see Users/sean/Code/cortexphp/cortex/src/Http/Controllers/AgentsController.php:66 +* @route '/api/agents/{agent}/stream' +*/ +stream.url = (args: { agent: string | number | { string: string | number } } | [agent: string | number | { string: string | number } ] | string | number | { string: string | number }, options?: RouteQueryOptions) => { + if (typeof args === 'string' || typeof args === 'number') { + args = { agent: args } + } + + if (typeof args === 'object' && !Array.isArray(args) && 'string' in args) { + args = { agent: args.string } + } + + if (Array.isArray(args)) { + args = { + agent: args[0], + } + } + + args = applyUrlDefaults(args) + + const parsedArgs = { + agent: typeof args.agent === 'object' + ? args.agent.string + : args.agent, + } + + return stream.definition.url + .replace('{agent}', parsedArgs.agent.toString()) + .replace(/\/+$/, '') + queryParams(options) +} + +/** +* @see \Cortex\Http\Controllers\AgentsController::stream +* @see Users/sean/Code/cortexphp/cortex/src/Http/Controllers/AgentsController.php:66 +* @route '/api/agents/{agent}/stream' +*/ +stream.get = (args: { agent: string | number | { string: string | number } } | [agent: string | number | { string: string | number } ] | string | number | { string: string | number }, options?: RouteQueryOptions): RouteDefinition<'get'> => ({ + url: stream.url(args, options), + method: 'get', +}) + +/** +* @see \Cortex\Http\Controllers\AgentsController::stream +* @see Users/sean/Code/cortexphp/cortex/src/Http/Controllers/AgentsController.php:66 +* @route '/api/agents/{agent}/stream' +*/ +stream.post = (args: { agent: string | number | { string: string | number } } | [agent: string | number | { string: string | number } ] | string | number | { string: string | number }, options?: RouteQueryOptions): RouteDefinition<'post'> => ({ + url: stream.url(args, options), + method: 'post', +}) + +/** +* @see \Cortex\Http\Controllers\AgentsController::stream +* @see Users/sean/Code/cortexphp/cortex/src/Http/Controllers/AgentsController.php:66 +* @route '/api/agents/{agent}/stream' +*/ +stream.head = (args: { agent: string | number | { string: string | number } } | [agent: string | number | { string: string | number } ] | string | number | { string: string | number }, options?: RouteQueryOptions): RouteDefinition<'head'> => ({ + url: stream.url(args, options), + method: 'head', +}) + +const agents = { + invoke: Object.assign(invoke, invoke), + stream: Object.assign(stream, stream), +} + +export default agents \ No newline at end of file diff --git a/resources/js/routes/cortex/api/agui/index.ts b/resources/js/routes/cortex/api/agui/index.ts new file mode 100644 index 0000000..3578a63 --- /dev/null +++ b/resources/js/routes/cortex/api/agui/index.ts @@ -0,0 +1,40 @@ +import { queryParams, type RouteQueryOptions, type RouteDefinition } from './../../../../wayfinder' +/** +* @see \Cortex\Http\Controllers\AGUIController::__invoke +* @see Users/sean/Code/cortexphp/cortex/src/Http/Controllers/AGUIController.php:19 +* @route '/api/agui' +*/ +export const invoke = (options?: RouteQueryOptions): RouteDefinition<'post'> => ({ + url: invoke.url(options), + method: 'post', +}) + +invoke.definition = { + methods: ["post"], + url: '/api/agui', +} satisfies RouteDefinition<["post"]> + +/** +* @see \Cortex\Http\Controllers\AGUIController::__invoke +* @see Users/sean/Code/cortexphp/cortex/src/Http/Controllers/AGUIController.php:19 +* @route '/api/agui' +*/ +invoke.url = (options?: RouteQueryOptions) => { + return invoke.definition.url + queryParams(options) +} + +/** +* @see \Cortex\Http\Controllers\AGUIController::__invoke +* @see Users/sean/Code/cortexphp/cortex/src/Http/Controllers/AGUIController.php:19 +* @route '/api/agui' +*/ +invoke.post = (options?: RouteQueryOptions): RouteDefinition<'post'> => ({ + url: invoke.url(options), + method: 'post', +}) + +const agui = { + invoke: Object.assign(invoke, invoke), +} + +export default agui \ No newline at end of file diff --git a/resources/js/routes/cortex/api/index.ts b/resources/js/routes/cortex/api/index.ts new file mode 100644 index 0000000..f5bb48a --- /dev/null +++ b/resources/js/routes/cortex/api/index.ts @@ -0,0 +1,9 @@ +import agents from './agents' +import agui from './agui' + +const api = { + agents: Object.assign(agents, agents), + agui: Object.assign(agui, agui), +} + +export default api \ No newline at end of file diff --git a/resources/js/routes/cortex/index.ts b/resources/js/routes/cortex/index.ts new file mode 100644 index 0000000..5584bc3 --- /dev/null +++ b/resources/js/routes/cortex/index.ts @@ -0,0 +1,50 @@ +import { queryParams, type RouteQueryOptions, type RouteDefinition } from './../../wayfinder' +import api from './api' +import agents from './agents' +/** +* @see Users/sean/Code/cortexphp/cortex/routes/web.php:8 +* @route '/cortex' +*/ +export const dashboard = (options?: RouteQueryOptions): RouteDefinition<'get'> => ({ + url: dashboard.url(options), + method: 'get', +}) + +dashboard.definition = { + methods: ["get","head"], + url: '/cortex', +} satisfies RouteDefinition<["get","head"]> + +/** +* @see Users/sean/Code/cortexphp/cortex/routes/web.php:8 +* @route '/cortex' +*/ +dashboard.url = (options?: RouteQueryOptions) => { + return dashboard.definition.url + queryParams(options) +} + +/** +* @see Users/sean/Code/cortexphp/cortex/routes/web.php:8 +* @route '/cortex' +*/ +dashboard.get = (options?: RouteQueryOptions): RouteDefinition<'get'> => ({ + url: dashboard.url(options), + method: 'get', +}) + +/** +* @see Users/sean/Code/cortexphp/cortex/routes/web.php:8 +* @route '/cortex' +*/ +dashboard.head = (options?: RouteQueryOptions): RouteDefinition<'head'> => ({ + url: dashboard.url(options), + method: 'head', +}) + +const cortex = { + api: Object.assign(api, api), + dashboard: Object.assign(dashboard, dashboard), + agents: Object.assign(agents, agents), +} + +export default cortex \ No newline at end of file diff --git a/resources/js/routes/storage/index.ts b/resources/js/routes/storage/index.ts new file mode 100644 index 0000000..33cad0e --- /dev/null +++ b/resources/js/routes/storage/index.ts @@ -0,0 +1,65 @@ +import { queryParams, type RouteQueryOptions, type RouteDefinition, applyUrlDefaults } from './../../wayfinder' +import localA91488 from './local' +/** +* @see Users/sean/Code/cortexphp/cortex/vendor/laravel/framework/src/Illuminate/Filesystem/FilesystemServiceProvider.php:98 +* @route '/storage/{path}' +*/ +export const local = (args: { path: string | number } | [path: string | number ] | string | number, options?: RouteQueryOptions): RouteDefinition<'get'> => ({ + url: local.url(args, options), + method: 'get', +}) + +local.definition = { + methods: ["get","head"], + url: '/storage/{path}', +} satisfies RouteDefinition<["get","head"]> + +/** +* @see Users/sean/Code/cortexphp/cortex/vendor/laravel/framework/src/Illuminate/Filesystem/FilesystemServiceProvider.php:98 +* @route '/storage/{path}' +*/ +local.url = (args: { path: string | number } | [path: string | number ] | string | number, options?: RouteQueryOptions) => { + if (typeof args === 'string' || typeof args === 'number') { + args = { path: args } + } + + if (Array.isArray(args)) { + args = { + path: args[0], + } + } + + args = applyUrlDefaults(args) + + const parsedArgs = { + path: args.path, + } + + return local.definition.url + .replace('{path}', parsedArgs.path.toString()) + .replace(/\/+$/, '') + queryParams(options) +} + +/** +* @see Users/sean/Code/cortexphp/cortex/vendor/laravel/framework/src/Illuminate/Filesystem/FilesystemServiceProvider.php:98 +* @route '/storage/{path}' +*/ +local.get = (args: { path: string | number } | [path: string | number ] | string | number, options?: RouteQueryOptions): RouteDefinition<'get'> => ({ + url: local.url(args, options), + method: 'get', +}) + +/** +* @see Users/sean/Code/cortexphp/cortex/vendor/laravel/framework/src/Illuminate/Filesystem/FilesystemServiceProvider.php:98 +* @route '/storage/{path}' +*/ +local.head = (args: { path: string | number } | [path: string | number ] | string | number, options?: RouteQueryOptions): RouteDefinition<'head'> => ({ + url: local.url(args, options), + method: 'head', +}) + +const storage = { + local: Object.assign(local, localA91488), +} + +export default storage \ No newline at end of file diff --git a/resources/js/routes/storage/local/index.ts b/resources/js/routes/storage/local/index.ts new file mode 100644 index 0000000..d86ca8a --- /dev/null +++ b/resources/js/routes/storage/local/index.ts @@ -0,0 +1,55 @@ +import { queryParams, type RouteQueryOptions, type RouteDefinition, applyUrlDefaults } from './../../../wayfinder' +/** +* @see Users/sean/Code/cortexphp/cortex/vendor/laravel/framework/src/Illuminate/Filesystem/FilesystemServiceProvider.php:106 +* @route '/storage/{path}' +*/ +export const upload = (args: { path: string | number } | [path: string | number ] | string | number, options?: RouteQueryOptions): RouteDefinition<'put'> => ({ + url: upload.url(args, options), + method: 'put', +}) + +upload.definition = { + methods: ["put"], + url: '/storage/{path}', +} satisfies RouteDefinition<["put"]> + +/** +* @see Users/sean/Code/cortexphp/cortex/vendor/laravel/framework/src/Illuminate/Filesystem/FilesystemServiceProvider.php:106 +* @route '/storage/{path}' +*/ +upload.url = (args: { path: string | number } | [path: string | number ] | string | number, options?: RouteQueryOptions) => { + if (typeof args === 'string' || typeof args === 'number') { + args = { path: args } + } + + if (Array.isArray(args)) { + args = { + path: args[0], + } + } + + args = applyUrlDefaults(args) + + const parsedArgs = { + path: args.path, + } + + return upload.definition.url + .replace('{path}', parsedArgs.path.toString()) + .replace(/\/+$/, '') + queryParams(options) +} + +/** +* @see Users/sean/Code/cortexphp/cortex/vendor/laravel/framework/src/Illuminate/Filesystem/FilesystemServiceProvider.php:106 +* @route '/storage/{path}' +*/ +upload.put = (args: { path: string | number } | [path: string | number ] | string | number, options?: RouteQueryOptions): RouteDefinition<'put'> => ({ + url: upload.url(args, options), + method: 'put', +}) + +const local = { + upload: Object.assign(upload, upload), +} + +export default local \ No newline at end of file diff --git a/resources/js/routes/workbench/index.ts b/resources/js/routes/workbench/index.ts new file mode 100644 index 0000000..6bc6f86 --- /dev/null +++ b/resources/js/routes/workbench/index.ts @@ -0,0 +1,250 @@ +import { queryParams, type RouteQueryOptions, type RouteDefinition, applyUrlDefaults, validateParameters } from './../../wayfinder' +/** +* @see \Orchestra\Workbench\Http\Controllers\WorkbenchController::start +* @see Users/sean/Code/cortexphp/cortex/vendor/orchestra/workbench/src/Http/Controllers/WorkbenchController.php:18 +* @route '/_workbench' +*/ +export const start = (options?: RouteQueryOptions): RouteDefinition<'get'> => ({ + url: start.url(options), + method: 'get', +}) + +start.definition = { + methods: ["get","head"], + url: '/_workbench', +} satisfies RouteDefinition<["get","head"]> + +/** +* @see \Orchestra\Workbench\Http\Controllers\WorkbenchController::start +* @see Users/sean/Code/cortexphp/cortex/vendor/orchestra/workbench/src/Http/Controllers/WorkbenchController.php:18 +* @route '/_workbench' +*/ +start.url = (options?: RouteQueryOptions) => { + return start.definition.url + queryParams(options) +} + +/** +* @see \Orchestra\Workbench\Http\Controllers\WorkbenchController::start +* @see Users/sean/Code/cortexphp/cortex/vendor/orchestra/workbench/src/Http/Controllers/WorkbenchController.php:18 +* @route '/_workbench' +*/ +start.get = (options?: RouteQueryOptions): RouteDefinition<'get'> => ({ + url: start.url(options), + method: 'get', +}) + +/** +* @see \Orchestra\Workbench\Http\Controllers\WorkbenchController::start +* @see Users/sean/Code/cortexphp/cortex/vendor/orchestra/workbench/src/Http/Controllers/WorkbenchController.php:18 +* @route '/_workbench' +*/ +start.head = (options?: RouteQueryOptions): RouteDefinition<'head'> => ({ + url: start.url(options), + method: 'head', +}) + +/** +* @see \Orchestra\Workbench\Http\Controllers\WorkbenchController::login +* @see Users/sean/Code/cortexphp/cortex/vendor/orchestra/workbench/src/Http/Controllers/WorkbenchController.php:60 +* @route '/_workbench/login/{userId}/{guard?}' +*/ +export const login = (args: { userId: string | number, guard?: string | number } | [userId: string | number, guard: string | number ], options?: RouteQueryOptions): RouteDefinition<'get'> => ({ + url: login.url(args, options), + method: 'get', +}) + +login.definition = { + methods: ["get","head"], + url: '/_workbench/login/{userId}/{guard?}', +} satisfies RouteDefinition<["get","head"]> + +/** +* @see \Orchestra\Workbench\Http\Controllers\WorkbenchController::login +* @see Users/sean/Code/cortexphp/cortex/vendor/orchestra/workbench/src/Http/Controllers/WorkbenchController.php:60 +* @route '/_workbench/login/{userId}/{guard?}' +*/ +login.url = (args: { userId: string | number, guard?: string | number } | [userId: string | number, guard: string | number ], options?: RouteQueryOptions) => { + if (Array.isArray(args)) { + args = { + userId: args[0], + guard: args[1], + } + } + + args = applyUrlDefaults(args) + + validateParameters(args, [ + "guard", + ]) + + const parsedArgs = { + userId: args.userId, + guard: args.guard, + } + + return login.definition.url + .replace('{userId}', parsedArgs.userId.toString()) + .replace('{guard?}', parsedArgs.guard?.toString() ?? '') + .replace(/\/+$/, '') + queryParams(options) +} + +/** +* @see \Orchestra\Workbench\Http\Controllers\WorkbenchController::login +* @see Users/sean/Code/cortexphp/cortex/vendor/orchestra/workbench/src/Http/Controllers/WorkbenchController.php:60 +* @route '/_workbench/login/{userId}/{guard?}' +*/ +login.get = (args: { userId: string | number, guard?: string | number } | [userId: string | number, guard: string | number ], options?: RouteQueryOptions): RouteDefinition<'get'> => ({ + url: login.url(args, options), + method: 'get', +}) + +/** +* @see \Orchestra\Workbench\Http\Controllers\WorkbenchController::login +* @see Users/sean/Code/cortexphp/cortex/vendor/orchestra/workbench/src/Http/Controllers/WorkbenchController.php:60 +* @route '/_workbench/login/{userId}/{guard?}' +*/ +login.head = (args: { userId: string | number, guard?: string | number } | [userId: string | number, guard: string | number ], options?: RouteQueryOptions): RouteDefinition<'head'> => ({ + url: login.url(args, options), + method: 'head', +}) + +/** +* @see \Orchestra\Workbench\Http\Controllers\WorkbenchController::logout +* @see Users/sean/Code/cortexphp/cortex/vendor/orchestra/workbench/src/Http/Controllers/WorkbenchController.php:84 +* @route '/_workbench/logout/{guard?}' +*/ +export const logout = (args?: { guard?: string | number } | [guard: string | number ] | string | number, options?: RouteQueryOptions): RouteDefinition<'get'> => ({ + url: logout.url(args, options), + method: 'get', +}) + +logout.definition = { + methods: ["get","head"], + url: '/_workbench/logout/{guard?}', +} satisfies RouteDefinition<["get","head"]> + +/** +* @see \Orchestra\Workbench\Http\Controllers\WorkbenchController::logout +* @see Users/sean/Code/cortexphp/cortex/vendor/orchestra/workbench/src/Http/Controllers/WorkbenchController.php:84 +* @route '/_workbench/logout/{guard?}' +*/ +logout.url = (args?: { guard?: string | number } | [guard: string | number ] | string | number, options?: RouteQueryOptions) => { + if (typeof args === 'string' || typeof args === 'number') { + args = { guard: args } + } + + if (Array.isArray(args)) { + args = { + guard: args[0], + } + } + + args = applyUrlDefaults(args) + + validateParameters(args, [ + "guard", + ]) + + const parsedArgs = { + guard: args?.guard, + } + + return logout.definition.url + .replace('{guard?}', parsedArgs.guard?.toString() ?? '') + .replace(/\/+$/, '') + queryParams(options) +} + +/** +* @see \Orchestra\Workbench\Http\Controllers\WorkbenchController::logout +* @see Users/sean/Code/cortexphp/cortex/vendor/orchestra/workbench/src/Http/Controllers/WorkbenchController.php:84 +* @route '/_workbench/logout/{guard?}' +*/ +logout.get = (args?: { guard?: string | number } | [guard: string | number ] | string | number, options?: RouteQueryOptions): RouteDefinition<'get'> => ({ + url: logout.url(args, options), + method: 'get', +}) + +/** +* @see \Orchestra\Workbench\Http\Controllers\WorkbenchController::logout +* @see Users/sean/Code/cortexphp/cortex/vendor/orchestra/workbench/src/Http/Controllers/WorkbenchController.php:84 +* @route '/_workbench/logout/{guard?}' +*/ +logout.head = (args?: { guard?: string | number } | [guard: string | number ] | string | number, options?: RouteQueryOptions): RouteDefinition<'head'> => ({ + url: logout.url(args, options), + method: 'head', +}) + +/** +* @see \Orchestra\Workbench\Http\Controllers\WorkbenchController::user +* @see Users/sean/Code/cortexphp/cortex/vendor/orchestra/workbench/src/Http/Controllers/WorkbenchController.php:39 +* @route '/_workbench/user/{guard?}' +*/ +export const user = (args?: { guard?: string | number } | [guard: string | number ] | string | number, options?: RouteQueryOptions): RouteDefinition<'get'> => ({ + url: user.url(args, options), + method: 'get', +}) + +user.definition = { + methods: ["get","head"], + url: '/_workbench/user/{guard?}', +} satisfies RouteDefinition<["get","head"]> + +/** +* @see \Orchestra\Workbench\Http\Controllers\WorkbenchController::user +* @see Users/sean/Code/cortexphp/cortex/vendor/orchestra/workbench/src/Http/Controllers/WorkbenchController.php:39 +* @route '/_workbench/user/{guard?}' +*/ +user.url = (args?: { guard?: string | number } | [guard: string | number ] | string | number, options?: RouteQueryOptions) => { + if (typeof args === 'string' || typeof args === 'number') { + args = { guard: args } + } + + if (Array.isArray(args)) { + args = { + guard: args[0], + } + } + + args = applyUrlDefaults(args) + + validateParameters(args, [ + "guard", + ]) + + const parsedArgs = { + guard: args?.guard, + } + + return user.definition.url + .replace('{guard?}', parsedArgs.guard?.toString() ?? '') + .replace(/\/+$/, '') + queryParams(options) +} + +/** +* @see \Orchestra\Workbench\Http\Controllers\WorkbenchController::user +* @see Users/sean/Code/cortexphp/cortex/vendor/orchestra/workbench/src/Http/Controllers/WorkbenchController.php:39 +* @route '/_workbench/user/{guard?}' +*/ +user.get = (args?: { guard?: string | number } | [guard: string | number ] | string | number, options?: RouteQueryOptions): RouteDefinition<'get'> => ({ + url: user.url(args, options), + method: 'get', +}) + +/** +* @see \Orchestra\Workbench\Http\Controllers\WorkbenchController::user +* @see Users/sean/Code/cortexphp/cortex/vendor/orchestra/workbench/src/Http/Controllers/WorkbenchController.php:39 +* @route '/_workbench/user/{guard?}' +*/ +user.head = (args?: { guard?: string | number } | [guard: string | number ] | string | number, options?: RouteQueryOptions): RouteDefinition<'head'> => ({ + url: user.url(args, options), + method: 'head', +}) + +const workbench = { + start: Object.assign(start, start), + login: Object.assign(login, login), + logout: Object.assign(logout, logout), + user: Object.assign(user, user), +} + +export default workbench \ No newline at end of file diff --git a/resources/js/ssr.tsx b/resources/js/ssr.tsx new file mode 100644 index 0000000..2a29cf6 --- /dev/null +++ b/resources/js/ssr.tsx @@ -0,0 +1,22 @@ +import { createInertiaApp } from "@inertiajs/react"; +import createServer from "@inertiajs/react/server"; +import { resolvePageComponent } from "laravel-vite-plugin/inertia-helpers"; +import ReactDOMServer from "react-dom/server"; + +const appName = import.meta.env.VITE_APP_NAME || "Cortex Studio"; + +createServer((page) => + createInertiaApp({ + page, + render: ReactDOMServer.renderToString, + title: (title) => (title ? `${title} - ${appName}` : appName), + resolve: (name) => + resolvePageComponent( + `./pages/${name}.tsx`, + import.meta.glob("./pages/**/*.tsx"), + ), + setup: ({ App, props }) => { + return ; + }, + }), +); diff --git a/resources/js/types/agents.d.ts b/resources/js/types/agents.d.ts new file mode 100644 index 0000000..feeb1c2 --- /dev/null +++ b/resources/js/types/agents.d.ts @@ -0,0 +1,5 @@ +export interface Agent { + id: string; + name: string; + description: string; +} diff --git a/resources/js/types/index.d.ts b/resources/js/types/index.d.ts new file mode 100644 index 0000000..9f3f572 --- /dev/null +++ b/resources/js/types/index.d.ts @@ -0,0 +1,42 @@ +import { InertiaLinkProps } from "@inertiajs/react"; +import { LucideIcon } from "lucide-react"; + +export interface Auth { + user: User; +} + +export interface BreadcrumbItem { + title: string; + href: string; +} + +export interface NavGroup { + title: string; + items: NavItem[]; +} + +export interface NavItem { + title: string; + href: NonNullable; + icon?: LucideIcon | null; + isActive?: boolean; +} + +export interface SharedData { + name: string; + auth: Auth; + sidebarOpen: boolean; + [key: string]: unknown; +} + +export interface User { + id: number; + name: string; + email: string; + avatar?: string; + email_verified_at: string | null; + two_factor_enabled?: boolean; + created_at: string; + updated_at: string; + [key: string]: unknown; // This allows for additional properties... +} diff --git a/resources/js/types/vite-env.d.ts b/resources/js/types/vite-env.d.ts new file mode 100644 index 0000000..11f02fe --- /dev/null +++ b/resources/js/types/vite-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/resources/js/wayfinder/index.ts b/resources/js/wayfinder/index.ts new file mode 100644 index 0000000..c9d6526 --- /dev/null +++ b/resources/js/wayfinder/index.ts @@ -0,0 +1,158 @@ +export type QueryParams = { + [key: string]: + | string + | number + | boolean + | (string | number)[] + | null + | undefined + | QueryParams; +}; + +type Method = "get" | "post" | "put" | "delete" | "patch" | "head" | "options"; +type UrlDefaults = Record; + +let urlDefaults: () => UrlDefaults = () => ({}); + +export type RouteDefinition = { + url: string; +} & (TMethod extends Method[] ? { methods: TMethod } : { method: TMethod }); + +export type RouteFormDefinition = { + action: string; + method: TMethod; +}; + +export type RouteQueryOptions = { + query?: QueryParams; + mergeQuery?: QueryParams; +}; + +const getValue = (value: string | number | boolean) => { + if (value === true) { + return "1"; + } + + if (value === false) { + return "0"; + } + + return value.toString(); +}; + +const addNestedParams = ( + obj: QueryParams, + prefix: string, + params: URLSearchParams, +) => { + Object.entries(obj).forEach(([subKey, value]) => { + if (value === undefined) return; + + const paramKey = `${prefix}[${subKey}]`; + + if (Array.isArray(value)) { + value.forEach((v) => params.append(`${paramKey}[]`, getValue(v))); + } else if (value !== null && typeof value === "object") { + addNestedParams(value, paramKey, params); + } else if (["string", "number", "boolean"].includes(typeof value)) { + params.set(paramKey, getValue(value as string | number | boolean)); + } + }); +}; + +export const queryParams = (options?: RouteQueryOptions) => { + if (!options || (!options.query && !options.mergeQuery)) { + return ""; + } + + const query = options.query ?? options.mergeQuery; + const includeExisting = options.mergeQuery !== undefined; + + const params = new URLSearchParams( + includeExisting && typeof window !== "undefined" + ? window.location.search + : "", + ); + + for (const key in query) { + const queryValue = query[key]; + + if (queryValue === undefined || queryValue === null) { + params.delete(key); + continue; + } + + if (Array.isArray(queryValue)) { + if (params.has(`${key}[]`)) { + params.delete(`${key}[]`); + } + + queryValue.forEach((value) => { + params.append(`${key}[]`, value.toString()); + }); + } else if (typeof queryValue === "object") { + params.forEach((_, paramKey) => { + if (paramKey.startsWith(`${key}[`)) { + params.delete(paramKey); + } + }); + + addNestedParams(queryValue, key, params); + } else { + params.set(key, getValue(queryValue)); + } + } + + const str = params.toString(); + + return str.length > 0 ? `?${str}` : ""; +}; + +export const setUrlDefaults = (params: UrlDefaults | (() => UrlDefaults)) => { + urlDefaults = typeof params === "function" ? params : () => params; +}; + +export const addUrlDefault = ( + key: string, + value: string | number | boolean, +) => { + const params = urlDefaults(); + params[key] = value; + + urlDefaults = () => params; +}; + +export const applyUrlDefaults = ( + existing: T, +): T => { + const existingParams = { ...(existing ?? ({} as UrlDefaults)) }; + const defaultParams = urlDefaults(); + + for (const key in defaultParams) { + if ( + existingParams[key] === undefined && + defaultParams[key] !== undefined + ) { + (existingParams as Record)[key] = + defaultParams[key]; + } + } + + return existingParams as T; +}; + +export const validateParameters = ( + args: Record | undefined, + optional: string[], +) => { + const missing = optional.filter((key) => !args?.[key]); + const expectedMissing = optional.slice(missing.length * -1); + + for (let i = 0; i < missing.length; i++) { + if (missing[i] !== expectedMissing[i]) { + throw Error( + "Unexpected optional parameters missing. Unable to generate a URL.", + ); + } + } +}; diff --git a/resources/views/app.blade.php b/resources/views/app.blade.php new file mode 100644 index 0000000..832ba11 --- /dev/null +++ b/resources/views/app.blade.php @@ -0,0 +1,49 @@ + + ($appearance ?? 'system') == 'dark'])> + + + + + {{-- Inline script to detect system dark mode preference and apply it immediately --}} + + + {{-- Inline style to set the HTML background color based on our theme in app.css --}} + + + {{ config('app.name', 'Cortex') }} + + + + + + + + + @viteReactRefresh + @vite(['resources/js/app.tsx', "resources/js/pages/{$page['component']}.tsx"]) + @inertiaHead + + + @inertia + + diff --git a/routes/api.php b/routes/api.php index d1a10e0..48b3e8f 100644 --- a/routes/api.php +++ b/routes/api.php @@ -1,11 +1,14 @@ name('cortex.')->group(function () { +Route::prefix('api')->name('cortex.api.')->group(function () { Route::prefix('agents')->name('agents.')->group(function () { - Route::get('/{agent}/invoke', [AgentsController::class, 'invoke'])->name('invoke'); - Route::get('/{agent}/stream', [AgentsController::class, 'stream'])->name('stream'); + Route::match(['get', 'post'], '/{agent:string}/invoke', [AgentsController::class, 'invoke'])->name('invoke'); + Route::match(['get', 'post'], '/{agent:string}/stream', [AgentsController::class, 'stream'])->name('stream'); }); + + Route::post('/agui', AGUIController::class)->name('agui.invoke'); }); diff --git a/routes/web.php b/routes/web.php new file mode 100644 index 0000000..cfae667 --- /dev/null +++ b/routes/web.php @@ -0,0 +1,31 @@ +name('cortex.')->group(function () { + Route::get('/', function () { + return Inertia::render('dashboard')->rootView('cortex::app'); + })->name('dashboard'); + + Route::prefix('agents')->name('agents.')->group(function () { + Route::get('/', function () { + return Inertia::render('agents/index', [ + 'agents' => AgentRegistry::toArray(), + ]) + ->rootView('cortex::app'); + })->name('index'); + + Route::get('/{agent:string}', function ($agent) { + $agent = AgentRegistry::get($agent); + return Inertia::render('agents/show', [ + 'agent' => [ + 'id' => $agent->getId(), + 'name' => $agent->getName(), + 'description' => $agent->getDescription(), + ], + ])->rootView('cortex::app'); + })->name('show'); + }); +}); diff --git a/scratchpad.php b/scratchpad.php index 1910990..8ece8ea 100644 --- a/scratchpad.php +++ b/scratchpad.php @@ -10,7 +10,7 @@ use Cortex\Agents\Prebuilt\WeatherAgent; use Cortex\LLM\Data\Messages\UserMessage; use Cortex\LLM\Data\Messages\SystemMessage; -use Cortex\Tools\Prebuilt\OpenMeteoWeatherTool; +use Cortex\Tools\Prebuilt\GetCurrentWeatherTool; $prompt = Cortex::prompt([ new SystemMessage('You are an expert at geography.'), @@ -45,7 +45,7 @@ ]); // Get a generic agent builder from the prompt -$agentBuilder = $prompt->agentBuilder(); +$agentBuilder = $prompt->agent(); $result = $agentBuilder->invoke(input: [ 'country' => 'France', @@ -69,6 +69,10 @@ new UserMessage('What is the capital of {country}?'), ]); +$result = $prompt->agent()->invoke(input: [ + 'country' => 'France', +]); + // Uses default llm driver $result = Cortex::llm()->invoke([ new SystemMessage('You are a helpful assistant'), @@ -93,7 +97,7 @@ ]); $jokeAgent = new Agent( - name: 'joke_generator', + id: 'joke_generator', prompt: 'You are a joke generator. You generate jokes about {topic}.', llm: 'ollama:gpt-oss:20b', ); @@ -117,11 +121,11 @@ // ])); $agent = Cortex::agent() - ->withName('weather_agent') + ->withId('weather_agent') ->withPrompt('You are a weather agent. You tell the weather in {location}.') ->withLLM('lmstudio/openai/gpt-oss-20b') ->withTools([ - OpenMeteoWeatherTool::class, + GetCurrentWeatherTool::class, ]) ->withOutput([ Schema::string('location')->required(), diff --git a/src/AGUI/Contracts/Event.php b/src/AGUI/Contracts/Event.php index 5c0a25d..530f204 100644 --- a/src/AGUI/Contracts/Event.php +++ b/src/AGUI/Contracts/Event.php @@ -4,14 +4,40 @@ namespace Cortex\AGUI\Contracts; -use DateTimeImmutable; +use DateTimeInterface; use Cortex\AGUI\Enums\EventType; interface Event { + /** + * The type of the event. + */ public EventType $type { get; } - public ?DateTimeImmutable $timestamp { get; } + /** + * The timestamp of the event. + */ + public ?DateTimeInterface $timestamp { get; } + /** + * The raw event data. + */ public mixed $rawEvent { get; } + + /** + * Set the timestamp for the event. + */ + public function withTimestamp(DateTimeInterface $timestamp): static; + + /** + * Set the raw event for the event. + */ + public function withRawEvent(mixed $rawEvent): static; + + /** + * Convert the event to an array. + * + * @return array + */ + public function toArray(): array; } diff --git a/src/AGUI/Enums/EventType.php b/src/AGUI/Enums/EventType.php index 671c760..ba0534e 100644 --- a/src/AGUI/Enums/EventType.php +++ b/src/AGUI/Enums/EventType.php @@ -6,30 +6,84 @@ enum EventType: string { + /** Signals the start of an agent run. */ + case RunStarted = 'RUN_STARTED'; + + /** Signals the successful completion of an agent run. */ + case RunFinished = 'RUN_FINISHED'; + + /** Signals an error during an agent run. */ + case RunError = 'RUN_ERROR'; + + /** Signals the start of a step within an agent run. */ + case StepStarted = 'STEP_STARTED'; + + /** Signals the completion of a step within an agent run. */ + case StepFinished = 'STEP_FINISHED'; + + /** Signals the start of a text message. */ case TextMessageStart = 'TEXT_MESSAGE_START'; + + /** Represents a chunk of content in a streaming text message. */ case TextMessageContent = 'TEXT_MESSAGE_CONTENT'; + + /** Signals the end of a text message. */ case TextMessageEnd = 'TEXT_MESSAGE_END'; + + /** Convenience event that expands to Start → Content → End automatically. */ case TextMessageChunk = 'TEXT_MESSAGE_CHUNK'; - case ThinkingTextMessageStart = 'THINKING_TEXT_MESSAGE_START'; - case ThinkingTextMessageContent = 'THINKING_TEXT_MESSAGE_CONTENT'; - case ThinkingTextMessageEnd = 'THINKING_TEXT_MESSAGE_END'; + + /** Signals the start of a tool call. */ case ToolCallStart = 'TOOL_CALL_START'; + + /** Represents a chunk of argument data for a tool call. */ case ToolCallArgs = 'TOOL_CALL_ARGS'; + + /** Signals the end of a tool call. */ case ToolCallEnd = 'TOOL_CALL_END'; + + /** Convenience event that expands to Start → Args → End automatically. */ case ToolCallChunk = 'TOOL_CALL_CHUNK'; + + /** Provides the result of a tool call execution. */ case ToolCallResult = 'TOOL_CALL_RESULT'; - case ThinkingStart = 'THINKING_START'; - case ThinkingEnd = 'THINKING_END'; + + /** Marks the start of reasoning. */ + case ReasoningStart = 'THINKING_START'; + + /** Marks the end of reasoning. */ + case ReasoningEnd = 'THINKING_END'; + + /** Signals the start of a reasoning message. */ + case ReasoningMessageStart = 'THINKING_TEXT_MESSAGE_START'; + + /** Represents a chunk of content in a streaming reasoning message. */ + case ReasoningMessageContent = 'THINKING_TEXT_MESSAGE_CONTENT'; + + /** Signals the end of a reasoning message. */ + case ReasoningMessageEnd = 'THINKING_TEXT_MESSAGE_END'; + + /** A convenience event to auto start/close reasoning messages. */ + case ReasoningMessageChunk = 'THINKING_TEXT_MESSAGE_CHUNK'; + + /** Provides a complete snapshot of an agent’s state. */ case StateSnapshot = 'STATE_SNAPSHOT'; + + /** Provides a partial update to an agent’s state using JSON Patch. */ case StateDelta = 'STATE_DELTA'; + + /** Provides a snapshot of all messages in a conversation. */ case MessagesSnapshot = 'MESSAGES_SNAPSHOT'; + + /** Delivers a complete snapshot of an activity message. */ case ActivitySnapshot = 'ACTIVITY_SNAPSHOT'; + + /** Applies incremental updates to an existing activity using JSON Patch operations. */ case ActivityDelta = 'ACTIVITY_DELTA'; + + /** Used to pass through events from external systems. */ case Raw = 'RAW'; + + /** Used for application-specific custom events. */ case Custom = 'CUSTOM'; - case RunStarted = 'RUN_STARTED'; - case RunFinished = 'RUN_FINISHED'; - case RunError = 'RUN_ERROR'; - case StepStarted = 'STEP_STARTED'; - case StepFinished = 'STEP_FINISHED'; } diff --git a/src/AGUI/Events/AbstractEvent.php b/src/AGUI/Events/AbstractEvent.php index 1394ca1..eb8cf57 100644 --- a/src/AGUI/Events/AbstractEvent.php +++ b/src/AGUI/Events/AbstractEvent.php @@ -4,16 +4,53 @@ namespace Cortex\AGUI\Events; -use DateTimeImmutable; +use DateTimeInterface; use Cortex\AGUI\Contracts\Event; use Cortex\AGUI\Enums\EventType; abstract class AbstractEvent implements Event { - public EventType $type; + public protected(set) EventType $type; - public function __construct( - public ?DateTimeImmutable $timestamp = null, - public mixed $rawEvent = null, - ) {} + public protected(set) ?DateTimeInterface $timestamp = null; + + public protected(set) mixed $rawEvent; + + public function withTimestamp(DateTimeInterface $timestamp): static + { + $this->timestamp = $timestamp; + + return $this; + } + + public function withRawEvent(mixed $rawEvent): static + { + $this->rawEvent = $rawEvent; + + return $this; + } + + protected function formattedTimestamp(): ?int + { + return $this->timestamp?->getTimestamp(); + } + + /** + * @param array $additional + * + * @return array + */ + protected function buildArray(array $additional = []): array + { + $payload = [ + 'type' => $this->type->value, + ...$additional, + ]; + + if ($this->timestamp !== null) { + $payload['timestamp'] = $this->timestamp->getTimestamp(); + } + + return array_filter($payload, fn(mixed $value): bool => $value !== null); + } } diff --git a/src/AGUI/Events/ActivityDelta.php b/src/AGUI/Events/ActivityDelta.php index ccbf378..a814669 100644 --- a/src/AGUI/Events/ActivityDelta.php +++ b/src/AGUI/Events/ActivityDelta.php @@ -4,22 +4,36 @@ namespace Cortex\AGUI\Events; -use DateTimeImmutable; use Cortex\AGUI\Enums\EventType; +use Illuminate\Contracts\Support\Arrayable; -final class ActivityDelta extends AbstractEvent +/** + * @implements Arrayable + */ +final class ActivityDelta extends AbstractEvent implements Arrayable { + public EventType $type = EventType::ActivityDelta; + /** - * @param array $patch + * @param string $messageId Identifier for the target activity message + * @param string $activityType Activity discriminator (mirrors the value from the most recent snapshot) + * @param array $patch Array of RFC 6902 JSON Patch operations to apply to the activity data */ public function __construct( - ?DateTimeImmutable $timestamp = null, - mixed $rawEvent = null, public string $messageId = '', public string $activityType = '', public array $patch = [], - ) { - parent::__construct($timestamp, $rawEvent); - $this->type = EventType::ActivityDelta; + ) {} + + /** + * @return array + */ + public function toArray(): array + { + return $this->buildArray([ + 'messageId' => $this->messageId, + 'activityType' => $this->activityType, + 'patch' => $this->patch, + ]); } } diff --git a/src/AGUI/Events/ActivitySnapshot.php b/src/AGUI/Events/ActivitySnapshot.php index 5ab8e1c..a3fafda 100644 --- a/src/AGUI/Events/ActivitySnapshot.php +++ b/src/AGUI/Events/ActivitySnapshot.php @@ -4,23 +4,36 @@ namespace Cortex\AGUI\Events; -use DateTimeImmutable; use Cortex\AGUI\Enums\EventType; +use Illuminate\Contracts\Support\Arrayable; -final class ActivitySnapshot extends AbstractEvent +/** + * @implements Arrayable + */ +final class ActivitySnapshot extends AbstractEvent implements Arrayable { + public EventType $type = EventType::ActivitySnapshot; + /** * @param array $content */ public function __construct( - ?DateTimeImmutable $timestamp = null, - mixed $rawEvent = null, public string $messageId = '', public string $activityType = '', public array $content = [], public bool $replace = true, - ) { - parent::__construct($timestamp, $rawEvent); - $this->type = EventType::ActivitySnapshot; + ) {} + + /** + * @return array + */ + public function toArray(): array + { + return $this->buildArray([ + 'messageId' => $this->messageId, + 'activityType' => $this->activityType, + 'content' => $this->content, + 'replace' => $this->replace, + ]); } } diff --git a/src/AGUI/Events/Custom.php b/src/AGUI/Events/Custom.php index a8c74aa..667a5c9 100644 --- a/src/AGUI/Events/Custom.php +++ b/src/AGUI/Events/Custom.php @@ -4,18 +4,29 @@ namespace Cortex\AGUI\Events; -use DateTimeImmutable; use Cortex\AGUI\Enums\EventType; +use Illuminate\Contracts\Support\Arrayable; -final class Custom extends AbstractEvent +/** + * @implements Arrayable + */ +final class Custom extends AbstractEvent implements Arrayable { + public EventType $type = EventType::Custom; + public function __construct( - ?DateTimeImmutable $timestamp = null, - mixed $rawEvent = null, public string $name = '', public mixed $value = null, - ) { - parent::__construct($timestamp, $rawEvent); - $this->type = EventType::Custom; + ) {} + + /** + * @return array + */ + public function toArray(): array + { + return $this->buildArray([ + 'name' => $this->name, + 'value' => $this->value, + ]); } } diff --git a/src/AGUI/Events/MessagesSnapshot.php b/src/AGUI/Events/MessagesSnapshot.php index 788de82..2a00c3b 100644 --- a/src/AGUI/Events/MessagesSnapshot.php +++ b/src/AGUI/Events/MessagesSnapshot.php @@ -4,20 +4,30 @@ namespace Cortex\AGUI\Events; -use DateTimeImmutable; use Cortex\AGUI\Enums\EventType; +use Illuminate\Contracts\Support\Arrayable; -final class MessagesSnapshot extends AbstractEvent +/** + * @implements Arrayable + */ +final class MessagesSnapshot extends AbstractEvent implements Arrayable { + public EventType $type = EventType::MessagesSnapshot; + /** * @param array $messages */ public function __construct( - ?DateTimeImmutable $timestamp = null, - mixed $rawEvent = null, public array $messages = [], - ) { - parent::__construct($timestamp, $rawEvent); - $this->type = EventType::MessagesSnapshot; + ) {} + + /** + * @return array + */ + public function toArray(): array + { + return $this->buildArray([ + 'messages' => $this->messages, + ]); } } diff --git a/src/AGUI/Events/Raw.php b/src/AGUI/Events/Raw.php index f79c473..485500c 100644 --- a/src/AGUI/Events/Raw.php +++ b/src/AGUI/Events/Raw.php @@ -4,18 +4,29 @@ namespace Cortex\AGUI\Events; -use DateTimeImmutable; use Cortex\AGUI\Enums\EventType; +use Illuminate\Contracts\Support\Arrayable; -final class Raw extends AbstractEvent +/** + * @implements Arrayable + */ +final class Raw extends AbstractEvent implements Arrayable { + public EventType $type = EventType::Raw; + public function __construct( - ?DateTimeImmutable $timestamp = null, - mixed $rawEvent = null, public mixed $event = null, public ?string $source = null, - ) { - parent::__construct($timestamp, $rawEvent); - $this->type = EventType::Raw; + ) {} + + /** + * @return array + */ + public function toArray(): array + { + return $this->buildArray([ + 'event' => $this->event, + 'source' => $this->source, + ]); } } diff --git a/src/AGUI/Events/ReasoningEnd.php b/src/AGUI/Events/ReasoningEnd.php new file mode 100644 index 0000000..fa89bb4 --- /dev/null +++ b/src/AGUI/Events/ReasoningEnd.php @@ -0,0 +1,30 @@ + + */ +final class ReasoningEnd extends AbstractEvent implements Arrayable +{ + public EventType $type = EventType::ReasoningEnd; + + public function __construct( + public string $messageId = '', + ) {} + + /** + * @return array + */ + public function toArray(): array + { + return $this->buildArray([ + 'messageId' => $this->messageId, + ]); + } +} diff --git a/src/AGUI/Events/ReasoningMessageContent.php b/src/AGUI/Events/ReasoningMessageContent.php new file mode 100644 index 0000000..ef2d94c --- /dev/null +++ b/src/AGUI/Events/ReasoningMessageContent.php @@ -0,0 +1,32 @@ + + */ +final class ReasoningMessageContent extends AbstractEvent implements Arrayable +{ + public EventType $type = EventType::ReasoningMessageContent; + + public function __construct( + public string $messageId = '', + public string $delta = '', + ) {} + + /** + * @return array + */ + public function toArray(): array + { + return $this->buildArray([ + 'messageId' => $this->messageId, + 'delta' => $this->delta, + ]); + } +} diff --git a/src/AGUI/Events/ReasoningMessageEnd.php b/src/AGUI/Events/ReasoningMessageEnd.php new file mode 100644 index 0000000..a01ca0b --- /dev/null +++ b/src/AGUI/Events/ReasoningMessageEnd.php @@ -0,0 +1,30 @@ + + */ +final class ReasoningMessageEnd extends AbstractEvent implements Arrayable +{ + public EventType $type = EventType::ReasoningMessageEnd; + + public function __construct( + public string $messageId = '', + ) {} + + /** + * @return array + */ + public function toArray(): array + { + return $this->buildArray([ + 'messageId' => $this->messageId, + ]); + } +} diff --git a/src/AGUI/Events/ReasoningMessageStart.php b/src/AGUI/Events/ReasoningMessageStart.php new file mode 100644 index 0000000..eedb193 --- /dev/null +++ b/src/AGUI/Events/ReasoningMessageStart.php @@ -0,0 +1,32 @@ + + */ +final class ReasoningMessageStart extends AbstractEvent implements Arrayable +{ + public EventType $type = EventType::ReasoningMessageStart; + + public function __construct( + public string $messageId = '', + public string $role = 'assistant', + ) {} + + /** + * @return array + */ + public function toArray(): array + { + return $this->buildArray([ + 'messageId' => $this->messageId, + 'role' => $this->role, + ]); + } +} diff --git a/src/AGUI/Events/ReasoningStart.php b/src/AGUI/Events/ReasoningStart.php new file mode 100644 index 0000000..483354e --- /dev/null +++ b/src/AGUI/Events/ReasoningStart.php @@ -0,0 +1,32 @@ + + */ +final class ReasoningStart extends AbstractEvent implements Arrayable +{ + public EventType $type = EventType::ReasoningStart; + + public function __construct( + public string $messageId = '', + public ?string $encryptedContent = null, + ) {} + + /** + * @return array + */ + public function toArray(): array + { + return $this->buildArray([ + 'messageId' => $this->messageId, + 'encryptedContent' => $this->encryptedContent, + ]); + } +} diff --git a/src/AGUI/Events/RunError.php b/src/AGUI/Events/RunError.php index adb58c2..dd41f11 100644 --- a/src/AGUI/Events/RunError.php +++ b/src/AGUI/Events/RunError.php @@ -4,18 +4,29 @@ namespace Cortex\AGUI\Events; -use DateTimeImmutable; use Cortex\AGUI\Enums\EventType; +use Illuminate\Contracts\Support\Arrayable; -final class RunError extends AbstractEvent +/** + * @implements Arrayable + */ +final class RunError extends AbstractEvent implements Arrayable { + public EventType $type = EventType::RunError; + public function __construct( - ?DateTimeImmutable $timestamp = null, - mixed $rawEvent = null, public string $message = '', public ?string $code = null, - ) { - parent::__construct($timestamp, $rawEvent); - $this->type = EventType::RunError; + ) {} + + /** + * @return array + */ + public function toArray(): array + { + return $this->buildArray([ + 'message' => $this->message, + 'code' => $this->code, + ]); } } diff --git a/src/AGUI/Events/RunFinished.php b/src/AGUI/Events/RunFinished.php index 4c2f6ed..dfb5bb8 100644 --- a/src/AGUI/Events/RunFinished.php +++ b/src/AGUI/Events/RunFinished.php @@ -4,19 +4,31 @@ namespace Cortex\AGUI\Events; -use DateTimeImmutable; use Cortex\AGUI\Enums\EventType; +use Illuminate\Contracts\Support\Arrayable; -final class RunFinished extends AbstractEvent +/** + * @implements Arrayable + */ +final class RunFinished extends AbstractEvent implements Arrayable { + public EventType $type = EventType::RunFinished; + public function __construct( - ?DateTimeImmutable $timestamp = null, - mixed $rawEvent = null, public string $threadId = '', public string $runId = '', public mixed $result = null, - ) { - parent::__construct($timestamp, $rawEvent); - $this->type = EventType::RunFinished; + ) {} + + /** + * @return array + */ + public function toArray(): array + { + return $this->buildArray([ + 'threadId' => $this->threadId, + 'runId' => $this->runId, + 'result' => $this->result, + ]); } } diff --git a/src/AGUI/Events/RunStarted.php b/src/AGUI/Events/RunStarted.php index 006cf05..ea2c2fa 100644 --- a/src/AGUI/Events/RunStarted.php +++ b/src/AGUI/Events/RunStarted.php @@ -4,20 +4,33 @@ namespace Cortex\AGUI\Events; -use DateTimeImmutable; use Cortex\AGUI\Enums\EventType; +use Illuminate\Contracts\Support\Arrayable; -final class RunStarted extends AbstractEvent +/** + * @implements Arrayable + */ +final class RunStarted extends AbstractEvent implements Arrayable { + public EventType $type = EventType::RunStarted; + public function __construct( - ?DateTimeImmutable $timestamp = null, - mixed $rawEvent = null, public string $threadId = '', public string $runId = '', public ?string $parentRunId = null, public mixed $input = null, - ) { - parent::__construct($timestamp, $rawEvent); - $this->type = EventType::RunStarted; + ) {} + + /** + * @return array + */ + public function toArray(): array + { + return $this->buildArray([ + 'threadId' => $this->threadId, + 'runId' => $this->runId, + 'parentRunId' => $this->parentRunId, + 'input' => $this->input, + ]); } } diff --git a/src/AGUI/Events/StateDelta.php b/src/AGUI/Events/StateDelta.php index 8f9f50b..7d62899 100644 --- a/src/AGUI/Events/StateDelta.php +++ b/src/AGUI/Events/StateDelta.php @@ -4,20 +4,30 @@ namespace Cortex\AGUI\Events; -use DateTimeImmutable; use Cortex\AGUI\Enums\EventType; +use Illuminate\Contracts\Support\Arrayable; -final class StateDelta extends AbstractEvent +/** + * @implements Arrayable + */ +final class StateDelta extends AbstractEvent implements Arrayable { + public EventType $type = EventType::StateDelta; + /** * @param array $delta */ public function __construct( - ?DateTimeImmutable $timestamp = null, - mixed $rawEvent = null, public array $delta = [], - ) { - parent::__construct($timestamp, $rawEvent); - $this->type = EventType::StateDelta; + ) {} + + /** + * @return array + */ + public function toArray(): array + { + return $this->buildArray([ + 'delta' => $this->delta, + ]); } } diff --git a/src/AGUI/Events/StateSnapshot.php b/src/AGUI/Events/StateSnapshot.php index ad17898..81894c8 100644 --- a/src/AGUI/Events/StateSnapshot.php +++ b/src/AGUI/Events/StateSnapshot.php @@ -4,17 +4,27 @@ namespace Cortex\AGUI\Events; -use DateTimeImmutable; use Cortex\AGUI\Enums\EventType; +use Illuminate\Contracts\Support\Arrayable; -final class StateSnapshot extends AbstractEvent +/** + * @implements Arrayable + */ +final class StateSnapshot extends AbstractEvent implements Arrayable { + public EventType $type = EventType::StateSnapshot; + public function __construct( - ?DateTimeImmutable $timestamp = null, - mixed $rawEvent = null, public mixed $snapshot = null, - ) { - parent::__construct($timestamp, $rawEvent); - $this->type = EventType::StateSnapshot; + ) {} + + /** + * @return array + */ + public function toArray(): array + { + return $this->buildArray([ + 'snapshot' => $this->snapshot, + ]); } } diff --git a/src/AGUI/Events/StepFinished.php b/src/AGUI/Events/StepFinished.php index dbfea42..d5d1a70 100644 --- a/src/AGUI/Events/StepFinished.php +++ b/src/AGUI/Events/StepFinished.php @@ -4,17 +4,27 @@ namespace Cortex\AGUI\Events; -use DateTimeImmutable; use Cortex\AGUI\Enums\EventType; +use Illuminate\Contracts\Support\Arrayable; -final class StepFinished extends AbstractEvent +/** + * @implements Arrayable + */ +final class StepFinished extends AbstractEvent implements Arrayable { + public EventType $type = EventType::StepFinished; + public function __construct( - ?DateTimeImmutable $timestamp = null, - mixed $rawEvent = null, public string $stepName = '', - ) { - parent::__construct($timestamp, $rawEvent); - $this->type = EventType::StepFinished; + ) {} + + /** + * @return array + */ + public function toArray(): array + { + return $this->buildArray([ + 'stepName' => $this->stepName, + ]); } } diff --git a/src/AGUI/Events/StepStarted.php b/src/AGUI/Events/StepStarted.php index c13d7cd..1ac5b3d 100644 --- a/src/AGUI/Events/StepStarted.php +++ b/src/AGUI/Events/StepStarted.php @@ -4,17 +4,27 @@ namespace Cortex\AGUI\Events; -use DateTimeImmutable; use Cortex\AGUI\Enums\EventType; +use Illuminate\Contracts\Support\Arrayable; -final class StepStarted extends AbstractEvent +/** + * @implements Arrayable + */ +final class StepStarted extends AbstractEvent implements Arrayable { + public EventType $type = EventType::StepStarted; + public function __construct( - ?DateTimeImmutable $timestamp = null, - mixed $rawEvent = null, public string $stepName = '', - ) { - parent::__construct($timestamp, $rawEvent); - $this->type = EventType::StepStarted; + ) {} + + /** + * @return array + */ + public function toArray(): array + { + return $this->buildArray([ + 'stepName' => $this->stepName, + ]); } } diff --git a/src/AGUI/Events/TextMessageChunk.php b/src/AGUI/Events/TextMessageChunk.php index ca0848a..8ff37ee 100644 --- a/src/AGUI/Events/TextMessageChunk.php +++ b/src/AGUI/Events/TextMessageChunk.php @@ -4,19 +4,31 @@ namespace Cortex\AGUI\Events; -use DateTimeImmutable; use Cortex\AGUI\Enums\EventType; +use Illuminate\Contracts\Support\Arrayable; -final class TextMessageChunk extends AbstractEvent +/** + * @implements Arrayable + */ +final class TextMessageChunk extends AbstractEvent implements Arrayable { + public EventType $type = EventType::TextMessageChunk; + public function __construct( - ?DateTimeImmutable $timestamp = null, - mixed $rawEvent = null, public ?string $messageId = null, public ?string $role = null, public ?string $delta = null, - ) { - parent::__construct($timestamp, $rawEvent); - $this->type = EventType::TextMessageChunk; + ) {} + + /** + * @return array + */ + public function toArray(): array + { + return $this->buildArray([ + 'messageId' => $this->messageId, + 'role' => $this->role, + 'delta' => $this->delta, + ]); } } diff --git a/src/AGUI/Events/TextMessageContent.php b/src/AGUI/Events/TextMessageContent.php index 9fae383..cc6407b 100644 --- a/src/AGUI/Events/TextMessageContent.php +++ b/src/AGUI/Events/TextMessageContent.php @@ -4,18 +4,29 @@ namespace Cortex\AGUI\Events; -use DateTimeImmutable; use Cortex\AGUI\Enums\EventType; +use Illuminate\Contracts\Support\Arrayable; -final class TextMessageContent extends AbstractEvent +/** + * @implements Arrayable + */ +final class TextMessageContent extends AbstractEvent implements Arrayable { + public EventType $type = EventType::TextMessageContent; + public function __construct( - ?DateTimeImmutable $timestamp = null, - mixed $rawEvent = null, public string $messageId = '', public string $delta = '', - ) { - parent::__construct($timestamp, $rawEvent); - $this->type = EventType::TextMessageContent; + ) {} + + /** + * @return array + */ + public function toArray(): array + { + return $this->buildArray([ + 'messageId' => $this->messageId, + 'delta' => $this->delta, + ]); } } diff --git a/src/AGUI/Events/TextMessageEnd.php b/src/AGUI/Events/TextMessageEnd.php index 4dc018c..434ea83 100644 --- a/src/AGUI/Events/TextMessageEnd.php +++ b/src/AGUI/Events/TextMessageEnd.php @@ -4,17 +4,27 @@ namespace Cortex\AGUI\Events; -use DateTimeImmutable; use Cortex\AGUI\Enums\EventType; +use Illuminate\Contracts\Support\Arrayable; -final class TextMessageEnd extends AbstractEvent +/** + * @implements Arrayable + */ +final class TextMessageEnd extends AbstractEvent implements Arrayable { + public EventType $type = EventType::TextMessageEnd; + public function __construct( - ?DateTimeImmutable $timestamp = null, - mixed $rawEvent = null, public string $messageId = '', - ) { - parent::__construct($timestamp, $rawEvent); - $this->type = EventType::TextMessageEnd; + ) {} + + /** + * @return array + */ + public function toArray(): array + { + return $this->buildArray([ + 'messageId' => $this->messageId, + ]); } } diff --git a/src/AGUI/Events/TextMessageStart.php b/src/AGUI/Events/TextMessageStart.php index 65075b1..8f5e5db 100644 --- a/src/AGUI/Events/TextMessageStart.php +++ b/src/AGUI/Events/TextMessageStart.php @@ -4,18 +4,29 @@ namespace Cortex\AGUI\Events; -use DateTimeImmutable; use Cortex\AGUI\Enums\EventType; +use Illuminate\Contracts\Support\Arrayable; -final class TextMessageStart extends AbstractEvent +/** + * @implements Arrayable + */ +final class TextMessageStart extends AbstractEvent implements Arrayable { + public EventType $type = EventType::TextMessageStart; + public function __construct( - ?DateTimeImmutable $timestamp = null, - mixed $rawEvent = null, public string $messageId = '', public string $role = 'assistant', - ) { - parent::__construct($timestamp, $rawEvent); - $this->type = EventType::TextMessageStart; + ) {} + + /** + * @return array + */ + public function toArray(): array + { + return $this->buildArray([ + 'messageId' => $this->messageId, + 'role' => $this->role, + ]); } } diff --git a/src/AGUI/Events/ThinkingEnd.php b/src/AGUI/Events/ThinkingEnd.php deleted file mode 100644 index ec1ae51..0000000 --- a/src/AGUI/Events/ThinkingEnd.php +++ /dev/null @@ -1,19 +0,0 @@ -type = EventType::ThinkingEnd; - } -} diff --git a/src/AGUI/Events/ThinkingStart.php b/src/AGUI/Events/ThinkingStart.php deleted file mode 100644 index 07178a4..0000000 --- a/src/AGUI/Events/ThinkingStart.php +++ /dev/null @@ -1,20 +0,0 @@ -type = EventType::ThinkingStart; - } -} diff --git a/src/AGUI/Events/ThinkingTextMessageContent.php b/src/AGUI/Events/ThinkingTextMessageContent.php deleted file mode 100644 index 404dc90..0000000 --- a/src/AGUI/Events/ThinkingTextMessageContent.php +++ /dev/null @@ -1,20 +0,0 @@ -type = EventType::ThinkingTextMessageContent; - } -} diff --git a/src/AGUI/Events/ThinkingTextMessageEnd.php b/src/AGUI/Events/ThinkingTextMessageEnd.php deleted file mode 100644 index 0f6de9f..0000000 --- a/src/AGUI/Events/ThinkingTextMessageEnd.php +++ /dev/null @@ -1,19 +0,0 @@ -type = EventType::ThinkingTextMessageEnd; - } -} diff --git a/src/AGUI/Events/ThinkingTextMessageStart.php b/src/AGUI/Events/ThinkingTextMessageStart.php deleted file mode 100644 index b12b970..0000000 --- a/src/AGUI/Events/ThinkingTextMessageStart.php +++ /dev/null @@ -1,19 +0,0 @@ -type = EventType::ThinkingTextMessageStart; - } -} diff --git a/src/AGUI/Events/ToolCallArgs.php b/src/AGUI/Events/ToolCallArgs.php index 241bfa5..1e2dc65 100644 --- a/src/AGUI/Events/ToolCallArgs.php +++ b/src/AGUI/Events/ToolCallArgs.php @@ -4,18 +4,29 @@ namespace Cortex\AGUI\Events; -use DateTimeImmutable; use Cortex\AGUI\Enums\EventType; +use Illuminate\Contracts\Support\Arrayable; -final class ToolCallArgs extends AbstractEvent +/** + * @implements Arrayable + */ +final class ToolCallArgs extends AbstractEvent implements Arrayable { + public EventType $type = EventType::ToolCallArgs; + public function __construct( - ?DateTimeImmutable $timestamp = null, - mixed $rawEvent = null, public string $toolCallId = '', public string $delta = '', - ) { - parent::__construct($timestamp, $rawEvent); - $this->type = EventType::ToolCallArgs; + ) {} + + /** + * @return array + */ + public function toArray(): array + { + return $this->buildArray([ + 'toolCallId' => $this->toolCallId, + 'delta' => $this->delta, + ]); } } diff --git a/src/AGUI/Events/ToolCallChunk.php b/src/AGUI/Events/ToolCallChunk.php index 41844fa..119c271 100644 --- a/src/AGUI/Events/ToolCallChunk.php +++ b/src/AGUI/Events/ToolCallChunk.php @@ -4,20 +4,33 @@ namespace Cortex\AGUI\Events; -use DateTimeImmutable; use Cortex\AGUI\Enums\EventType; +use Illuminate\Contracts\Support\Arrayable; -final class ToolCallChunk extends AbstractEvent +/** + * @implements Arrayable + */ +final class ToolCallChunk extends AbstractEvent implements Arrayable { + public EventType $type = EventType::ToolCallChunk; + public function __construct( - ?DateTimeImmutable $timestamp = null, - mixed $rawEvent = null, public ?string $toolCallId = null, public ?string $toolCallName = null, public ?string $parentMessageId = null, public ?string $delta = null, - ) { - parent::__construct($timestamp, $rawEvent); - $this->type = EventType::ToolCallChunk; + ) {} + + /** + * @return array + */ + public function toArray(): array + { + return $this->buildArray([ + 'toolCallId' => $this->toolCallId, + 'toolCallName' => $this->toolCallName, + 'parentMessageId' => $this->parentMessageId, + 'delta' => $this->delta, + ]); } } diff --git a/src/AGUI/Events/ToolCallEnd.php b/src/AGUI/Events/ToolCallEnd.php index cd8f7c3..f210c4d 100644 --- a/src/AGUI/Events/ToolCallEnd.php +++ b/src/AGUI/Events/ToolCallEnd.php @@ -4,17 +4,27 @@ namespace Cortex\AGUI\Events; -use DateTimeImmutable; use Cortex\AGUI\Enums\EventType; +use Illuminate\Contracts\Support\Arrayable; -final class ToolCallEnd extends AbstractEvent +/** + * @implements Arrayable + */ +final class ToolCallEnd extends AbstractEvent implements Arrayable { + public EventType $type = EventType::ToolCallEnd; + public function __construct( - ?DateTimeImmutable $timestamp = null, - mixed $rawEvent = null, public string $toolCallId = '', - ) { - parent::__construct($timestamp, $rawEvent); - $this->type = EventType::ToolCallEnd; + ) {} + + /** + * @return array + */ + public function toArray(): array + { + return $this->buildArray([ + 'toolCallId' => $this->toolCallId, + ]); } } diff --git a/src/AGUI/Events/ToolCallResult.php b/src/AGUI/Events/ToolCallResult.php index bf66d2d..b25aa4f 100644 --- a/src/AGUI/Events/ToolCallResult.php +++ b/src/AGUI/Events/ToolCallResult.php @@ -4,20 +4,33 @@ namespace Cortex\AGUI\Events; -use DateTimeImmutable; use Cortex\AGUI\Enums\EventType; +use Illuminate\Contracts\Support\Arrayable; -final class ToolCallResult extends AbstractEvent +/** + * @implements Arrayable + */ +final class ToolCallResult extends AbstractEvent implements Arrayable { + public EventType $type = EventType::ToolCallResult; + public function __construct( - ?DateTimeImmutable $timestamp = null, - mixed $rawEvent = null, public string $messageId = '', public string $toolCallId = '', public string $content = '', public ?string $role = 'tool', - ) { - parent::__construct($timestamp, $rawEvent); - $this->type = EventType::ToolCallResult; + ) {} + + /** + * @return array + */ + public function toArray(): array + { + return $this->buildArray([ + 'messageId' => $this->messageId, + 'toolCallId' => $this->toolCallId, + 'content' => $this->content, + 'role' => $this->role, + ]); } } diff --git a/src/AGUI/Events/ToolCallStart.php b/src/AGUI/Events/ToolCallStart.php index 491ed38..4808dba 100644 --- a/src/AGUI/Events/ToolCallStart.php +++ b/src/AGUI/Events/ToolCallStart.php @@ -4,19 +4,31 @@ namespace Cortex\AGUI\Events; -use DateTimeImmutable; use Cortex\AGUI\Enums\EventType; +use Illuminate\Contracts\Support\Arrayable; -final class ToolCallStart extends AbstractEvent +/** + * @implements Arrayable + */ +final class ToolCallStart extends AbstractEvent implements Arrayable { + public EventType $type = EventType::ToolCallStart; + public function __construct( - ?DateTimeImmutable $timestamp = null, - mixed $rawEvent = null, public string $toolCallId = '', public string $toolCallName = '', public ?string $parentMessageId = null, - ) { - parent::__construct($timestamp, $rawEvent); - $this->type = EventType::ToolCallStart; + ) {} + + /** + * @return array + */ + public function toArray(): array + { + return $this->buildArray([ + 'toolCallId' => $this->toolCallId, + 'toolCallName' => $this->toolCallName, + 'parentMessageId' => $this->parentMessageId, + ]); } } diff --git a/src/Agents/AbstractAgentBuilder.php b/src/Agents/AbstractAgentBuilder.php index 52f508f..08e5d61 100644 --- a/src/Agents/AbstractAgentBuilder.php +++ b/src/Agents/AbstractAgentBuilder.php @@ -22,6 +22,16 @@ abstract class AbstractAgentBuilder implements AgentBuilder { + public function name(): ?string + { + return null; + } + + public function description(): ?string + { + return null; + } + public function llm(): LLM|string|null { return null; @@ -84,9 +94,11 @@ public function middleware(): array public function build(): Agent { return new Agent( - name: $this->name(), + id: $this->id(), prompt: $this->prompt(), llm: $this->llm(), + name: $this->name(), + description: $this->description(), tools: $this->tools(), toolChoice: $this->toolChoice(), output: $this->output(), diff --git a/src/Agents/Agent.php b/src/Agents/Agent.php index d19b6c8..8f8af47 100644 --- a/src/Agents/Agent.php +++ b/src/Agents/Agent.php @@ -10,6 +10,7 @@ use Cortex\LLM\Data\Usage; use Cortex\Prompts\Prompt; use Cortex\Events\AgentEnd; +use Cortex\Tools\AgentTool; use Illuminate\Support\Str; use Cortex\Contracts\ToolKit; use Cortex\Events\AgentStart; @@ -38,7 +39,6 @@ use Cortex\Memory\Stores\InMemoryStore; use Cortex\LLM\Data\ChatGenerationChunk; use Cortex\Agents\Stages\HandleToolCalls; -use Cortex\Agents\Stages\TrackAgentStart; use Cortex\JsonSchema\Types\ObjectSchema; use Cortex\LLM\Data\Messages\UserMessage; use Cortex\LLM\Enums\StructuredOutputMode; @@ -83,11 +83,13 @@ class Agent implements Pipeable * @param array|\Cortex\Contracts\ToolKit $tools * @param array $initialPromptVariables * @param array $middleware + * @param array $metadata */ public function __construct( - protected string $name, + protected string $id, ChatPromptTemplate|ChatPromptBuilder|string|null $prompt = null, LLMContract|string|null $llm = null, + protected ?string $name = null, protected ?string $description = null, protected array|ToolKit $tools = [], protected ToolChoice|string $toolChoice = ToolChoice::Auto, @@ -99,9 +101,10 @@ public function __construct( protected bool $strict = true, protected ?RuntimeConfig $runtimeConfig = null, protected array $middleware = [], + protected array $metadata = [], ) { $this->prompt = self::buildPromptTemplate($prompt, $strict, $initialPromptVariables); - $this->memory = self::buildMemory($this->prompt, $this->memoryStore); + $this->memory = self::buildMemory($this->prompt, $runtimeConfig?->threadId, $this->memoryStore); $this->middleware = [...$this->defaultMiddleware(), ...$this->middleware]; // Reset the prompt to only the message placeholders, since the initial @@ -111,7 +114,7 @@ public function __construct( $this->output = self::buildOutput($output); $this->llm = self::buildLLM( $this->prompt, - $this->name, + $this->id, $llm, $this->tools, $this->toolChoice, @@ -125,18 +128,16 @@ public function __construct( /** * @param array $messages * @param array $input + * + * @return ($streaming is true ? \Cortex\LLM\Data\ChatStreamResult : \Cortex\LLM\Data\ChatResult) */ public function invoke( MessageCollection|UserMessage|array|string $messages = [], array $input = [], ?RuntimeConfig $config = null, - ): ChatResult { - return $this->invokePipeline( - messages: $messages, - input: $input, - config: $config, - streaming: false, - ); + bool $streaming = false, + ): ChatResult|ChatStreamResult { + return $this->__invoke($messages, $input, $config, $streaming); } /** @@ -150,12 +151,7 @@ public function stream( array $input = [], ?RuntimeConfig $config = null, ): ChatStreamResult { - return $this->invokePipeline( - messages: $messages, - input: $input, - config: $config, - streaming: true, - ); + return $this->__invoke($messages, $input, $config, true); } /** @@ -198,7 +194,28 @@ public function handlePipeable(mixed $payload, RuntimeConfig $config, Closure $n return $next($this->invoke($messages, $input, $config), $config); } - public function getName(): string + /** + * Wrap the agent as a tool, which can be used by another agent. + */ + public function asTool( + ?string $name = null, + ?string $description = null, + ?ObjectSchema $schema = null, + ): AgentTool { + return new AgentTool( + $this, + $name ?? $this->id, + $description ?? $this->description, + $schema ?? $this->getInputSchema(), + ); + } + + public function getId(): string + { + return $this->id; + } + + public function getName(): ?string { return $this->name; } @@ -265,6 +282,34 @@ public function getRuntimeConfig(): ?RuntimeConfig return $this->runtimeConfig; } + /** + * Get the output schema for the agent. + */ + public function getOutputSchema(): ?ObjectSchema + { + if ($this->output === null) { + return null; + } + + $schema = $this->output instanceof ObjectSchema + ? $this->output + : Schema::from($this->output); + + if (! $schema instanceof ObjectSchema) { + throw new GenericException(sprintf('Output schema for agent [%s] is not an object schema', $this->id)); + } + + return $schema; + } + + /** + * Get the input schema for the agent. + */ + public function getInputSchema(): ObjectSchema + { + return $this->prompt->getInputSchema(); + } + /** * Register a listener for the start of the agent. */ @@ -320,7 +365,7 @@ public function withLLM(LLMContract|string|null $llm): self { $this->llm = self::buildLLM( $this->prompt, - $this->name, + $this->id, $llm, $this->tools, $this->toolChoice, @@ -352,17 +397,28 @@ public function withRuntimeConfig(RuntimeConfig $runtimeConfig): self return $this; } + /** + * Check if the agent has structured output. + */ + public function hasStructuredOutput(): bool + { + return $this->output !== null; + } + + /** + * Check if the agent has prompt variables. + */ + public function hasPromptVariables(): bool + { + return $this->prompt->variables()->isNotEmpty(); + } + protected function buildPipeline(): Pipeline { - $tools = Utils::toToolCollection($this->getTools()); $executionStages = $this->executionStages(); + $tools = Utils::toToolCollection($this->getTools()); - $pipeline = new Pipeline( - new TrackAgentStart($this), - ...$executionStages, - ); - - return $pipeline->when( + return new Pipeline(...$executionStages)->when( $tools->isNotEmpty(), fn(Pipeline $pipeline): Pipeline => $pipeline->pipe( new HandleToolCalls($tools, $this->memory, $executionStages, $this->maxSteps), @@ -405,6 +461,10 @@ protected function invokePipeline( $config ??= $this->runtimeConfig ?? new RuntimeConfig(); $this->withRuntimeConfig($config); + foreach ($config->tools as $tool) { + $this->llm->addTool($tool); + } + if ($streaming) { $config->onStreamChunk(function (RuntimeConfigStreamChunk $event): void { $this->withRuntimeConfig($event->config); @@ -428,6 +488,7 @@ protected function invokePipeline( $messages = Utils::toMessageCollection($messages); $this->memory + ->setThreadId($config->threadId) ->setMessages($this->memory->getMessages()->merge($messages)) ->setVariables([ ...$this->initialPromptVariables, @@ -442,19 +503,42 @@ protected function invokePipeline( $result = $this->pipeline ->enableStreaming($streaming) ->onStart(function (PipelineStart $event): void { + $event->config->pushChunkWhenStreaming( + new ChatGenerationChunk( + ChunkType::RunStart, + metadata: [ + 'run_id' => $event->config->runId, + 'thread_id' => $event->config->threadId, + ], + ), + fn() => $this->dispatchEvent(new AgentStart($this, $event->config)), + ); $this->withRuntimeConfig($event->config); }) ->onEnd(function (PipelineEnd $event): void { $this->withRuntimeConfig($event->config); $event->config->pushChunkWhenStreaming( - new ChatGenerationChunk(ChunkType::RunEnd), + new ChatGenerationChunk( + ChunkType::RunEnd, + metadata: [ + 'run_id' => $event->config->runId, + 'thread_id' => $event->config->threadId, + ], + ), fn() => $this->dispatchEvent(new AgentEnd($this, $event->config)), ); }) ->onError(function (PipelineError $event): void { $this->withRuntimeConfig($event->config); $event->config->pushChunkWhenStreaming( - new ChatGenerationChunk(ChunkType::Error), + new ChatGenerationChunk( + ChunkType::Error, + exception: $event->exception, + metadata: [ + 'run_id' => $event->config->runId, + 'thread_id' => $event->config->threadId, + ], + ), fn() => $this->dispatchEvent(new AgentStepError($this, $event->exception, $event->config)), ); }) @@ -498,9 +582,12 @@ protected static function buildPromptTemplate( /** * Build the memory instance for the agent. */ - protected static function buildMemory(ChatPromptTemplate $prompt, ?Store $memoryStore = null): ChatMemoryContract - { - $memoryStore ??= new InMemoryStore(Str::uuid7()->toString()); + protected static function buildMemory( + ChatPromptTemplate $prompt, + ?string $threadId = null, + ?Store $memoryStore = null, + ): ChatMemoryContract { + $memoryStore ??= new InMemoryStore($threadId ?? Str::uuid7()->toString()); $memoryStore->setMessages($prompt->messages->withoutPlaceholders()); return new ChatMemory($memoryStore); @@ -558,7 +645,7 @@ protected static function buildOutput(ObjectSchema|array|string|null $output): O try { collect($output)->ensure(JsonSchema::class); } catch (UnexpectedValueException $e) { - throw new GenericException('Invalid output schema: ' . $e->getMessage(), previous: $e); + throw new GenericException('Invalid output schema: ' . $e->getMessage(), $e->getCode(), previous: $e); } return Schema::object()->properties(...$output); diff --git a/src/Agents/Contracts/AgentBuilder.php b/src/Agents/Contracts/AgentBuilder.php index 75aa0da..003278e 100644 --- a/src/Agents/Contracts/AgentBuilder.php +++ b/src/Agents/Contracts/AgentBuilder.php @@ -15,10 +15,20 @@ interface AgentBuilder { + /** + * Specify the id of the agent. + */ + public static function id(): string; + /** * Specify the name of the agent. */ - public static function name(): string; + public function name(): ?string; + + /** + * Specify the description of the agent. + */ + public function description(): ?string; /** * Specify the prompt for the agent. diff --git a/src/Agents/Middleware/SkillMiddleware.php b/src/Agents/Middleware/SkillMiddleware.php new file mode 100644 index 0000000..b88ed00 --- /dev/null +++ b/src/Agents/Middleware/SkillMiddleware.php @@ -0,0 +1,136 @@ + $autoActivateSkills Skills to automatically activate + */ + public function __construct( + protected SkillRegistry $registry, + protected array $autoActivateSkills = [], + ) {} + + #[Override] + public function beforePrompt(mixed $payload, RuntimeConfig $config, Closure $next): mixed + { + // Get active skills from context (set by UseSkillTool) + /** @var array $activeSkills */ + $activeSkills = $config->context->get(UseSkillTool::ACTIVE_SKILLS_KEY, []); + + // Merge with auto-activated skills + foreach ($this->autoActivateSkills as $skillName) { + if ($this->registry->has($skillName)) { + $activeSkills[$skillName] = true; + } + } + + // If no active skills, pass through + if ($activeSkills === []) { + return $next($payload, $config); + } + + // Build skill instructions to inject + $skillInstructions = $this->buildSkillInstructions(array_keys($activeSkills)); + + if ($skillInstructions === '') { + return $next($payload, $config); + } + + // Inject skill instructions into the payload + $payload = $this->injectSkillInstructions($payload, $skillInstructions); + + return $next($payload, $config); + } + + /** + * Build the skill instructions string from active skills. + * + * @param array $skillNames + */ + protected function buildSkillInstructions(array $skillNames): string + { + $instructions = []; + + foreach ($skillNames as $name) { + if (! $this->registry->has($name)) { + continue; + } + + $skill = $this->registry->get($name); + $instructions[] = sprintf( + "\n%s\n", + $skill->name, + $skill->instructions, + ); + } + + if ($instructions === []) { + return ''; + } + + return "\n" . implode("\n\n", $instructions) . "\n"; + } + + /** + * Inject skill instructions into the payload. + */ + protected function injectSkillInstructions(mixed $payload, string $skillInstructions): mixed + { + if (! is_array($payload)) { + return $payload; + } + + // Add skill instructions as a variable that can be used in prompts + $payload['skill_instructions'] = $skillInstructions; + + // If there are messages in the payload, insert skill instructions after existing system messages + if (isset($payload['messages']) && $payload['messages'] instanceof MessageCollection) { + $payload['messages'] = $this->insertAfterSystemMessages( + $payload['messages'], + new SystemMessage($skillInstructions), + ); + } + + return $payload; + } + + /** + * Insert a message after all existing system messages. + */ + protected function insertAfterSystemMessages( + MessageCollection $messages, + SystemMessage $skillMessage, + ): MessageCollection { + // Find the index after the last system message + $lastSystemIndex = -1; + + foreach ($messages->values() as $index => $message) { + if ($message instanceof SystemMessage) { + $lastSystemIndex = $index; + } + } + + // Insert after the last system message, or at the beginning if none exist + $insertPosition = $lastSystemIndex + 1; + + // Split the collection and insert the skill message + $before = $messages->slice(0, $insertPosition); + $after = $messages->slice($insertPosition); + + return $before->push($skillMessage)->merge($after); + } +} diff --git a/src/Agents/Prebuilt/GenericAgentBuilder.php b/src/Agents/Prebuilt/GenericAgentBuilder.php index a23b8db..884c852 100644 --- a/src/Agents/Prebuilt/GenericAgentBuilder.php +++ b/src/Agents/Prebuilt/GenericAgentBuilder.php @@ -18,11 +18,11 @@ class GenericAgentBuilder extends AbstractAgentBuilder { use SetsAgentProperties; - protected static string $name = 'generic_agent'; + protected static string $id = 'generic_agent'; - public static function name(): string + public static function id(): string { - return static::$name; + return static::$id; } public function prompt(): ChatPromptTemplate|ChatPromptBuilder|string @@ -82,9 +82,9 @@ public function middleware(): array return $this->middleware; } - public function withName(string $name): self + public function withId(string $id): self { - static::$name = $name; + static::$id = $id; return $this; } diff --git a/src/Agents/Prebuilt/SkillsAgent.php b/src/Agents/Prebuilt/SkillsAgent.php new file mode 100644 index 0000000..8f0b790 --- /dev/null +++ b/src/Agents/Prebuilt/SkillsAgent.php @@ -0,0 +1,183 @@ + + */ + protected array $autoActivateSkills = []; + + public function __construct( + protected ?string $skillsDirectory = null, + protected ?LLM $llm = null, + ) {} + + public static function id(): string + { + return 'skills_agent'; + } + + public function name(): ?string + { + return 'Skills Agent'; + } + + public function description(): ?string + { + return 'An agent that can discover, read, and use skills from SKILL.md files to accomplish tasks.'; + } + + public function prompt(): ChatPromptTemplate|ChatPromptBuilder|string + { + return Cortex::prompt([ + new SystemMessage( + <<<'INSTRUCTIONS' + You are a helpful assistant with access to skills that provide specialized instructions for various tasks. + + ## Available Tools + + You have access to the following skill-related tools: + - `list_skills`: List all available skills with their names and descriptions + - `read_skill`: Read a skill's full instructions by name + - `use_skill`: Activate a skill to follow its instructions + + ## How to Use Skills + + 1. When the user asks for help with a task, first use `list_skills` to see what skills are available + 2. If a relevant skill exists, use `read_skill` to understand its instructions + 3. Use `use_skill` to activate the skill and follow its instructions to complete the task + 4. If no relevant skill exists, help the user with your general knowledge + + ## Guidelines + + - Always check available skills before attempting a task that might have a specialized skill + - Follow skill instructions carefully when a skill is activated + - Be transparent about which skill you're using to help the user + - If a skill's instructions conflict with the user's request, clarify with the user + INSTRUCTIONS, + ), + ]); + } + + public function llm(): LLM|string|null + { + return $this->llm ?? Cortex::llm('lmstudio', 'openai/gpt-oss-20b')->ignoreFeatures(); + } + + #[Override] + public function tools(): array|ToolKit + { + return $this->getToolkit(); + } + + /** + * @return array + */ + #[Override] + public function middleware(): array + { + return [ + new SkillMiddleware($this->getRegistry(), $this->autoActivateSkills), + ]; + } + + #[Override] + public function maxSteps(): int + { + return 10; + } + + /** + * Set the skills directory to load skills from. + */ + public function withSkillsDirectory(string $directory): self + { + $this->skillsDirectory = $directory; + $this->registry = null; + $this->toolkit = null; + + return $this; + } + + /** + * Set a custom skill registry. + */ + public function withRegistry(SkillRegistry $registry): self + { + $this->registry = $registry; + $this->toolkit = null; + + return $this; + } + + /** + * Set skills to automatically activate. + * + * @param array $skillNames + */ + public function withAutoActivateSkills(array $skillNames): self + { + $this->autoActivateSkills = $skillNames; + + return $this; + } + + /** + * Set the LLM to use. + */ + public function withLLM(LLM|string $llm): self + { + $this->llm = is_string($llm) ? Cortex::llm($llm) : $llm; + + return $this; + } + + /** + * Get the skill registry, creating it if necessary. + */ + protected function getRegistry(): SkillRegistry + { + if ($this->registry === null) { + $this->registry = new SkillRegistry(); + + if ($this->skillsDirectory !== null) { + $this->registry->registerFromDirectory($this->skillsDirectory); + } + } + + return $this->registry; + } + + /** + * Get the skill toolkit, creating it if necessary. + */ + protected function getToolkit(): SkillToolKit + { + if ($this->toolkit === null) { + $this->toolkit = new SkillToolKit($this->getRegistry()); + } + + return $this->toolkit; + } +} diff --git a/src/Agents/Prebuilt/WeatherAgent.php b/src/Agents/Prebuilt/WeatherAgent.php index 64858e7..7cac370 100644 --- a/src/Agents/Prebuilt/WeatherAgent.php +++ b/src/Agents/Prebuilt/WeatherAgent.php @@ -11,16 +11,26 @@ use Cortex\Agents\AbstractAgentBuilder; use Cortex\LLM\Data\Messages\SystemMessage; use Cortex\Prompts\Builders\ChatPromptBuilder; -use Cortex\Tools\Prebuilt\OpenMeteoWeatherTool; use Cortex\Prompts\Templates\ChatPromptTemplate; +use Cortex\Tools\Prebuilt\GetCurrentWeatherTool; class WeatherAgent extends AbstractAgentBuilder { - public static function name(): string + public static function id(): string { return 'weather'; } + public function name(): ?string + { + return 'Weather Assistant'; + } + + public function description(): ?string + { + return 'A helpful weather assistant that provides accurate weather information and can help planning activities based on the weather.'; + } + public function prompt(): ChatPromptTemplate|ChatPromptBuilder|string { return Cortex::prompt([ @@ -35,8 +45,9 @@ public function prompt(): ChatPromptTemplate|ChatPromptBuilder|string - Keep responses concise but informative - If the user asks for activities and provides the weather forecast, suggest activities based on the weather forecast. - If the user asks for activities, respond in the format they request. + - Respond in sentences, you don't need to show the weather data, since it's handled with the tool output. - Use the get_weather tool to fetch current weather data. + Use the `get_weather` tool to fetch current weather data. INSTRUCTIONS, ), ]); @@ -44,16 +55,16 @@ public function prompt(): ChatPromptTemplate|ChatPromptBuilder|string public function llm(): LLM|string|null { - // return Cortex::llm('ollama', 'gpt-oss:20b')->ignoreFeatures(); - // return Cortex::llm('openai', 'gpt-4.1-mini')->ignoreFeatures(); return Cortex::llm('lmstudio', 'openai/gpt-oss-20b')->ignoreFeatures(); + // return Cortex::llm('anthropic', 'claude-haiku-4-5')->ignoreFeatures(); + // return Cortex::llm('openai', 'gpt-5-mini')->ignoreFeatures(); } #[Override] public function tools(): array|ToolKit { return [ - OpenMeteoWeatherTool::class, + GetCurrentWeatherTool::class, ]; } } diff --git a/src/Agents/Registry.php b/src/Agents/Registry.php index 3a4fec3..eb06da8 100644 --- a/src/Agents/Registry.php +++ b/src/Agents/Registry.php @@ -20,7 +20,7 @@ final class Registry * * @param \Cortex\Agents\Agent|class-string<\Cortex\Agents\AbstractAgentBuilder> $agent */ - public function register(Agent|string $agent, ?string $nameOverride = null): void + public function register(Agent|string $agent, ?string $idOverride = null): void { if (is_string($agent)) { if (! class_exists($agent)) { @@ -40,30 +40,30 @@ public function register(Agent|string $agent, ?string $nameOverride = null): voi ); } - $name = $agent::name(); + $id = $agent::id(); } else { - $name = $agent->getName(); + $id = $agent->getId(); } - $this->agents[$nameOverride ?? $name] = $agent; + $this->agents[$idOverride ?? $id] = $agent; } /** - * Get an agent instance by name. + * Get an agent instance by id. * * @param array $parameters * * @throws \InvalidArgumentException */ - public function get(string $name, array $parameters = []): Agent + public function get(string $id, array $parameters = []): Agent { - if (! isset($this->agents[$name])) { + if (! isset($this->agents[$id])) { throw new InvalidArgumentException( - sprintf('Agent [%s] not found.', $name), + sprintf('Agent [%s] not found.', $id), ); } - $agent = $this->agents[$name]; + $agent = $this->agents[$id]; if ($agent instanceof Agent) { return $agent; @@ -76,18 +76,38 @@ public function get(string $name, array $parameters = []): Agent /** * Check if an agent is registered. */ - public function has(string $name): bool + public function has(string $id): bool { - return isset($this->agents[$name]); + return isset($this->agents[$id]); } /** - * Get all registered agent names. + * Get all registered agent ids. * * @return array */ - public function names(): array + public function ids(): array { return array_keys($this->agents); } + + /** + * @return array + */ + public function toArray(): array + { + return collect($this->agents) + ->map(function (string|Agent $agent): Agent { + return $agent instanceof Agent ? $agent : $agent::make(); + }) + ->map(function (Agent $agent): array { + return [ + 'id' => $agent->getId(), + 'name' => $agent->getName(), + 'description' => $agent->getDescription(), + ]; + }) + ->values() + ->toArray(); + } } diff --git a/src/Agents/Stages/HandleToolCalls.php b/src/Agents/Stages/HandleToolCalls.php index 528cd3e..a38c6bb 100644 --- a/src/Agents/Stages/HandleToolCalls.php +++ b/src/Agents/Stages/HandleToolCalls.php @@ -18,6 +18,7 @@ use Cortex\LLM\Data\ChatStreamResult; use Cortex\LLM\Data\ChatGenerationChunk; use Cortex\LLM\Data\Messages\ToolMessage; +use Cortex\LLM\Data\Messages\AssistantMessage; class HandleToolCalls implements Pipeable { @@ -39,7 +40,10 @@ public function __construct( public function handlePipeable(mixed $payload, RuntimeConfig $config, Closure $next): mixed { return match (true) { - $payload instanceof ChatGenerationChunk && $payload->type === ChunkType::ToolInputEnd => $this->handleStreamingChunk($payload, $config, $next), + // We trigger on isFinal (not ToolInputEnd) so that AddMessageToMemoryMiddleware + // has already added the assistant message before we process tool calls. + // We must check that message is AssistantMessage since ToolMessage doesn't have hasToolCalls() + $payload instanceof ChatGenerationChunk && $payload->isFinal && $payload->message instanceof AssistantMessage && $payload->message->hasToolCalls() => $this->handleStreamingChunk($payload, $config, $next), $payload instanceof ChatStreamResult => $this->handleStreamingResult($payload), default => $this->handleNonStreaming($payload, $config, $next), }; @@ -58,15 +62,28 @@ protected function handleStreamingChunk(ChatGenerationChunk $chunk, RuntimeConfi if ($nestedPayload !== null) { // Return stream with ToolInputEnd chunk + nested stream - // AbstractLLM will yield from this stream - return new ChatStreamResult(function () use ($processedChunk, $nestedPayload): Generator { + // We need to recursively process nested chunks through handlePipeable + // to handle any subsequent tool calls in the nested stream + return new ChatStreamResult(function () use ($processedChunk, $nestedPayload, $config, $next): Generator { if ($processedChunk instanceof ChatGenerationChunk) { yield $processedChunk; } if ($nestedPayload instanceof ChatStreamResult) { foreach ($nestedPayload as $nestedChunk) { - yield $nestedChunk; + // Recursively process nested chunks to handle any tool calls + // This is critical for multi-step tool calling during streaming + $processedNestedChunk = $this->handlePipeable($nestedChunk, $config, $next); + + // If the processed chunk is itself a stream (from recursive tool call handling), + // yield from it to flatten the stream + if ($processedNestedChunk instanceof ChatStreamResult) { + foreach ($processedNestedChunk as $recursiveChunk) { + yield $recursiveChunk; + } + } else { + yield $processedNestedChunk; + } } } }); @@ -158,7 +175,7 @@ protected function getGeneration(mixed $payload): ChatGeneration|ChatGenerationC { return match (true) { $payload instanceof ChatGeneration => $payload, - $payload instanceof ChatGenerationChunk && $payload->type === ChunkType::ToolInputEnd => $payload, + $payload instanceof ChatGenerationChunk && $payload->isFinal && $payload->message instanceof AssistantMessage && $payload->message->hasToolCalls() => $payload, $payload instanceof ChatResult => $payload->generation, default => null, }; diff --git a/src/Agents/Stages/TrackAgentStart.php b/src/Agents/Stages/TrackAgentStart.php deleted file mode 100644 index 8b34e3c..0000000 --- a/src/Agents/Stages/TrackAgentStart.php +++ /dev/null @@ -1,33 +0,0 @@ -pushChunkWhenStreaming( - new ChatGenerationChunk(ChunkType::RunStart), - fn() => $this->agent->dispatchEvent(new AgentStart($this->agent, $config)), - ); - - return $next($payload, $config); - } -} diff --git a/src/Console/AgentChat.php b/src/Console/AgentChat.php index 478b499..7ed223c 100644 --- a/src/Console/AgentChat.php +++ b/src/Console/AgentChat.php @@ -60,13 +60,13 @@ protected function getAgent(): ?Agent } catch (InvalidArgumentException) { promptsError(sprintf("Agent '%s' not found in registry.", $agentName)); - $availableAgents = AgentRegistry::names(); + $availableAgents = AgentRegistry::ids(); if (! empty($availableAgents)) { info('Available agents:'); table( - headers: ['Name'], - rows: array_map(fn(string $name): array => [$name], $availableAgents), + headers: ['Id'], + rows: array_map(fn(string $id): array => [$id], $availableAgents), ); } diff --git a/src/Console/ChatPrompt.php b/src/Console/ChatPrompt.php index 4ed4be8..d936332 100644 --- a/src/Console/ChatPrompt.php +++ b/src/Console/ChatPrompt.php @@ -407,6 +407,8 @@ protected function processAgentResponse(string $userInput): void // Stream response in real-time foreach ($result as $chunk) { + $textSoFar = $chunk->textSoFar(); + // Handle tool calls in debug mode if ($this->debug) { match ($chunk->type) { @@ -417,9 +419,9 @@ protected function processAgentResponse(string $userInput): void }; } - if ($chunk->type === ChunkType::TextDelta && $chunk->contentSoFar !== '') { - $fullResponse = $chunk->contentSoFar; - $this->streamingContent = $chunk->contentSoFar; + if ($chunk->type === ChunkType::TextDelta && $textSoFar !== null) { + $fullResponse = $textSoFar; + $this->streamingContent = $textSoFar; // Auto-scroll to bottom during streaming if enabled if ($this->autoScroll) { @@ -430,9 +432,9 @@ protected function processAgentResponse(string $userInput): void $this->throttledRender(); } - if ($chunk->isFinal && $chunk->contentSoFar !== '') { - $fullResponse = $chunk->contentSoFar; - $this->streamingContent = $chunk->contentSoFar; + if ($chunk->isFinal && $textSoFar !== null) { + $fullResponse = $textSoFar; + $this->streamingContent = $textSoFar; // Force render for final chunk (not throttled) $this->render(); } diff --git a/src/Console/ChatRenderer.php b/src/Console/ChatRenderer.php index 7488b31..0817e3c 100644 --- a/src/Console/ChatRenderer.php +++ b/src/Console/ChatRenderer.php @@ -54,7 +54,7 @@ public function __invoke(ChatPrompt $prompt): string protected function drawHeader(ChatPrompt $prompt): void { - $agentName = $prompt->agent?->getName() ?? 'Unknown'; + $agentName = $prompt->agent?->getId() ?? 'Unknown'; $width = Prompt::terminal()->cols(); $this->line(str_repeat('═', $width)); diff --git a/src/Contracts/ChatMemory.php b/src/Contracts/ChatMemory.php index db96b22..0efd8ec 100644 --- a/src/Contracts/ChatMemory.php +++ b/src/Contracts/ChatMemory.php @@ -50,4 +50,9 @@ public function getVariables(): array; * Delegates to the underlying store - the store is the source of truth for threadId. */ public function getThreadId(): string; + + /** + * Set the thread ID for this memory instance. + */ + public function setThreadId(string $threadId): static; } diff --git a/src/Cortex.php b/src/Cortex.php index 416864b..79eacf0 100644 --- a/src/Cortex.php +++ b/src/Cortex.php @@ -69,15 +69,15 @@ public static function llm(?string $provider = null, Closure|string|null $model } /** - * Get an agent instance from the registry by name. + * Get an agent instance from the registry by id. * - * @return ($name is null ? \Cortex\Agents\Prebuilt\GenericAgentBuilder : \Cortex\Agents\Agent) + * @return ($id is null ? \Cortex\Agents\Prebuilt\GenericAgentBuilder : \Cortex\Agents\Agent) */ - public static function agent(?string $name = null): Agent|GenericAgentBuilder + public static function agent(?string $id = null): Agent|GenericAgentBuilder { - return $name === null + return $id === null ? new GenericAgentBuilder() - : AgentRegistry::get($name); + : AgentRegistry::get($id); } /** diff --git a/src/CortexServiceProvider.php b/src/CortexServiceProvider.php index 7f9284e..77acb85 100644 --- a/src/CortexServiceProvider.php +++ b/src/CortexServiceProvider.php @@ -5,11 +5,14 @@ namespace Cortex; use Throwable; +use Monolog\Logger; use Cortex\LLM\LLMManager; use Cortex\Agents\Registry; use Cortex\Console\AgentChat; use Cortex\LLM\Contracts\LLM; use Cortex\Mcp\McpServerManager; +use Monolog\Handler\StreamHandler; +use Monolog\Formatter\LineFormatter; use Illuminate\Support\Facades\Blade; use Cortex\ModelInfo\ModelInfoFactory; use Spatie\LaravelPackageTools\Package; @@ -18,7 +21,9 @@ use Cortex\Embeddings\Contracts\Embeddings; use Cortex\Prompts\Contracts\PromptFactory; use Illuminate\Contracts\Container\Container; +use Cortex\Support\Events\InternalEventDispatcher; use Spatie\LaravelPackageTools\PackageServiceProvider; +use Cortex\Support\Events\Subscribers\LoggingSubscriber; class CortexServiceProvider extends PackageServiceProvider { @@ -26,7 +31,8 @@ public function configurePackage(Package $package): void { $package->name('cortex') ->hasConfigFile() - ->hasRoutes('api') + ->hasRoutes('api', 'web') + ->hasViews('cortex') ->hasCommand(AgentChat::class); } @@ -47,6 +53,8 @@ public function packageBooted(): void } $this->registerBladeDirectives(); + + $this->setupLogging(); } protected function registerBladeDirectives(): void @@ -147,4 +155,20 @@ protected function registerAgentRegistry(): void $this->app->singleton('cortex.agent_registry', fn(Container $app): Registry => new Registry()); $this->app->alias('cortex.agent_registry', Registry::class); } + + protected function setupLogging(): void + { + if ($this->app->runningUnitTests()) { + return; + } + + // TODO: This will be configurable. + $logger = new Logger('cortex'); + $handler = new StreamHandler('php://stdout'); + $handler->setFormatter(new LineFormatter()); + + $logger->pushHandler($handler); + + InternalEventDispatcher::instance()->subscribe(new LoggingSubscriber($logger)); + } } diff --git a/src/Events/AgentEnd.php b/src/Events/AgentEnd.php index 7358a25..1a480bf 100644 --- a/src/Events/AgentEnd.php +++ b/src/Events/AgentEnd.php @@ -14,4 +14,18 @@ public function __construct( public Agent $agent, public ?RuntimeConfig $config = null, ) {} + + public function eventId(): string + { + return 'agent.end'; + } + + public function toArray(): array + { + return [ + 'run_id' => $this->config?->runId, + 'thread_id' => $this->config?->threadId, + 'agent' => $this->agent->getId(), + ]; + } } diff --git a/src/Events/AgentStart.php b/src/Events/AgentStart.php index 51bca2b..7d21214 100644 --- a/src/Events/AgentStart.php +++ b/src/Events/AgentStart.php @@ -14,4 +14,18 @@ public function __construct( public Agent $agent, public ?RuntimeConfig $config = null, ) {} + + public function eventId(): string + { + return 'agent.start'; + } + + public function toArray(): array + { + return [ + 'run_id' => $this->config?->runId, + 'thread_id' => $this->config?->threadId, + 'agent' => $this->agent->getId(), + ]; + } } diff --git a/src/Events/AgentStepEnd.php b/src/Events/AgentStepEnd.php index 1dccdd6..0a8dc49 100644 --- a/src/Events/AgentStepEnd.php +++ b/src/Events/AgentStepEnd.php @@ -14,4 +14,18 @@ public function __construct( public Agent $agent, public ?RuntimeConfig $config = null, ) {} + + public function eventId(): string + { + return 'agent.step_end'; + } + + public function toArray(): array + { + return [ + 'run_id' => $this->config?->runId, + 'thread_id' => $this->config?->threadId, + 'agent' => $this->agent->getId(), + ]; + } } diff --git a/src/Events/AgentStepError.php b/src/Events/AgentStepError.php index ce05c61..85105eb 100644 --- a/src/Events/AgentStepError.php +++ b/src/Events/AgentStepError.php @@ -16,4 +16,19 @@ public function __construct( public Throwable $exception, public ?RuntimeConfig $config = null, ) {} + + public function eventId(): string + { + return 'agent.step_error'; + } + + public function toArray(): array + { + return [ + 'run_id' => $this->config?->runId, + 'thread_id' => $this->config?->threadId, + 'agent' => $this->agent->getId(), + 'error' => $this->exception->getMessage(), + ]; + } } diff --git a/src/Events/AgentStepStart.php b/src/Events/AgentStepStart.php index a98e54b..41246e4 100644 --- a/src/Events/AgentStepStart.php +++ b/src/Events/AgentStepStart.php @@ -14,4 +14,18 @@ public function __construct( public Agent $agent, public ?RuntimeConfig $config = null, ) {} + + public function eventId(): string + { + return 'agent.step_start'; + } + + public function toArray(): array + { + return [ + 'run_id' => $this->config?->runId, + 'thread_id' => $this->config?->threadId, + 'agent' => $this->agent->getId(), + ]; + } } diff --git a/src/Events/AgentStreamChunk.php b/src/Events/AgentStreamChunk.php index f7c6f5f..d415ad7 100644 --- a/src/Events/AgentStreamChunk.php +++ b/src/Events/AgentStreamChunk.php @@ -16,4 +16,18 @@ public function __construct( public ChatGenerationChunk $chunk, public ?RuntimeConfig $config = null, ) {} + + public function eventId(): string + { + return 'agent.stream_chunk'; + } + + public function toArray(): array + { + return [ + 'agent' => $this->agent->getId(), + // 'chunk' => $this->chunk->toArray(), + // 'config' => $this->config->toArray(), + ]; + } } diff --git a/src/Events/ChatModelEnd.php b/src/Events/ChatModelEnd.php index a4d9fa7..360c47b 100644 --- a/src/Events/ChatModelEnd.php +++ b/src/Events/ChatModelEnd.php @@ -15,4 +15,14 @@ public function __construct( public LLM $llm, public ChatResult|ChatStreamResult $result, ) {} + + public function eventId(): string + { + return 'chat_model.end'; + } + + public function toArray(): array + { + return []; + } } diff --git a/src/Events/ChatModelError.php b/src/Events/ChatModelError.php index ab60d54..2e483e2 100644 --- a/src/Events/ChatModelError.php +++ b/src/Events/ChatModelError.php @@ -18,4 +18,14 @@ public function __construct( public array $parameters, public Throwable $exception, ) {} + + public function eventId(): string + { + return 'chat_model.error'; + } + + public function toArray(): array + { + return []; + } } diff --git a/src/Events/ChatModelStart.php b/src/Events/ChatModelStart.php index 2d12658..eff1e57 100644 --- a/src/Events/ChatModelStart.php +++ b/src/Events/ChatModelStart.php @@ -18,4 +18,16 @@ public function __construct( public MessageCollection $messages, public array $parameters = [], ) {} + + public function eventId(): string + { + return 'chat_model.start'; + } + + public function toArray(): array + { + return [ + 'parameters' => $this->parameters, + ]; + } } diff --git a/src/Events/ChatModelStream.php b/src/Events/ChatModelStream.php index 37c15b4..0befb79 100644 --- a/src/Events/ChatModelStream.php +++ b/src/Events/ChatModelStream.php @@ -14,4 +14,14 @@ public function __construct( public LLM $llm, public ChatGenerationChunk $chunk, ) {} + + public function eventId(): string + { + return 'chat_model.stream'; + } + + public function toArray(): array + { + return []; + } } diff --git a/src/Events/ChatModelStreamEnd.php b/src/Events/ChatModelStreamEnd.php index e53ae15..51ec278 100644 --- a/src/Events/ChatModelStreamEnd.php +++ b/src/Events/ChatModelStreamEnd.php @@ -14,4 +14,14 @@ public function __construct( public LLM $llm, public ChatGenerationChunk $chunk, ) {} + + public function eventId(): string + { + return 'chat_model.stream_end'; + } + + public function toArray(): array + { + return []; + } } diff --git a/src/Events/Contracts/AgentEvent.php b/src/Events/Contracts/AgentEvent.php index 8c260b3..b4f8f63 100644 --- a/src/Events/Contracts/AgentEvent.php +++ b/src/Events/Contracts/AgentEvent.php @@ -6,7 +6,7 @@ use Cortex\Agents\Agent; -interface AgentEvent +interface AgentEvent extends CortexEvent { public Agent $agent { get; } } diff --git a/src/Events/Contracts/ChatModelEvent.php b/src/Events/Contracts/ChatModelEvent.php index fd4c792..6acf3d0 100644 --- a/src/Events/Contracts/ChatModelEvent.php +++ b/src/Events/Contracts/ChatModelEvent.php @@ -6,7 +6,7 @@ use Cortex\LLM\Contracts\LLM; -interface ChatModelEvent +interface ChatModelEvent extends CortexEvent { public LLM $llm { get; } } diff --git a/src/Events/Contracts/CortexEvent.php b/src/Events/Contracts/CortexEvent.php new file mode 100644 index 0000000..1fef055 --- /dev/null +++ b/src/Events/Contracts/CortexEvent.php @@ -0,0 +1,15 @@ + + */ + public function toArray(): array; +} diff --git a/src/Events/Contracts/OutputParserEvent.php b/src/Events/Contracts/OutputParserEvent.php index 427a619..b765ea7 100644 --- a/src/Events/Contracts/OutputParserEvent.php +++ b/src/Events/Contracts/OutputParserEvent.php @@ -6,7 +6,7 @@ use Cortex\Contracts\OutputParser; -interface OutputParserEvent +interface OutputParserEvent extends CortexEvent { public OutputParser $outputParser { get; } } diff --git a/src/Events/Contracts/PipelineEvent.php b/src/Events/Contracts/PipelineEvent.php index 329a660..6615a8a 100644 --- a/src/Events/Contracts/PipelineEvent.php +++ b/src/Events/Contracts/PipelineEvent.php @@ -6,7 +6,7 @@ use Cortex\Pipeline; -interface PipelineEvent +interface PipelineEvent extends CortexEvent { public Pipeline $pipeline { get; } } diff --git a/src/Events/Contracts/RuntimeConfigEvent.php b/src/Events/Contracts/RuntimeConfigEvent.php index 8a7e903..da41aed 100644 --- a/src/Events/Contracts/RuntimeConfigEvent.php +++ b/src/Events/Contracts/RuntimeConfigEvent.php @@ -6,7 +6,7 @@ use Cortex\Pipeline\RuntimeConfig; -interface RuntimeConfigEvent +interface RuntimeConfigEvent extends CortexEvent { public RuntimeConfig $config { get; } } diff --git a/src/Events/Contracts/StageEvent.php b/src/Events/Contracts/StageEvent.php index bf5ce5e..2cf64a5 100644 --- a/src/Events/Contracts/StageEvent.php +++ b/src/Events/Contracts/StageEvent.php @@ -8,7 +8,7 @@ use Cortex\Pipeline; use Cortex\Contracts\Pipeable; -interface StageEvent +interface StageEvent extends CortexEvent { public Pipeline $pipeline { get; } diff --git a/src/Events/Contracts/ToolCallEvent.php b/src/Events/Contracts/ToolCallEvent.php new file mode 100644 index 0000000..68a0fc4 --- /dev/null +++ b/src/Events/Contracts/ToolCallEvent.php @@ -0,0 +1,7 @@ + $this->config->runId, + 'thread_id' => $this->config->threadId, + 'stage' => $this->stage::class, + ]; + } } diff --git a/src/Events/StageError.php b/src/Events/StageError.php index 1790f7e..fd97261 100644 --- a/src/Events/StageError.php +++ b/src/Events/StageError.php @@ -20,4 +20,14 @@ public function __construct( public RuntimeConfig $config, public Throwable $exception, ) {} + + public function eventId(): string + { + return 'stage.error'; + } + + public function toArray(): array + { + return []; + } } diff --git a/src/Events/StageStart.php b/src/Events/StageStart.php index 1379585..f5ab81f 100644 --- a/src/Events/StageStart.php +++ b/src/Events/StageStart.php @@ -18,4 +18,18 @@ public function __construct( public mixed $payload, public RuntimeConfig $config, ) {} + + public function eventId(): string + { + return 'stage.start'; + } + + public function toArray(): array + { + return [ + 'run_id' => $this->config->runId, + 'thread_id' => $this->config->threadId, + 'stage' => $this->stage::class, + ]; + } } diff --git a/src/Events/ToolCallEnd.php b/src/Events/ToolCallEnd.php new file mode 100644 index 0000000..da1c01d --- /dev/null +++ b/src/Events/ToolCallEnd.php @@ -0,0 +1,31 @@ + $this->config?->runId, + 'thread_id' => $this->config?->threadId, + 'tool_message' => $this->toolMessage->toArray(), + ]; + } +} diff --git a/src/Events/ToolCallStart.php b/src/Events/ToolCallStart.php new file mode 100644 index 0000000..ae521fd --- /dev/null +++ b/src/Events/ToolCallStart.php @@ -0,0 +1,31 @@ + $this->config?->runId, + 'thread_id' => $this->config?->threadId, + 'tool_call' => $this->toolCall->toArray(), + ]; + } +} diff --git a/src/Facades/AgentRegistry.php b/src/Facades/AgentRegistry.php index 9bb47ed..fb42238 100644 --- a/src/Facades/AgentRegistry.php +++ b/src/Facades/AgentRegistry.php @@ -7,10 +7,10 @@ use Illuminate\Support\Facades\Facade; /** - * @method static \Cortex\Agents\Agent get(string $name) + * @method static \Cortex\Agents\Agent get(string $id) * @method static void register(\Cortex\Agents\Agent|class-string<\Cortex\Agents\AbstractAgentBuilder> $agent, ?string $nameOverride = null) - * @method static bool has(string $name) - * @method static array names() + * @method static bool has(string $id) + * @method static array ids() * * @see \Cortex\Agents\Registry */ diff --git a/src/Http/Controllers/AGUIController.php b/src/Http/Controllers/AGUIController.php new file mode 100644 index 0000000..7097845 --- /dev/null +++ b/src/Http/Controllers/AGUIController.php @@ -0,0 +1,47 @@ +all()); + + $messages = $request->collect('messages') + ->map(function (array $message): UserMessage { + return new UserMessage( + content: $message['content'], + id: $message['id'] ?? null, + ); + }); + + try { + return Cortex::agent('weather') + ->stream( + messages: $messages->all(), + input: $request->all(), + config: new RuntimeConfig( + state: new State($request->input('state', [])), + threadId: $request->input('thread_id'), + runId: $request->input('run_id'), + ), + ) + ->streamResponse(StreamingProtocol::AGUI); + } catch (Throwable $e) { + dd($e); + } + } +} diff --git a/src/Http/Controllers/AgentsController.php b/src/Http/Controllers/AgentsController.php index 99ff7ee..1fd3067 100644 --- a/src/Http/Controllers/AgentsController.php +++ b/src/Http/Controllers/AgentsController.php @@ -6,15 +6,20 @@ use Throwable; use Cortex\Cortex; -use Cortex\Events\AgentEnd; +use Illuminate\Support\Arr; use Illuminate\Http\Request; -use Cortex\Events\AgentStart; -use Cortex\Events\AgentStepEnd; -use Cortex\Events\AgentStepError; -use Cortex\Events\AgentStepStart; +use Cortex\Pipeline\Metadata; +use Cortex\Tools\FrontendTool; +use Cortex\LLM\Contracts\Content; use Illuminate\Http\JsonResponse; +use Cortex\Pipeline\RuntimeConfig; use Illuminate\Routing\Controller; +use Cortex\LLM\Enums\StreamingProtocol; use Cortex\LLM\Data\Messages\UserMessage; +use Cortex\LLM\Data\Messages\Content\FileContent; +use Cortex\LLM\Data\Messages\Content\TextContent; +use Cortex\LLM\Data\Messages\Content\ImageContent; +use Symfony\Component\HttpFoundation\StreamedResponse; class AgentsController extends Controller { @@ -22,31 +27,18 @@ public function invoke(string $agent, Request $request): JsonResponse { try { $agent = Cortex::agent($agent); - $agent->onStart(function (AgentStart $event): void { - // dump('-- agent start'); - }); - $agent->onEnd(function (AgentEnd $event): void { - // dump('-- agent end'); - }); - - $agent->onStepStart(function (AgentStepStart $event): void { - // dump( - // sprintf('---- step %d start', $event->config?->context?->getCurrentStepNumber()), - // // $event->config?->context->toArray(), - // ); - }); - $agent->onStepEnd(function (AgentStepEnd $event): void { - // dump( - // sprintf('---- step %d end', $event->config?->context?->getCurrentStepNumber()), - // // $event->config?->toArray(), - // ); - }); - $agent->onStepError(function (AgentStepError $event): void { - // dump(sprintf('step error: %d', $event->config?->context?->getCurrentStepNumber())); - // dump($event->exception->getMessage()); - // dump($event->exception->getTraceAsString()); - }); - $result = $agent->invoke(input: $request->all()); + + $result = $agent->invoke( + messages: $request->has('message') ? [ + new UserMessage($request->input('message')), + ] : [], + input: $request->input('input', []), + config: new RuntimeConfig( + metadata: new Metadata($request->input('metadata', [])), + threadId: $request->input('id'), + runId: $request->input('run_id'), + ), + ); } catch (Throwable $e) { return response()->json([ 'error' => $e->getMessage(), @@ -71,58 +63,86 @@ public function invoke(string $agent, Request $request): JsonResponse ]); } - public function stream(string $agent, Request $request): void// : StreamedResponse + public function stream(string $agent, Request $request): StreamedResponse { - $agent = Cortex::agent($agent); - - // $agent->onStart(function (AgentStart $event): void { - // dump('---- AGENT START ----'); - // }); - // $agent->onEnd(function (AgentEnd $event): void { - // dump('---- AGENT END ----'); - // }); - // $agent->onStepStart(function (AgentStepStart $event): void { - // dump('-- STEP START --'); - // }); - // $agent->onStepEnd(function (AgentStepEnd $event): void { - // dump('-- STEP END --'); - // }); - // $agent->onStepError(function (AgentStepError $event): void { - // dump('-- STEP ERROR --'); - // }); - // $agent->onChunk(function (AgentStreamChunk $event): void { - // dump($event->chunk->type->value); - // $toolCalls = $event->chunk->message->toolCalls; - - // if ($toolCalls !== null) { - // dump(sprintf('chunk: %s', $event->chunk->message->toolCalls?->toJson())); - // } else { - // dump(sprintf('chunk: %s', $event->chunk->message->content)); - // } - // }); - - $result = $agent->stream( - messages: $request->has('message') ? [ - new UserMessage($request->input('message')), - ] : [], - input: $request->all(), - ); - try { - foreach ($result as $chunk) { - dump(sprintf('%s: %s', $chunk->type->value, $chunk->message->content())); + $agent = Cortex::agent($agent); + + if ($request->isMethod('post')) { + $messages = $this->getMessages($request); + } else { + $messages = $request->has('message') ? [ + new UserMessage($request->input('message')), + ] : []; } - // return $result->streamResponse(); + /** @var array> $toolsInput */ + $toolsInput = $request->input('tools', []); + $tools = collect($toolsInput) + ->filter(function (array $tool): bool { + return $tool !== []; + }) + ->map(function (array $tool, string $toolName): FrontendTool { + return new FrontendTool( + $toolName, + $tool['description'] ?? null, + $tool['parameters'] ?? [], + ); + }) + ->values(); + + $result = $agent->stream( + messages: $messages, + input: $request->input('input', []), + config: new RuntimeConfig( + tools: $tools->all(), + // temporary hack to get a unique thread id for the session + threadId: $request->input('id') . '-' . Arr::get($request->collect('messages')->first(), 'id', ''), + ), + ); + + /** @var StreamingProtocol $defaultProtocol */ + $defaultProtocol = config('cortex.default_streaming_protocol') ?? StreamingProtocol::Vercel; + + return $result->streamResponse( + $request->enum('protocol', StreamingProtocol::class, $defaultProtocol), + ); } catch (Throwable $e) { dd($e); } - dd([ - 'total_usage' => $agent->getTotalUsage()->toArray(), - 'steps' => $agent->getSteps()->toArray(), - 'parsed_output' => $agent->getParsedOutput(), - 'memory' => $agent->getMemory()->getMessages()->toArray(), - ]); + // dd([ + // 'total_usage' => $agent->getTotalUsage()->toArray(), + // 'steps' => $agent->getSteps()->toArray(), + // 'parsed_output' => $agent->getParsedOutput(), + // 'memory' => $agent->getMemory()->getMessages()->toArray(), + // ]); + } + + /** + * @return array + */ + protected function getMessages(Request $request): array + { + return $request->collect('messages') + ->map(function (array $message): UserMessage { + /** @var array> $parts */ + $parts = $message['parts']; + $content = collect($parts) + ->map(function (array $part): ?Content { + return match (true) { + $part['type'] === 'text' => new TextContent($part['text']), + $part['type'] === 'file' && str_starts_with((string) $part['mediaType'], 'image/') => new ImageContent($part['url'], $part['mediaType'] ?? null), + $part['type'] === 'file' => new FileContent($part['url'], $part['mediaType'] ?? null, $part['filename'] ?? null), + default => null, + }; + }) + ->filter() + ->values() + ->all(); + + return new UserMessage($content, id: $message['id'] ?? null); + }) + ->all(); } } diff --git a/src/LLM/AbstractLLM.php b/src/LLM/AbstractLLM.php index c9c4a36..d5f47c4 100644 --- a/src/LLM/AbstractLLM.php +++ b/src/LLM/AbstractLLM.php @@ -6,6 +6,7 @@ use Closure; use Generator; +use Throwable; use BackedEnum; use Cortex\Pipeline; use Cortex\Support\Utils; @@ -16,6 +17,7 @@ use Cortex\LLM\Contracts\Tool; use Cortex\Events\ChatModelEnd; use Cortex\LLM\Data\ToolConfig; +use Cortex\LLM\Enums\ChunkType; use Cortex\LLM\Enums\ToolChoice; use Cortex\Events\ChatModelError; use Cortex\Events\ChatModelStart; @@ -62,6 +64,10 @@ abstract class AbstractLLM implements LLM */ protected array $parameters = []; + protected ?int $maxTokens = null; + + protected ?float $temperature = null; + protected ?ToolConfig $toolConfig = null; protected ?StructuredOutputConfig $structuredOutputConfig = null; @@ -103,6 +109,13 @@ public function __construct( } } + public function stream( + MessageCollection|Message|array|string $messages, + array $additionalParameters = [], + ): ChatStreamResult { + return $this->withStreaming(true)->invoke($messages, $additionalParameters); + } + public function handlePipeable(mixed $payload, RuntimeConfig $config, Closure $next): mixed { $this->shouldParseOutput($config->context->shouldParseOutput()); @@ -136,14 +149,18 @@ public function handlePipeable(mixed $payload, RuntimeConfig $config, Closure $n // Otherwise, we return the message as is. return $result instanceof ChatStreamResult ? new ChatStreamResult(function () use ($result, $config, $next) { - foreach ($result as $chunk) { - try { - $chunk = $next($chunk, $config); - - yield from $this->flattenAndYield($chunk, $config, dispatchEvents: true); - } catch (OutputParserException) { - // Ignore any parsing errors and continue + try { + foreach ($result as $chunk) { + try { + $chunk = $next($chunk, $config); + + yield from $this->flattenAndYield($chunk, $config, dispatchEvents: true); + } catch (OutputParserException) { + // Ignore any parsing errors and continue + } } + } catch (Throwable $e) { + yield from $this->yieldErrorChunk($e, $config); } }) : $next($result, $config); @@ -154,8 +171,12 @@ protected function flattenAndYield(mixed $content, RuntimeConfig $config, bool $ if ($content instanceof ChatStreamResult) { // When flattening a nested stream, don't dispatch events here // The inner stream's AbstractLLM already dispatched them - foreach ($content as $chunk) { - yield from $this->flattenAndYield($chunk, $config, dispatchEvents: false); + try { + foreach ($content as $chunk) { + yield from $this->flattenAndYield($chunk, $config, dispatchEvents: false); + } + } catch (Throwable $e) { + yield from $this->yieldErrorChunk($e, $config); } } else { $shouldDispatchEvent = $dispatchEvents && $content instanceof ChatGenerationChunk; @@ -183,6 +204,31 @@ protected function flattenAndYield(mixed $content, RuntimeConfig $config, bool $ } } + /** + * Yield an error chunk and re-throw the exception. + * + * This ensures errors during streaming are output to the stream protocol (e.g., Vercel AI SDK) + * instead of being caught by Laravel's exception handler and output as HTML. + */ + protected function yieldErrorChunk(Throwable $e, RuntimeConfig $config): Generator + { + $config->setException($e); + + $errorChunk = new ChatGenerationChunk( + type: ChunkType::Error, + exception: $e, + ); + + $config->dispatchEvent( + event: new RuntimeConfigStreamChunk($config, $errorChunk), + dispatchToGlobalDispatcher: false, + ); + + yield $errorChunk; + + throw $e; + } + public function output(OutputParser $parser): Pipeline { return $this->pipe($parser); @@ -303,14 +349,14 @@ public function withModel(string $model): static public function withTemperature(?float $temperature): static { - $this->parameters['temperature'] = $temperature; + $this->temperature = $temperature; return $this; } public function withMaxTokens(?int $maxTokens): static { - $this->parameters['max_tokens'] = $maxTokens; + $this->maxTokens = $maxTokens; return $this; } @@ -356,6 +402,16 @@ public function getParameters(): array return $this->parameters; } + public function getTemperature(): ?float + { + return $this->temperature; + } + + public function getMaxTokens(): ?int + { + return $this->maxTokens; + } + public function isStreaming(): bool { return $this->streaming; @@ -403,6 +459,16 @@ public function getOutputParserError(): ?string return $this->outputParserError; } + public function getStructuredOutputConfig(): ?StructuredOutputConfig + { + return $this->structuredOutputConfig; + } + + public function getStructuredOutputMode(): StructuredOutputMode + { + return $this->structuredOutputMode; + } + /** * @return array<\Cortex\ModelInfo\Enums\ModelFeature> */ @@ -522,10 +588,8 @@ protected function applyOutputParserIfApplicable( ): ChatGeneration|ChatGenerationChunk { if ($this->shouldParseOutput && $this->outputParser !== null) { try { - // $this->streamBuffer?->push(new ChatGenerationChunk(type: ChunkType::OutputParserStart)); $this->dispatchEvent(new OutputParserStart($this->outputParser, $generationOrChunk)); $parsedOutput = $this->outputParser->parse($generationOrChunk); - // $this->streamBuffer?->push(new ChatGenerationChunk(type: ChunkType::OutputParserEnd)); $this->dispatchEvent(new OutputParserEnd($this->outputParser, $parsedOutput)); $generationOrChunk = $generationOrChunk->cloneWithParsedOutput($parsedOutput); diff --git a/src/LLM/Contracts/LLM.php b/src/LLM/Contracts/LLM.php index 20c02f8..8d18566 100644 --- a/src/LLM/Contracts/LLM.php +++ b/src/LLM/Contracts/LLM.php @@ -14,6 +14,7 @@ use Cortex\JsonSchema\Types\ObjectSchema; use Cortex\ModelInfo\Enums\ModelProvider; use Cortex\LLM\Enums\StructuredOutputMode; +use Cortex\LLM\Data\StructuredOutputConfig; use Cortex\LLM\Data\Messages\MessageCollection; interface LLM extends Pipeable @@ -31,6 +32,19 @@ public function invoke( array $additionalParameters = [], ): ChatResult|ChatStreamResult; + /** + * Convenience method to stream the LLM response. + * + * @param \Cortex\LLM\Data\Messages\MessageCollection|\Cortex\LLM\Contracts\Message|array|string $messages + * @param array $additionalParameters + * + * @return \Cortex\LLM\Data\ChatStreamResult<\Cortex\LLM\Data\ChatGenerationChunk> + */ + public function stream( + MessageCollection|Message|array|string $messages, + array $additionalParameters = [], + ): ChatStreamResult; + /** * Specify the tools to use for the LLM. * @@ -151,6 +165,33 @@ public function getModelProvider(): ModelProvider; */ public function getModelInfo(): ?ModelInfo; + /** + * Get the parameters for the LLM. + * + * @return array + */ + public function getParameters(): array; + + /** + * Get the temperature for the LLM. + */ + public function getTemperature(): ?float; + + /** + * Get the max tokens for the LLM. + */ + public function getMaxTokens(): ?int; + + /** + * Get the structured output config for the LLM. + */ + public function getStructuredOutputConfig(): ?StructuredOutputConfig; + + /** + * Get the structured output mode for the LLM. + */ + public function getStructuredOutputMode(): StructuredOutputMode; + /** * Set whether the raw provider response should be included in the result, if available. */ diff --git a/src/LLM/Contracts/StreamingProtocol.php b/src/LLM/Contracts/StreamingProtocol.php deleted file mode 100644 index 1d20116..0000000 --- a/src/LLM/Contracts/StreamingProtocol.php +++ /dev/null @@ -1,19 +0,0 @@ - - */ - public function mapChunkToPayload(ChatGenerationChunk $chunk): array; -} diff --git a/src/LLM/Contracts/StreamingProtocolDriver.php b/src/LLM/Contracts/StreamingProtocolDriver.php new file mode 100644 index 0000000..39226b6 --- /dev/null +++ b/src/LLM/Contracts/StreamingProtocolDriver.php @@ -0,0 +1,23 @@ + + */ + public function headers(): array; +} diff --git a/src/LLM/Data/ChatGenerationChunk.php b/src/LLM/Data/ChatGenerationChunk.php index 936bdd0..ac6cc2b 100644 --- a/src/LLM/Data/ChatGenerationChunk.php +++ b/src/LLM/Data/ChatGenerationChunk.php @@ -8,10 +8,13 @@ use DateTimeImmutable; use DateTimeInterface; use Cortex\LLM\Enums\ChunkType; +use Cortex\LLM\Contracts\Content; use Cortex\LLM\Enums\FinishReason; use Cortex\LLM\Data\Messages\ToolMessage; use Illuminate\Contracts\Support\Arrayable; use Cortex\LLM\Data\Messages\AssistantMessage; +use Cortex\LLM\Data\Messages\Content\TextContent; +use Cortex\LLM\Data\Messages\Content\ReasoningContent; /** * @implements Arrayable @@ -19,6 +22,7 @@ readonly class ChatGenerationChunk implements Arrayable { /** + * @param array<\Cortex\LLM\Contracts\Content> $contentSoFar * @param array|null $rawChunk * @param array $metadata */ @@ -29,7 +33,7 @@ public function __construct( public DateTimeInterface $createdAt = new DateTimeImmutable(), public ?FinishReason $finishReason = null, public ?Usage $usage = null, - public string $contentSoFar = '', + public array $contentSoFar = [], public bool $isFinal = false, public mixed $parsedOutput = null, public ?string $outputParserError = null, @@ -48,6 +52,50 @@ public function text(): ?string return $this->message->text(); } + public function reasoning(): ?string + { + return $this->message->reasoning(); + } + + public function isTextEmpty(): bool + { + return $this->message->isTextEmpty(); + } + + public function isReasoningEmpty(): bool + { + return $this->message->isReasoningEmpty(); + } + + public function isToolInputEmpty(): bool + { + return $this->message->isToolInputEmpty(); + } + + /** + * Get the text content that has been streamed so far. + */ + public function textSoFar(): ?string + { + /** @var \Cortex\LLM\Data\Messages\Content\TextContent|null $textContent */ + $textContent = collect($this->contentSoFar) + ->first(fn(Content $content): bool => $content instanceof TextContent); + + return $textContent?->text; + } + + /** + * Get the reasoning content that has been streamed so far. + */ + public function reasoningSoFar(): ?string + { + /** @var \Cortex\LLM\Data\Messages\Content\ReasoningContent|null $reasoningContent */ + $reasoningContent = collect($this->contentSoFar) + ->first(fn(Content $content): bool => $content instanceof ReasoningContent); + + return $reasoningContent?->reasoning; + } + public function cloneWithParsedOutput(mixed $parsedOutput): self { return new self( diff --git a/src/LLM/Data/ChatResult.php b/src/LLM/Data/ChatResult.php index 9e01ade..dcdc07c 100644 --- a/src/LLM/Data/ChatResult.php +++ b/src/LLM/Data/ChatResult.php @@ -15,11 +15,13 @@ /** * @param array|null $rawResponse + * @param array $metadata */ public function __construct( public ChatGeneration $generation, public Usage $usage, public ?array $rawResponse = null, + public array $metadata = [], ) { $this->parsedOutput = $this->generation->parsedOutput; } @@ -50,6 +52,7 @@ public function toArray(): array 'generation' => $this->generation, 'usage' => $this->usage, 'raw_response' => $this->rawResponse, + 'metadata' => $this->metadata, ]; } } diff --git a/src/LLM/Data/ChatStreamResult.php b/src/LLM/Data/ChatStreamResult.php index 313fa1b..6fb6a8e 100644 --- a/src/LLM/Data/ChatStreamResult.php +++ b/src/LLM/Data/ChatStreamResult.php @@ -12,6 +12,7 @@ use Cortex\Events\RuntimeConfigStreamChunk; use Cortex\LLM\Data\Messages\AssistantMessage; use Cortex\LLM\Data\Concerns\HasStreamResponses; +use Cortex\LLM\Data\Messages\Content\TextContent; /** * @extends LazyCollection @@ -29,13 +30,29 @@ public function text(): self } /** - * Stream only chunks where message content is not empty. + * Stream only chunks where delta content is not empty. */ - public function withoutEmpty(): self + public function withoutEmptyDeltas(): self { - return $this->reject(fn(ChatGenerationChunk $chunk): bool => $chunk->type->isText() && empty($chunk->content())); + return $this->reject(fn(ChatGenerationChunk $chunk): bool => match ($chunk->type) { + ChunkType::TextDelta => $chunk->isTextEmpty(), + ChunkType::ReasoningDelta => $chunk->isReasoningEmpty(), + ChunkType::ToolInputDelta => $chunk->isToolInputEmpty(), + default => false, + }); + } + + /** + * Stream without reasoning chunks. + */ + public function withoutReasoning(): self + { + return $this->reject(fn(ChatGenerationChunk $chunk): bool => $chunk->type->isReasoning()); } + /** + * Append the stream buffer to the result. + */ public function appendStreamBuffer(RuntimeConfig $config): self { return new self(function () use ($config): Generator { @@ -93,7 +110,7 @@ public static function fake(?string $string = null, ?ToolCallCollection $toolCal completionTokens: $index, totalTokens: $index, ), - contentSoFar: $contentSoFar, + contentSoFar: [new TextContent($contentSoFar)], isFinal: $isFinal, ); diff --git a/src/LLM/Data/Concerns/HasStreamResponses.php b/src/LLM/Data/Concerns/HasStreamResponses.php index 317e2b4..d885e32 100644 --- a/src/LLM/Data/Concerns/HasStreamResponses.php +++ b/src/LLM/Data/Concerns/HasStreamResponses.php @@ -4,11 +4,8 @@ namespace Cortex\LLM\Data\Concerns; -use Cortex\LLM\Streaming\AgUiDataStream; -use Cortex\LLM\Streaming\VercelDataStream; -use Cortex\LLM\Streaming\VercelTextStream; -use Cortex\LLM\Contracts\StreamingProtocol; -use Cortex\LLM\Streaming\DefaultDataStream; +use Cortex\LLM\Enums\StreamingProtocol; +use Cortex\LLM\Contracts\StreamingProtocolDriver; use Symfony\Component\HttpFoundation\StreamedResponse; /** @mixin \Cortex\LLM\Data\ChatStreamResult */ @@ -17,49 +14,24 @@ trait HasStreamResponses /** * Create a streaming response using the Vercel AI SDK protocol. */ - public function streamResponse(): StreamedResponse + public function streamResponse(StreamingProtocol $protocol): StreamedResponse { - return $this->toStreamedResponse(new DefaultDataStream()); - } - - /** - * Create a plain text streaming response (Vercel AI SDK text format). - * Streams only the text content without any JSON encoding or metadata. - * - * @see https://sdk.vercel.ai/docs/ai-sdk-core/generating-text - */ - public function vercelTextStreamResponse(): StreamedResponse - { - return $this->toStreamedResponse(new VercelTextStream()); - } - - public function vercelDataStreamResponse(): StreamedResponse - { - return $this->toStreamedResponse(new VercelDataStream()); - } - - /** - * Create a streaming response using the AG-UI protocol. - * - * @see https://docs.ag-ui.com/concepts/events.md - */ - public function agUiStreamResponse(): StreamedResponse - { - return $this->toStreamedResponse(new AgUiDataStream()); + return $this->toStreamedResponse($protocol->driver()); } /** * Create a streaming response using a custom streaming protocol. */ - public function toStreamedResponse(StreamingProtocol $protocol): StreamedResponse + public function toStreamedResponse(StreamingProtocolDriver $driver): StreamedResponse { /** @var \Illuminate\Routing\ResponseFactory $responseFactory */ $responseFactory = response(); - return $responseFactory->stream($protocol->streamResponse($this), headers: [ + return $responseFactory->stream($driver->streamResponse($this), headers: [ 'Content-Type' => 'text/event-stream', 'Cache-Control' => 'no-cache', 'X-Accel-Buffering' => 'no', + ...$driver->headers(), ]); } } diff --git a/src/LLM/Data/FunctionCall.php b/src/LLM/Data/FunctionCall.php index d838c4e..d43c6b5 100644 --- a/src/LLM/Data/FunctionCall.php +++ b/src/LLM/Data/FunctionCall.php @@ -17,6 +17,7 @@ public function __construct( public string $name, public array $arguments, + public ?string $delta = null, ) {} public function toArray(): array @@ -24,6 +25,7 @@ public function toArray(): array return [ 'name' => $this->name, 'arguments' => $this->arguments, + 'delta' => $this->delta, ]; } } diff --git a/src/LLM/Data/Messages/AssistantMessage.php b/src/LLM/Data/Messages/AssistantMessage.php index f8e2b51..14fdabe 100644 --- a/src/LLM/Data/Messages/AssistantMessage.php +++ b/src/LLM/Data/Messages/AssistantMessage.php @@ -43,11 +43,17 @@ public function content(): string|array|null public function text(): ?string { if (is_array($this->content)) { + $texts = []; + foreach ($this->content as $content) { if ($content instanceof TextContent) { - return $content->text; + $texts[] = $content->text; } } + + return $texts !== [] + ? implode(PHP_EOL, $texts) + : null; } return is_string($this->content) @@ -55,29 +61,45 @@ public function text(): ?string : null; } - public function isTextEmpty(): bool - { - $text = $this->text(); - - return $text === null || $text === ''; - } - /** * Get the reasoning content of the message. */ public function reasoning(): ?string { if (is_array($this->content)) { + $reasonings = []; + foreach ($this->content as $content) { if ($content instanceof ReasoningContent) { - return $content->reasoning; + $reasonings[] = $content->reasoning; } } + + return $reasonings !== [] + ? implode(PHP_EOL, $reasonings) + : null; } return null; } + public function isTextEmpty(): bool + { + return in_array($this->text(), [null, ''], true); + } + + public function isReasoningEmpty(): bool + { + return in_array($this->reasoning(), [null, ''], true); + } + + public function isToolInputEmpty(): bool + { + $toolCall = $this->toolCalls?->first(); + + return in_array($toolCall->function->arguments ?? null, [null, ''], true); + } + /** * Determine if the message has tool calls. */ diff --git a/src/LLM/Data/Messages/Content/ReasoningContent.php b/src/LLM/Data/Messages/Content/ReasoningContent.php index b5b49e3..87a1016 100644 --- a/src/LLM/Data/Messages/Content/ReasoningContent.php +++ b/src/LLM/Data/Messages/Content/ReasoningContent.php @@ -6,8 +6,18 @@ final class ReasoningContent extends AbstractContent { + /** + * @param array $metadata + */ public function __construct( - public string $id, public string $reasoning, + public array $metadata = [], ) {} + + public function append(string $reasoning): self + { + $this->reasoning .= $reasoning; + + return $this; + } } diff --git a/src/LLM/Data/Messages/Content/TextContent.php b/src/LLM/Data/Messages/Content/TextContent.php index 4611bf9..c0d77e8 100644 --- a/src/LLM/Data/Messages/Content/TextContent.php +++ b/src/LLM/Data/Messages/Content/TextContent.php @@ -27,4 +27,15 @@ public function replaceVariables(array $variables): static return new self($this->getCompiler()->compile($this->text, $variables)); } + + public function append(string $text): self + { + if ($this->text === null) { + return new self($text); + } + + $this->text .= $text; + + return $this; + } } diff --git a/src/LLM/Data/Messages/UserMessage.php b/src/LLM/Data/Messages/UserMessage.php index 0bff5ed..d678e5e 100644 --- a/src/LLM/Data/Messages/UserMessage.php +++ b/src/LLM/Data/Messages/UserMessage.php @@ -21,6 +21,7 @@ */ public function __construct( public string|array $content, + public ?string $id = null, public ?string $name = null, ) { $this->role = MessageRole::User; @@ -61,6 +62,10 @@ public function toArray(): array 'content' => $this->content, ]; + if ($this->id !== null) { + $data['id'] = $this->id; + } + if ($this->name !== null) { $data['name'] = $this->name; } @@ -70,7 +75,7 @@ public function toArray(): array public function cloneWithContent(mixed $content): self { - return new self($content, $this->name); + return new self($content, $this->id, $this->name); } /** diff --git a/src/LLM/Data/ToolCallCollection.php b/src/LLM/Data/ToolCallCollection.php index 8958c59..a45f6a6 100644 --- a/src/LLM/Data/ToolCallCollection.php +++ b/src/LLM/Data/ToolCallCollection.php @@ -4,7 +4,9 @@ namespace Cortex\LLM\Data; +use Cortex\Events\ToolCallEnd; use Cortex\LLM\Contracts\Tool; +use Cortex\Events\ToolCallStart; use Cortex\Pipeline\RuntimeConfig; use Illuminate\Support\Collection; use Cortex\LLM\Data\Messages\MessageCollection; @@ -37,7 +39,7 @@ public function invokeAsToolMessages(Collection $availableTools, ?RuntimeConfig if ($matchingTool === null) { // If we didn't find a matching tool, and there is only one tool and // one tool call, we will assume it's the correct tool. - if ($availableTools->containsOneItem() && $this->containsOneItem()) { + if ($availableTools->hasSole() && $this->hasSole()) { /** @var \Cortex\LLM\Contracts\Tool $matchingTool */ $matchingTool = $availableTools->first(); } else { @@ -46,7 +48,11 @@ public function invokeAsToolMessages(Collection $availableTools, ?RuntimeConfig } } - return $matchingTool->invokeAsToolMessage($toolCall, $config); + $config->dispatchEvent(new ToolCallStart($toolCall, $config)); + $toolMessage = $matchingTool->invokeAsToolMessage($toolCall, $config); + $config->dispatchEvent(new ToolCallEnd($toolMessage, $config)); + + return $toolMessage; }) ->filter() ->values() diff --git a/src/LLM/Drivers/Anthropic/AnthropicChat.php b/src/LLM/Drivers/Anthropic/AnthropicChat.php index 541df14..295e396 100644 --- a/src/LLM/Drivers/Anthropic/AnthropicChat.php +++ b/src/LLM/Drivers/Anthropic/AnthropicChat.php @@ -4,53 +4,37 @@ namespace Cortex\LLM\Drivers\Anthropic; -use Generator; use Throwable; -use JsonException; -use DateTimeImmutable; -use Cortex\LLM\Data\Usage; use Cortex\LLM\AbstractLLM; -use Illuminate\Support\Arr; -use Cortex\LLM\Data\ToolCall; use Cortex\LLM\Contracts\Tool; -use Cortex\Events\ChatModelEnd; use Cortex\LLM\Data\ChatResult; -use Cortex\LLM\Enums\ChunkType; use Cortex\LLM\Enums\ToolChoice; -use Anthropic\Testing\ClientFake; use Cortex\Events\ChatModelError; use Cortex\Events\ChatModelStart; use Cortex\LLM\Contracts\Message; -use Cortex\LLM\Data\FunctionCall; -use Cortex\LLM\Enums\MessageRole; -use Cortex\Events\ChatModelStream; -use Cortex\LLM\Enums\FinishReason; use Cortex\Exceptions\LLMException; -use Cortex\LLM\Data\ChatGeneration; +use Cortex\SDK\Anthropic\Anthropic; use Cortex\LLM\Data\ChatStreamResult; -use Cortex\LLM\Data\ResponseMetadata; -use Anthropic\Contracts\ClientContract; -use Cortex\LLM\Data\ToolCallCollection; -use Cortex\LLM\Data\ChatGenerationChunk; -use Cortex\LLM\Data\Messages\ToolMessage; use Cortex\ModelInfo\Enums\ModelProvider; use Cortex\LLM\Enums\StructuredOutputMode; use Cortex\LLM\Data\Messages\SystemMessage; -use Cortex\LLM\Data\Messages\AssistantMessage; use Cortex\LLM\Data\Messages\MessageCollection; -use Anthropic\Responses\Messages\CreateResponse; -use Anthropic\Responses\Messages\StreamResponse; -use Cortex\LLM\Data\Messages\Content\TextContent; -use Anthropic\Responses\Messages\CreateResponseUsage; -use Cortex\LLM\Data\Messages\Content\ReasoningContent; -use Anthropic\Responses\Messages\CreateResponseContent; -use Anthropic\Responses\Messages\CreateStreamedResponseUsage; -use Anthropic\Testing\Responses\Fixtures\Messages\CreateResponseFixture; +use Cortex\LLM\Drivers\Anthropic\Concerns\MapsUsage; +use Cortex\LLM\Drivers\Anthropic\Concerns\MapsMessages; +use Cortex\LLM\Drivers\Anthropic\Concerns\MapsResponse; +use Cortex\LLM\Drivers\Anthropic\Concerns\MapsFinishReason; +use Cortex\LLM\Drivers\Anthropic\Concerns\MapStreamResponse; class AnthropicChat extends AbstractLLM { + use MapsUsage; + use MapsMessages; + use MapsResponse; + use MapsFinishReason; + use MapStreamResponse; + public function __construct( - protected readonly ClientContract $client, + protected readonly Anthropic $client, protected string $model, protected ModelProvider $modelProvider = ModelProvider::Anthropic, protected bool $ignoreModelFeatures = false, @@ -64,32 +48,45 @@ public function invoke( ): ChatResult|ChatStreamResult { $messages = $this->resolveMessages($messages); - [$systemMessages, $messages] = $messages->partition( + /** @var \Illuminate\Support\Collection $systemMessages */ + $systemMessages = $messages->filter( fn(Message $message): bool => $message instanceof SystemMessage, ); - if ($systemMessages->count() > 1) { - throw new LLMException('Only one system message is supported.'); - } + $nonSystemMessages = $messages->reject( + fn(Message $message): bool => $message instanceof SystemMessage, + ); $params = $this->buildParams([ ...$additionalParameters, - 'messages' => static::mapMessagesForInput($messages), + 'messages' => $this->mapMessagesForInput($nonSystemMessages), ]); - /** @var \Cortex\LLM\Data\Messages\SystemMessage|null $systemMessage */ - $systemMessage = $systemMessages->first(); - - if ($systemMessage !== null) { - $params['system'] = $systemMessage->text(); + // Anthropic only supports a single system message, so concatenate multiple if they exist + if ($systemMessages->isNotEmpty()) { + $params['system'] = $systemMessages + ->map(fn(SystemMessage $message): string => $message->text()) + ->filter() + ->implode("\n\n"); } $this->dispatchEvent(new ChatModelStart($this, $messages, $params)); + if ($this->streaming) { + $params['stream'] = true; + } + try { + $response = $this->client->messages()->create( + parameters: $params, + cacheEnabled: $this->useCache, + ); + + $isCached = $response->isCached(); + return $this->streaming - ? $this->mapStreamResponse($this->client->messages()->createStreamed($params)) - : $this->mapResponse($this->client->messages()->create($params)); + ? $this->mapStreamResponse($response->dtoOrFail(), $isCached) + : $this->mapResponse($response->dtoOrFail(), $isCached); } catch (Throwable $e) { $this->dispatchEvent(new ChatModelError($this, $params, $e)); @@ -97,309 +94,6 @@ public function invoke( } } - /** - * Map a standard (non-streaming) response to a ChatResult. - */ - protected function mapResponse(CreateResponse $response): ChatResult - { - $toolCalls = array_filter( - $response->content, - fn(CreateResponseContent $content): bool => $content->type === 'tool_use', - ); - - $toolCalls = collect($toolCalls) - ->map(fn(CreateResponseContent $content): ToolCall => new ToolCall( - $content->id, - new FunctionCall($content->name, $content->input ?? []), - )) - ->values() - ->all(); - - $toolCalls = $toolCalls !== [] - ? new ToolCallCollection($toolCalls) - : null; - - $usage = $this->mapUsage($response->usage); - $finishReason = static::mapFinishReason($response->stop_reason ?? null); - - $contents = collect($response->content) - ->map(function (CreateResponseContent $content): TextContent|ReasoningContent|null { - return match ($content->type) { - 'text' => new TextContent($content->text), - // TODO: Use different anthropic client, since current one seems abandoned. - 'thinking' => $content->id !== null ? new ReasoningContent( - $content->id, - $content->text ?? '', - ) : null, - // 'tool_use' => new ToolUseContent($content->tool_use), - default => null, - }; - }) - ->filter() - ->all(); - - $generation = new ChatGeneration( - message: new AssistantMessage( - content: $contents, - toolCalls: $toolCalls, - metadata: new ResponseMetadata( - id: $response->id, - model: $response->model, - provider: $this->modelProvider, - finishReason: $finishReason, - usage: $usage, - ), - ), - createdAt: new DateTimeImmutable(), - finishReason: $finishReason, - ); - - $generation = $this->applyOutputParserIfApplicable($generation); - - $result = new ChatResult( - $generation, - $usage, - $response->toArray(), // @phpstan-ignore argument.type - ); - - $this->dispatchEvent(new ChatModelEnd($this, $result)); - - return $result; - } - - /** - * Map a streaming response to a ChatStreamResult. - * - * @param StreamResponse<\Anthropic\Responses\Messages\CreateStreamedResponse> $response - * - * @return ChatStreamResult - */ - protected function mapStreamResponse(StreamResponse $response): ChatStreamResult - { - return new ChatStreamResult(function () use ($response): Generator { - $contentSoFar = ''; - $toolCallsSoFar = []; - $messageId = null; - $model = null; - $finishReason = null; - $usage = null; - $currentToolCall = null; - - /** @var \Anthropic\Responses\Messages\CreateStreamedResponse $chunk */ - foreach ($response as $chunk) { - $chunkDelta = null; - $accumulatedToolCallsSoFar = null; - $finishReason = static::mapFinishReason($chunk->delta->stop_reason); - - switch ($chunk->type) { - case 'message_start': - $messageId = $chunk->message->id; - $model = $chunk->message->model; - $usage = $chunk->usage !== null ? $this->mapUsage($chunk->usage) : null; - break; - - case 'content_block_start': - if ($chunk->content_block_start->type === 'tool_use') { - // Start of a new tool call - $currentToolCall = [ - 'id' => $chunk->content_block_start->id, - 'function' => [ - 'name' => $chunk->content_block_start->name, - 'arguments' => '', - ], - ]; - $toolCallsSoFar[] = $currentToolCall; - } - - break; - - case 'content_block_delta': - if ($chunk->delta->type === 'text_delta') { - // Text content delta - $chunkDelta = $chunk->delta->text; - $contentSoFar .= $chunkDelta; - } elseif ($chunk->delta->type === 'input_json_delta' && $currentToolCall !== null) { - // Tool call arguments delta - $lastIndex = count($toolCallsSoFar) - 1; - - if ($lastIndex >= 0) { - $toolCallsSoFar[$lastIndex]['function']['arguments'] .= $chunk->delta->partial_json; - } - } - - break; - - case 'content_block_stop': - // Content block finished - finalize current tool call if applicable - $currentToolCall = null; - break; - - case 'message_delta': - if ($chunk->usage !== null) { - $usage = $this->mapUsage($chunk->usage); - } - - break; - - case 'message_stop': - // Final event - this will be the last chunk - break; - - case 'ping': - // Skip ping events - continue 2; - } - - // Build accumulated tool calls if any exist - if ($toolCallsSoFar !== []) { - $accumulatedToolCallsSoFar = new ToolCallCollection( - collect($toolCallsSoFar) - ->map(function (array $toolCall): ToolCall { - try { - $arguments = json_decode((string) $toolCall['function']['arguments'], true, flags: JSON_THROW_ON_ERROR); - } catch (JsonException) { - $arguments = []; - } - - return new ToolCall( - $toolCall['id'], - new FunctionCall( - $toolCall['function']['name'], - $arguments, - ), - ); - }) - ->values() - ->all(), - ); - } - - $chunk = new ChatGenerationChunk( - type: ChunkType::TextDelta, - id: $messageId, - message: new AssistantMessage( - content: $chunkDelta, - toolCalls: $accumulatedToolCallsSoFar, - metadata: new ResponseMetadata( - id: $messageId ?? 'unknown', - model: $model ?? $this->model, - provider: $this->modelProvider, - finishReason: $finishReason, - usage: $usage, - ), - ), - createdAt: new DateTimeImmutable(), // TODO - finishReason: $finishReason, - usage: $usage, - contentSoFar: $contentSoFar, - isFinal: $finishReason !== null, - ); - - $chunk = $this->applyOutputParserIfApplicable($chunk); - - $this->dispatchEvent(new ChatModelStream($this, $chunk)); - - yield $chunk; - } - }); - } - - /** - * Map the OpenAI usage response to a Usage object. - */ - protected function mapUsage(CreateResponseUsage|CreateStreamedResponseUsage $usage): Usage - { - return new Usage( - promptTokens: $usage->inputTokens ?? 0, - completionTokens: $usage->outputTokens ?? null, - cachedTokens: $usage->cacheCreationInputTokens ?? null, - inputCost: $this->modelProvider->inputCostForTokens($this->model, $usage->inputTokens ?? 0), - outputCost: $this->modelProvider->outputCostForTokens($this->model, $usage->outputTokens ?? 0), - ); - } - - /** - * Take the given messages and format them for the OpenAI API. - * - * @return array> - */ - protected static function mapMessagesForInput(MessageCollection $messages): array - { - return $messages - ->map(function (Message $message) { - if ($message instanceof ToolMessage) { - return [ - 'role' => MessageRole::User->value, - 'content' => [ - [ - 'type' => 'tool_use', - 'tool_use_id' => $message->id, - 'content' => $message->content, - ], - ], - ]; - } - - if ($message instanceof AssistantMessage && $message->toolCalls?->isNotEmpty()) { - $formattedMessage = $message->toArray(); - - // Ensure the function arguments are encoded as a string - foreach ($message->toolCalls as $index => $toolCall) { - Arr::set( - $formattedMessage, - 'tool_calls.' . $index . '.function.arguments', - json_encode($toolCall->function->arguments), - ); - } - - return $formattedMessage; - } - - $formattedMessage = $message->toArray(); - - if (isset($formattedMessage['content']) && is_array($formattedMessage['content'])) { - $formattedMessage['content'] = array_map(function (mixed $content) { - return match (true) { - $content instanceof TextContent => [ - 'type' => 'text', - 'text' => $content->text, - ], - // $content instanceof ImageContent => [ - // 'type' => 'image_url', - // 'image_url' => [ - // 'url' => $content->urlOrBase64, - // ], - // ], - // $content instanceof DocumentContent => [ - // 'type' => 'document', - // 'document' => $content->data, - // ], - default => $content, - }; - }, $formattedMessage['content']); - } - - return $formattedMessage; - }) - ->values() - ->toArray(); - } - - protected static function mapFinishReason(?string $finishReason): ?FinishReason - { - if ($finishReason === null) { - return null; - } - - return match ($finishReason) { - 'end_turn' => FinishReason::Stop, - 'max_tokens' => FinishReason::Length, - 'stop_sequence' => FinishReason::StopSequence, - 'tool_use' => FinishReason::ToolCalls, - default => FinishReason::Unknown, - }; - } - /** * @param array $additionalParameters * @@ -411,8 +105,22 @@ protected function buildParams(array $additionalParameters): array 'model' => $this->model, ]; + if ($this->maxTokens !== null) { + $params['max_tokens'] = $this->maxTokens; + } + + if ($this->temperature !== null) { + $params['temperature'] = $this->temperature; + } + if ($this->structuredOutputConfig !== null) { $this->structuredOutputMode = StructuredOutputMode::Tool; + + $params['output_format'] = [ + 'type' => 'json_schema', + 'schema' => $this->structuredOutputConfig->schema->additionalProperties(false)->toArray(), + ]; + } elseif ($this->forceJsonOutput) { $this->structuredOutputMode = StructuredOutputMode::Json; } @@ -445,29 +153,44 @@ protected function buildParams(array $additionalParameters): array ->toArray(); } - return [ + $finalParams = [ ...$params, ...$this->parameters, ...$additionalParameters, ]; - } - public function getClient(): ClientContract - { - return $this->client; + if (! isset($finalParams['max_tokens'])) { + throw new LLMException('`max_tokens` parameter is required for Anthropic.'); + } + + if ($this->structuredOutputConfig !== null) { + $finalParams['betas'] ??= []; + $finalParams['betas'][] = 'structured-outputs-2025-11-13'; + } + + return $finalParams; } /** - * @param array $responses + * @param array $responses */ - public static function fake(array $responses, ?string $model = null, ?ModelProvider $modelProvider = null): self - { - $client = new ClientFake($responses); - - return new self( + public static function fake( + array $responses, + ?string $apiKey = null, + ?string $model = null, + ?ModelProvider $modelProvider = null, + ): self { + $client = Anthropic::fake($responses, $apiKey); + + $instance = new self( $client, - $model ?? CreateResponseFixture::ATTRIBUTES['model'], + $model ?? 'claude-4-5-sonnet-20250926', $modelProvider ?? ModelProvider::Anthropic, ); + + // Set a default max_tokens for testing + $instance->withMaxTokens(8096); + + return $instance; } } diff --git a/src/LLM/Drivers/Anthropic/Concerns/MapStreamResponse.php b/src/LLM/Drivers/Anthropic/Concerns/MapStreamResponse.php new file mode 100644 index 0000000..1efa3ba --- /dev/null +++ b/src/LLM/Drivers/Anthropic/Concerns/MapStreamResponse.php @@ -0,0 +1,398 @@ + + */ + private array $completedContent = []; + + private ?TextContent $pendingTextContent = null; + + private ?ReasoningContent $pendingReasoningContent = null; + + /** + * @var array> + */ + private array $contentBlockTypes = []; + + /** + * @var array + */ + private array $completedToolCalls = []; + + /** + * @var array{id: string, name: string, partialJson: string}|null + */ + private ?array $pendingToolCall = null; + + private ?int $pendingToolCallIndex = null; + + /** + * Map a streaming response to a ChatStreamResult. + * + * @param \Cortex\SDK\Anthropic\Data\Messages\MessageStream<\Cortex\SDK\Anthropic\Contracts\StreamEvent> $response + */ + protected function mapStreamResponse(MessageStream $response, bool $isCached = false): ChatStreamResult + { + return new ChatStreamResult(function () use ($response, $isCached): Generator { + $this->resetStreamState(); + + yield from $this->streamBuffer?->drain() ?? []; + + $chatGenerationChunk = null; + $messageId = null; + + foreach ($response as $event) { + yield from $this->streamBuffer?->drain() ?? []; + + $chunkType = $this->mapChunkType($event, $this->contentBlockTypes); + + if ($chunkType === null) { + continue; + } + + $messageId = $this->extractMessageId($event, $messageId); + + $this->processStreamEvent($event); + + $chatGenerationChunk = $this->applyOutputParserIfApplicable( + $this->buildChunk($event, $chunkType, $messageId, $isCached), + ); + + $this->dispatchEvent(new ChatModelStream($this, $chatGenerationChunk)); + + yield $chatGenerationChunk; + } + + yield from $this->streamBuffer?->drain() ?? []; + + if ($chatGenerationChunk !== null) { + $this->dispatchEvent(new ChatModelStreamEnd($this, $chatGenerationChunk)); + } + }); + } + + private function resetStreamState(): void + { + $this->completedContent = []; + $this->pendingTextContent = null; + $this->pendingReasoningContent = null; + $this->contentBlockTypes = []; + $this->completedToolCalls = []; + $this->pendingToolCall = null; + $this->pendingToolCallIndex = null; + } + + private function processStreamEvent(StreamEvent $event): void + { + match (true) { + $event instanceof ContentBlockStart => $this->handleContentBlockStart($event), + $event instanceof ContentBlockDelta => $this->handleContentBlockDelta($event), + $event instanceof ContentBlockStop => $this->handleContentBlockStop($event), + default => null, + }; + } + + private function handleContentBlockStart(ContentBlockStart $event): void + { + $this->contentBlockTypes[$event->index] = $event->contentBlock::class; + + match (true) { + $event->contentBlock instanceof TextContentBlock => $this->pendingTextContent = new TextContent($event->contentBlock->text), + $event->contentBlock instanceof ThinkingContentBlock => $this->pendingReasoningContent = new ReasoningContent( + reasoning: $event->contentBlock->thinking, + metadata: [ + 'signature' => $event->contentBlock->signature, + 'type' => 'thinking', + ], + ), + $event->contentBlock instanceof RedactedThinkingContentBlock => $this->pendingReasoningContent = new ReasoningContent( + reasoning: $event->contentBlock->data, + metadata: [ + 'text' => $event->contentBlock->text, + 'type' => 'redacted_thinking', + ], + ), + $event->contentBlock instanceof ToolUseContentBlock => $this->startToolCall($event), + default => null, + }; + } + + private function startToolCall(ContentBlockStart $event): void + { + /** @var ToolUseContentBlock $contentBlock */ + $contentBlock = $event->contentBlock; + + $this->pendingToolCall = [ + 'id' => $contentBlock->id, + 'name' => $contentBlock->name, + 'partialJson' => '', + ]; + $this->pendingToolCallIndex = $event->index; + } + + private function handleContentBlockDelta(ContentBlockDelta $event): void + { + match (true) { + $event->delta instanceof TextDelta && $this->pendingTextContent !== null => $this->pendingTextContent = $this->pendingTextContent->append($event->delta->text), + $event->delta instanceof ThinkingDelta && $this->pendingReasoningContent !== null => $this->pendingReasoningContent = $this->pendingReasoningContent->append($event->delta->thinking), + $event->delta instanceof SignatureDelta && $this->pendingReasoningContent !== null => $this->pendingReasoningContent = new ReasoningContent( + reasoning: $this->pendingReasoningContent->reasoning, + metadata: array_merge($this->pendingReasoningContent->metadata, [ + 'signature' => $event->delta->signature, + ]), + ), + $event->delta instanceof InputJsonDelta && $this->pendingToolCall !== null => $this->pendingToolCall['partialJson'] .= $event->delta->partialJson, + default => null, + }; + } + + private function handleContentBlockStop(ContentBlockStop $event): void + { + if ($this->pendingTextContent !== null) { + $this->completedContent[] = $this->pendingTextContent; + $this->pendingTextContent = null; + + return; + } + + if ($this->pendingReasoningContent !== null) { + $this->completedContent[] = $this->pendingReasoningContent; + $this->pendingReasoningContent = null; + + return; + } + + if ($this->pendingToolCall !== null && $this->pendingToolCallIndex === $event->index) { + $this->finalizeToolCall(); + } + } + + private function finalizeToolCall(): void + { + if ($this->pendingToolCall === null) { + return; + } + + $this->completedToolCalls[] = new ToolCall( + $this->pendingToolCall['id'], + new FunctionCall( + $this->pendingToolCall['name'], + $this->parseJsonSafely($this->pendingToolCall['partialJson']), + $this->pendingToolCall['partialJson'], + ), + ); + + $this->pendingToolCall = null; + $this->pendingToolCallIndex = null; + } + + /** + * @return array + */ + private function parseJsonSafely(string $json): array + { + if ($json === '') { + return []; + } + + try { + return new JsonOutputParser()->parse($json); + } catch (OutputParserException) { + return []; + } + } + + private function extractMessageId(StreamEvent $event, ?string $currentId): ?string + { + return $event instanceof MessageStart + ? $event->message->id + : $currentId; + } + + /** + * @return array + */ + private function buildContentSnapshot(): array + { + $snapshot = [...$this->completedContent]; + + if ($this->pendingTextContent !== null) { + $snapshot[] = new TextContent($this->pendingTextContent->text); + } elseif ($this->pendingReasoningContent !== null) { + $snapshot[] = new ReasoningContent( + reasoning: $this->pendingReasoningContent->reasoning, + metadata: $this->pendingReasoningContent->metadata, + ); + } + + return $snapshot; + } + + private function buildToolCallCollection(): ?ToolCallCollection + { + $toolCalls = [...$this->completedToolCalls]; + + if ($this->pendingToolCall !== null) { + $toolCalls[] = new ToolCall( + $this->pendingToolCall['id'], + new FunctionCall( + $this->pendingToolCall['name'], + $this->parseJsonSafely($this->pendingToolCall['partialJson']), + $this->pendingToolCall['partialJson'], + ), + ); + } + + return $toolCalls !== [] ? new ToolCallCollection($toolCalls) : null; + } + + private function extractContentDelta(StreamEvent $event): TextContent|ReasoningContent|null + { + if (! $event instanceof ContentBlockDelta) { + return null; + } + + return match (true) { + $event->delta instanceof TextDelta => new TextContent($event->delta->text), + $event->delta instanceof ThinkingDelta => new ReasoningContent($event->delta->thinking), + default => null, + }; + } + + private function buildChunk( + StreamEvent $event, + ChunkType $chunkType, + ?string $messageId, + bool $isCached = false, + ): ChatGenerationChunk { + $finishReason = $event instanceof MessageDelta + ? $this->mapFinishReason($event->stopReason) + : null; + + $usage = $event instanceof MessageDelta + ? $this->mapUsage($event->cumulativeUsage) + : null; + + $meta = $event->meta(); + + return new ChatGenerationChunk( + type: $chunkType, + id: $messageId, + message: new AssistantMessage( + content: [$this->extractContentDelta($event)], + toolCalls: $this->buildToolCallCollection(), + metadata: new ResponseMetadata( + id: $messageId, + model: $this->model, + provider: $this->modelProvider, + finishReason: $finishReason, + usage: $usage, + processingTime: $meta?->processingTime, + providerMetadata: $meta->raw ?? [], + ), + id: $messageId, + ), + createdAt: $meta->createdAt ?? new DateTimeImmutable(), + finishReason: $finishReason, + usage: $usage, + contentSoFar: $this->buildContentSnapshot(), + isFinal: $usage !== null, + rawChunk: $this->includeRaw ? $event->raw() : null, + metadata: [ + 'is_cached' => $isCached, + ], + ); + } + + /** + * @param array> $contentBlocksByIndex + */ + protected function mapChunkType(StreamEvent $event, array $contentBlocksByIndex = []): ?ChunkType + { + return match (true) { + $event instanceof MessageStart => ChunkType::MessageStart, + $event instanceof MessageDelta => ChunkType::MessageEnd, + $event instanceof ContentBlockStart => $this->mapContentBlockStartType($event), + $event instanceof ContentBlockDelta => $this->mapContentBlockDeltaType($event), + $event instanceof ContentBlockStop => $this->mapContentBlockStopType($event, $contentBlocksByIndex), + default => null, + }; + } + + private function mapContentBlockStartType(ContentBlockStart $event): ?ChunkType + { + return match ($event->contentBlock::class) { + TextContentBlock::class => ChunkType::TextStart, + ThinkingContentBlock::class, RedactedThinkingContentBlock::class => ChunkType::ReasoningStart, + ToolUseContentBlock::class => ChunkType::ToolInputStart, + default => null, + }; + } + + private function mapContentBlockDeltaType(ContentBlockDelta $event): ?ChunkType + { + return match ($event->delta::class) { + TextDelta::class => ChunkType::TextDelta, + ThinkingDelta::class => ChunkType::ReasoningDelta, + InputJsonDelta::class => ChunkType::ToolInputDelta, + default => null, + }; + } + + /** + * @param array> $contentBlocksByIndex + */ + private function mapContentBlockStopType(ContentBlockStop $event, array $contentBlocksByIndex): ChunkType + { + $contentBlockClass = $contentBlocksByIndex[$event->index] ?? null; + + return match ($contentBlockClass) { + TextContentBlock::class => ChunkType::TextEnd, + ThinkingContentBlock::class, RedactedThinkingContentBlock::class => ChunkType::ReasoningEnd, + ToolUseContentBlock::class => ChunkType::ToolInputEnd, + WebSearchToolResultContentBlock::class, ServerToolUseContentBlock::class => ChunkType::ToolOutputEnd, + default => ChunkType::TextEnd, + }; + } +} diff --git a/src/LLM/Drivers/Anthropic/Concerns/MapsFinishReason.php b/src/LLM/Drivers/Anthropic/Concerns/MapsFinishReason.php new file mode 100644 index 0000000..a87aba0 --- /dev/null +++ b/src/LLM/Drivers/Anthropic/Concerns/MapsFinishReason.php @@ -0,0 +1,26 @@ + FinishReason::Stop, + 'max_tokens' => FinishReason::Length, + 'stop_sequence' => FinishReason::StopSequence, + 'tool_use' => FinishReason::ToolCalls, + default => FinishReason::Unknown, + }; + } +} diff --git a/src/LLM/Drivers/Anthropic/Concerns/MapsMessages.php b/src/LLM/Drivers/Anthropic/Concerns/MapsMessages.php new file mode 100644 index 0000000..20dde31 --- /dev/null +++ b/src/LLM/Drivers/Anthropic/Concerns/MapsMessages.php @@ -0,0 +1,262 @@ +> + */ + protected function mapMessagesForInput(MessageCollection $messages): array + { + $mapped = []; + $pendingToolResults = []; + + foreach ($messages as $message) { + if ($message instanceof ToolMessage) { + $pendingToolResults[] = $this->mapToolResultBlock($message); + + continue; + } + + // Flush any pending tool results before adding the next message + if ($pendingToolResults !== []) { + $mapped[] = $this->createToolResultsMessage($pendingToolResults); + $pendingToolResults = []; + } + + $mapped[] = $this->mapMessage($message); + } + + // Flush any remaining tool results at the end + if ($pendingToolResults !== []) { + $mapped[] = $this->createToolResultsMessage($pendingToolResults); + } + + return $mapped; + } + + /** + * Create a single user message containing all tool results. + * + * @param array> $toolResults + * + * @return array + */ + private function createToolResultsMessage(array $toolResults): array + { + return [ + 'role' => MessageRole::User->value, + 'content' => $toolResults, + ]; + } + + /** + * Map a tool message to a tool_result content block. + * + * @return array + */ + private function mapToolResultBlock(ToolMessage $message): array + { + return [ + 'type' => 'tool_result', + 'tool_use_id' => $message->id, + 'content' => $message->text() ?? '', + ]; + } + + /** + * @return array + */ + private function mapMessage(Message $message): array + { + return match (true) { + $message instanceof AssistantMessage => $this->mapAssistantMessage($message), + default => $this->mapGenericMessage($message), + }; + } + + /** + * Map an assistant message to Anthropic format. + * + * Tool calls are represented as tool_use content blocks in Anthropic. + * + * @return array + */ + private function mapAssistantMessage(AssistantMessage $message): array + { + $content = $this->mapMessageContent($message->content); + + if ($message->toolCalls?->isNotEmpty()) { + foreach ($message->toolCalls as $toolCall) { + $content[] = [ + 'type' => 'tool_use', + 'id' => $toolCall->id, + 'name' => $toolCall->function->name, + 'input' => new ArrayObject($toolCall->function->arguments), + ]; + } + } + + return [ + 'role' => MessageRole::Assistant->value, + 'content' => $content, + ]; + } + + /** + * Map a generic message (user, etc.) to Anthropic format. + * + * @return array + */ + private function mapGenericMessage(Message $message): array + { + $formattedMessage = $message->toArray(); + + if (isset($formattedMessage['content']) && is_array($formattedMessage['content'])) { + $formattedMessage['content'] = $this->mapMessageContent($formattedMessage['content']); + } + + // Anthropic does not support message IDs, so we remove it. + unset($formattedMessage['id']); + + return $formattedMessage; + } + + /** + * Map message content to Anthropic content blocks. + * + * @param string|array|null $content + * + * @return array> + */ + private function mapMessageContent(string|array|null $content): array + { + if ($content === null) { + return []; + } + + if (is_string($content)) { + return [ + [ + 'type' => 'text', + 'text' => $content, + ], + ]; + } + + return array_values(array_filter( + array_map(fn(mixed $item): ?array => $this->mapContentBlock($item), $content), + )); + } + + /** + * Map a single content block to Anthropic format. + * + * @return array|null + */ + private function mapContentBlock(mixed $content): ?array + { + return match (true) { + $content instanceof TextContent => $this->mapTextContent($content), + $content instanceof ImageContent => $this->mapImageContent($content), + $content instanceof ReasoningContent => $this->mapReasoningContent($content), + is_array($content) => $content, + default => null, + }; + } + + /** + * @return array + */ + private function mapTextContent(TextContent $content): array + { + return [ + 'type' => 'text', + 'text' => $content->text, + ]; + } + + /** + * Map reasoning content back to Anthropic's thinking block format. + * + * According to Anthropic's documentation, thinking blocks must be passed + * back unmodified in multi-turn conversations to maintain reasoning flow. + * + * @return array + */ + private function mapReasoningContent(ReasoningContent $content): array + { + // Check if this is a redacted_thinking block + if (isset($content->metadata['type']) && $content->metadata['type'] === 'redacted_thinking') { + return [ + 'type' => 'redacted_thinking', + 'data' => $content->reasoning, + ]; + } + + // Default to thinking block + $block = [ + 'type' => 'thinking', + 'thinking' => $content->reasoning, + ]; + + // Include signature if it exists in metadata + if (isset($content->metadata['signature'])) { + $block['signature'] = $content->metadata['signature']; + } + + return $block; + } + + /** + * Map image content to Anthropic's image format. + * + * @return array + */ + private function mapImageContent(ImageContent $content): array + { + $this->supportsFeatureOrFail(ModelFeature::Vision); + + if (Utils::isDataUrl($content->url)) { + $dataUrl = $content->toDataUrl(); + + return [ + 'type' => 'image', + 'source' => [ + 'type' => 'base64', + 'media_type' => $dataUrl->mediaType, + 'data' => $dataUrl->data, + ], + ]; + } + + return [ + 'type' => 'image', + 'source' => [ + 'type' => 'url', + 'url' => $content->url, + ], + ]; + } +} diff --git a/src/LLM/Drivers/Anthropic/Concerns/MapsResponse.php b/src/LLM/Drivers/Anthropic/Concerns/MapsResponse.php new file mode 100644 index 0000000..c8e6e0c --- /dev/null +++ b/src/LLM/Drivers/Anthropic/Concerns/MapsResponse.php @@ -0,0 +1,115 @@ +mapUsage($message->usage); + $finishReason = $this->mapFinishReason($message->stopReason); + $meta = $message->getMeta(); + + /** @var \Illuminate\Support\Collection $contentCollection */ + $contentCollection = collect($message->content); + /** @var \Illuminate\Support\Collection $toolUseBlocks */ + $toolUseBlocks = $contentCollection->filter(fn(object $content): bool => $content instanceof ToolUseContentBlock); + $toolCalls = $toolUseBlocks + ->map(function (ToolUseContentBlock $content): ToolCall { + return new ToolCall( + $content->id, + new FunctionCall( + $content->name, + $content->input, + ), + ); + }) + ->values() + ->all(); + + $generation = new ChatGeneration( + message: new AssistantMessage( + content: $this->mapContent($message->content), + toolCalls: new ToolCallCollection($toolCalls), + metadata: new ResponseMetadata( + id: $message->id ?? 'unknown', + model: $message->model, + provider: $this->modelProvider, + finishReason: $finishReason, + usage: $usage, + processingTime: $meta?->processingTime, + providerMetadata: $meta->raw ?? [], + ), + id: $message->id, + ), + createdAt: $meta->createdAt ?? new DateTimeImmutable(), + finishReason: $finishReason, + ); + + $generation = $this->applyOutputParserIfApplicable($generation); + + /** @var array|null $rawResponse */ + $rawResponse = $this->includeRaw + ? $message->getResponse()->json() + : null; + + return new ChatResult( + $generation, + $usage, + $rawResponse, // @phpstan-ignore argument.type + metadata: [ + 'is_cached' => $isCached, + ], + ); + } + + /** + * @param array $content + * + * @return array + */ + protected function mapContent(array $content): array + { + return collect($content) + ->reject(fn(object $content): bool => $content instanceof ToolUseContentBlock) + ->map(function (object $content): mixed { + return match (true) { + $content instanceof TextContentBlock => new TextContent($content->text), + $content instanceof ThinkingContentBlock => new ReasoningContent($content->thinking, metadata: [ + 'signature' => $content->signature, + 'type' => 'thinking', + ]), + $content instanceof RedactedThinkingContentBlock => new ReasoningContent($content->data, metadata: [ + 'text' => $content->text, + 'type' => 'redacted_thinking', + ]), + default => null, + }; + }) + ->filter() + ->values() + ->all(); + } +} diff --git a/src/LLM/Drivers/Anthropic/Concerns/MapsUsage.php b/src/LLM/Drivers/Anthropic/Concerns/MapsUsage.php new file mode 100644 index 0000000..fef4761 --- /dev/null +++ b/src/LLM/Drivers/Anthropic/Concerns/MapsUsage.php @@ -0,0 +1,23 @@ +inputTokens, + completionTokens: $usage->outputTokens, + cachedTokens: $usage->cacheCreationInputTokens, + inputCost: $this->modelProvider->inputCostForTokens($this->model, $usage->inputTokens), + outputCost: $this->modelProvider->outputCostForTokens($this->model, $usage->outputTokens), + ); + } +} diff --git a/src/LLM/Drivers/OpenAI/Chat/Concerns/MapsStreamResponse.php b/src/LLM/Drivers/OpenAI/Chat/Concerns/MapsStreamResponse.php index 42d2267..7dd3f55 100644 --- a/src/LLM/Drivers/OpenAI/Chat/Concerns/MapsStreamResponse.php +++ b/src/LLM/Drivers/OpenAI/Chat/Concerns/MapsStreamResponse.php @@ -21,6 +21,7 @@ use Cortex\OutputParsers\JsonOutputParser; use Cortex\Exceptions\OutputParserException; use Cortex\LLM\Data\Messages\AssistantMessage; +use Cortex\LLM\Data\Messages\Content\TextContent; use OpenAI\Responses\Chat\CreateStreamedResponseChoice; /** @mixin \Cortex\LLM\Drivers\OpenAI\Chat\OpenAIChat */ @@ -36,7 +37,8 @@ trait MapsStreamResponse protected function mapStreamResponse(StreamResponse $response): ChatStreamResult { return new ChatStreamResult(function () use ($response): Generator { - $contentSoFar = ''; + $contentSoFar = []; + $currentTextContent = null; $toolCallsSoFar = []; $isActiveText = false; $finishReason = null; @@ -68,8 +70,39 @@ protected function mapStreamResponse(StreamResponse $response): ChatStreamResult $usage, ); - // Now update content and tool call tracking - $contentSoFar .= $choice->delta->content; + // Hack to push the delta from TextStart to TextDelta + if ($chunkType === ChunkType::TextStart) { + $this->streamBuffer?->push(new ChatGenerationChunk( + type: ChunkType::TextDelta, + id: $chunk->id, + message: new AssistantMessage( + content: $choice->delta->content, + metadata: new ResponseMetadata( + id: $chunk->id, + model: $this->model, + provider: $this->modelProvider, + finishReason: $finishReason, + usage: $usage, + ), + id: $chunk->id, + ), + createdAt: DateTimeImmutable::createFromFormat('U', (string) $chunk->created), + finishReason: $finishReason, + usage: $usage, + contentSoFar: [], + isFinal: false, + rawChunk: $this->includeRaw ? $chunk->toArray() : null, + )); + } + + // Handle text content - initialize or append + if ($choice->delta->content !== null) { + if ($currentTextContent === null) { + $currentTextContent = new TextContent($choice->delta->content); + } else { + $currentTextContent = $currentTextContent->append($choice->delta->content); + } + } // Track tool calls across chunks foreach ($choice->delta->toolCalls as $toolCall) { @@ -125,6 +158,7 @@ protected function mapStreamResponse(StreamResponse $response): ChatStreamResult new FunctionCall( $toolCall['function']['name'], $arguments, + $toolCall['function']['arguments'], ), ); }) @@ -158,11 +192,25 @@ protected function mapStreamResponse(StreamResponse $response): ChatStreamResult } } + // Finalize text content if this is the end + if ($usage !== null && $currentTextContent !== null) { + $contentSoFar[] = $currentTextContent; + $currentTextContent = null; + } + + // Build current contentSoFar array including partial content being built + // Clone TextContent to avoid mutating references in previous chunks + $currentContentSoFar = [...$contentSoFar]; + + if ($currentTextContent !== null) { + $currentContentSoFar[] = new TextContent($currentTextContent->text); + } + $chatGenerationChunk = new ChatGenerationChunk( type: $chunkType, id: $chunk->id, message: new AssistantMessage( - content: $choice->delta->content ?? null, + content: $chunk->choices !== [] ? ($chunk->choices[0]->delta->content ?? null) : null, toolCalls: $accumulatedToolCallsSoFar ?? null, metadata: new ResponseMetadata( id: $chunk->id, @@ -175,7 +223,7 @@ protected function mapStreamResponse(StreamResponse $response): ChatStreamResult createdAt: DateTimeImmutable::createFromFormat('U', (string) $chunk->created), finishReason: $usage !== null ? $finishReason : null, usage: $usage, - contentSoFar: $contentSoFar, + contentSoFar: $currentContentSoFar, isFinal: $usage !== null, rawChunk: $this->includeRaw ? $chunk->toArray() : null, ); diff --git a/src/LLM/Drivers/OpenAI/Chat/OpenAIChat.php b/src/LLM/Drivers/OpenAI/Chat/OpenAIChat.php index 7c1e072..eda7749 100644 --- a/src/LLM/Drivers/OpenAI/Chat/OpenAIChat.php +++ b/src/LLM/Drivers/OpenAI/Chat/OpenAIChat.php @@ -85,6 +85,14 @@ protected function buildParams(array $additionalParameters): array 'model' => $this->model, ]; + if ($this->maxTokens !== null) { + $params['max_tokens'] = $this->maxTokens; + } + + if ($this->temperature !== null) { + $params['temperature'] = $this->temperature; + } + if ($this->structuredOutputConfig !== null) { $this->supportsFeatureOrFail(ModelFeature::StructuredOutput); diff --git a/src/LLM/Drivers/OpenAI/Responses/Concerns/MapsMessages.php b/src/LLM/Drivers/OpenAI/Responses/Concerns/MapsMessages.php index d885db7..6a5f000 100644 --- a/src/LLM/Drivers/OpenAI/Responses/Concerns/MapsMessages.php +++ b/src/LLM/Drivers/OpenAI/Responses/Concerns/MapsMessages.php @@ -27,62 +27,84 @@ trait MapsMessages protected function mapMessagesForInput(MessageCollection $messages): array { return $messages - ->map(function (Message $message): array { - // Handle ToolMessage specifically for Responses API - if ($message instanceof ToolMessage) { - return [ - 'role' => $message->role->value, - 'content' => $message->content(), - 'tool_call_id' => $message->id, - ]; - } - - // Handle AssistantMessage with tool calls - if ($message instanceof AssistantMessage && $message->toolCalls?->isNotEmpty()) { - $baseMessage = [ - 'role' => $message->role->value, - ]; - - // Add content if present - if ($message->content() !== null) { - $baseMessage['content'] = $message->content(); - } - - // Add tool calls - $baseMessage['tool_calls'] = $message->toolCalls->map(function (ToolCall $toolCall): array { - return [ - 'id' => $toolCall->id, - 'type' => 'function', - 'function' => [ - 'name' => $toolCall->function->name, - 'arguments' => json_encode($toolCall->function->arguments), - ], - ]; - })->toArray(); - - return $baseMessage; - } - - // Handle all other message types - $formattedMessage = [ + ->flatMap(fn(Message $message): array => match (true) { + $message instanceof ToolMessage => [$this->mapToolMessage($message)], + $message instanceof AssistantMessage && $message->toolCalls?->isNotEmpty() => $this->mapAssistantMessageWithToolCalls($message), + default => [[ 'role' => $message->role()->value, 'content' => $this->mapContentForInput($message->content()), - ]; - - return $formattedMessage; + ]], }) ->values() ->toArray(); } /** - * Map content to the OpenAI Responses API format. + * @return array + */ + private function mapToolMessage(ToolMessage $message): array + { + $output = $message->text(); + + if ($output === null) { + $encodedOutput = json_encode($this->mapContentForInput($message->content())); + $output = $encodedOutput !== false ? $encodedOutput : ''; + } + + return [ + 'type' => 'function_call_output', + 'call_id' => $message->id, + 'output' => $output, + ]; + } + + /** + * @return array> + */ + private function mapAssistantMessageWithToolCalls(AssistantMessage $message): array + { + $mapped = []; + + $content = $this->mapAssistantContentForInput($message->content()); + + if ($content !== []) { + $mapped[] = [ + 'role' => $message->role()->value, + 'content' => $content, + ]; + } + + foreach ($message->toolCalls as $toolCall) { + /** @var ToolCall $toolCall */ + $arguments = $toolCall->function->delta; + + if ($arguments === null) { + $encodedArguments = json_encode($toolCall->function->arguments); + $arguments = $encodedArguments !== false ? $encodedArguments : '{}'; + } + + $mapped[] = [ + 'type' => 'function_call', + 'call_id' => $toolCall->id, + 'name' => $toolCall->function->name, + 'arguments' => $arguments, + ]; + } + + return $mapped; + } + + /** + * Map assistant message content for the Responses API input. + * + * ReasoningContent is excluded because the Responses API does not + * accept it back in the `input` field of subsequent requests. * * @param string|array|null $content * * @return array> */ - protected function mapContentForInput(string|array|null $content): array + protected function mapAssistantContentForInput(string|array|null $content): array { if ($content === null) { return []; @@ -91,57 +113,99 @@ protected function mapContentForInput(string|array|null $content): array if (is_string($content)) { return [ [ - 'type' => 'input_text', + 'type' => 'output_text', 'text' => $content, ], ]; } - return array_map(function (mixed $item): array { - if ($item instanceof ImageContent) { - $this->supportsFeatureOrFail(ModelFeature::Vision); + $mapped = []; - return [ - 'type' => 'input_image', - 'image_url' => $item->url, - 'detail' => 'auto', // Default detail level - ]; + foreach ($content as $item) { + // Skip reasoning content — the Responses API doesn't accept it in input + if ($item instanceof ReasoningContent) { + continue; } - if ($item instanceof AudioContent) { - $this->supportsFeatureOrFail(ModelFeature::AudioInput); - - return [ - 'type' => 'input_audio', - 'data' => $item->base64Data, - 'format' => $item->format, + if ($item instanceof TextContent) { + $mapped[] = [ + 'type' => 'output_text', + 'text' => $item->text ?? '', ]; } + } - if ($item instanceof FileContent) { - return [ - 'type' => 'input_file', - 'file_id' => $item->fileName, // Assuming file_id should be the fileName - ]; - } + return $mapped; + } - if ($item instanceof TextContent) { - return [ + /** + * Map content to the OpenAI Responses API format. + * + * @param string|array|null $content + * + * @return array> + */ + protected function mapContentForInput(string|array|null $content): array + { + if ($content === null) { + return []; + } + + if (is_string($content)) { + return [ + [ 'type' => 'input_text', - 'text' => $item->text ?? '', - ]; - } + 'text' => $content, + ], + ]; + } - // Handle ReasoningContent and ToolContent - if ($item instanceof ReasoningContent) { - return [ + return array_map(function (mixed $item): array { + return match (true) { + $item instanceof ImageContent => $this->mapImageContent($item), + $item instanceof AudioContent => $this->mapAudioContent($item), + $item instanceof FileContent => [ + 'type' => 'input_file', + 'file_id' => $item->fileName, + ], + $item instanceof TextContent => [ + 'type' => 'input_text', + 'text' => $item->text ?? '', + ], + $item instanceof ReasoningContent => [ 'type' => 'input_text', 'text' => $item->reasoning, - ]; - } - - // Fallback for unknown content types - return []; + ], + default => [], + }; }, $content); } + + /** + * @return array + */ + private function mapImageContent(ImageContent $item): array + { + $this->supportsFeatureOrFail(ModelFeature::Vision); + + return [ + 'type' => 'input_image', + 'image_url' => $item->url, + 'detail' => 'auto', + ]; + } + + /** + * @return array + */ + private function mapAudioContent(AudioContent $item): array + { + $this->supportsFeatureOrFail(ModelFeature::AudioInput); + + return [ + 'type' => 'input_audio', + 'data' => $item->base64Data, + 'format' => $item->format, + ]; + } } diff --git a/src/LLM/Drivers/OpenAI/Responses/Concerns/MapsResponse.php b/src/LLM/Drivers/OpenAI/Responses/Concerns/MapsResponse.php index a381a91..f699a05 100644 --- a/src/LLM/Drivers/OpenAI/Responses/Concerns/MapsResponse.php +++ b/src/LLM/Drivers/OpenAI/Responses/Concerns/MapsResponse.php @@ -5,24 +5,22 @@ namespace Cortex\LLM\Drivers\OpenAI\Responses\Concerns; use DateTimeImmutable; -use Illuminate\Support\Arr; use Cortex\LLM\Data\ToolCall; use Cortex\LLM\Data\ChatResult; use Cortex\LLM\Data\FunctionCall; use Cortex\Exceptions\LLMException; use Cortex\LLM\Data\ChatGeneration; use Cortex\LLM\Data\ResponseMetadata; -use OpenAI\Contracts\ResponseContract; use Cortex\LLM\Data\ToolCallCollection; use Cortex\LLM\Data\Messages\AssistantMessage; -use OpenAI\Responses\Responses\CreateResponse; +use Cortex\SDK\OpenAI\Data\Responses\Response; use Cortex\LLM\Data\Messages\Content\TextContent; -use OpenAI\Responses\Responses\Output\OutputMessage; +use Cortex\SDK\OpenAI\Data\Responses\Message\Refusal; use Cortex\LLM\Data\Messages\Content\ReasoningContent; -use OpenAI\Responses\Responses\Output\OutputReasoning; -use OpenAI\Responses\Responses\Output\OutputFunctionToolCall; -use OpenAI\Responses\Responses\Output\OutputMessageContentRefusal; -use OpenAI\Responses\Responses\Output\OutputMessageContentOutputText; +use Cortex\SDK\OpenAI\Data\Responses\Message\OutputText; +use Cortex\SDK\OpenAI\Data\Responses\OutputItems\MessageOutputItem; +use Cortex\SDK\OpenAI\Data\Responses\OutputItems\ReasoningOutputItem; +use Cortex\SDK\OpenAI\Data\Responses\OutputItems\FunctionToolCallOutputItem; /** @mixin \Cortex\LLM\Drivers\OpenAI\Responses\OpenAIResponses */ trait MapsResponse @@ -30,31 +28,38 @@ trait MapsResponse /** * Map a standard (non-streaming) response to a ChatResult. */ - protected function mapResponse(CreateResponse $response): ChatResult + protected function mapResponse(Response $response): ChatResult { $output = collect($response->output); $usage = $this->mapUsage($response->usage); $finishReason = $this->mapFinishReason($response->status); + $meta = $response->getMeta(); - /** @var \OpenAI\Responses\Responses\Output\OutputMessage $outputMessage */ - $outputMessage = $output->filter(fn(ResponseContract $item): bool => $item instanceof OutputMessage)->first(); + /** @var MessageOutputItem|null $outputMessage */ + $outputMessage = $output->filter(fn(object $item): bool => $item instanceof MessageOutputItem)->first(); - $outputMessageContent = collect($outputMessage->content); + $textContent = null; - if ($outputMessageContent->contains(fn(ResponseContract $item): bool => $item instanceof OutputMessageContentRefusal)) { - throw new LLMException('LLM refusal: ' . $outputMessageContent->first()->refusal); - } + if ($outputMessage !== null) { + $outputMessageContent = collect($outputMessage->content); + + if ($outputMessageContent->contains(fn(object $item): bool => $item instanceof Refusal)) { + /** @var Refusal $refusal */ + $refusal = $outputMessageContent->first(fn(object $item): bool => $item instanceof Refusal); - /** @var \OpenAI\Responses\Responses\Output\OutputMessageContentOutputText $textContent */ - $textContent = $outputMessageContent - ->filter(fn(ResponseContract $item): bool => $item instanceof OutputMessageContentOutputText) - ->first(); + throw new LLMException('LLM refusal: ' . $refusal->reason); + } - // TODO: Ignore other provider specific tool calls - // and only support function tool calls for now - $toolCalls = $output - ->filter(fn(ResponseContract $item): bool => $item instanceof OutputFunctionToolCall) - ->map(fn(OutputFunctionToolCall $toolCall): ToolCall => new ToolCall( + /** @var OutputText|null $textContent */ + $textContent = $outputMessageContent + ->filter(fn(object $item): bool => $item instanceof OutputText) + ->first(); + } + + /** @var \Illuminate\Support\Collection $functionToolCalls */ + $functionToolCalls = $output->filter(fn(object $item): bool => $item instanceof FunctionToolCallOutputItem); + $toolCalls = $functionToolCalls + ->map(fn(FunctionToolCallOutputItem $toolCall): ToolCall => new ToolCall( $toolCall->id, new FunctionCall( $toolCall->name, @@ -64,20 +69,32 @@ protected function mapResponse(CreateResponse $response): ChatResult ->values() ->all(); - $reasoningContent = $output - ->filter(fn(ResponseContract $item): bool => $item instanceof OutputReasoning) - ->map(fn(OutputReasoning $reasoning): ReasoningContent => new ReasoningContent( - $reasoning->id, - Arr::first($reasoning->summary)->text ?? '', + /** @var \Illuminate\Support\Collection $reasoningItems */ + $reasoningItems = $output->filter(fn(object $item): bool => $item instanceof ReasoningOutputItem); + $reasoningContent = $reasoningItems + ->map(fn(ReasoningOutputItem $reasoning): ReasoningContent => new ReasoningContent( + reasoning: ($first = collect($reasoning->content)->first()) !== null ? $first->text : '', + metadata: [ + 'id' => $reasoning->id, + 'status' => $reasoning->status, + 'type' => $reasoning->type, + 'encrypted_content' => $reasoning->encryptedContent, + 'summary' => array_map(fn(object $s): array => [ + 'text' => $s->text, + ], $reasoning->summary), + ], )) ->all(); + $content = [...$reasoningContent]; + + if ($textContent !== null) { + $content[] = new TextContent($textContent->text); + } + $generation = new ChatGeneration( message: new AssistantMessage( - content: [ - ...$reasoningContent, - new TextContent($textContent->text), - ], + content: $content !== [] ? $content : null, toolCalls: $toolCalls !== [] ? new ToolCallCollection($toolCalls) : null, metadata: new ResponseMetadata( id: $response->id, @@ -85,10 +102,14 @@ protected function mapResponse(CreateResponse $response): ChatResult provider: $this->modelProvider, finishReason: $finishReason, usage: $usage, + processingTime: $meta?->processingTime, + providerMetadata: $meta->raw ?? [], ), - id: $outputMessage->id, + id: $outputMessage?->id, ), - createdAt: DateTimeImmutable::createFromFormat('U', (string) $response->createdAt), + createdAt: $response->createdAt instanceof DateTimeImmutable + ? $response->createdAt + : DateTimeImmutable::createFromInterface($response->createdAt), finishReason: $finishReason, ); @@ -96,7 +117,7 @@ protected function mapResponse(CreateResponse $response): ChatResult /** @var array|null $rawResponse */ $rawResponse = $this->includeRaw - ? $response->toArray() + ? $response->getResponse()?->json() : null; return new ChatResult( diff --git a/src/LLM/Drivers/OpenAI/Responses/Concerns/MapsStreamResponse.php b/src/LLM/Drivers/OpenAI/Responses/Concerns/MapsStreamResponse.php index 4cfd444..5c15846 100644 --- a/src/LLM/Drivers/OpenAI/Responses/Concerns/MapsStreamResponse.php +++ b/src/LLM/Drivers/OpenAI/Responses/Concerns/MapsStreamResponse.php @@ -7,362 +7,653 @@ use Generator; use JsonException; use DateTimeImmutable; +use Cortex\LLM\Data\Usage; use Cortex\LLM\Data\ToolCall; use Cortex\LLM\Enums\ChunkType; use Cortex\LLM\Data\FunctionCall; use Cortex\Events\ChatModelStream; use Cortex\LLM\Enums\FinishReason; -use OpenAI\Responses\StreamResponse; use Cortex\Events\ChatModelStreamEnd; use Cortex\LLM\Data\ChatStreamResult; use Cortex\LLM\Data\ResponseMetadata; use Cortex\LLM\Data\ToolCallCollection; use Cortex\LLM\Data\ChatGenerationChunk; +use Cortex\SDK\OpenAI\Contracts\StreamEvent; use Cortex\LLM\Data\Messages\AssistantMessage; -use OpenAI\Responses\Responses\CreateResponse; -use OpenAI\Responses\Responses\Output\OutputMessage; -use OpenAI\Responses\Responses\Streaming\OutputItem; -use OpenAI\Responses\Responses\Output\OutputReasoning; -use OpenAI\Responses\Responses\Streaming\OutputTextDelta; -use OpenAI\Responses\Responses\Streaming\ReasoningTextDelta; -use OpenAI\Responses\Responses\Output\OutputFunctionToolCall; -use OpenAI\Responses\Responses\Streaming\ReasoningSummaryTextDelta; -use OpenAI\Responses\Responses\Streaming\FunctionCallArgumentsDelta; +use Cortex\LLM\Data\Messages\Content\TextContent; +use Cortex\SDK\OpenAI\Data\Responses\ResponseStream; +use Cortex\LLM\Data\Messages\Content\ReasoningContent; +use Cortex\SDK\OpenAI\Data\Responses\Streaming\Events\Error; +use Cortex\SDK\OpenAI\Data\Responses\OutputItems\MessageOutputItem; +use Cortex\SDK\OpenAI\Data\Responses\OutputItems\ReasoningOutputItem; +use Cortex\SDK\OpenAI\Data\Responses\Streaming\Events\ResponseFailed; +use Cortex\SDK\OpenAI\Data\Responses\Streaming\Events\ResponseQueued; +use Cortex\SDK\OpenAI\Data\Responses\Streaming\Events\ResponseCreated; +use Cortex\SDK\OpenAI\Data\Responses\Streaming\Events\ResponseCompleted; +use Cortex\SDK\OpenAI\Data\Responses\Streaming\Events\ResponseIncomplete; +use Cortex\SDK\OpenAI\Data\Responses\Streaming\Events\ResponseInProgress; +use Cortex\SDK\OpenAI\Data\Responses\Streaming\Events\ResponseRefusalDone; +use Cortex\SDK\OpenAI\Data\Responses\Streaming\Events\ResponseRefusalDelta; +use Cortex\SDK\OpenAI\Data\Responses\OutputItems\FunctionToolCallOutputItem; +use Cortex\SDK\OpenAI\Data\Responses\Streaming\Events\ResponseMcpCallFailed; +use Cortex\SDK\OpenAI\Data\Responses\Streaming\Events\ResponseOutputItemDone; +use Cortex\SDK\OpenAI\Data\Responses\Streaming\Events\ResponseOutputTextDone; +use Cortex\SDK\OpenAI\Data\Responses\Streaming\Events\ResponseContentPartDone; +use Cortex\SDK\OpenAI\Data\Responses\Streaming\Events\ResponseOutputItemAdded; +use Cortex\SDK\OpenAI\Data\Responses\Streaming\Events\ResponseOutputTextDelta; +use Cortex\SDK\OpenAI\Data\Responses\Streaming\Events\ResponseContentPartAdded; +use Cortex\SDK\OpenAI\Data\Responses\Streaming\Events\ResponseMcpCallCompleted; +use Cortex\SDK\OpenAI\Data\Responses\Streaming\Events\ResponseMcpCallInProgress; +use Cortex\SDK\OpenAI\Data\Responses\Streaming\Events\ResponseReasoningTextDone; +use Cortex\SDK\OpenAI\Data\Responses\Streaming\Events\ResponseMcpListToolsFailed; +use Cortex\SDK\OpenAI\Data\Responses\Streaming\Events\ResponseReasoningTextDelta; +use Cortex\SDK\OpenAI\Data\Responses\Streaming\Events\ResponseMcpCallArgumentsDone; +use Cortex\SDK\OpenAI\Data\Responses\Streaming\Events\ResponseMcpCallArgumentsDelta; +use Cortex\SDK\OpenAI\Data\Responses\Streaming\Events\ResponseMcpListToolsCompleted; +use Cortex\SDK\OpenAI\Data\Responses\Streaming\Events\ResponseMcpListToolsInProgress; +use Cortex\SDK\OpenAI\Data\Responses\Streaming\Events\ResponseWebSearchCallCompleted; +use Cortex\SDK\OpenAI\Data\Responses\Streaming\Events\ResponseWebSearchCallSearching; +use Cortex\SDK\OpenAI\Data\Responses\Streaming\Events\ResponseFileSearchCallCompleted; +use Cortex\SDK\OpenAI\Data\Responses\Streaming\Events\ResponseFileSearchCallSearching; +use Cortex\SDK\OpenAI\Data\Responses\Streaming\Events\ResponseWebSearchCallInProgress; +use Cortex\SDK\OpenAI\Data\Responses\Streaming\Events\ResponseFileSearchCallInProgress; +use Cortex\SDK\OpenAI\Data\Responses\Streaming\Events\ResponseReasoningContentPartDone; +use Cortex\SDK\OpenAI\Data\Responses\Streaming\Events\ResponseReasoningSummaryPartDone; +use Cortex\SDK\OpenAI\Data\Responses\Streaming\Events\ResponseReasoningSummaryTextDone; +use Cortex\SDK\OpenAI\Data\Responses\Streaming\Events\ResponseFunctionCallArgumentsDone; +use Cortex\SDK\OpenAI\Data\Responses\Streaming\Events\ResponseOutputTextAnnotationAdded; +use Cortex\SDK\OpenAI\Data\Responses\Streaming\Events\ResponseReasoningContentPartAdded; +use Cortex\SDK\OpenAI\Data\Responses\Streaming\Events\ResponseReasoningSummaryPartAdded; +use Cortex\SDK\OpenAI\Data\Responses\Streaming\Events\ResponseReasoningSummaryTextDelta; +use Cortex\SDK\OpenAI\Data\Responses\Streaming\Events\ResponseFunctionCallArgumentsDelta; +use Cortex\SDK\OpenAI\Data\Responses\Streaming\Events\ResponseCodeInterpreterCallCodeDone; +use Cortex\SDK\OpenAI\Data\Responses\Streaming\Events\ResponseCodeInterpreterCallCodeDelta; +use Cortex\SDK\OpenAI\Data\Responses\Streaming\Events\ResponseCodeInterpreterCallCompleted; +use Cortex\SDK\OpenAI\Data\Responses\Streaming\Events\ResponseCodeInterpreterCallExecuting; +use Cortex\SDK\OpenAI\Data\Responses\Streaming\Events\ResponseImageGenerationCallCompleted; +use Cortex\SDK\OpenAI\Data\Responses\Streaming\Events\ResponseCodeInterpreterCallInProgress; +use Cortex\SDK\OpenAI\Data\Responses\Streaming\Events\ResponseImageGenerationCallGenerating; +use Cortex\SDK\OpenAI\Data\Responses\Streaming\Events\ResponseImageGenerationCallInProgress; +use Cortex\SDK\OpenAI\Data\Responses\Streaming\Events\ResponseCodeInterpreterCallInterpreting; +use Cortex\SDK\OpenAI\Data\Responses\Streaming\Events\ResponseImageGenerationCallPartialImage; /** @mixin \Cortex\LLM\Drivers\OpenAI\Responses\OpenAIResponses */ trait MapsStreamResponse { - /** - * Map a streaming response to a ChatStreamResult. - * - * @param StreamResponse<\OpenAI\Responses\Responses\CreateStreamedResponse> $response - * - * @return \Cortex\LLM\Data\ChatStreamResult<\Cortex\LLM\Data\ChatGenerationChunk> - */ - protected function mapStreamResponse(StreamResponse $response): ChatStreamResult + protected function mapStreamResponse(ResponseStream $response): ChatStreamResult { return new ChatStreamResult(function () use ($response): Generator { - $contentSoFar = ''; - $toolCallsSoFar = []; - $reasoningSoFar = []; - $reasoningTextSoFar = []; - $responseId = null; - $responseModel = null; - $responseCreatedAt = null; - $responseUsage = null; - $responseStatus = null; - $messageId = null; - $chatGenerationChunk = null; - $isNewToolCall = false; + $this->resetStreamState(); yield from $this->streamBuffer?->drain() ?? []; - /** @var \OpenAI\Responses\Responses\CreateStreamedResponse $streamChunk */ - foreach ($response as $streamChunk) { + $chatGenerationChunk = null; + $messageId = null; + + foreach ($response as $event) { yield from $this->streamBuffer?->drain() ?? []; - $event = $streamChunk->event; - $data = $streamChunk->response; - - // Track response-level metadata - if ($data instanceof CreateResponse) { - $responseId = $data->id; - $responseModel = $data->model; - $responseCreatedAt = $data->createdAt; - $responseStatus = $data->status; - - if ($data->usage !== null) { - $responseUsage = $this->mapUsage($data->usage); - } - // Extract tool calls from the completed response if present - // This ensures tool calls are included in the final chunk - foreach ($data->output as $outputItem) { - if ($outputItem instanceof OutputFunctionToolCall) { - $toolCallsSoFar[$outputItem->id] = [ - 'id' => $outputItem->id, - 'function' => [ - 'name' => $outputItem->name, - 'arguments' => $outputItem->arguments ?? '', - ], - ]; - - // If we have tool calls but no message ID yet, use the response ID as fallback - if ($messageId === null) { - $messageId = $responseId; - } - } - } + $this->processStreamEvent($event); + + $messageId = $this->extractMessageId($event, $messageId); + + $chunkType = $this->mapChunkType($event); + + if ($chunkType === null) { + continue; } - // Handle output items (message, tool calls, reasoning) - if ($data instanceof OutputItem) { - $item = $data->item; + $chatGenerationChunk = $this->applyOutputParserIfApplicable( + $this->buildChunk($event, $chunkType, $messageId), + ); - // Track message ID when we encounter a message item - if ($item instanceof OutputMessage) { - $messageId = $item->id; - } + $this->dispatchEvent(new ChatModelStream($this, $chatGenerationChunk)); - // Track function tool calls - this indicates tool call start - if ($item instanceof OutputFunctionToolCall) { - $isNewToolCall = ! isset($toolCallsSoFar[$item->id]); - $toolCallsSoFar[$item->id] = [ - 'id' => $item->id, - 'function' => [ - 'name' => $item->name, - 'arguments' => $item->arguments ?? '', - ], - ]; - - // If we have tool calls but no message ID yet, use the response ID as fallback - // This handles cases where Responses API returns only tool calls without a message item - if ($messageId === null && $responseId !== null) { - $messageId = $responseId; - } - } + yield $chatGenerationChunk; + } - // Track reasoning blocks - if ($item instanceof OutputReasoning) { - $reasoningSoFar[$item->id] = [ - 'id' => $item->id, - 'summary' => '', - ]; - } - } + yield from $this->streamBuffer?->drain() ?? []; - // Handle text deltas - $currentDelta = null; + if ($chatGenerationChunk !== null) { + $this->dispatchEvent(new ChatModelStreamEnd($this, $chatGenerationChunk)); + } + }); + } - if ($data instanceof OutputTextDelta) { - $currentDelta = $data->delta; - $contentSoFar .= $currentDelta; - } + /** + * @var array + */ + private array $completedContent = []; - // Handle function call arguments deltas - if ($data instanceof FunctionCallArgumentsDelta) { - $itemId = $data->itemId; + private ?TextContent $pendingTextContent = null; - if (isset($toolCallsSoFar[$itemId])) { - $toolCallsSoFar[$itemId]['function']['arguments'] .= $data->delta; - } - } + /** + * @var array + */ + private array $pendingReasoningContent = []; - // Handle reasoning summary text deltas - if ($data instanceof ReasoningSummaryTextDelta) { - $itemId = $data->itemId; + /** + * @var array + */ + private array $toolCallsSoFar = []; - if (isset($reasoningSoFar[$itemId])) { - $reasoningSoFar[$itemId]['summary'] .= $data->delta; - } - } + /** + * @var array + */ + private array $reasoningSoFar = []; + + /** + * @var array + */ + private array $reasoningTextSoFar = []; - // Handle reasoning text deltas (full reasoning content, not just summary) - if ($data instanceof ReasoningTextDelta) { - $itemId = $data->itemId; + private ?string $responseId = null; - if (! isset($reasoningTextSoFar[$itemId])) { - $reasoningTextSoFar[$itemId] = ''; - } + private ?string $responseModel = null; - $reasoningTextSoFar[$itemId] .= $data->delta; - } + private ?int $responseCreatedAt = null; - // Build accumulated tool calls - $accumulatedToolCalls = null; - - if ($toolCallsSoFar !== []) { - $accumulatedToolCalls = new ToolCallCollection( - collect($toolCallsSoFar) - ->map(function (array $toolCall): ToolCall { - try { - $arguments = json_decode($toolCall['function']['arguments'], true, 512, JSON_THROW_ON_ERROR); - } catch (JsonException) { - $arguments = []; - } - - return new ToolCall( - $toolCall['id'], - new FunctionCall( - $toolCall['function']['name'], - $arguments, - ), - ); - }) - ->values() - ->all(), - ); - } + private ?Usage $responseUsage = null; - // Determine finish reason - $finishReason = $this->mapFinishReason($responseStatus); - $isFinal = in_array($event, [ - 'response.completed', - 'response.failed', - 'response.incomplete', - ], true); - - // Determine chunk type - // For output_item.added events, check if it's a new tool call to determine chunk type - $chunkType = $this->resolveResponsesChunkType( - $event, - $currentDelta, - $contentSoFar, - $finishReason, - $event === 'response.output_item.added' && $isNewToolCall && $data instanceof OutputItem && $data->item instanceof OutputFunctionToolCall, - ); + private ?string $responseStatus = null; - /** @var array|null $rawChunk */ - $rawChunk = $this->includeRaw - ? $streamChunk->toArray() - : null; - - // Determine content for message - use delta for text deltas, null otherwise (matching Chat API pattern) - // The accumulated content is tracked in contentSoFar, not in message->content - $messageContent = $data instanceof OutputTextDelta ? $currentDelta : null; - - $chatGenerationChunk = new ChatGenerationChunk( - type: $chunkType, - id: $responseId, - message: new AssistantMessage( - content: $messageContent, - toolCalls: $accumulatedToolCalls, - metadata: new ResponseMetadata( - id: $responseId, - model: $responseModel ?? $this->model, - provider: $this->modelProvider, - finishReason: $finishReason, - usage: $responseUsage, - ), - id: $messageId, - ), - createdAt: $responseCreatedAt !== null - ? DateTimeImmutable::createFromFormat('U', (string) $responseCreatedAt) - : new DateTimeImmutable(), - finishReason: $finishReason, - usage: $responseUsage, - contentSoFar: $contentSoFar, - isFinal: $isFinal, - rawChunk: $rawChunk, - ); + private bool $isNewToolCall = false; - $chatGenerationChunk = $this->applyOutputParserIfApplicable($chatGenerationChunk); + private bool $hasEmittedMessageStart = false; - $this->dispatchEvent(new ChatModelStream($this, $chatGenerationChunk)); + private bool $hasEmittedMessageEnd = false; - yield $chatGenerationChunk; + private bool $hasOpenTextChunk = false; + + /** + * @var array + */ + private array $openToolCallChunks = []; - // Reset tool call flag after processing - $isNewToolCall = false; + /** + * @var array + */ + private array $openReasoningChunks = []; + + private function resetStreamState(): void + { + $this->completedContent = []; + $this->pendingTextContent = null; + $this->pendingReasoningContent = []; + $this->toolCallsSoFar = []; + $this->reasoningSoFar = []; + $this->reasoningTextSoFar = []; + $this->responseId = null; + $this->responseModel = null; + $this->responseCreatedAt = null; + $this->responseUsage = null; + $this->responseStatus = null; + $this->isNewToolCall = false; + $this->hasEmittedMessageStart = false; + $this->hasEmittedMessageEnd = false; + $this->hasOpenTextChunk = false; + $this->openToolCallChunks = []; + $this->openReasoningChunks = []; + } + + private function processStreamEvent(StreamEvent $event): void + { + $this->trackResponseMetadata($event); + $this->handleOutputItemEvents($event); + $this->handleTextDelta($event); + $this->handleFunctionCallArgumentsDelta($event); + $this->handleReasoningSummaryTextDelta($event); + $this->handleReasoningTextDelta($event); + $this->handleTerminalEvent($event); + } + + private function trackResponseMetadata(StreamEvent $event): void + { + $response = match (true) { + $event instanceof ResponseCreated, + $event instanceof ResponseInProgress, + $event instanceof ResponseQueued, + $event instanceof ResponseCompleted, + $event instanceof ResponseFailed, + $event instanceof ResponseIncomplete => $event->response, + default => null, + }; + + if ($response === null) { + return; + } + + $this->responseId = $response->id; + $this->responseModel = $response->model; + $this->responseCreatedAt = (int) $response->createdAt->format('U'); + $this->responseStatus = $response->status; + + if ($response->usage->totalTokens > 0) { + $this->responseUsage = $this->mapUsage($response->usage); + } + + foreach ($response->output as $outputItem) { + if ($outputItem instanceof FunctionToolCallOutputItem) { + $this->toolCallsSoFar[$outputItem->id] = [ + 'id' => $outputItem->id, + 'name' => $outputItem->name, + 'arguments' => $outputItem->arguments, + ]; } + } + } - yield from $this->streamBuffer?->drain() ?? []; + private function handleOutputItemEvents(StreamEvent $event): void + { + if (! $event instanceof ResponseOutputItemAdded) { + return; + } - $this->dispatchEvent(new ChatModelStreamEnd($this, $chatGenerationChunk)); - }); + $item = $event->item; + + if ($item instanceof FunctionToolCallOutputItem) { + $this->isNewToolCall = ! isset($this->toolCallsSoFar[$item->id]); + $this->toolCallsSoFar[$item->id] = [ + 'id' => $item->id, + 'name' => $item->name, + 'arguments' => $item->arguments ?? '', + ]; + $this->openToolCallChunks[$item->id] = true; + } + + if ($item instanceof ReasoningOutputItem) { + $this->reasoningSoFar[$item->id] = [ + 'id' => $item->id, + 'summary' => '', + ]; + $this->pendingReasoningContent[$item->id] ??= new ReasoningContent(''); + } + } + + private function handleTextDelta(StreamEvent $event): void + { + if (! $event instanceof ResponseOutputTextDelta) { + return; + } + + if ($this->pendingTextContent === null) { + $this->pendingTextContent = new TextContent($event->delta); + } else { + $this->pendingTextContent = $this->pendingTextContent->append($event->delta); + } + } + + private function handleFunctionCallArgumentsDelta(StreamEvent $event): void + { + if (! $event instanceof ResponseFunctionCallArgumentsDelta) { + return; + } + + if (isset($this->toolCallsSoFar[$event->itemId])) { + $this->toolCallsSoFar[$event->itemId]['arguments'] .= $event->delta; + } + } + + private function handleReasoningSummaryTextDelta(StreamEvent $event): void + { + if (! $event instanceof ResponseReasoningSummaryTextDelta) { + return; + } + + if (isset($this->reasoningSoFar[$event->itemId])) { + $this->reasoningSoFar[$event->itemId]['summary'] .= $event->delta; + } + } + + private function handleReasoningTextDelta(StreamEvent $event): void + { + if (! $event instanceof ResponseReasoningTextDelta) { + return; + } + + $itemId = $event->itemId; + $this->reasoningTextSoFar[$itemId] = ($this->reasoningTextSoFar[$itemId] ?? '') . $event->delta; + + if (isset($this->pendingReasoningContent[$itemId])) { + $this->pendingReasoningContent[$itemId] = $this->pendingReasoningContent[$itemId]->append($event->delta); + } else { + $this->pendingReasoningContent[$itemId] = new ReasoningContent( + reasoning: $this->reasoningTextSoFar[$itemId], + metadata: [ + 'id' => $this->reasoningSoFar[$itemId]['id'] ?? $itemId, + ], + ); + } + } + + private function handleTerminalEvent(StreamEvent $event): void + { + if (! $this->isTerminalEvent($event)) { + return; + } + + if ($this->pendingTextContent !== null) { + $this->completedContent[] = $this->pendingTextContent; + $this->pendingTextContent = null; + } + + array_push($this->completedContent, ...array_values($this->pendingReasoningContent)); + $this->pendingReasoningContent = []; + } + + private function isTerminalEvent(StreamEvent $event): bool + { + return $event instanceof ResponseCompleted + || $event instanceof ResponseFailed + || $event instanceof ResponseIncomplete; + } + + private function extractMessageId(StreamEvent $event, ?string $currentId): ?string + { + if ($event instanceof ResponseOutputItemAdded && $event->item instanceof MessageOutputItem) { + return $event->item->id; + } + + if ($currentId === null && $this->toolCallsSoFar !== [] && $this->responseId !== null) { + return $this->responseId; + } + + return $currentId; } /** - * Resolve the chunk type based on OpenAI Responses API streaming events. - * - * The Responses API uses structured event types that make chunk type detection - * more straightforward than raw delta analysis. This method maps event types - * to the appropriate ChunkType enum values. + * @return array */ - protected function resolveResponsesChunkType( - string $event, - ?string $currentDelta, - string $contentSoFar, - ?FinishReason $finishReason, - bool $isNewToolCall = false, - ): ChunkType { - // Handle error events - if ($event === 'error') { - return ChunkType::Error; - } - - // Final chunks based on response status - if ($finishReason !== null) { - return match ($finishReason) { - FinishReason::ToolCalls => ChunkType::ToolInputEnd, - default => ChunkType::Done, - }; - } - - // Map event types to chunk types - return match ($event) { - // Response lifecycle events - 'response.created' => ChunkType::MessageStart, - 'response.in_progress' => ChunkType::MessageStart, - 'response.completed', 'response.failed', 'response.incomplete' => ChunkType::Done, - - // Output item events - // When a function call is added, it's the start of tool input - 'response.output_item.added' => $isNewToolCall ? ChunkType::ToolInputStart : ChunkType::MessageStart, - 'response.output_item.done' => ChunkType::MessageEnd, - - // Content part events - 'response.content_part.added' => ChunkType::TextStart, - 'response.content_part.done' => ChunkType::TextEnd, - - // Text delta events - 'response.output_text.delta' => $contentSoFar === $currentDelta ? ChunkType::TextStart : ChunkType::TextDelta, - 'response.output_text.done' => ChunkType::TextEnd, - 'response.output_text.annotation.added' => ChunkType::TextDelta, // Annotation is part of text - - // Tool call events - 'response.function_call_arguments.delta' => ChunkType::ToolInputDelta, - 'response.function_call_arguments.done' => ChunkType::ToolInputEnd, - - // Reasoning events - summary - 'response.reasoning_summary_part.added' => ChunkType::ReasoningStart, - 'response.reasoning_summary_part.done' => ChunkType::ReasoningEnd, - 'response.reasoning_summary_text.delta' => ChunkType::ReasoningDelta, - 'response.reasoning_summary_text.done' => ChunkType::ReasoningEnd, - - // Reasoning events - full text - 'response.reasoning_text.delta' => ChunkType::ReasoningDelta, - 'response.reasoning_text.done' => ChunkType::ReasoningEnd, - - // Refusal events - 'response.refusal.delta' => ChunkType::TextDelta, - 'response.refusal.done' => ChunkType::Done, - - // Tool-specific events (file search, web search, code interpreter, etc.) - // These are treated as tool calls, map to appropriate chunk types - 'response.file_search_call.in_progress', - 'response.file_search_call.searching' => ChunkType::ToolInputDelta, - 'response.file_search_call.completed' => ChunkType::ToolInputEnd, - - 'response.web_search_call.in_progress', - 'response.web_search_call.searching' => ChunkType::ToolInputDelta, - 'response.web_search_call.completed' => ChunkType::ToolInputEnd, - - 'response.code_interpreter_call.in_progress', - 'response.code_interpreter_call.running', - 'response.code_interpreter_call.interpreting' => ChunkType::ToolInputDelta, - 'response.code_interpreter_call.completed' => ChunkType::ToolInputEnd, - 'response.code_interpreter_call_code.delta' => ChunkType::ToolInputDelta, - 'response.code_interpreter_call_code.done' => ChunkType::ToolInputEnd, - - // MCP (Model Context Protocol) events - treat as tool calls - 'response.mcp_list_tools.in_progress', - 'response.mcp_list_tools.failed', - 'response.mcp_list_tools.completed' => ChunkType::ToolInputDelta, - 'response.mcp_call.in_progress', - 'response.mcp_call.failed', - 'response.mcp_call.completed' => ChunkType::ToolInputDelta, - 'response.mcp_call.arguments.delta', - 'response.mcp_call_arguments.delta' => ChunkType::ToolInputDelta, - 'response.mcp_call.arguments.done', - 'response.mcp_call_arguments.done' => ChunkType::ToolInputEnd, - - // Image generation events - treat as tool output - 'response.image_generation_call.in_progress', - 'response.image_generation_call.generating', - 'response.image_generation_call.completed', - 'response.image_generation_call.partial_image' => ChunkType::ToolOutputEnd, + private function buildContentSnapshot(): array + { + $snapshot = [...$this->completedContent]; + + if ($this->pendingTextContent !== null) { + $snapshot[] = $this->pendingTextContent; + } + + array_push($snapshot, ...array_values($this->pendingReasoningContent)); + + return $snapshot; + } + + private function buildToolCallCollection(): ?ToolCallCollection + { + if ($this->toolCallsSoFar === []) { + return null; + } + + return new ToolCallCollection( + collect($this->toolCallsSoFar) + ->map(function (array $toolCall): ToolCall { + try { + $arguments = json_decode((string) $toolCall['arguments'], true, 512, JSON_THROW_ON_ERROR); + } catch (JsonException) { + $arguments = []; + } + return new ToolCall( + $toolCall['id'], + new FunctionCall( + $toolCall['name'], + $arguments, + $toolCall['arguments'], + ), + ); + }) + ->values() + ->all(), + ); + } + + private function buildChunk( + StreamEvent $event, + ChunkType $chunkType, + ?string $messageId, + ): ChatGenerationChunk { + $finishReason = $this->mapFinishReason($this->responseStatus); + $isFinal = $this->isTerminalEvent($event); + + if ($isFinal && $finishReason !== null && $this->toolCallsSoFar !== []) { + $finishReason = FinishReason::ToolCalls; + } + + $messageContent = $event instanceof ResponseOutputTextDelta + ? $event->delta + : null; + + $meta = $event->meta(); + + return new ChatGenerationChunk( + type: $chunkType, + id: $this->responseId, + message: new AssistantMessage( + content: $messageContent, + toolCalls: $this->buildToolCallCollection(), + metadata: new ResponseMetadata( + id: $this->responseId, + model: $this->responseModel ?? $this->model, + provider: $this->modelProvider, + finishReason: $isFinal ? $finishReason : null, + usage: $this->responseUsage, + processingTime: $meta?->processingTime, + providerMetadata: $meta->raw ?? [], + ), + id: $messageId, + ), + createdAt: $this->responseCreatedAt !== null + ? DateTimeImmutable::createFromFormat('U', (string) $this->responseCreatedAt) + : new DateTimeImmutable(), + finishReason: $isFinal ? $finishReason : null, + usage: $this->responseUsage, + contentSoFar: $this->buildContentSnapshot(), + isFinal: $isFinal, + rawChunk: $this->includeRaw ? $event->raw() : null, + ); + } + + protected function mapChunkType(StreamEvent $event): ?ChunkType + { + $type = match (true) { + $event instanceof Error => ChunkType::Error, + $event instanceof ResponseCreated, + $event instanceof ResponseQueued, + $event instanceof ResponseInProgress => $this->emitMessageStartOnce(), + $event instanceof ResponseCompleted, + $event instanceof ResponseFailed, + $event instanceof ResponseIncomplete => $this->resolveTerminalChunkType(), + $event instanceof ResponseOutputItemAdded => $this->resolveOutputItemAddedType($event), + $event instanceof ResponseOutputItemDone => null, + $event instanceof ResponseContentPartAdded => $this->openTextChunk(), + $event instanceof ResponseContentPartDone => $this->closeTextChunk(), + $event instanceof ResponseOutputTextDelta => $this->hasOpenTextChunk + ? ChunkType::TextDelta + : $this->openTextChunk(), + $event instanceof ResponseOutputTextDone => $this->closeTextChunk(), + $event instanceof ResponseOutputTextAnnotationAdded => ChunkType::TextDelta, + $event instanceof ResponseRefusalDelta => $this->hasOpenTextChunk + ? ChunkType::TextDelta + : $this->openTextChunk(), + $event instanceof ResponseRefusalDone => $this->closeTextChunk(), + $event instanceof ResponseFunctionCallArgumentsDelta => ChunkType::ToolInputDelta, + $event instanceof ResponseFunctionCallArgumentsDone => $this->closeToolCallChunk($event->itemId), + $event instanceof ResponseReasoningSummaryPartAdded => $this->openReasoningChunk($event), + $event instanceof ResponseReasoningSummaryPartDone => $this->closeReasoningChunk($event), + $event instanceof ResponseReasoningSummaryTextDelta => $this->isReasoningChunkOpen($event) + ? ChunkType::ReasoningDelta + : $this->openReasoningChunk($event), + $event instanceof ResponseReasoningSummaryTextDone => $this->closeReasoningChunk($event), + $event instanceof ResponseReasoningContentPartAdded => $this->openReasoningChunk($event), + $event instanceof ResponseReasoningContentPartDone => $this->closeReasoningChunk($event), + $event instanceof ResponseReasoningTextDelta => $this->isReasoningChunkOpen($event) + ? ChunkType::ReasoningDelta + : $this->openReasoningChunk($event), + $event instanceof ResponseReasoningTextDone => $this->closeReasoningChunk($event), + $event instanceof ResponseFileSearchCallInProgress, + $event instanceof ResponseFileSearchCallSearching => ChunkType::ToolInputDelta, + $event instanceof ResponseFileSearchCallCompleted => ChunkType::ToolInputEnd, + $event instanceof ResponseWebSearchCallInProgress, + $event instanceof ResponseWebSearchCallSearching => ChunkType::ToolInputDelta, + $event instanceof ResponseWebSearchCallCompleted => ChunkType::ToolInputEnd, + $event instanceof ResponseCodeInterpreterCallInProgress, + $event instanceof ResponseCodeInterpreterCallExecuting, + $event instanceof ResponseCodeInterpreterCallInterpreting => ChunkType::ToolInputDelta, + $event instanceof ResponseCodeInterpreterCallCompleted => ChunkType::ToolInputEnd, + $event instanceof ResponseCodeInterpreterCallCodeDelta => ChunkType::ToolInputDelta, + $event instanceof ResponseCodeInterpreterCallCodeDone => ChunkType::ToolInputEnd, + $event instanceof ResponseMcpListToolsInProgress, + $event instanceof ResponseMcpListToolsFailed, + $event instanceof ResponseMcpListToolsCompleted => ChunkType::ToolInputDelta, + $event instanceof ResponseMcpCallInProgress, + $event instanceof ResponseMcpCallFailed, + $event instanceof ResponseMcpCallCompleted => ChunkType::ToolInputDelta, + $event instanceof ResponseMcpCallArgumentsDelta => ChunkType::ToolInputDelta, + $event instanceof ResponseMcpCallArgumentsDone => ChunkType::ToolInputEnd, + $event instanceof ResponseImageGenerationCallInProgress, + $event instanceof ResponseImageGenerationCallGenerating, + $event instanceof ResponseImageGenerationCallCompleted, + $event instanceof ResponseImageGenerationCallPartialImage => ChunkType::ToolOutputEnd, default => ChunkType::Custom, }; + + if ($event instanceof ResponseOutputItemAdded) { + $this->isNewToolCall = false; + } + + return $type; + } + + private function resolveTerminalChunkType(): ?ChunkType + { + if ($this->hasEmittedMessageEnd) { + return null; + } + + $this->hasEmittedMessageEnd = true; + + return ChunkType::MessageEnd; + } + + private function resolveOutputItemAddedType(ResponseOutputItemAdded $event): ?ChunkType + { + if ($this->isNewToolCall && $event->item instanceof FunctionToolCallOutputItem) { + $this->openToolCallChunks[$event->item->id] = true; + + return ChunkType::ToolInputStart; + } + + return null; + } + + private function emitMessageStartOnce(): ?ChunkType + { + if ($this->hasEmittedMessageStart) { + return null; + } + + $this->hasEmittedMessageStart = true; + + return ChunkType::MessageStart; + } + + private function openTextChunk(): ?ChunkType + { + if ($this->hasOpenTextChunk) { + return null; + } + + $this->hasOpenTextChunk = true; + + return ChunkType::TextStart; + } + + private function closeTextChunk(): ?ChunkType + { + if (! $this->hasOpenTextChunk) { + return null; + } + + $this->hasOpenTextChunk = false; + + return ChunkType::TextEnd; + } + + private function closeToolCallChunk(string $itemId): ?ChunkType + { + if (! ($this->openToolCallChunks[$itemId] ?? false)) { + return null; + } + + $this->openToolCallChunks[$itemId] = false; + + return ChunkType::ToolInputEnd; + } + + private function openReasoningChunk(StreamEvent $event): ?ChunkType + { + $key = $this->reasoningChunkKey($event); + + if ($key === null) { + return ChunkType::ReasoningStart; + } + + if ($this->openReasoningChunks[$key] ?? false) { + return null; + } + + $this->openReasoningChunks[$key] = true; + + return ChunkType::ReasoningStart; + } + + private function closeReasoningChunk(StreamEvent $event): ?ChunkType + { + $key = $this->reasoningChunkKey($event); + + if ($key === null) { + return null; + } + + if (! ($this->openReasoningChunks[$key] ?? false)) { + return null; + } + + $this->openReasoningChunks[$key] = false; + + return ChunkType::ReasoningEnd; + } + + private function isReasoningChunkOpen(StreamEvent $event): bool + { + $key = $this->reasoningChunkKey($event); + + if ($key === null) { + return false; + } + + return (bool) ($this->openReasoningChunks[$key] ?? false); + } + + private function reasoningChunkKey(StreamEvent $event): ?string + { + return match (true) { + $event instanceof ResponseReasoningSummaryPartAdded, + $event instanceof ResponseReasoningSummaryPartDone, + $event instanceof ResponseReasoningSummaryTextDelta, + $event instanceof ResponseReasoningSummaryTextDone => sprintf( + 'summary:%s:%d', + $event->itemId, + $event->summaryIndex, + ), + $event instanceof ResponseReasoningContentPartAdded, + $event instanceof ResponseReasoningContentPartDone, + $event instanceof ResponseReasoningTextDelta, + $event instanceof ResponseReasoningTextDone => sprintf( + 'content:%s:%d', + $event->itemId, + $event->contentIndex, + ), + default => null, + }; } } diff --git a/src/LLM/Drivers/OpenAI/Responses/Concerns/MapsUsage.php b/src/LLM/Drivers/OpenAI/Responses/Concerns/MapsUsage.php index 9cf59af..65e1a57 100644 --- a/src/LLM/Drivers/OpenAI/Responses/Concerns/MapsUsage.php +++ b/src/LLM/Drivers/OpenAI/Responses/Concerns/MapsUsage.php @@ -5,15 +5,15 @@ namespace Cortex\LLM\Drivers\OpenAI\Responses\Concerns; use Cortex\LLM\Data\Usage; -use OpenAI\Responses\Responses\CreateResponseUsage; +use Cortex\SDK\OpenAI\Data\Responses\Usage as UsageResponse; /** @mixin \Cortex\LLM\Drivers\OpenAI\Responses\OpenAIResponses */ trait MapsUsage { /** - * Map the OpenAI Responses usage response to a Usage object. + * Map the OpenAI Responses SDK usage to a Usage object. */ - protected function mapUsage(?CreateResponseUsage $usage): ?Usage + protected function mapUsage(?UsageResponse $usage): ?Usage { if ($usage === null) { return null; @@ -22,8 +22,8 @@ protected function mapUsage(?CreateResponseUsage $usage): ?Usage return new Usage( promptTokens: $usage->inputTokens, completionTokens: $usage->outputTokens, - cachedTokens: $usage->inputTokensDetails->cachedTokens, - reasoningTokens: $usage->outputTokensDetails->reasoningTokens, + cachedTokens: $usage->inputTokensDetails['cached_tokens'] ?? 0, + reasoningTokens: $usage->outputTokensDetails['reasoning_tokens'] ?? 0, totalTokens: $usage->totalTokens, inputCost: $this->modelProvider->inputCostForTokens($this->model, $usage->inputTokens), outputCost: $this->modelProvider->outputCostForTokens($this->model, $usage->outputTokens), diff --git a/src/LLM/Drivers/OpenAI/Responses/OpenAIResponses.php b/src/LLM/Drivers/OpenAI/Responses/OpenAIResponses.php index 4f6a7cb..618eddc 100644 --- a/src/LLM/Drivers/OpenAI/Responses/OpenAIResponses.php +++ b/src/LLM/Drivers/OpenAI/Responses/OpenAIResponses.php @@ -6,14 +6,14 @@ use Throwable; use Cortex\LLM\AbstractLLM; +use Cortex\SDK\OpenAI\OpenAI; use Cortex\LLM\Contracts\Tool; -use OpenAI\Testing\ClientFake; use Cortex\Events\ChatModelEnd; use Cortex\LLM\Data\ChatResult; use Cortex\Events\ChatModelError; use Cortex\Events\ChatModelStart; use Cortex\LLM\Contracts\Message; -use OpenAI\Contracts\ClientContract; +use Saloon\Http\Faking\MockResponse; use Cortex\LLM\Data\ChatStreamResult; use Cortex\ModelInfo\Enums\ModelFeature; use Cortex\ModelInfo\Enums\ModelProvider; @@ -23,7 +23,6 @@ use Cortex\LLM\Drivers\OpenAI\Responses\Concerns\MapsResponse; use Cortex\LLM\Drivers\OpenAI\Responses\Concerns\MapsFinishReason; use Cortex\LLM\Drivers\OpenAI\Responses\Concerns\MapsStreamResponse; -use OpenAI\Testing\Responses\Fixtures\Responses\CreateResponseFixture; class OpenAIResponses extends AbstractLLM { @@ -34,7 +33,7 @@ class OpenAIResponses extends AbstractLLM use MapsStreamResponse; public function __construct( - protected readonly ClientContract $client, + protected readonly OpenAI $client, protected string $model, protected ModelProvider $modelProvider = ModelProvider::OpenAI, protected bool $ignoreModelFeatures = false, @@ -55,10 +54,18 @@ public function invoke( $this->dispatchEvent(new ChatModelStart($this, $messages, $params)); + if ($this->streaming) { + $params['stream'] = true; + } + try { + $response = $this->streaming + ? $this->client->responses()->stream($params) + : $this->client->responses()->create($params); + $result = $this->streaming - ? $this->mapStreamResponse($this->client->responses()->createStreamed($params)) - : $this->mapResponse($this->client->responses()->create($params)); + ? $this->mapStreamResponse($response->dtoOrFail()) + : $this->mapResponse($response->dtoOrFail()); } catch (Throwable $e) { $this->dispatchEvent(new ChatModelError($this, $params, $e)); @@ -83,6 +90,14 @@ protected function buildParams(array $additionalParameters): array 'model' => $this->model, ]; + if ($this->maxTokens !== null) { + $params['max_output_tokens'] = $this->maxTokens; + } + + if ($this->temperature !== null) { + $params['temperature'] = $this->temperature; + } + if ($this->structuredOutputConfig !== null) { $this->supportsFeatureOrFail(ModelFeature::StructuredOutput); @@ -119,7 +134,7 @@ protected function buildParams(array $additionalParameters): array $params['tools'] = collect($this->toolConfig->tools) ->map(fn(Tool $tool): array => [ 'type' => 'function', - 'function' => $tool->format(), + ...$tool->format(), ]) ->toArray(); } @@ -131,12 +146,6 @@ protected function buildParams(array $additionalParameters): array ]; if ($this->modelProvider === ModelProvider::OpenAI) { - if (array_key_exists('max_tokens', $allParams)) { - // Backwards compatibility for `max_tokens` - $allParams['max_output_tokens'] = $allParams['max_tokens']; - unset($allParams['max_tokens']); - } - if (array_key_exists('temperature', $allParams) && $allParams['temperature'] === null) { unset($allParams['temperature']); } @@ -149,23 +158,24 @@ protected function buildParams(array $additionalParameters): array return $allParams; } - public function getClient(): ClientContract + public function getClient(): OpenAI { return $this->client; } /** - * @param array|\OpenAI\Responses\StreamResponse<\OpenAI\Responses\Chat\CreateStreamedResponse>|string> $responses - * - * @phpstan-ignore-next-line + * @param array $responses */ - public static function fake(array $responses, ?string $model = null, ?ModelProvider $modelProvider = null): self - { - $client = new ClientFake($responses); + public static function fake( + array $responses, + ?string $model = null, + ?ModelProvider $modelProvider = null, + ): self { + $client = OpenAI::fake($responses); return new self( $client, - $model ?? CreateResponseFixture::ATTRIBUTES['model'], + $model ?? 'gpt-4o', $modelProvider ?? ModelProvider::OpenAI, ); } diff --git a/src/LLM/Enums/ChunkType.php b/src/LLM/Enums/ChunkType.php index c3ea25c..01905e2 100644 --- a/src/LLM/Enums/ChunkType.php +++ b/src/LLM/Enums/ChunkType.php @@ -6,82 +6,126 @@ enum ChunkType: string { - /** Indicates the beginning of a new message with metadata. */ + /** + * Indicates the beginning of a new message with metadata. + */ case MessageStart = 'message_start'; - /** Indicates the completion of a message. */ + /** + * Indicates the completion of a message. + */ case MessageEnd = 'message_end'; - /** Indicates the beginning of a text block. */ + /** + * Indicates the beginning of a text block. + * It should not contain any content, the following delta chunks will contain the content. + */ case TextStart = 'text_start'; - /** Contains incremental text content for the text block. */ + /** + * Contains incremental text content for the text block. + */ case TextDelta = 'text_delta'; - /** Indicates the completion of a text block. */ + /** + * Indicates the completion of a text block. + */ case TextEnd = 'text_end'; - /** Reasoning content is streamed using a start/delta/end pattern with unique IDs for each reasoning block. */ + /** + * Reasoning content is streamed using a start/delta/end pattern with unique IDs for each reasoning block. + */ case ReasoningStart = 'reasoning_start'; - /** Contains incremental reasoning content for the reasoning block. */ + /** + * Contains incremental reasoning content for the reasoning block. + */ case ReasoningDelta = 'reasoning_delta'; - /** Indicates the completion of a reasoning block. */ + /** + * Indicates the completion of a reasoning block. + */ case ReasoningEnd = 'reasoning_end'; - /** References to documents or files. */ + /** + * References to documents or files. + */ case SourceDocument = 'source_document'; - /** The file parts contain references to files with their media type. */ + /** + * The file parts contain references to files with their media type. + */ case File = 'file'; - /** Indicates the beginning of tool input streaming. */ + /** + * Indicates the beginning of tool input streaming. + */ case ToolInputStart = 'tool_input_start'; - /** Incremental chunks of tool input as it's being generated. */ + /** + * Incremental chunks of tool input as it's being generated. + */ case ToolInputDelta = 'tool_input_delta'; - /** Indicates that tool input is complete and ready for execution. */ + /** + * Indicates that tool input is complete and ready for execution. + */ case ToolInputEnd = 'tool_input_end'; - /** Contains the result of tool execution. */ + /** + * Contains the result of tool execution. + */ case ToolOutputEnd = 'tool_output_end'; - /** Indicates the beginning of a run. */ + /** + * Indicates the beginning of a run. + */ case RunStart = 'run_start'; - /** Indicates the end of a run. */ + /** + * Indicates the end of a run. + */ case RunEnd = 'run_end'; - /** A part indicating the start of a step. */ + /** + * A part indicating the start of a step. + */ case StepStart = 'step_start'; - /** A part indicating that a step (i.e., one LLM API call) has been completed. */ + /** + * A part indicating that a step (i.e., one LLM API call) has been completed. + */ case StepEnd = 'step_end'; - /** Indicates that the streaming has completed. */ - case Done = 'done'; - - /** Indicates that an error occurred during streaming. */ + /** + * Indicates that an error occurred during streaming. + */ case Error = 'error'; case Custom = 'custom'; + case ChatModelStart = 'chat_model_start'; + + case ChatModelEnd = 'chat_model_end'; + case OutputParserStart = 'output_parser_start'; case OutputParserEnd = 'output_parser_end'; - case ChatModelStart = 'chat_model_start'; - - case ChatModelEnd = 'chat_model_end'; + case OutputParserError = 'output_parser_error'; public function isText(): bool { return match ($this) { - self::TextStart, - self::TextDelta => true, - self::TextEnd => true, + self::TextStart, self::TextDelta, self::TextEnd => true, + default => false, + }; + } + + public function isReasoning(): bool + { + return match ($this) { + self::ReasoningStart, self::ReasoningDelta, self::ReasoningEnd => true, default => false, }; } @@ -93,9 +137,22 @@ public function isOperational(): bool self::RunEnd, self::StepStart, self::StepEnd, + self::MessageStart, + self::MessageEnd, ], true); } + public function isToolCall(): bool + { + return match ($this) { + self::ToolInputStart, + self::ToolInputDelta, + self::ToolInputEnd, + self::ToolOutputEnd => true, + default => false, + }; + } + public function isStart(): bool { return match ($this) { @@ -105,7 +162,6 @@ public function isStart(): bool self::ToolInputStart, self::RunStart, self::ChatModelStart, - self::OutputParserStart, self::StepStart => true, default => false, }; @@ -121,7 +177,6 @@ public function isEnd(): bool self::ToolOutputEnd, self::RunEnd, self::ChatModelEnd, - self::OutputParserEnd, self::StepEnd => true, default => false, }; diff --git a/src/LLM/Enums/StreamingProtocol.php b/src/LLM/Enums/StreamingProtocol.php new file mode 100644 index 0000000..298edb7 --- /dev/null +++ b/src/LLM/Enums/StreamingProtocol.php @@ -0,0 +1,29 @@ + new RawDataStream(), + self::AGUI => new AgUiDataStream(), + self::Vercel => new VercelDataStream(), + self::Text => new TextStream(), + }; + } +} diff --git a/src/LLM/LLMManager.php b/src/LLM/LLMManager.php index 5f26771..302bae3 100644 --- a/src/LLM/LLMManager.php +++ b/src/LLM/LLMManager.php @@ -6,15 +6,17 @@ use OpenAI; use Override; -use Anthropic; use Illuminate\Support\Arr; use Illuminate\Support\Str; use Cortex\LLM\Contracts\LLM; use InvalidArgumentException; use Cortex\LLM\Enums\LLMDriver; use Illuminate\Support\Manager; +use Illuminate\Cache\Repository; +use Cortex\SDK\Anthropic\Anthropic; use OpenAI\Contracts\ClientContract; use Cortex\ModelInfo\Enums\ModelProvider; +use Cortex\SDK\OpenAI\OpenAI as OpenAISDK; use Cortex\LLM\Drivers\OpenAI\Chat\OpenAIChat; use Cortex\LLM\Drivers\Anthropic\AnthropicChat; use Cortex\Support\IlluminateEventDispatcherBridge; @@ -71,6 +73,10 @@ protected function createDriver($driver): LLM // @pest-ignore-type ? $driver->value : $driver; + if ($driver === 'cache') { + throw new InvalidArgumentException('Invalid driver - cache keyword is reserved for internal use.'); + } + if (isset($this->customCreators[$driver])) { return $this->callCustomCreator($config); } @@ -111,8 +117,31 @@ public function createOpenAIChatDriver(array $config, string $name): OpenAIChat */ public function createOpenAIResponsesDriver(array $config, string $name): OpenAIResponses { + $cacheEnabled = (bool) Arr::get($config, 'options.cache_enabled', false); + $cacheStore = $cacheEnabled + ? $this->getCacheStore() + : null; + + $client = new OpenAISDK( + apiKey: Arr::get($config, 'options.api_key') ?? '', + baseUri: Arr::get($config, 'options.base_uri'), + organization: Arr::get($config, 'options.organization'), + project: Arr::get($config, 'options.project'), + cacheStore: $cacheStore, + cacheExpiryInSeconds: Arr::get($config, 'options.cache_ttl', 3600), + connectTimeout: Arr::get($config, 'options.connect_timeout', 10), + requestTimeout: Arr::get($config, 'options.request_timeout', 300), + ); + + // $driver = new OpenAIResponses( + // $this->buildOpenAIClient($config), + // $config['default_model'], + // $this->getModelProviderFromConfig($config, $name), + // $this->config->get('cortex.model_info.ignore_features', false), + // ); + $driver = new OpenAIResponses( - $this->buildOpenAIClient($config), + $client, $config['default_model'], $this->getModelProviderFromConfig($config, $name), $this->config->get('cortex.model_info.ignore_features', false), @@ -131,28 +160,27 @@ public function createOpenAIResponsesDriver(array $config, string $name): OpenAI */ public function createAnthropicDriver(array $config, string $name): AnthropicChat { - $client = Anthropic::factory() - ->withApiKey(Arr::get($config, 'options.api_key') ?? '') - ->withHttpHeader('anthropic-version', Arr::get($config, 'options.version', '2023-06-01')); - - foreach (Arr::get($config, 'options.headers', []) as $key => $value) { - $client->withHttpHeader($key, $value); - } - - foreach (Arr::get($config, 'options.query_params', []) as $key => $value) { - $client->withQueryParam($key, $value); - } - - if ($baseUri = Arr::get($config, 'options.base_uri')) { - $client->withBaseUri($baseUri); - } + $cacheEnabled = (bool) Arr::get($config, 'options.cache_enabled', false); + $cacheStore = $cacheEnabled + ? $this->getCacheStore() + : null; + + $client = new Anthropic( + apiKey: Arr::get($config, 'options.api_key') ?? '', + baseUri: Arr::get($config, 'options.base_uri'), + version: Arr::get($config, 'options.version'), + cacheStore: $cacheStore, + cacheExpiryInSeconds: Arr::get($config, 'options.cache_ttl', 3600), + connectTimeout: Arr::get($config, 'options.connect_timeout', 10), + requestTimeout: Arr::get($config, 'options.request_timeout', 300), + ); if (! isset($config['default_model'])) { throw new InvalidArgumentException('default_model is required.'); } $driver = new AnthropicChat( - $client->make(), + $client, $config['default_model'], $this->getModelProviderFromConfig($config, $name), $this->config->get('cortex.model_info.ignore_features', false), @@ -163,6 +191,8 @@ public function createAnthropicDriver(array $config, string $name): AnthropicCha ); $driver->setEventDispatcher(new IlluminateEventDispatcherBridge($this->container->make('events'))); + $driver->withCaching($cacheEnabled); + return $driver; } @@ -213,4 +243,9 @@ protected function getModelProviderFromConfig(array $config, string $name): Mode return $modelProvider; } + + protected function getCacheStore(): ?Repository + { + return $this->container->make('cache')->store($this->config->get('cortex.llm.cache.store')); + } } diff --git a/src/LLM/Streaming/AgUiDataStream.php b/src/LLM/Streaming/AgUiDataStream.php index 875c783..a3bbd52 100644 --- a/src/LLM/Streaming/AgUiDataStream.php +++ b/src/LLM/Streaming/AgUiDataStream.php @@ -5,23 +5,39 @@ namespace Cortex\LLM\Streaming; use Closure; -use Illuminate\Support\Js; +use Generator; +use Cortex\AGUI\Events\Custom; use Cortex\LLM\Enums\ChunkType; +use Cortex\AGUI\Events\RunError; +use Cortex\LLM\Enums\MessageRole; +use Cortex\AGUI\Events\RunStarted; +use Cortex\AGUI\Events\RunFinished; +use Cortex\AGUI\Events\StepStarted; +use Cortex\AGUI\Events\ToolCallEnd; +use Cortex\AGUI\Events\ReasoningEnd; +use Cortex\AGUI\Events\StepFinished; +use Cortex\AGUI\Events\ToolCallArgs; +use Cortex\AGUI\Events\ToolCallStart; use Cortex\LLM\Data\ChatStreamResult; +use Cortex\AGUI\Events\ReasoningStart; +use Cortex\AGUI\Events\TextMessageEnd; +use Cortex\AGUI\Events\ToolCallResult; +use Cortex\AGUI\Events\TextMessageStart; use Cortex\LLM\Data\ChatGenerationChunk; -use Cortex\LLM\Contracts\StreamingProtocol; +use Cortex\AGUI\Events\TextMessageContent; +use Cortex\AGUI\Events\ReasoningMessageEnd; +use Cortex\AGUI\Contracts\Event as AgUiEvent; +use Cortex\AGUI\Events\ReasoningMessageStart; +use Cortex\AGUI\Events\ReasoningMessageContent; +use Cortex\LLM\Contracts\StreamingProtocolDriver; /** * AG-UI (Agent User Interaction Protocol) streaming implementation. * * @see https://docs.ag-ui.com/concepts/events.md */ -class AgUiDataStream implements StreamingProtocol +class AgUiDataStream implements StreamingProtocolDriver { - private bool $messageStarted = false; - - private bool $runStarted = false; - private ?string $currentMessageId = null; private ?string $runId = null; @@ -30,272 +46,146 @@ class AgUiDataStream implements StreamingProtocol public function streamResponse(ChatStreamResult $result): Closure { - return function () use ($result): void { + return function () use ($result): Generator { foreach ($result as $chunk) { if (connection_aborted() !== 0) { break; } - $events = $this->mapChunkToEvents($chunk); - - foreach ($events as $event) { - $payload = Js::encode($event); + $event = $this->mapChunkToEvent($chunk); - echo 'event: message' . "\n"; - echo 'data: ' . $payload . "\n\n"; - - if (ob_get_level() > 0) { - ob_flush(); + if ($event !== null) { + if (is_array($event)) { + foreach ($event as $e) { + yield $this->getEventLine($e, $chunk); + } + } else { + yield $this->getEventLine($event, $chunk); } - - flush(); } } - - // Send final events if needed - if ($this->messageStarted) { - $this->sendEvent([ - 'type' => 'TextMessageEnd', - 'messageId' => $this->currentMessageId, - 'timestamp' => now()->toIso8601String(), - ]); - } - - if ($this->runStarted) { - $this->sendEvent([ - 'type' => 'RunFinished', - 'runId' => $this->runId, - 'threadId' => $this->threadId, - 'timestamp' => now()->toIso8601String(), - ]); - } - - if (ob_get_level() > 0) { - ob_flush(); - } - - flush(); }; } - public function mapChunkToPayload(ChatGenerationChunk $chunk): array + public function headers(): array { - // For compatibility with the interface, return the first event - $events = $this->mapChunkToEvents($chunk); - - return $events[0] ?? []; + return []; } /** - * Map a ChatGenerationChunk to one or more AG-UI events. - * - * @return array> + * Map a ChatGenerationChunk to an AG-UI event. */ - protected function mapChunkToEvents(ChatGenerationChunk $chunk): array + protected function mapChunkToEvent(ChatGenerationChunk $chunk): AgUiEvent|array|null { - $events = []; - $timestamp = $chunk->createdAt->format('c'); - - // Handle lifecycle events - if ($chunk->type === ChunkType::MessageStart) { - // Start run if not started - if (! $this->runStarted) { - // Use chunk ID as basis for run and thread IDs - $this->runId = 'run_' . $chunk->id; - $this->threadId = 'thread_' . $chunk->id; - - $events[] = [ - 'type' => 'RunStarted', - 'runId' => $this->runId, - 'threadId' => $this->threadId, - 'timestamp' => $timestamp, - ]; - $this->runStarted = true; - } - - // Start text message - $this->currentMessageId = $chunk->message->metadata?->id ?? $chunk->id; - $events[] = [ - 'type' => 'TextMessageStart', - 'messageId' => $this->currentMessageId, - 'role' => 'assistant', - 'timestamp' => $timestamp, - ]; - $this->messageStarted = true; - } - - // Handle text content - if ($chunk->type === ChunkType::TextDelta && $chunk->message->content !== null && $chunk->message->content !== '') { - $events[] = [ - 'type' => 'TextMessageContent', - 'messageId' => $this->currentMessageId ?? ($chunk->message->metadata?->id ?? $chunk->id), - 'delta' => $chunk->message->content, - 'timestamp' => $timestamp, - ]; - } - - // Handle text end - if ($chunk->type === ChunkType::TextEnd) { - $events[] = [ - 'type' => 'TextMessageEnd', - 'messageId' => $this->currentMessageId ?? ($chunk->message->metadata?->id ?? $chunk->id), - 'timestamp' => $timestamp, - ]; - $this->messageStarted = false; - } - - // Handle reasoning content (using draft reasoning events) - if ($chunk->type === ChunkType::ReasoningStart) { - $reasoningId = $chunk->message->metadata?->id ?? $chunk->id; - $events[] = [ - 'type' => 'ReasoningStart', - 'messageId' => $reasoningId, - 'timestamp' => $timestamp, - ]; - } - - if ($chunk->type === ChunkType::ReasoningDelta && $chunk->message->content !== null && $chunk->message->content !== '') { - $events[] = [ - 'type' => 'ReasoningMessageContent', - 'messageId' => $chunk->message->metadata?->id ?? $chunk->id, - 'delta' => $chunk->message->content, - 'timestamp' => $timestamp, - ]; - } - - if ($chunk->type === ChunkType::ReasoningEnd) { - $events[] = [ - 'type' => 'ReasoningEnd', - 'messageId' => $chunk->message->metadata?->id ?? $chunk->id, - 'timestamp' => $timestamp, - ]; - } - - // Handle tool calls - if ($chunk->type === ChunkType::ToolInputStart && $chunk->message->toolCalls !== null) { - foreach ($chunk->message->toolCalls as $toolCall) { - $events[] = [ - 'type' => 'ToolCallStart', - 'toolCallId' => $toolCall->id, - 'toolName' => $toolCall->function->name, - 'timestamp' => $timestamp, - ]; - } - } - - if ($chunk->type === ChunkType::ToolInputDelta && $chunk->message->toolCalls !== null) { - foreach ($chunk->message->toolCalls as $toolCall) { - $events[] = [ - 'type' => 'ToolCallContent', - 'toolCallId' => $toolCall->id, - 'delta' => json_encode($toolCall->function->arguments), - 'timestamp' => $timestamp, - ]; - } - } - - if ($chunk->type === ChunkType::ToolInputEnd && $chunk->message->toolCalls !== null) { - foreach ($chunk->message->toolCalls as $toolCall) { - $events[] = [ - 'type' => 'ToolCallEnd', - 'toolCallId' => $toolCall->id, - 'timestamp' => $timestamp, - ]; - } - } - - // Handle tool output - if ($chunk->type === ChunkType::ToolOutputEnd && $chunk->message->toolCalls !== null) { - foreach ($chunk->message->toolCalls as $toolCall) { - $events[] = [ - 'type' => 'ToolCallResult', - 'toolCallId' => $toolCall->id, - 'result' => $toolCall->result ?? null, - 'timestamp' => $timestamp, - ]; - } - } - - // Handle step events - if ($chunk->type === ChunkType::StepStart) { - $events[] = [ - 'type' => 'StepStarted', - 'stepName' => 'step_' . $chunk->id, - 'timestamp' => $timestamp, - ]; - } - - if ($chunk->type === ChunkType::StepEnd) { - $events[] = [ - 'type' => 'StepFinished', - 'stepName' => 'step_' . $chunk->id, - 'timestamp' => $timestamp, - ]; - } - - // Handle errors - if ($chunk->type === ChunkType::Error) { - $events[] = [ - 'type' => 'RunError', - 'message' => $chunk->message->content ?? 'An error occurred', - 'timestamp' => $timestamp, - ]; - $this->runStarted = false; - $this->messageStarted = false; - } - - // Handle final chunk - if ($chunk->isFinal && $chunk->type === ChunkType::MessageEnd) { - // End message if it was started - if ($this->messageStarted) { - $events[] = [ - 'type' => 'TextMessageEnd', - 'messageId' => $this->currentMessageId ?? ($chunk->message->metadata?->id ?? $chunk->id), - 'timestamp' => $timestamp, - ]; - $this->messageStarted = false; - } - - // End run - if ($this->runStarted) { - $event = [ - 'type' => 'RunFinished', - 'runId' => $this->runId, - 'threadId' => $this->threadId, - 'timestamp' => $timestamp, - ]; - - // Add usage as result if available - if ($chunk->usage !== null) { - $event['result'] = [ - 'usage' => $chunk->usage->toArray(), - ]; - } - - $events[] = $event; - $this->runStarted = false; - } - } - - return $events; + $this->currentMessageId = $chunk->message->metadata?->id ?? $chunk->id ?? ''; + + return match ($chunk->type) { + ChunkType::RunStart => new RunStarted( + threadId: $this->threadId = $chunk->metadata['thread_id'], + runId: $this->runId = $chunk->metadata['run_id'], + ), + + ChunkType::RunEnd => new RunFinished( + threadId: $this->threadId, + runId: $this->runId, + ), + + ChunkType::TextStart => new TextMessageStart( + messageId: $this->currentMessageId = $chunk->message->metadata?->id ?? $chunk->id ?? '', + ), + + ChunkType::TextDelta => in_array($chunk->message->text(), [null, ''], true) + ? null + : new TextMessageContent( + messageId: $this->currentMessageId, + delta: $chunk->message->text(), + ), + + ChunkType::TextEnd => new TextMessageEnd( + messageId: $this->currentMessageId, + ), + + ChunkType::ReasoningStart => [ + new ReasoningStart( + messageId: $this->currentMessageId, + ), + new ReasoningMessageStart( + messageId: $this->currentMessageId, + ), + ], + + ChunkType::ReasoningDelta => in_array($chunk->message->reasoning(), [null, ''], true) + ? null + : new ReasoningMessageContent( + messageId: $this->currentMessageId, + delta: $chunk->message->reasoning(), + ), + + ChunkType::ReasoningEnd => [ + new ReasoningMessageEnd( + messageId: $this->currentMessageId, + ), + new ReasoningEnd( + messageId: $this->currentMessageId, + ), + ], + + ChunkType::ToolInputStart => $chunk->message->toolCalls !== null + ? new ToolCallStart( + toolCallId: $chunk->message->toolCalls->first()->id, + toolCallName: $chunk->message->toolCalls->first()->function->name, + parentMessageId: $this->currentMessageId, + ) + : null, + + ChunkType::ToolInputDelta => $chunk->message->toolCalls !== null + ? new ToolCallArgs( + toolCallId: $chunk->message->toolCalls->first()->id, + delta: json_encode($chunk->message->toolCalls->first()->function->arguments), + ) + : null, + + ChunkType::ToolInputEnd => $chunk->message->toolCalls !== null + ? new ToolCallEnd( + toolCallId: $chunk->message->toolCalls->first()->id, + ) + : null, + + ChunkType::ToolOutputEnd => $chunk->message->role === MessageRole::Tool + ? new ToolCallResult( + messageId: $this->currentMessageId, + toolCallId: $chunk->message->id, + content: $chunk->message->text() ?? '', + ) + : null, + + ChunkType::StepStart => new StepStarted( + stepName: 'step_' . ($chunk->id ?? ''), + ), + + ChunkType::StepEnd => new StepFinished( + stepName: 'step_' . ($chunk->id ?? ''), + ), + + ChunkType::Error => new RunError( + message: $chunk->exception?->getMessage() ?? 'An error occurred', + code: $chunk->exception !== null + ? (string) $chunk->exception->getCode() + : null, + ), + + default => new Custom( + name: $chunk->type->value, + ), + }; } - /** - * Send a single event to the output stream. - * - * @param array $event - */ - protected function sendEvent(array $event): void + protected function getEventLine(AgUiEvent $event, ChatGenerationChunk $chunk): string { - $payload = Js::encode($event); - - echo 'event: message' . "\n"; - echo 'data: ' . $payload . "\n\n"; - - if (ob_get_level() > 0) { - ob_flush(); - } + $payload = $event->withRawEvent($chunk->toArray()) + ->withTimestamp($chunk->createdAt) + ->toArray(); - flush(); + return 'event: message' . "\n" . 'data: ' . json_encode($payload, JSON_THROW_ON_ERROR) . "\n\n"; } } diff --git a/src/LLM/Streaming/DefaultDataStream.php b/src/LLM/Streaming/DefaultDataStream.php deleted file mode 100644 index 754da47..0000000 --- a/src/LLM/Streaming/DefaultDataStream.php +++ /dev/null @@ -1,55 +0,0 @@ -mapChunkToPayload($chunk)); - - echo 'data: ' . $payload; - echo "\n\n"; - - if (ob_get_level() > 0) { - ob_flush(); - } - - flush(); - } - - echo '[DONE]'; - - if (ob_get_level() > 0) { - ob_flush(); - } - - flush(); - }; - } - - public function mapChunkToPayload(ChatGenerationChunk $chunk): array - { - return [ - 'id' => $chunk->id, - 'type' => $chunk->type->value, - 'content' => $chunk->message->content, - 'finish_reason' => $chunk->finishReason?->value, - 'created_at' => $chunk->createdAt->getTimestamp(), - ]; - } -} diff --git a/src/LLM/Streaming/RawDataStream.php b/src/LLM/Streaming/RawDataStream.php new file mode 100644 index 0000000..32f0c47 --- /dev/null +++ b/src/LLM/Streaming/RawDataStream.php @@ -0,0 +1,57 @@ +withoutEmptyDeltas() as $chunk) { + if (connection_aborted() !== 0) { + break; + } + + $event = $this->mapChunkToEvent($chunk); + + yield 'data: ' . json_encode($event, JSON_THROW_ON_ERROR) . "\n\n"; + } + + yield 'data: [DONE]' . "\n\n"; + }; + } + + public function headers(): array + { + return []; + } + + public function mapChunkToEvent(ChatGenerationChunk $chunk): array + { + $event = [ + 'type' => $chunk->type->value, + ]; + + if ($chunk->id !== null) { + $event['id'] = $chunk->id ?? $chunk->message->id; + } + + if ($chunk->message->content() !== null) { + $event['content'] = $chunk->message->content(); + } + + if ($chunk->message->toolCalls !== null) { + $event['tool_calls'] = $chunk->message->toolCalls->toArray(); + } + + return $event; + } +} diff --git a/src/LLM/Streaming/TextStream.php b/src/LLM/Streaming/TextStream.php new file mode 100644 index 0000000..a84fe1a --- /dev/null +++ b/src/LLM/Streaming/TextStream.php @@ -0,0 +1,31 @@ +text()->withoutEmptyDeltas() as $chunk) { + if (connection_aborted() !== 0) { + break; + } + + yield $chunk->text(); + } + }; + } + + public function headers(): array + { + return []; + } +} diff --git a/src/LLM/Streaming/VercelDataStream.php b/src/LLM/Streaming/VercelDataStream.php index 109ee1c..9d20ece 100644 --- a/src/LLM/Streaming/VercelDataStream.php +++ b/src/LLM/Streaming/VercelDataStream.php @@ -5,124 +5,146 @@ namespace Cortex\LLM\Streaming; use Closure; -use Illuminate\Support\Js; +use Generator; +use ArrayObject; use Cortex\LLM\Enums\ChunkType; +use Cortex\LLM\Enums\FinishReason; use Cortex\LLM\Data\ChatStreamResult; use Cortex\LLM\Data\ChatGenerationChunk; -use Cortex\LLM\Contracts\StreamingProtocol; +use Cortex\LLM\Contracts\StreamingProtocolDriver; -class VercelDataStream implements StreamingProtocol +class VercelDataStream implements StreamingProtocolDriver { + protected ?string $currentMessageId = null; + + protected ?string $finishReason = null; + public function streamResponse(ChatStreamResult $result): Closure { - return function () use ($result): void { - foreach ($result as $chunk) { + return function () use ($result): Generator { + foreach ($result->withoutEmptyDeltas() as $chunk) { if (connection_aborted() !== 0) { break; } - $payload = Js::encode($this->mapChunkToPayload($chunk)); + $event = $this->mapChunkToEvent($chunk); - echo 'data: ' . $payload; - echo "\n\n"; - - if (ob_get_level() > 0) { - ob_flush(); + if ($event !== null) { + yield 'data: ' . json_encode($event, JSON_THROW_ON_ERROR) . "\n\n"; } - - flush(); - } - - echo '[DONE]'; - - if (ob_get_level() > 0) { - ob_flush(); } - flush(); + yield 'data: [DONE]' . "\n\n"; }; } - public function mapChunkToPayload(ChatGenerationChunk $chunk): array + public function headers(): array { - $payload = [ - 'type' => $this->mapChunkTypeToVercelType($chunk->type), + return [ + 'x-vercel-ai-ui-message-stream' => 'v1', ]; + } - // Add messageId for message start events (per Vercel protocol) - if ($chunk->type === ChunkType::MessageStart) { - $payload['messageId'] = $chunk->id; - } - - // Add unique ID for text/reasoning blocks - if (in_array($chunk->type, [ - ChunkType::TextStart, - ChunkType::TextDelta, - ChunkType::TextEnd, - ChunkType::ReasoningStart, - ChunkType::ReasoningDelta, - ChunkType::ReasoningEnd, - ], true)) { - // Use message ID as the block identifier - $payload['id'] = $chunk->message->metadata?->id ?? $chunk->id; - } - - // Add delta content for incremental updates - if (in_array($chunk->type, [ChunkType::TextDelta, ChunkType::ReasoningDelta], true)) { - $payload['delta'] = $chunk->message->content; - } - - // Add full message content for other types - if (! isset($payload['delta']) && $chunk->message->content !== null) { - $payload['content'] = $chunk->message->content; - } - - // Add tool calls if present - if ($chunk->message->toolCalls !== null && $chunk->message->toolCalls->isNotEmpty()) { - $payload['toolCalls'] = $chunk->message->toolCalls->toArray(); - } - - // Add usage information if available - if ($chunk->usage !== null) { - $payload['usage'] = $chunk->usage->toArray(); - } + /** + * Map a ChatGenerationChunk to an AG-UI event. + */ + protected function mapChunkToEvent(ChatGenerationChunk $chunk): ?array + { + $this->currentMessageId = $chunk->message->metadata?->id ?? $chunk->id ?? ''; - // Add finish reason if final chunk - if ($chunk->isFinal && $chunk->finishReason !== null) { - $payload['finishReason'] = $chunk->finishReason->value; + if ($chunk->finishReason !== null) { + $this->finishReason = $this->mapFinishReason($chunk->finishReason); } - return $payload; + return match ($chunk->type) { + ChunkType::RunStart => [ + 'type' => 'start', + ], + + ChunkType::RunEnd => array_filter([ + 'type' => 'finish', + 'finishReason' => $this->finishReason, + ]), + + ChunkType::StepStart => [ + 'type' => 'start-step', + ], + + ChunkType::StepEnd => [ + 'type' => 'finish-step', + ], + + ChunkType::TextStart => [ + 'type' => 'text-start', + 'id' => $this->currentMessageId, + ], + + ChunkType::TextDelta => [ + 'type' => 'text-delta', + 'id' => $this->currentMessageId, + 'delta' => $chunk->message->text(), + ], + + ChunkType::TextEnd => [ + 'type' => 'text-end', + 'id' => $this->currentMessageId, + ], + + ChunkType::ReasoningStart => [ + 'type' => 'reasoning-start', + 'id' => $this->currentMessageId, + ], + + ChunkType::ReasoningDelta => [ + 'type' => 'reasoning-delta', + 'id' => $this->currentMessageId, + 'delta' => $chunk->message->reasoning(), + ], + + ChunkType::ReasoningEnd => [ + 'type' => 'reasoning-end', + 'id' => $this->currentMessageId, + ], + + ChunkType::ToolInputStart => [ + 'type' => 'tool-input-start', + 'toolCallId' => $chunk->message->toolCalls->first()?->id, + 'toolName' => $chunk->message->toolCalls->first()?->function->name, + ], + + ChunkType::ToolInputDelta => [ + 'type' => 'tool-input-delta', + 'toolCallId' => $chunk->message->toolCalls->first()?->id, + 'inputTextDelta' => $chunk->message->toolCalls->first()?->function->delta ?? '', + ], + + ChunkType::ToolInputEnd => [ + 'type' => 'tool-input-available', + 'toolCallId' => $chunk->message->toolCalls->first()?->id, + 'toolName' => $chunk->message->toolCalls->first()?->function->name, + 'input' => new ArrayObject($chunk->message->toolCalls->first()?->function->arguments ?? []), + ], + + ChunkType::ToolOutputEnd => [ + 'type' => 'tool-output-available', + 'toolCallId' => $chunk->message->id, + 'output' => $chunk->message->text() ?? '', + ], + + ChunkType::Error => [ + 'type' => 'error', + 'errorText' => $chunk->exception?->getMessage() ?? 'An error occurred', + ], + + default => null, + }; } - protected function mapChunkTypeToVercelType(ChunkType $type): string + protected function mapFinishReason(FinishReason $finishReason): string { - return match ($type) { - ChunkType::MessageStart => 'start', - ChunkType::MessageEnd => 'finish', - - ChunkType::TextStart => 'text-start', - ChunkType::TextDelta => 'text-delta', - ChunkType::TextEnd => 'text-end', - - ChunkType::ReasoningStart => 'reasoning-start', - ChunkType::ReasoningDelta => 'reasoning-delta', - ChunkType::ReasoningEnd => 'reasoning-end', - - ChunkType::SourceDocument => 'source-document', - ChunkType::File => 'file', - - ChunkType::ToolInputStart => 'tool-input-start', - ChunkType::ToolInputDelta => 'tool-input-delta', - ChunkType::ToolInputEnd => 'tool-input-available', - ChunkType::ToolOutputEnd => 'tool-output-available', - - ChunkType::StepStart => 'start-step', - ChunkType::StepEnd => 'finish-step', - - ChunkType::Error => 'error', - - default => $type->value, + return match ($finishReason) { + FinishReason::ToolCalls => 'tool-calls', + default => $finishReason->value, }; } } diff --git a/src/LLM/Streaming/VercelTextStream.php b/src/LLM/Streaming/VercelTextStream.php deleted file mode 100644 index aa5fd80..0000000 --- a/src/LLM/Streaming/VercelTextStream.php +++ /dev/null @@ -1,66 +0,0 @@ -shouldOutputChunk($chunk) && $chunk->message->content !== null && $chunk->message->content !== '') { - echo $chunk->message->content; - - if (ob_get_level() > 0) { - ob_flush(); - } - - flush(); - } - } - }; - } - - public function mapChunkToPayload(ChatGenerationChunk $chunk): array - { - // For text stream, we don't use JSON payloads - // Return minimal structure for interface compatibility - return [ - 'content' => $chunk->message->content ?? '', - ]; - } - - /** - * Determine if this chunk should be output to the stream. - * Only text and reasoning deltas are output. - */ - protected function shouldOutputChunk(ChatGenerationChunk $chunk): bool - { - return in_array($chunk->type, [ - ChunkType::TextDelta, - ChunkType::ReasoningDelta, - ], true); - } -} diff --git a/src/Memory/ChatMemory.php b/src/Memory/ChatMemory.php index 138855b..dd62ccf 100644 --- a/src/Memory/ChatMemory.php +++ b/src/Memory/ChatMemory.php @@ -99,4 +99,11 @@ public function getThreadId(): string { return $this->store->getThreadId(); } + + public function setThreadId(string $threadId): static + { + $this->store->setThreadId($threadId); + + return $this; + } } diff --git a/src/Memory/Contracts/Store.php b/src/Memory/Contracts/Store.php index 5b77698..aed65ea 100644 --- a/src/Memory/Contracts/Store.php +++ b/src/Memory/Contracts/Store.php @@ -40,4 +40,9 @@ public function reset(): void; * Get the thread ID for this store. */ public function getThreadId(): string; + + /** + * Set the thread ID for this store. + */ + public function setThreadId(string $threadId): void; } diff --git a/src/Memory/Stores/CacheStore.php b/src/Memory/Stores/CacheStore.php index 21e335b..0dff31b 100644 --- a/src/Memory/Stores/CacheStore.php +++ b/src/Memory/Stores/CacheStore.php @@ -70,4 +70,9 @@ public function getThreadId(): string { return $this->threadId; } + + public function setThreadId(string $threadId): void + { + $this->threadId = $threadId; + } } diff --git a/src/Memory/Stores/InMemoryStore.php b/src/Memory/Stores/InMemoryStore.php index 77237f0..8c096b3 100644 --- a/src/Memory/Stores/InMemoryStore.php +++ b/src/Memory/Stores/InMemoryStore.php @@ -44,4 +44,9 @@ public function getThreadId(): string { return $this->threadId; } + + public function setThreadId(string $threadId): void + { + $this->threadId = $threadId; + } } diff --git a/src/OutputParsers/EnumOutputParser.php b/src/OutputParsers/EnumOutputParser.php index 4bd6861..45d8665 100644 --- a/src/OutputParsers/EnumOutputParser.php +++ b/src/OutputParsers/EnumOutputParser.php @@ -42,7 +42,7 @@ public function parse(ChatGeneration|ChatGenerationChunk|string $output): Backed } $textOutput = match (true) { - $output instanceof ChatGenerationChunk => $output->contentSoFar, + $output instanceof ChatGenerationChunk => $output->textSoFar(), $output instanceof ChatGeneration => $output->message->text() ?? '', default => $output, }; diff --git a/src/OutputParsers/JsonOutputParser.php b/src/OutputParsers/JsonOutputParser.php index f83fe6c..e5c51fc 100644 --- a/src/OutputParsers/JsonOutputParser.php +++ b/src/OutputParsers/JsonOutputParser.php @@ -6,81 +6,31 @@ use Override; use JsonException; -use Ahc\Json\Fixer; use Cortex\LLM\Data\ChatGeneration; use Cortex\LLM\Data\ChatGenerationChunk; use Cortex\Exceptions\OutputParserException; +use Cortex\JsonRepair\Exceptions\JsonRepairException; + +use function Cortex\JsonRepair\json_repair_decode; class JsonOutputParser extends AbstractOutputParser { - protected const array PATTERNS = [ - '/```json\s*(\{.*?\})\s*```/s', // Code block with correct language identifier - '/```(?:\w+)?\s*(\{.*?\})\s*```/s', // Code block without language identifier - '/```(?:\w+)?\s*(\{.*?\})\s*/s', // Code block without closing backticks - '/(?:\w+)?\s*(\{.*?\})\s*/s', // Without any code block - ]; - /** * @return array */ public function parse(ChatGeneration|ChatGenerationChunk|string $output): array { $output = match (true) { - $output instanceof ChatGenerationChunk => $output->contentSoFar, + $output instanceof ChatGenerationChunk => $output->textSoFar() ?? '', $output instanceof ChatGeneration => $output->message->text() ?? '', default => $output, }; try { - $value = json_decode($output, true, flags: JSON_THROW_ON_ERROR); - - if (! is_array($value)) { - throw OutputParserException::failed('Could not parse JSON from output.', $output); - } - - return $value; - } catch (JsonException) { - foreach (self::PATTERNS as $pattern) { - preg_match($pattern, $output, $matches); - - if (isset($matches[1])) { - $validJson = $this->handleJsonMatch($matches[1]); - - if ($validJson !== null) { - return $validJson; - } - } - } - - $validJson = $this->handleJsonMatch($output); - - if (is_array($validJson)) { - return $validJson; - } - } - - throw OutputParserException::failed('Could not parse JSON from output.', $output); - } - - /** - * @return array|null - */ - protected function handleJsonMatch(string $json): ?array - { - if (json_validate($json) || json_validate($json = $this->repairJson($json))) { - return json_decode($json, true); + return json_repair_decode($output, omitEmptyValues: true); + } catch (JsonRepairException|JsonException $e) { + throw OutputParserException::failed('Could not parse JSON from output.', $output, previous: $e); } - - return null; - } - - protected function repairJson(string $json): string - { - // Repair objects with just a key and no value defined. - // Note: This only works for top level at the moment. - $json = preg_replace('/\{(\s*"\w+"\s*:\s*)\{[^}]*?\}\}/', '{$1{}}', $json); - - return new Fixer()->silent()->fix((string) $json); } #[Override] diff --git a/src/OutputParsers/JsonOutputToolsParser.php b/src/OutputParsers/JsonOutputToolsParser.php index dd607ea..62d3818 100644 --- a/src/OutputParsers/JsonOutputToolsParser.php +++ b/src/OutputParsers/JsonOutputToolsParser.php @@ -29,7 +29,7 @@ public function parse(ChatGeneration|ChatGenerationChunk|string $output): array throw OutputParserException::failed('Invalid input. Expected a message with tool calls.'); } - if ($this->singleToolCall && $output->message->toolCalls->containsOneItem()) { + if ($this->singleToolCall && $output->message->toolCalls->hasSole()) { return $output->message->toolCalls->first()->function->arguments; } diff --git a/src/OutputParsers/XmlOutputParser.php b/src/OutputParsers/XmlOutputParser.php index d5e7dda..960b2d5 100644 --- a/src/OutputParsers/XmlOutputParser.php +++ b/src/OutputParsers/XmlOutputParser.php @@ -26,7 +26,7 @@ class XmlOutputParser extends AbstractOutputParser public function parse(ChatGeneration|ChatGenerationChunk|string $output): array { $output = match (true) { - $output instanceof ChatGenerationChunk => $output->contentSoFar, + $output instanceof ChatGenerationChunk => $output->textSoFar(), $output instanceof ChatGeneration => $output->message->text() ?? '', default => $output, }; @@ -35,7 +35,7 @@ public function parse(ChatGeneration|ChatGenerationChunk|string $output): array return $this->parseXml($output); } catch (Exception) { foreach (self::PATTERNS as $pattern) { - preg_match($pattern, $output, $matches); + preg_match($pattern, (string) $output, $matches); if (isset($matches[1])) { return $this->parseXml($matches[1]); diff --git a/src/OutputParsers/XmlTagOutputParser.php b/src/OutputParsers/XmlTagOutputParser.php index 3614a15..bd468e3 100644 --- a/src/OutputParsers/XmlTagOutputParser.php +++ b/src/OutputParsers/XmlTagOutputParser.php @@ -34,7 +34,7 @@ public function __construct( public function parse(ChatGeneration|ChatGenerationChunk|string $output): string { $output = match (true) { - $output instanceof ChatGenerationChunk => $output->contentSoFar, + $output instanceof ChatGenerationChunk => $output->textSoFar(), $output instanceof ChatGeneration => $output->message->text() ?? '', default => $output, }; @@ -44,7 +44,7 @@ public function parse(ChatGeneration|ChatGenerationChunk|string $output): string // Try closed tag first $pattern = '/<' . preg_quote($this->tagName, '/') . '>((?:(?!<' . preg_quote($this->tagName, '/') . ').)*?)<\/' . preg_quote($this->tagName, '/') . '>/s'; - if (preg_match($pattern, $output, $matches)) { + if (preg_match($pattern, (string) $output, $matches)) { return trim($matches[1]); } @@ -52,7 +52,7 @@ public function parse(ChatGeneration|ChatGenerationChunk|string $output): string if ($this->allowUnclosedTags) { $pattern = '/<' . preg_quote($this->tagName, '/') . '>([^<]*?)(?:<\/' . preg_quote($this->tagName, '/') . '>|\s*$)/s'; - if (preg_match($pattern, $output, $matches)) { + if (preg_match($pattern, (string) $output, $matches)) { return trim($matches[1]); } } @@ -65,7 +65,7 @@ public function parse(ChatGeneration|ChatGenerationChunk|string $output): string // Try closed tags first foreach (self::CLOSED_TAG_PATTERNS as $pattern) { - if (preg_match($pattern, $output, $matches)) { + if (preg_match($pattern, (string) $output, $matches)) { return trim($matches[2]); } } @@ -73,7 +73,7 @@ public function parse(ChatGeneration|ChatGenerationChunk|string $output): string // If unclosed tags are allowed, try those patterns if ($this->allowUnclosedTags) { foreach (self::UNCLOSED_TAG_PATTERNS as $pattern) { - if (preg_match($pattern, $output, $matches)) { + if (preg_match($pattern, (string) $output, $matches)) { return trim($matches[2]); } } diff --git a/src/Pipeline.php b/src/Pipeline.php index 20bcfc1..60cc57c 100644 --- a/src/Pipeline.php +++ b/src/Pipeline.php @@ -36,11 +36,6 @@ class Pipeline implements Pipeable */ protected array $stages = []; - /** - * The runtime context for this pipeline execution. - */ - // protected ?RuntimeConfig $config = null; - protected bool $streaming = false; /** diff --git a/src/Pipeline/RuntimeConfig.php b/src/Pipeline/RuntimeConfig.php index 2b6a4a6..4a12aae 100644 --- a/src/Pipeline/RuntimeConfig.php +++ b/src/Pipeline/RuntimeConfig.php @@ -44,11 +44,14 @@ class RuntimeConfig implements Arrayable /** * @param Closure(string $threadId): Store|null $storeFactory Factory to create stores for the given threadId + * @param array $tools */ public function __construct( public Context $context = new Context(), public Metadata $metadata = new Metadata(), + public State $state = new State(), public StreamBuffer $stream = new StreamBuffer(), + public array $tools = [], public ?Throwable $exception = null, ?string $threadId = null, ?string $runId = null, diff --git a/src/Pipeline/State.php b/src/Pipeline/State.php new file mode 100644 index 0000000..8c3a92c --- /dev/null +++ b/src/Pipeline/State.php @@ -0,0 +1,12 @@ + + */ +class State extends Fluent {} diff --git a/src/Prompts/Builders/ChatPromptBuilder.php b/src/Prompts/Builders/ChatPromptBuilder.php index deed4f4..e1be429 100644 --- a/src/Prompts/Builders/ChatPromptBuilder.php +++ b/src/Prompts/Builders/ChatPromptBuilder.php @@ -44,7 +44,7 @@ public function messages(MessageCollection|array|string $messages): self /** * Convenience method to build a generic agent builder from the chat prompt builder. */ - public function agentBuilder(): GenericAgentBuilder + public function agent(): GenericAgentBuilder { return new GenericAgentBuilder()->withPrompt($this); } diff --git a/src/Prompts/Factories/LangfusePromptFactory.php b/src/Prompts/Factories/LangfusePromptFactory.php index 808e791..27a9ba9 100644 --- a/src/Prompts/Factories/LangfusePromptFactory.php +++ b/src/Prompts/Factories/LangfusePromptFactory.php @@ -191,7 +191,7 @@ protected function getResponseContent(string $name, array $options = []): array try { $result = json_decode((string) $response->getBody(), true, flags: JSON_THROW_ON_ERROR); } catch (JsonException $e) { - throw new PromptException('Invalid JSON response from Langfuse: ' . $e->getMessage()); + throw new PromptException('Invalid JSON response from Langfuse: ' . $e->getMessage(), $e->getCode(), $e); } if ($cache !== null) { diff --git a/src/Prompts/Factories/McpPromptFactory.php b/src/Prompts/Factories/McpPromptFactory.php index 31c334b..b8720ce 100644 --- a/src/Prompts/Factories/McpPromptFactory.php +++ b/src/Prompts/Factories/McpPromptFactory.php @@ -91,7 +91,7 @@ protected function getPromptDefinition(string $name): PromptDefinition /** @var array $prompts */ $prompts = $this->client->listPrompts(); } catch (Throwable $e) { - throw new PromptException('Failed to list prompts: ' . $e->getMessage(), previous: $e); + throw new PromptException('Failed to list prompts: ' . $e->getMessage(), $e->getCode(), previous: $e); } $prompt = collect($prompts)->firstWhere('name', $name); diff --git a/src/Prompts/Templates/AbstractPromptTemplate.php b/src/Prompts/Templates/AbstractPromptTemplate.php index 33c36fd..247e1d4 100644 --- a/src/Prompts/Templates/AbstractPromptTemplate.php +++ b/src/Prompts/Templates/AbstractPromptTemplate.php @@ -6,7 +6,7 @@ use Closure; use Cortex\Pipeline; -use Cortex\Facades\LLM; +use Cortex\Support\Utils; use Cortex\JsonSchema\Schema; use Cortex\Contracts\Pipeable; use Cortex\Pipeline\RuntimeConfig; @@ -37,7 +37,7 @@ public function handlePipeable(mixed $payload, RuntimeConfig $config, Closure $n // If there is only one variable, and the input is a string, // we can assume the user is passing the value directly. - if (is_string($payload) && $variables->containsOneItem()) { + if (is_string($payload) && $variables->hasSole()) { return $next($this->format([ $variables->first() => $payload, ]), $config); @@ -79,17 +79,7 @@ public function llm( throw new PromptException('No LLM provider or metadata provided.'); } - if ($provider instanceof LLMContract) { - $llm = $provider; - } elseif ($provider === null) { - if (is_string($this->metadata?->provider)) { - $llm = LLM::provider($this->metadata->provider); - } else { - $llm = $this->metadata->provider; - } - } else { - $llm = LLM::provider($provider); - } + $llm = Utils::llm($provider ?? $this->metadata?->provider); if (is_string($model)) { $llm->withModel($model); @@ -128,6 +118,13 @@ public function llm( return $this->pipe($llm); } + public function withMetadata(PromptMetadata $metadata): self + { + $this->metadata = $metadata; + + return $this; + } + public function withCompiler(PromptCompiler $compiler): self { $this->compiler = $compiler; diff --git a/src/Prompts/Templates/ChatPromptTemplate.php b/src/Prompts/Templates/ChatPromptTemplate.php index 0a03550..58affe8 100644 --- a/src/Prompts/Templates/ChatPromptTemplate.php +++ b/src/Prompts/Templates/ChatPromptTemplate.php @@ -34,16 +34,15 @@ public function __construct( public bool $strict = true, ) { $this->messages = Utils::toMessageCollection($messages); - - if ($this->messages->isEmpty()) { - throw new PromptException('Messages cannot be empty.'); - } - $this->inputSchema ??= $this->defaultInputSchema(); } public function format(?array $variables = null): MessageCollection { + if ($this->messages->isEmpty()) { + throw new PromptException('Messages cannot be empty.'); + } + $variables = array_merge($this->initialVariables, $variables ?? []); if ($this->strict && $variables !== []) { @@ -101,10 +100,15 @@ public function defaultInputSchema(): ObjectSchema ->properties(...$properties); } + public function getInputSchema(): ObjectSchema + { + return $this->inputSchema ?? $this->defaultInputSchema(); + } + /** * Convenience method to build a generic agent builder from the prompt template. */ - public function agentBuilder(): GenericAgentBuilder + public function agent(): GenericAgentBuilder { return new GenericAgentBuilder()->withPrompt($this); } diff --git a/src/Prompts/Templates/TextPromptTemplate.php b/src/Prompts/Templates/TextPromptTemplate.php index d1e2d1d..31a8cda 100644 --- a/src/Prompts/Templates/TextPromptTemplate.php +++ b/src/Prompts/Templates/TextPromptTemplate.php @@ -40,4 +40,9 @@ public function variables(): Collection ->merge(array_keys($this->initialVariables)) ->unique(); } + + public function getInputSchema(): ObjectSchema + { + return $this->inputSchema ?? $this->defaultInputSchema(); + } } diff --git a/src/SDK/Anthropic/Anthropic.php b/src/SDK/Anthropic/Anthropic.php new file mode 100644 index 0000000..e4bf96b --- /dev/null +++ b/src/SDK/Anthropic/Anthropic.php @@ -0,0 +1,75 @@ +cacheStore, + $this->cacheExpiryInSeconds, + ); + } + + public function resolveBaseUrl(): string + { + return $this->baseUri ?? static::defaultBaseUri(); + } + + protected function defaultAuth(): HeaderAuthenticator + { + return new HeaderAuthenticator($this->apiKey, 'x-api-key'); + } + + protected function defaultHeaders(): array + { + return [ + 'anthropic-version' => $this->version ?? static::defaultVersion(), + ]; + } + + protected static function defaultBaseUri(): string + { + return 'https://api.anthropic.com/v1'; + } + + protected static function defaultVersion(): string + { + return '2023-06-01'; + } + + /** + * @param array $responses + */ + public static function fake(array $responses, ?string $apiKey = null): self + { + MockClient::global($responses); + + return new self($apiKey ?? 'test-api-key'); + } +} diff --git a/src/SDK/Anthropic/Contracts/ContentBlock.php b/src/SDK/Anthropic/Contracts/ContentBlock.php new file mode 100644 index 0000000..da92e6e --- /dev/null +++ b/src/SDK/Anthropic/Contracts/ContentBlock.php @@ -0,0 +1,15 @@ + $payload + */ + public static function from(array $payload): self; +} diff --git a/src/SDK/Anthropic/Contracts/StreamEvent.php b/src/SDK/Anthropic/Contracts/StreamEvent.php new file mode 100644 index 0000000..a07a828 --- /dev/null +++ b/src/SDK/Anthropic/Contracts/StreamEvent.php @@ -0,0 +1,22 @@ + $payload + */ + public static function from(array $payload): self; + + /** + * @return array + */ + public function raw(): array; + + public function meta(): ?Meta; +} diff --git a/src/SDK/Anthropic/Data/Messages/ContentBlocks/RedactedThinkingContentBlock.php b/src/SDK/Anthropic/Data/Messages/ContentBlocks/RedactedThinkingContentBlock.php new file mode 100644 index 0000000..7a11e9e --- /dev/null +++ b/src/SDK/Anthropic/Data/Messages/ContentBlocks/RedactedThinkingContentBlock.php @@ -0,0 +1,25 @@ + $input + */ + public function __construct( + public string $id, + public string $name, + public array $input, + ) {} + + public static function from(array $payload): self + { + return new self( + id: $payload['id'], + name: $payload['name'], + input: $payload['input'], + ); + } +} diff --git a/src/SDK/Anthropic/Data/Messages/ContentBlocks/TextContentBlock.php b/src/SDK/Anthropic/Data/Messages/ContentBlocks/TextContentBlock.php new file mode 100644 index 0000000..1b17f4b --- /dev/null +++ b/src/SDK/Anthropic/Data/Messages/ContentBlocks/TextContentBlock.php @@ -0,0 +1,23 @@ + $input + */ + public function __construct( + public string $id, + public string $name, + public array $input, + ) {} + + public static function from(array $payload): self + { + return new self( + id: $payload['id'], + name: $payload['name'], + input: $payload['input'], + ); + } +} diff --git a/src/SDK/Anthropic/Data/Messages/ContentBlocks/WebSearchToolResultContentBlock.php b/src/SDK/Anthropic/Data/Messages/ContentBlocks/WebSearchToolResultContentBlock.php new file mode 100644 index 0000000..a886a42 --- /dev/null +++ b/src/SDK/Anthropic/Data/Messages/ContentBlocks/WebSearchToolResultContentBlock.php @@ -0,0 +1,28 @@ + $content + */ + public function __construct( + public string $toolUseId, + public array $content, + ) {} + + public static function from(array $payload): self + { + return new self( + toolUseId: $payload['tool_use_id'], + content: $payload['content'], + ); + } +} diff --git a/src/SDK/Anthropic/Data/Messages/Message.php b/src/SDK/Anthropic/Data/Messages/Message.php new file mode 100644 index 0000000..57c8423 --- /dev/null +++ b/src/SDK/Anthropic/Data/Messages/Message.php @@ -0,0 +1,100 @@ + $content + */ + public function __construct( + public string $model, + public string $id, + public string $type, + public string $role, + public array $content, + public ?string $stopReason = null, + public ?string $stopSequence = null, + public ?Usage $usage = null, + ) {} + + /** + * @param array $payload + */ + public static function from(array $payload): self + { + return new self( + model: $payload['model'], + id: $payload['id'], + type: $payload['type'], + role: $payload['role'], + content: array_map([self::class, 'mapContentBlock'], $payload['content']), + stopReason: $payload['stop_reason'] ?? null, + stopSequence: $payload['stop_sequence'] ?? null, + usage: $payload['usage'] ? Usage::from($payload['usage']) : null, + ); + } + + public static function fromResponse(Response $response): self + { + return self::from($response->json()) + ->setResponse($response); + } + + public function setResponse(Response $response): self + { + $this->response = $response; + $this->meta = Meta::from($response->headers()->all()); + + return $this; + } + + public function setMeta(Meta $meta): self + { + $this->meta = $meta; + + return $this; + } + + public function getResponse(): ?Response + { + return $this->response; + } + + public function getMeta(): ?Meta + { + return $this->meta; + } + + /** + * @param array{type: string, ...} $content + */ + public static function mapContentBlock(array $content): ContentBlock + { + return match ($content['type']) { + 'text' => TextContentBlock::from($content), + 'thinking' => ThinkingContentBlock::from($content), + 'redacted_thinking' => RedactedThinkingContentBlock::from($content), + 'tool_use' => ToolUseContentBlock::from($content), + 'server_tool_use' => ServerToolUseContentBlock::from($content), + 'web_search_tool_result' => WebSearchToolResultContentBlock::from($content), + default => throw new InvalidArgumentException('Invalid content type: ' . $content['type']), + }; + } +} diff --git a/src/SDK/Anthropic/Data/Messages/MessageStream.php b/src/SDK/Anthropic/Data/Messages/MessageStream.php new file mode 100644 index 0000000..d231bad --- /dev/null +++ b/src/SDK/Anthropic/Data/Messages/MessageStream.php @@ -0,0 +1,84 @@ + + */ +final readonly class MessageStream implements IteratorAggregate +{ + private StreamInterface $body; + + public function __construct( + private Response $response, + ) { + $this->body = $response->getPsrResponse()->getBody(); + } + + /** + * Get an iterator for the stream. + * + * @return \Generator<\Cortex\SDK\Anthropic\Contracts\StreamEvent> + */ + public function getIterator(): Generator + { + $meta = Meta::from($this->response->headers()->all()); + + try { + while (! $this->body->eof()) { + $line = Utils::readLine($this->body); + + if (! str_starts_with($line, 'data:')) { + continue; + } + + $data = trim(substr($line, strlen('data:'))); + + $payload = json_decode($data, true, flags: JSON_THROW_ON_ERROR); + + $event = match ($payload['type']) { + 'message_start' => MessageStart::from($payload), + 'message_delta' => MessageDelta::from($payload), + 'message_stop' => MessageStop::from($payload), + 'content_block_start' => ContentBlockStart::from($payload), + 'content_block_delta' => ContentBlockDelta::from($payload), + 'content_block_stop' => ContentBlockStop::from($payload), + 'ping' => Ping::from($payload), + default => Unknown::from($payload), + }; + + $event->setMeta($meta); + + yield $event; + } + } finally { + $this->body->close(); + } + } + + public function getResponse(): Response + { + return $this->response; + } + + public static function fromResponse(Response $response): self + { + return new self($response); + } +} diff --git a/src/SDK/Anthropic/Data/Messages/Meta.php b/src/SDK/Anthropic/Data/Messages/Meta.php new file mode 100644 index 0000000..3d6a9a9 --- /dev/null +++ b/src/SDK/Anthropic/Data/Messages/Meta.php @@ -0,0 +1,44 @@ + $raw + */ + public function __construct( + public array $raw, + public ?int $processingTime = null, + public ?DateTimeImmutable $createdAt = null, + ) {} + + /** + * @param array $payload + */ + public static function from(array $payload): self + { + $createdAt = $payload['Date'] + ? DateTimeImmutable::createFromFormat(DateTimeInterface::RFC7231, $payload['Date']) + : null; + + $processingTime = isset($payload['x-envoy-upstream-service-time']) + ? (int) $payload['x-envoy-upstream-service-time'] + : null; + + $filteredMetadata = array_filter( + $payload, + static function (string $value, string $key): bool { + return str_starts_with($key, 'anthropic-') || $key === 'request-id'; + }, + ARRAY_FILTER_USE_BOTH, + ); + + return new self($filteredMetadata, $processingTime, $createdAt); + } +} diff --git a/src/SDK/Anthropic/Data/Messages/Streaming/CitationsDelta.php b/src/SDK/Anthropic/Data/Messages/Streaming/CitationsDelta.php new file mode 100644 index 0000000..1535c29 --- /dev/null +++ b/src/SDK/Anthropic/Data/Messages/Streaming/CitationsDelta.php @@ -0,0 +1,25 @@ + $citation + */ + public function __construct( + public array $citation, + ) {} + + /** + * @param array{citation: array} $payload + */ + public static function from(array $payload): self + { + return new self( + citation: $payload['citation'], + ); + } +} diff --git a/src/SDK/Anthropic/Data/Messages/Streaming/Events/AbstractStreamEvent.php b/src/SDK/Anthropic/Data/Messages/Streaming/Events/AbstractStreamEvent.php new file mode 100644 index 0000000..d098eb6 --- /dev/null +++ b/src/SDK/Anthropic/Data/Messages/Streaming/Events/AbstractStreamEvent.php @@ -0,0 +1,47 @@ + + */ + protected array $raw = []; + + protected ?Meta $meta = null; + + /** + * @return array + */ + public function raw(): array + { + return $this->raw; + } + + public function meta(): ?Meta + { + return $this->meta; + } + + /** + * @param array $payload + */ + public function setRaw(array $payload): static + { + $this->raw = $payload; + + return $this; + } + + public function setMeta(Meta $meta): static + { + $this->meta = $meta; + + return $this; + } +} diff --git a/src/SDK/Anthropic/Data/Messages/Streaming/Events/ContentBlockDelta.php b/src/SDK/Anthropic/Data/Messages/Streaming/Events/ContentBlockDelta.php new file mode 100644 index 0000000..01a84f2 --- /dev/null +++ b/src/SDK/Anthropic/Data/Messages/Streaming/Events/ContentBlockDelta.php @@ -0,0 +1,44 @@ + $payload + */ + public static function from(array $payload): self + { + $delta = match ($payload['delta']['type']) { + 'text_delta' => TextDelta::from($payload['delta']), + 'input_json_delta' => InputJsonDelta::from($payload['delta']), + 'thinking_delta' => ThinkingDelta::from($payload['delta']), + 'signature_delta' => SignatureDelta::from($payload['delta']), + 'citations_delta' => CitationsDelta::from($payload['delta']), + default => throw new InvalidArgumentException('Invalid delta type: ' . $payload['delta']['type']), + }; + + return new self( + index: $payload['index'], + delta: $delta, + )->setRaw($payload); + } +} diff --git a/src/SDK/Anthropic/Data/Messages/Streaming/Events/ContentBlockStart.php b/src/SDK/Anthropic/Data/Messages/Streaming/Events/ContentBlockStart.php new file mode 100644 index 0000000..e00801a --- /dev/null +++ b/src/SDK/Anthropic/Data/Messages/Streaming/Events/ContentBlockStart.php @@ -0,0 +1,31 @@ + $payload + */ + public static function from(array $payload): self + { + return new self( + index: $payload['index'], + contentBlock: Message::mapContentBlock($payload['content_block']), + )->setRaw($payload); + } +} diff --git a/src/SDK/Anthropic/Data/Messages/Streaming/Events/ContentBlockStop.php b/src/SDK/Anthropic/Data/Messages/Streaming/Events/ContentBlockStop.php new file mode 100644 index 0000000..37ad6d0 --- /dev/null +++ b/src/SDK/Anthropic/Data/Messages/Streaming/Events/ContentBlockStop.php @@ -0,0 +1,27 @@ + $payload + */ + public static function from(array $payload): self + { + return new self( + index: $payload['index'], + )->setRaw($payload); + } +} diff --git a/src/SDK/Anthropic/Data/Messages/Streaming/Events/MessageDelta.php b/src/SDK/Anthropic/Data/Messages/Streaming/Events/MessageDelta.php new file mode 100644 index 0000000..ad6ef07 --- /dev/null +++ b/src/SDK/Anthropic/Data/Messages/Streaming/Events/MessageDelta.php @@ -0,0 +1,29 @@ + $payload + */ + public static function from(array $payload): self + { + return new self( + stopReason: $payload['delta']['stop_reason'], + cumulativeUsage: Usage::from($payload['usage']), + stopSequence: $payload['delta']['stop_sequence'] ?? null, + )->setRaw($payload); + } +} diff --git a/src/SDK/Anthropic/Data/Messages/Streaming/Events/MessageStart.php b/src/SDK/Anthropic/Data/Messages/Streaming/Events/MessageStart.php new file mode 100644 index 0000000..a00032f --- /dev/null +++ b/src/SDK/Anthropic/Data/Messages/Streaming/Events/MessageStart.php @@ -0,0 +1,25 @@ + $payload + */ + public static function from(array $payload): self + { + return new self( + message: Message::from($payload['message']), + )->setRaw($payload); + } +} diff --git a/src/SDK/Anthropic/Data/Messages/Streaming/Events/MessageStop.php b/src/SDK/Anthropic/Data/Messages/Streaming/Events/MessageStop.php new file mode 100644 index 0000000..e382de9 --- /dev/null +++ b/src/SDK/Anthropic/Data/Messages/Streaming/Events/MessageStop.php @@ -0,0 +1,18 @@ + $payload + */ + public static function from(array $payload): self + { + return new self()->setRaw($payload); + } +} diff --git a/src/SDK/Anthropic/Data/Messages/Streaming/Events/Ping.php b/src/SDK/Anthropic/Data/Messages/Streaming/Events/Ping.php new file mode 100644 index 0000000..600240c --- /dev/null +++ b/src/SDK/Anthropic/Data/Messages/Streaming/Events/Ping.php @@ -0,0 +1,18 @@ + $payload + */ + public static function from(array $payload): self + { + return new self()->setRaw($payload); + } +} diff --git a/src/SDK/Anthropic/Data/Messages/Streaming/Events/Unknown.php b/src/SDK/Anthropic/Data/Messages/Streaming/Events/Unknown.php new file mode 100644 index 0000000..dd73dcf --- /dev/null +++ b/src/SDK/Anthropic/Data/Messages/Streaming/Events/Unknown.php @@ -0,0 +1,29 @@ + $payload + */ + public function __construct( + public string $type, + public array $payload, + ) {} + + /** + * @param array $payload + */ + public static function from(array $payload): self + { + return new self( + type: $payload['type'], + payload: $payload, + )->setRaw($payload); + } +} diff --git a/src/SDK/Anthropic/Data/Messages/Streaming/InputJsonDelta.php b/src/SDK/Anthropic/Data/Messages/Streaming/InputJsonDelta.php new file mode 100644 index 0000000..f2e0841 --- /dev/null +++ b/src/SDK/Anthropic/Data/Messages/Streaming/InputJsonDelta.php @@ -0,0 +1,22 @@ +|null $cacheCreation + * @param array $serverToolUse + */ + public function __construct( + public int $inputTokens, + public int $outputTokens, + public ?int $cacheCreationInputTokens = null, + public ?int $cacheReadInputTokens = null, + public ?array $cacheCreation = null, + public ?string $serviceTier = null, + public array $serverToolUse = [], + ) {} + + /** + * @param array $payload + */ + public static function from(array $payload): self + { + return new self( + inputTokens: $payload['input_tokens'] ?? 0, + outputTokens: $payload['output_tokens'] ?? 0, + cacheCreationInputTokens: $payload['cache_creation_input_tokens'] ?? null, + cacheReadInputTokens: $payload['cache_read_input_tokens'] ?? null, + cacheCreation: $payload['cache_creation'] ?? null, + serviceTier: $payload['service_tier'] ?? null, + serverToolUse: $payload['server_tool_use'] ?? [], + ); + } +} diff --git a/src/SDK/Anthropic/Requests/CountTokens.php b/src/SDK/Anthropic/Requests/CountTokens.php new file mode 100644 index 0000000..a7321fa --- /dev/null +++ b/src/SDK/Anthropic/Requests/CountTokens.php @@ -0,0 +1,23 @@ + $parameters + */ + public function __construct( + protected array $parameters, + protected ?Repository $cacheStore = null, + protected ?int $cacheExpiryInSeconds = null, + ) {} + + protected Method $method = Method::POST; + + public function resolveEndpoint(): string + { + return '/messages'; + } + + /** + * @return array + */ + protected function defaultBody(): array + { + return array_filter( + $this->parameters, + fn(string $key): bool => $key !== 'betas', + ARRAY_FILTER_USE_KEY, + ); + } + + /** + * @return array + */ + protected function defaultHeaders(): array + { + $headers = []; + $betas = $this->parameters['betas'] ?? []; + + if ($betas !== []) { + $headers['anthropic-beta'] = implode(',', $betas); + } + + return $headers; + } + + protected function defaultConfig(): array + { + return [ + 'stream' => $this->parameters['stream'] ?? false, + ]; + } + + public function createDtoFromResponse(Response $response): mixed + { + return $this->parameters['stream'] ?? false + ? MessageStream::fromResponse($response) + : Message::fromResponse($response); + } + + public function resolveCacheDriver(): Driver + { + return new LaravelCacheDriver($this->cacheStore ?? Cache::store()); + } + + public function cacheExpiryInSeconds(): int + { + return $this->cacheExpiryInSeconds ?? 3600; + } + + /** + * @return array<\Saloon\Enums\Method> + */ + protected function getCacheableMethods(): array + { + return [$this->method]; + } + + protected function cacheKey(PendingRequest $pendingRequest): ?string + { + $className = $pendingRequest->getRequest()::class; + $requestUrl = $pendingRequest->getUrl(); + $query = $pendingRequest->query()->all(); + $headers = $pendingRequest->headers()->all(); + $body = $pendingRequest->body()->all(); + + return hash( + 'sha256', + json_encode([ + 'className' => $className, + 'requestUrl' => $requestUrl, + 'query' => $query, + 'headers' => $headers, + 'body' => $body, + ], JSON_THROW_ON_ERROR), + ); + } +} diff --git a/src/SDK/Anthropic/Resources/Messages.php b/src/SDK/Anthropic/Resources/Messages.php new file mode 100644 index 0000000..297f7da --- /dev/null +++ b/src/SDK/Anthropic/Resources/Messages.php @@ -0,0 +1,82 @@ + $parameters + */ + public function create(array $parameters, bool $cacheEnabled = false): Response + { + $request = new CreateMessage( + $parameters, + $this->cacheStore, + $this->cacheExpiryInSeconds, + ); + + if ($cacheEnabled) { + $request->enableCaching(); + } else { + $request->disableCaching(); + } + + $response = $this->connector->send($request); + + return $response->throw(); + } + + /** + * Create a streamed message. + * + * @param array $parameters + */ + public function stream(array $parameters, bool $cacheEnabled = false): Response + { + return $this->create([ + ...$parameters, + 'stream' => true, + ], $cacheEnabled); + } + + /** + * @param array $parameters + */ + public function countTokens(array $parameters, bool $cacheEnabled = false): int + { + $request = new CountTokens( + $parameters, + $this->cacheStore, + $this->cacheExpiryInSeconds, + ); + + if ($cacheEnabled) { + $request->enableCaching(); + } else { + $request->disableCaching(); + } + + $response = $this->connector->send($request); + + return $response->throw()->json('input_tokens'); + } +} diff --git a/src/SDK/OpenAI/Contracts/OutputItem.php b/src/SDK/OpenAI/Contracts/OutputItem.php new file mode 100644 index 0000000..411a296 --- /dev/null +++ b/src/SDK/OpenAI/Contracts/OutputItem.php @@ -0,0 +1,15 @@ + $payload + */ + public static function from(array $payload): self; +} diff --git a/src/SDK/OpenAI/Contracts/StreamEvent.php b/src/SDK/OpenAI/Contracts/StreamEvent.php new file mode 100644 index 0000000..64ef15a --- /dev/null +++ b/src/SDK/OpenAI/Contracts/StreamEvent.php @@ -0,0 +1,22 @@ + $payload + */ + public static function from(array $payload): self; + + /** + * @return array + */ + public function raw(): array; + + public function meta(): ?Meta; +} diff --git a/src/SDK/OpenAI/Data/Responses/Message/OutputText.php b/src/SDK/OpenAI/Data/Responses/Message/OutputText.php new file mode 100644 index 0000000..178ba62 --- /dev/null +++ b/src/SDK/OpenAI/Data/Responses/Message/OutputText.php @@ -0,0 +1,32 @@ + $annotations + * @param array $logProbs + */ + public function __construct( + public string $text, + public array $annotations = [], + public array $logProbs = [], + ) {} + + /** + * @param array $payload + */ + public static function from(array $payload): self + { + return new self( + text: $payload['text'], + annotations: $payload['annotations'] ?? [], + logProbs: $payload['logProbs'] ?? [], + ); + } +} diff --git a/src/SDK/OpenAI/Data/Responses/Message/Refusal.php b/src/SDK/OpenAI/Data/Responses/Message/Refusal.php new file mode 100644 index 0000000..e029894 --- /dev/null +++ b/src/SDK/OpenAI/Data/Responses/Message/Refusal.php @@ -0,0 +1,24 @@ + $payload + */ + public static function from(array $payload): self + { + return new self( + reason: $payload['reason'], + ); + } +} diff --git a/src/SDK/OpenAI/Data/Responses/Meta.php b/src/SDK/OpenAI/Data/Responses/Meta.php new file mode 100644 index 0000000..53edc82 --- /dev/null +++ b/src/SDK/OpenAI/Data/Responses/Meta.php @@ -0,0 +1,35 @@ + $raw + */ + public function __construct( + public array $raw, + public ?int $processingTime = null, + ) {} + + /** + * @param array $payload + */ + public static function from(array $payload): self + { + $processingTime = isset($payload['openai-processing-ms']) + ? (int) $payload['openai-processing-ms'] + : null; + + $filteredMetadata = array_filter( + $payload, + static fn(mixed $value, string $key): bool => + str_starts_with($key, 'openai-') || str_starts_with($key, 'x-'), + ARRAY_FILTER_USE_BOTH, + ); + + return new self($filteredMetadata, $processingTime); + } +} diff --git a/src/SDK/OpenAI/Data/Responses/OutputItems/FunctionToolCallOutputItem.php b/src/SDK/OpenAI/Data/Responses/OutputItems/FunctionToolCallOutputItem.php new file mode 100644 index 0000000..f5c1ec0 --- /dev/null +++ b/src/SDK/OpenAI/Data/Responses/OutputItems/FunctionToolCallOutputItem.php @@ -0,0 +1,34 @@ + $content + */ + public function __construct( + public string $id, + public array $content, + public string $status, + public string $role = 'assistant', + ) {} + + public static function from(array $payload): self + { + return new self( + id: $payload['id'], + content: array_map([self::class, 'mapContent'], $payload['content']), + status: $payload['status'], + role: $payload['role'], + ); + } + + /** + * @param array $content + */ + public static function mapContent(array $content): OutputText|Refusal|ReasoningText + { + return match ($content['type']) { + 'output_text' => OutputText::from($content), + 'refusal' => Refusal::from($content), + 'reasoning_text' => ReasoningText::from($content), + default => throw new InvalidArgumentException('Invalid content type: ' . $content['type']), + }; + } +} diff --git a/src/SDK/OpenAI/Data/Responses/OutputItems/ReasoningOutputItem.php b/src/SDK/OpenAI/Data/Responses/OutputItems/ReasoningOutputItem.php new file mode 100644 index 0000000..e393d83 --- /dev/null +++ b/src/SDK/OpenAI/Data/Responses/OutputItems/ReasoningOutputItem.php @@ -0,0 +1,48 @@ + $summary + * @param array $content + * @param 'in_progress'|'completed'|'incomplete' $status + */ + public function __construct( + public string $id, + public array $summary, + public array $content, + public string $status, + public ?string $encryptedContent = null, + ) {} + + public static function from(array $payload): self + { + $summaries = array_map( + ReasoningSummaryText::from(...), + $payload['summary'] ?? [], + ); + + $contents = array_map( + ReasoningText::from(...), + $payload['content'] ?? [], + ); + + return new self( + id: $payload['id'], + summary: $summaries, + content: $contents, + status: $payload['status'] ?? 'completed', + encryptedContent: $payload['encrypted_content'] ?? null, + ); + } +} diff --git a/src/SDK/OpenAI/Data/Responses/Reasoning/ReasoningSummaryText.php b/src/SDK/OpenAI/Data/Responses/Reasoning/ReasoningSummaryText.php new file mode 100644 index 0000000..16b79fd --- /dev/null +++ b/src/SDK/OpenAI/Data/Responses/Reasoning/ReasoningSummaryText.php @@ -0,0 +1,24 @@ + $payload + */ + public static function from(array $payload): self + { + return new self( + text: $payload['text'], + ); + } +} diff --git a/src/SDK/OpenAI/Data/Responses/Reasoning/ReasoningText.php b/src/SDK/OpenAI/Data/Responses/Reasoning/ReasoningText.php new file mode 100644 index 0000000..ff73c64 --- /dev/null +++ b/src/SDK/OpenAI/Data/Responses/Reasoning/ReasoningText.php @@ -0,0 +1,24 @@ + $payload + */ + public static function from(array $payload): self + { + return new self( + text: $payload['text'], + ); + } +} diff --git a/src/SDK/OpenAI/Data/Responses/Response.php b/src/SDK/OpenAI/Data/Responses/Response.php new file mode 100644 index 0000000..e7cd282 --- /dev/null +++ b/src/SDK/OpenAI/Data/Responses/Response.php @@ -0,0 +1,132 @@ + $output + * @param array|null $error + * @param array|null $incompleteDetails + * @param string|array $instructions + * @param array|null $reasoning + * @param array|null $text + * @param string|array|null $toolChoice + * @param array|null $metadata + * @param array $tools + */ + public function __construct( + public string $id, + public string $object, + public DateTimeInterface $createdAt, + public string $status, + public ?DateTimeInterface $completedAt, + public ?array $error, + public ?array $incompleteDetails, + public string|array $instructions, + public ?int $maxOutputTokens, + public string $model, + public array $output, + public bool $parallelToolCalls, + public ?string $previousResponseId, + public ?array $reasoning, + public ?float $temperature, + public ?array $text, + public string|array|null $toolChoice, + public array $tools, + public ?float $topP, + public string $truncation, + public Usage $usage, + public ?array $metadata, + ) {} + + /** + * @param array $payload + */ + public static function from(array $payload): self + { + return new self( + id: $payload['id'], + object: $payload['object'], + createdAt: DateTimeImmutable::createFromTimestamp((int) $payload['created_at']), + status: $payload['status'], + completedAt: isset($payload['completed_at']) ? DateTimeImmutable::createFromTimestamp((int) $payload['completed_at']) : null, + error: $payload['error'] ?? null, + incompleteDetails: $payload['incomplete_details'] ?? null, + instructions: $payload['instructions'] ?? [], + maxOutputTokens: $payload['max_output_tokens'] ?? null, + model: $payload['model'], + output: array_map([self::class, 'mapOutputItem'], $payload['output']), + parallelToolCalls: $payload['parallel_tool_calls'] ?? false, + previousResponseId: $payload['previous_response_id'] ?? null, + reasoning: $payload['reasoning'] ?? null, + temperature: is_numeric($payload['temperature']) ? (float) $payload['temperature'] : null, + text: $payload['text'] ?? null, + toolChoice: $payload['tool_choice'] ?? null, + tools: $payload['tools'] ?? [], + topP: is_numeric($payload['top_p']) ? (float) $payload['top_p'] : null, + truncation: $payload['truncation'] ?? 'disabled', + usage: Usage::from($payload['usage'] ?? []), + metadata: $payload['metadata'] ?? null, + ); + } + + public static function fromResponse(SaloonResponse $response): self + { + return self::from($response->json()) + ->setResponse($response); + } + + public function setResponse(SaloonResponse $response): self + { + $this->response = $response; + $this->meta = Meta::from($response->headers()->all()); + + return $this; + } + + public function setMeta(Meta $meta): self + { + $this->meta = $meta; + + return $this; + } + + public function getResponse(): ?SaloonResponse + { + return $this->response; + } + + public function getMeta(): ?Meta + { + return $this->meta; + } + + /** + * @param array{type: string, ...} $outputItem + */ + public static function mapOutputItem(array $outputItem): OutputItem + { + return match ($outputItem['type']) { + 'message' => MessageOutputItem::from($outputItem), + 'function_call' => FunctionToolCallOutputItem::from($outputItem), + 'reasoning' => ReasoningOutputItem::from($outputItem), + default => throw new InvalidArgumentException('Invalid output item type: ' . $outputItem['type']), + }; + } +} diff --git a/src/SDK/OpenAI/Data/Responses/ResponseStream.php b/src/SDK/OpenAI/Data/Responses/ResponseStream.php new file mode 100644 index 0000000..7fefdea --- /dev/null +++ b/src/SDK/OpenAI/Data/Responses/ResponseStream.php @@ -0,0 +1,170 @@ + + */ +final readonly class ResponseStream implements IteratorAggregate +{ + private StreamInterface $body; + + public function __construct( + private Response $response, + ) { + $this->body = $response->getPsrResponse()->getBody(); + } + + /** + * Get an iterator for the stream. + * + * @return \Generator<\Cortex\SDK\OpenAI\Contracts\StreamEvent> + */ + public function getIterator(): Generator + { + $meta = Meta::from($this->response->headers()->all()); + + try { + while (! $this->body->eof()) { + $line = Utils::readLine($this->body); + + if (! str_starts_with($line, 'data:')) { + continue; + } + + $data = trim(substr($line, strlen('data:'))); + + $payload = json_decode($data, true, flags: JSON_THROW_ON_ERROR); + + $event = match ($payload['type']) { + 'response.created' => ResponseCreated::from($payload), + 'response.queued' => ResponseQueued::from($payload), + 'response.in_progress' => ResponseInProgress::from($payload), + 'response.completed' => ResponseCompleted::from($payload), + 'response.failed' => ResponseFailed::from($payload), + 'response.incomplete' => ResponseIncomplete::from($payload), + 'response.output_item.added' => ResponseOutputItemAdded::from($payload), + 'response.output_item.done' => ResponseOutputItemDone::from($payload), + 'response.content_part.added' => ResponseContentPartAdded::from($payload), + 'response.content_part.done' => ResponseContentPartDone::from($payload), + 'response.output_text.delta' => ResponseOutputTextDelta::from($payload), + 'response.output_text.done' => ResponseOutputTextDone::from($payload), + 'response.output_text.annotation.added' => ResponseOutputTextAnnotationAdded::from($payload), + 'response.refusal.delta' => ResponseRefusalDelta::from($payload), + 'response.refusal.done' => ResponseRefusalDone::from($payload), + 'response.function_call_arguments.delta' => ResponseFunctionCallArgumentsDelta::from($payload), + 'response.function_call_arguments.done' => ResponseFunctionCallArgumentsDone::from($payload), + 'response.file_search_call.in_progress' => ResponseFileSearchCallInProgress::from($payload), + 'response.file_search_call.searching' => ResponseFileSearchCallSearching::from($payload), + 'response.file_search_call.completed' => ResponseFileSearchCallCompleted::from($payload), + 'response.web_search_call.in_progress' => ResponseWebSearchCallInProgress::from($payload), + 'response.web_search_call.searching' => ResponseWebSearchCallSearching::from($payload), + 'response.web_search_call.completed' => ResponseWebSearchCallCompleted::from($payload), + 'response.reasoning_summary_part.added' => ResponseReasoningSummaryPartAdded::from($payload), + 'response.reasoning_summary_part.done' => ResponseReasoningSummaryPartDone::from($payload), + 'response.reasoning_summary_text.delta' => ResponseReasoningSummaryTextDelta::from($payload), + 'response.reasoning_summary_text.done' => ResponseReasoningSummaryTextDone::from($payload), + 'response.reasoning_content_part.added' => ResponseReasoningContentPartAdded::from($payload), + 'response.reasoning_content_part.done' => ResponseReasoningContentPartDone::from($payload), + 'response.reasoning_text.delta' => ResponseReasoningTextDelta::from($payload), + 'response.reasoning_text.done' => ResponseReasoningTextDone::from($payload), + 'response.image_generation_call.in_progress' => ResponseImageGenerationCallInProgress::from($payload), + 'response.image_generation_call.generating' => ResponseImageGenerationCallGenerating::from($payload), + 'response.image_generation_call.partial_image' => ResponseImageGenerationCallPartialImage::from($payload), + 'response.image_generation_call.completed' => ResponseImageGenerationCallCompleted::from($payload), + 'response.code_interpreter_call.in_progress' => ResponseCodeInterpreterCallInProgress::from($payload), + 'response.code_interpreter_call.executing' => ResponseCodeInterpreterCallExecuting::from($payload), + 'response.code_interpreter_call.interpreting' => ResponseCodeInterpreterCallInterpreting::from($payload), + 'response.code_interpreter_call.code.delta' => ResponseCodeInterpreterCallCodeDelta::from($payload), + 'response.code_interpreter_call.code.done' => ResponseCodeInterpreterCallCodeDone::from($payload), + 'response.code_interpreter_call.completed' => ResponseCodeInterpreterCallCompleted::from($payload), + 'response.mcp_call.in_progress' => ResponseMcpCallInProgress::from($payload), + 'response.mcp_call.arguments.delta' => ResponseMcpCallArgumentsDelta::from($payload), + 'response.mcp_call.arguments.done' => ResponseMcpCallArgumentsDone::from($payload), + 'response.mcp_call.completed' => ResponseMcpCallCompleted::from($payload), + 'response.mcp_call.failed' => ResponseMcpCallFailed::from($payload), + 'response.mcp_list_tools.in_progress' => ResponseMcpListToolsInProgress::from($payload), + 'response.mcp_list_tools.completed' => ResponseMcpListToolsCompleted::from($payload), + 'response.mcp_list_tools.failed' => ResponseMcpListToolsFailed::from($payload), + 'error' => Error::from($payload), + default => Unknown::from($payload), + }; + + $event->setMeta($meta); + + yield $event; + } + } finally { + $this->body->close(); + } + } + + public function getResponse(): Response + { + return $this->response; + } + + public static function fromResponse(Response $response): self + { + return new self($response); + } +} diff --git a/src/SDK/OpenAI/Data/Responses/Streaming/Events/AbstractStreamEvent.php b/src/SDK/OpenAI/Data/Responses/Streaming/Events/AbstractStreamEvent.php new file mode 100644 index 0000000..de15ffd --- /dev/null +++ b/src/SDK/OpenAI/Data/Responses/Streaming/Events/AbstractStreamEvent.php @@ -0,0 +1,47 @@ + + */ + protected array $raw = []; + + protected ?Meta $meta = null; + + /** + * @return array + */ + public function raw(): array + { + return $this->raw; + } + + public function meta(): ?Meta + { + return $this->meta; + } + + /** + * @param array $payload + */ + public function setRaw(array $payload): static + { + $this->raw = $payload; + + return $this; + } + + public function setMeta(Meta $meta): static + { + $this->meta = $meta; + + return $this; + } +} diff --git a/src/SDK/OpenAI/Data/Responses/Streaming/Events/Error.php b/src/SDK/OpenAI/Data/Responses/Streaming/Events/Error.php new file mode 100644 index 0000000..fb53eb0 --- /dev/null +++ b/src/SDK/OpenAI/Data/Responses/Streaming/Events/Error.php @@ -0,0 +1,27 @@ + $error + */ + public function __construct( + public array $error, + ) {} + + /** + * @param array $payload + */ + public static function from(array $payload): self + { + return new self( + error: $payload['error'], + )->setRaw($payload); + } +} diff --git a/src/SDK/OpenAI/Data/Responses/Streaming/Events/ResponseCodeInterpreterCallCodeDelta.php b/src/SDK/OpenAI/Data/Responses/Streaming/Events/ResponseCodeInterpreterCallCodeDelta.php new file mode 100644 index 0000000..276a15e --- /dev/null +++ b/src/SDK/OpenAI/Data/Responses/Streaming/Events/ResponseCodeInterpreterCallCodeDelta.php @@ -0,0 +1,28 @@ + $payload + */ + public static function from(array $payload): self + { + return new self( + itemId: $payload['item_id'], + outputIndex: $payload['output_index'], + delta: $payload['delta'], + )->setRaw($payload); + } +} diff --git a/src/SDK/OpenAI/Data/Responses/Streaming/Events/ResponseCodeInterpreterCallCodeDone.php b/src/SDK/OpenAI/Data/Responses/Streaming/Events/ResponseCodeInterpreterCallCodeDone.php new file mode 100644 index 0000000..38ec4ed --- /dev/null +++ b/src/SDK/OpenAI/Data/Responses/Streaming/Events/ResponseCodeInterpreterCallCodeDone.php @@ -0,0 +1,28 @@ + $payload + */ + public static function from(array $payload): self + { + return new self( + itemId: $payload['item_id'], + outputIndex: $payload['output_index'], + code: $payload['code'], + )->setRaw($payload); + } +} diff --git a/src/SDK/OpenAI/Data/Responses/Streaming/Events/ResponseCodeInterpreterCallCompleted.php b/src/SDK/OpenAI/Data/Responses/Streaming/Events/ResponseCodeInterpreterCallCompleted.php new file mode 100644 index 0000000..bf78aed --- /dev/null +++ b/src/SDK/OpenAI/Data/Responses/Streaming/Events/ResponseCodeInterpreterCallCompleted.php @@ -0,0 +1,31 @@ + $outputs + */ + public function __construct( + public string $itemId, + public int $outputIndex, + public array $outputs, + ) {} + + /** + * @param array $payload + */ + public static function from(array $payload): self + { + return new self( + itemId: $payload['item_id'], + outputIndex: $payload['output_index'], + outputs: $payload['outputs'] ?? [], + )->setRaw($payload); + } +} diff --git a/src/SDK/OpenAI/Data/Responses/Streaming/Events/ResponseCodeInterpreterCallExecuting.php b/src/SDK/OpenAI/Data/Responses/Streaming/Events/ResponseCodeInterpreterCallExecuting.php new file mode 100644 index 0000000..20c1ae7 --- /dev/null +++ b/src/SDK/OpenAI/Data/Responses/Streaming/Events/ResponseCodeInterpreterCallExecuting.php @@ -0,0 +1,26 @@ + $payload + */ + public static function from(array $payload): self + { + return new self( + itemId: $payload['item_id'], + outputIndex: $payload['output_index'], + )->setRaw($payload); + } +} diff --git a/src/SDK/OpenAI/Data/Responses/Streaming/Events/ResponseCodeInterpreterCallInProgress.php b/src/SDK/OpenAI/Data/Responses/Streaming/Events/ResponseCodeInterpreterCallInProgress.php new file mode 100644 index 0000000..42f460e --- /dev/null +++ b/src/SDK/OpenAI/Data/Responses/Streaming/Events/ResponseCodeInterpreterCallInProgress.php @@ -0,0 +1,26 @@ + $payload + */ + public static function from(array $payload): self + { + return new self( + itemId: $payload['item_id'], + outputIndex: $payload['output_index'], + )->setRaw($payload); + } +} diff --git a/src/SDK/OpenAI/Data/Responses/Streaming/Events/ResponseCodeInterpreterCallInterpreting.php b/src/SDK/OpenAI/Data/Responses/Streaming/Events/ResponseCodeInterpreterCallInterpreting.php new file mode 100644 index 0000000..441ad2a --- /dev/null +++ b/src/SDK/OpenAI/Data/Responses/Streaming/Events/ResponseCodeInterpreterCallInterpreting.php @@ -0,0 +1,26 @@ + $payload + */ + public static function from(array $payload): self + { + return new self( + itemId: $payload['item_id'], + outputIndex: $payload['output_index'], + )->setRaw($payload); + } +} diff --git a/src/SDK/OpenAI/Data/Responses/Streaming/Events/ResponseCompleted.php b/src/SDK/OpenAI/Data/Responses/Streaming/Events/ResponseCompleted.php new file mode 100644 index 0000000..f48d910 --- /dev/null +++ b/src/SDK/OpenAI/Data/Responses/Streaming/Events/ResponseCompleted.php @@ -0,0 +1,25 @@ + $payload + */ + public static function from(array $payload): self + { + return new self( + response: Response::from($payload['response']), + )->setRaw($payload); + } +} diff --git a/src/SDK/OpenAI/Data/Responses/Streaming/Events/ResponseContentPartAdded.php b/src/SDK/OpenAI/Data/Responses/Streaming/Events/ResponseContentPartAdded.php new file mode 100644 index 0000000..eba96ff --- /dev/null +++ b/src/SDK/OpenAI/Data/Responses/Streaming/Events/ResponseContentPartAdded.php @@ -0,0 +1,39 @@ + $payload + */ + public static function from(array $payload): self + { + return new self( + itemId: $payload['item_id'], + outputIndex: $payload['output_index'], + contentIndex: $payload['content_index'], + part: self::mapPart($payload['part']), + )->setRaw($payload); + } + + /** + * @param array $part + */ + protected static function mapPart(array $part): object + { + return MessageOutputItem::mapContent($part); + } +} diff --git a/src/SDK/OpenAI/Data/Responses/Streaming/Events/ResponseContentPartDone.php b/src/SDK/OpenAI/Data/Responses/Streaming/Events/ResponseContentPartDone.php new file mode 100644 index 0000000..0de3064 --- /dev/null +++ b/src/SDK/OpenAI/Data/Responses/Streaming/Events/ResponseContentPartDone.php @@ -0,0 +1,33 @@ + $part + */ + public function __construct( + public string $itemId, + public int $outputIndex, + public int $contentIndex, + public array $part, + ) {} + + /** + * @param array $payload + */ + public static function from(array $payload): self + { + return new self( + itemId: $payload['item_id'], + outputIndex: $payload['output_index'], + contentIndex: $payload['content_index'], + part: $payload['part'], + )->setRaw($payload); + } +} diff --git a/src/SDK/OpenAI/Data/Responses/Streaming/Events/ResponseCreated.php b/src/SDK/OpenAI/Data/Responses/Streaming/Events/ResponseCreated.php new file mode 100644 index 0000000..281ee96 --- /dev/null +++ b/src/SDK/OpenAI/Data/Responses/Streaming/Events/ResponseCreated.php @@ -0,0 +1,25 @@ + $payload + */ + public static function from(array $payload): self + { + return new self( + response: Response::from($payload['response']), + )->setRaw($payload); + } +} diff --git a/src/SDK/OpenAI/Data/Responses/Streaming/Events/ResponseFailed.php b/src/SDK/OpenAI/Data/Responses/Streaming/Events/ResponseFailed.php new file mode 100644 index 0000000..43688ce --- /dev/null +++ b/src/SDK/OpenAI/Data/Responses/Streaming/Events/ResponseFailed.php @@ -0,0 +1,25 @@ + $payload + */ + public static function from(array $payload): self + { + return new self( + response: Response::from($payload['response']), + )->setRaw($payload); + } +} diff --git a/src/SDK/OpenAI/Data/Responses/Streaming/Events/ResponseFileSearchCallCompleted.php b/src/SDK/OpenAI/Data/Responses/Streaming/Events/ResponseFileSearchCallCompleted.php new file mode 100644 index 0000000..7ce3f6a --- /dev/null +++ b/src/SDK/OpenAI/Data/Responses/Streaming/Events/ResponseFileSearchCallCompleted.php @@ -0,0 +1,31 @@ + $results + */ + public function __construct( + public string $itemId, + public int $outputIndex, + public array $results, + ) {} + + /** + * @param array $payload + */ + public static function from(array $payload): self + { + return new self( + itemId: $payload['item_id'], + outputIndex: $payload['output_index'], + results: $payload['results'] ?? [], + )->setRaw($payload); + } +} diff --git a/src/SDK/OpenAI/Data/Responses/Streaming/Events/ResponseFileSearchCallInProgress.php b/src/SDK/OpenAI/Data/Responses/Streaming/Events/ResponseFileSearchCallInProgress.php new file mode 100644 index 0000000..7d5cf31 --- /dev/null +++ b/src/SDK/OpenAI/Data/Responses/Streaming/Events/ResponseFileSearchCallInProgress.php @@ -0,0 +1,26 @@ + $payload + */ + public static function from(array $payload): self + { + return new self( + itemId: $payload['item_id'], + outputIndex: $payload['output_index'], + )->setRaw($payload); + } +} diff --git a/src/SDK/OpenAI/Data/Responses/Streaming/Events/ResponseFileSearchCallSearching.php b/src/SDK/OpenAI/Data/Responses/Streaming/Events/ResponseFileSearchCallSearching.php new file mode 100644 index 0000000..7c25d2c --- /dev/null +++ b/src/SDK/OpenAI/Data/Responses/Streaming/Events/ResponseFileSearchCallSearching.php @@ -0,0 +1,26 @@ + $payload + */ + public static function from(array $payload): self + { + return new self( + itemId: $payload['item_id'], + outputIndex: $payload['output_index'], + )->setRaw($payload); + } +} diff --git a/src/SDK/OpenAI/Data/Responses/Streaming/Events/ResponseFunctionCallArgumentsDelta.php b/src/SDK/OpenAI/Data/Responses/Streaming/Events/ResponseFunctionCallArgumentsDelta.php new file mode 100644 index 0000000..d202ce9 --- /dev/null +++ b/src/SDK/OpenAI/Data/Responses/Streaming/Events/ResponseFunctionCallArgumentsDelta.php @@ -0,0 +1,28 @@ + $payload + */ + public static function from(array $payload): self + { + return new self( + itemId: $payload['item_id'], + outputIndex: $payload['output_index'], + delta: $payload['delta'], + )->setRaw($payload); + } +} diff --git a/src/SDK/OpenAI/Data/Responses/Streaming/Events/ResponseFunctionCallArgumentsDone.php b/src/SDK/OpenAI/Data/Responses/Streaming/Events/ResponseFunctionCallArgumentsDone.php new file mode 100644 index 0000000..3d69baf --- /dev/null +++ b/src/SDK/OpenAI/Data/Responses/Streaming/Events/ResponseFunctionCallArgumentsDone.php @@ -0,0 +1,28 @@ + $payload + */ + public static function from(array $payload): self + { + return new self( + itemId: $payload['item_id'], + outputIndex: $payload['output_index'], + arguments: $payload['arguments'], + )->setRaw($payload); + } +} diff --git a/src/SDK/OpenAI/Data/Responses/Streaming/Events/ResponseImageGenerationCallCompleted.php b/src/SDK/OpenAI/Data/Responses/Streaming/Events/ResponseImageGenerationCallCompleted.php new file mode 100644 index 0000000..164e3f0 --- /dev/null +++ b/src/SDK/OpenAI/Data/Responses/Streaming/Events/ResponseImageGenerationCallCompleted.php @@ -0,0 +1,31 @@ + $images + */ + public function __construct( + public string $itemId, + public int $outputIndex, + public array $images, + ) {} + + /** + * @param array $payload + */ + public static function from(array $payload): self + { + return new self( + itemId: $payload['item_id'], + outputIndex: $payload['output_index'], + images: $payload['images'] ?? [], + )->setRaw($payload); + } +} diff --git a/src/SDK/OpenAI/Data/Responses/Streaming/Events/ResponseImageGenerationCallGenerating.php b/src/SDK/OpenAI/Data/Responses/Streaming/Events/ResponseImageGenerationCallGenerating.php new file mode 100644 index 0000000..3b1d266 --- /dev/null +++ b/src/SDK/OpenAI/Data/Responses/Streaming/Events/ResponseImageGenerationCallGenerating.php @@ -0,0 +1,26 @@ + $payload + */ + public static function from(array $payload): self + { + return new self( + itemId: $payload['item_id'], + outputIndex: $payload['output_index'], + )->setRaw($payload); + } +} diff --git a/src/SDK/OpenAI/Data/Responses/Streaming/Events/ResponseImageGenerationCallInProgress.php b/src/SDK/OpenAI/Data/Responses/Streaming/Events/ResponseImageGenerationCallInProgress.php new file mode 100644 index 0000000..ad17708 --- /dev/null +++ b/src/SDK/OpenAI/Data/Responses/Streaming/Events/ResponseImageGenerationCallInProgress.php @@ -0,0 +1,26 @@ + $payload + */ + public static function from(array $payload): self + { + return new self( + itemId: $payload['item_id'], + outputIndex: $payload['output_index'], + )->setRaw($payload); + } +} diff --git a/src/SDK/OpenAI/Data/Responses/Streaming/Events/ResponseImageGenerationCallPartialImage.php b/src/SDK/OpenAI/Data/Responses/Streaming/Events/ResponseImageGenerationCallPartialImage.php new file mode 100644 index 0000000..451762d --- /dev/null +++ b/src/SDK/OpenAI/Data/Responses/Streaming/Events/ResponseImageGenerationCallPartialImage.php @@ -0,0 +1,32 @@ + $payload + */ + public static function from(array $payload): self + { + return new self( + itemId: $payload['item_id'], + outputIndex: $payload['output_index'], + partialImageB64: $payload['partial_image_b64'], + partialImageIndex: $payload['partial_image_index'], + sequenceNumber: $payload['sequence_number'], + )->setRaw($payload); + } +} diff --git a/src/SDK/OpenAI/Data/Responses/Streaming/Events/ResponseInProgress.php b/src/SDK/OpenAI/Data/Responses/Streaming/Events/ResponseInProgress.php new file mode 100644 index 0000000..22628ca --- /dev/null +++ b/src/SDK/OpenAI/Data/Responses/Streaming/Events/ResponseInProgress.php @@ -0,0 +1,25 @@ + $payload + */ + public static function from(array $payload): self + { + return new self( + response: Response::from($payload['response']), + )->setRaw($payload); + } +} diff --git a/src/SDK/OpenAI/Data/Responses/Streaming/Events/ResponseIncomplete.php b/src/SDK/OpenAI/Data/Responses/Streaming/Events/ResponseIncomplete.php new file mode 100644 index 0000000..748ac86 --- /dev/null +++ b/src/SDK/OpenAI/Data/Responses/Streaming/Events/ResponseIncomplete.php @@ -0,0 +1,25 @@ + $payload + */ + public static function from(array $payload): self + { + return new self( + response: Response::from($payload['response']), + )->setRaw($payload); + } +} diff --git a/src/SDK/OpenAI/Data/Responses/Streaming/Events/ResponseMcpCallArgumentsDelta.php b/src/SDK/OpenAI/Data/Responses/Streaming/Events/ResponseMcpCallArgumentsDelta.php new file mode 100644 index 0000000..7098eab --- /dev/null +++ b/src/SDK/OpenAI/Data/Responses/Streaming/Events/ResponseMcpCallArgumentsDelta.php @@ -0,0 +1,28 @@ + $payload + */ + public static function from(array $payload): self + { + return new self( + itemId: $payload['item_id'], + outputIndex: $payload['output_index'], + delta: $payload['delta'], + )->setRaw($payload); + } +} diff --git a/src/SDK/OpenAI/Data/Responses/Streaming/Events/ResponseMcpCallArgumentsDone.php b/src/SDK/OpenAI/Data/Responses/Streaming/Events/ResponseMcpCallArgumentsDone.php new file mode 100644 index 0000000..9f369a7 --- /dev/null +++ b/src/SDK/OpenAI/Data/Responses/Streaming/Events/ResponseMcpCallArgumentsDone.php @@ -0,0 +1,28 @@ + $payload + */ + public static function from(array $payload): self + { + return new self( + itemId: $payload['item_id'], + outputIndex: $payload['output_index'], + arguments: $payload['arguments'], + )->setRaw($payload); + } +} diff --git a/src/SDK/OpenAI/Data/Responses/Streaming/Events/ResponseMcpCallCompleted.php b/src/SDK/OpenAI/Data/Responses/Streaming/Events/ResponseMcpCallCompleted.php new file mode 100644 index 0000000..cf24c6a --- /dev/null +++ b/src/SDK/OpenAI/Data/Responses/Streaming/Events/ResponseMcpCallCompleted.php @@ -0,0 +1,31 @@ + $result + */ + public function __construct( + public string $itemId, + public int $outputIndex, + public array $result, + ) {} + + /** + * @param array $payload + */ + public static function from(array $payload): self + { + return new self( + itemId: $payload['item_id'], + outputIndex: $payload['output_index'], + result: $payload['result'] ?? [], + )->setRaw($payload); + } +} diff --git a/src/SDK/OpenAI/Data/Responses/Streaming/Events/ResponseMcpCallFailed.php b/src/SDK/OpenAI/Data/Responses/Streaming/Events/ResponseMcpCallFailed.php new file mode 100644 index 0000000..debfdb2 --- /dev/null +++ b/src/SDK/OpenAI/Data/Responses/Streaming/Events/ResponseMcpCallFailed.php @@ -0,0 +1,31 @@ + $error + */ + public function __construct( + public string $itemId, + public int $outputIndex, + public array $error, + ) {} + + /** + * @param array $payload + */ + public static function from(array $payload): self + { + return new self( + itemId: $payload['item_id'], + outputIndex: $payload['output_index'], + error: $payload['error'] ?? [], + )->setRaw($payload); + } +} diff --git a/src/SDK/OpenAI/Data/Responses/Streaming/Events/ResponseMcpCallInProgress.php b/src/SDK/OpenAI/Data/Responses/Streaming/Events/ResponseMcpCallInProgress.php new file mode 100644 index 0000000..1b7e765 --- /dev/null +++ b/src/SDK/OpenAI/Data/Responses/Streaming/Events/ResponseMcpCallInProgress.php @@ -0,0 +1,26 @@ + $payload + */ + public static function from(array $payload): self + { + return new self( + itemId: $payload['item_id'], + outputIndex: $payload['output_index'], + )->setRaw($payload); + } +} diff --git a/src/SDK/OpenAI/Data/Responses/Streaming/Events/ResponseMcpListToolsCompleted.php b/src/SDK/OpenAI/Data/Responses/Streaming/Events/ResponseMcpListToolsCompleted.php new file mode 100644 index 0000000..69b93f5 --- /dev/null +++ b/src/SDK/OpenAI/Data/Responses/Streaming/Events/ResponseMcpListToolsCompleted.php @@ -0,0 +1,31 @@ + $tools + */ + public function __construct( + public string $itemId, + public int $outputIndex, + public array $tools, + ) {} + + /** + * @param array $payload + */ + public static function from(array $payload): self + { + return new self( + itemId: $payload['item_id'], + outputIndex: $payload['output_index'], + tools: $payload['tools'] ?? [], + )->setRaw($payload); + } +} diff --git a/src/SDK/OpenAI/Data/Responses/Streaming/Events/ResponseMcpListToolsFailed.php b/src/SDK/OpenAI/Data/Responses/Streaming/Events/ResponseMcpListToolsFailed.php new file mode 100644 index 0000000..c6127f4 --- /dev/null +++ b/src/SDK/OpenAI/Data/Responses/Streaming/Events/ResponseMcpListToolsFailed.php @@ -0,0 +1,31 @@ + $error + */ + public function __construct( + public string $itemId, + public int $outputIndex, + public array $error, + ) {} + + /** + * @param array $payload + */ + public static function from(array $payload): self + { + return new self( + itemId: $payload['item_id'], + outputIndex: $payload['output_index'], + error: $payload['error'] ?? [], + )->setRaw($payload); + } +} diff --git a/src/SDK/OpenAI/Data/Responses/Streaming/Events/ResponseMcpListToolsInProgress.php b/src/SDK/OpenAI/Data/Responses/Streaming/Events/ResponseMcpListToolsInProgress.php new file mode 100644 index 0000000..13f356b --- /dev/null +++ b/src/SDK/OpenAI/Data/Responses/Streaming/Events/ResponseMcpListToolsInProgress.php @@ -0,0 +1,26 @@ + $payload + */ + public static function from(array $payload): self + { + return new self( + itemId: $payload['item_id'], + outputIndex: $payload['output_index'], + )->setRaw($payload); + } +} diff --git a/src/SDK/OpenAI/Data/Responses/Streaming/Events/ResponseOutputItemAdded.php b/src/SDK/OpenAI/Data/Responses/Streaming/Events/ResponseOutputItemAdded.php new file mode 100644 index 0000000..2eebd9b --- /dev/null +++ b/src/SDK/OpenAI/Data/Responses/Streaming/Events/ResponseOutputItemAdded.php @@ -0,0 +1,28 @@ + $payload + */ + public static function from(array $payload): self + { + return new self( + outputIndex: $payload['output_index'], + item: Response::mapOutputItem($payload['item']), + )->setRaw($payload); + } +} diff --git a/src/SDK/OpenAI/Data/Responses/Streaming/Events/ResponseOutputItemDone.php b/src/SDK/OpenAI/Data/Responses/Streaming/Events/ResponseOutputItemDone.php new file mode 100644 index 0000000..6578bbe --- /dev/null +++ b/src/SDK/OpenAI/Data/Responses/Streaming/Events/ResponseOutputItemDone.php @@ -0,0 +1,28 @@ + $payload + */ + public static function from(array $payload): self + { + return new self( + outputIndex: $payload['output_index'], + item: Response::mapOutputItem($payload['item']), + )->setRaw($payload); + } +} diff --git a/src/SDK/OpenAI/Data/Responses/Streaming/Events/ResponseOutputTextAnnotationAdded.php b/src/SDK/OpenAI/Data/Responses/Streaming/Events/ResponseOutputTextAnnotationAdded.php new file mode 100644 index 0000000..94873ea --- /dev/null +++ b/src/SDK/OpenAI/Data/Responses/Streaming/Events/ResponseOutputTextAnnotationAdded.php @@ -0,0 +1,35 @@ + $annotation + */ + public function __construct( + public string $itemId, + public int $outputIndex, + public int $contentIndex, + public int $annotationIndex, + public array $annotation, + ) {} + + /** + * @param array $payload + */ + public static function from(array $payload): self + { + return new self( + itemId: $payload['item_id'], + outputIndex: $payload['output_index'], + contentIndex: $payload['content_index'], + annotationIndex: $payload['annotation_index'], + annotation: $payload['annotation'], + )->setRaw($payload); + } +} diff --git a/src/SDK/OpenAI/Data/Responses/Streaming/Events/ResponseOutputTextDelta.php b/src/SDK/OpenAI/Data/Responses/Streaming/Events/ResponseOutputTextDelta.php new file mode 100644 index 0000000..91a43a7 --- /dev/null +++ b/src/SDK/OpenAI/Data/Responses/Streaming/Events/ResponseOutputTextDelta.php @@ -0,0 +1,32 @@ + $payload + */ + public static function from(array $payload): self + { + return new self( + itemId: $payload['item_id'], + outputIndex: $payload['output_index'], + contentIndex: $payload['content_index'], + delta: $payload['delta'], + sequenceNumber: $payload['sequence_number'], + )->setRaw($payload); + } +} diff --git a/src/SDK/OpenAI/Data/Responses/Streaming/Events/ResponseOutputTextDone.php b/src/SDK/OpenAI/Data/Responses/Streaming/Events/ResponseOutputTextDone.php new file mode 100644 index 0000000..143d589 --- /dev/null +++ b/src/SDK/OpenAI/Data/Responses/Streaming/Events/ResponseOutputTextDone.php @@ -0,0 +1,32 @@ + $payload + */ + public static function from(array $payload): self + { + return new self( + itemId: $payload['item_id'], + outputIndex: $payload['output_index'], + contentIndex: $payload['content_index'], + text: $payload['text'], + sequenceNumber: $payload['sequence_number'], + )->setRaw($payload); + } +} diff --git a/src/SDK/OpenAI/Data/Responses/Streaming/Events/ResponseQueued.php b/src/SDK/OpenAI/Data/Responses/Streaming/Events/ResponseQueued.php new file mode 100644 index 0000000..2cecfef --- /dev/null +++ b/src/SDK/OpenAI/Data/Responses/Streaming/Events/ResponseQueued.php @@ -0,0 +1,25 @@ + $payload + */ + public static function from(array $payload): self + { + return new self( + response: Response::from($payload['response']), + )->setRaw($payload); + } +} diff --git a/src/SDK/OpenAI/Data/Responses/Streaming/Events/ResponseReasoningContentPartAdded.php b/src/SDK/OpenAI/Data/Responses/Streaming/Events/ResponseReasoningContentPartAdded.php new file mode 100644 index 0000000..f025612 --- /dev/null +++ b/src/SDK/OpenAI/Data/Responses/Streaming/Events/ResponseReasoningContentPartAdded.php @@ -0,0 +1,33 @@ + $part + */ + public function __construct( + public string $itemId, + public int $outputIndex, + public int $contentIndex, + public array $part, + ) {} + + /** + * @param array $payload + */ + public static function from(array $payload): self + { + return new self( + itemId: $payload['item_id'], + outputIndex: $payload['output_index'], + contentIndex: $payload['content_index'], + part: $payload['part'], + )->setRaw($payload); + } +} diff --git a/src/SDK/OpenAI/Data/Responses/Streaming/Events/ResponseReasoningContentPartDone.php b/src/SDK/OpenAI/Data/Responses/Streaming/Events/ResponseReasoningContentPartDone.php new file mode 100644 index 0000000..d8c8076 --- /dev/null +++ b/src/SDK/OpenAI/Data/Responses/Streaming/Events/ResponseReasoningContentPartDone.php @@ -0,0 +1,33 @@ + $part + */ + public function __construct( + public string $itemId, + public int $outputIndex, + public int $contentIndex, + public array $part, + ) {} + + /** + * @param array $payload + */ + public static function from(array $payload): self + { + return new self( + itemId: $payload['item_id'], + outputIndex: $payload['output_index'], + contentIndex: $payload['content_index'], + part: $payload['part'], + )->setRaw($payload); + } +} diff --git a/src/SDK/OpenAI/Data/Responses/Streaming/Events/ResponseReasoningSummaryPartAdded.php b/src/SDK/OpenAI/Data/Responses/Streaming/Events/ResponseReasoningSummaryPartAdded.php new file mode 100644 index 0000000..34fdd88 --- /dev/null +++ b/src/SDK/OpenAI/Data/Responses/Streaming/Events/ResponseReasoningSummaryPartAdded.php @@ -0,0 +1,33 @@ + $part + */ + public function __construct( + public string $itemId, + public int $outputIndex, + public int $summaryIndex, + public array $part, + ) {} + + /** + * @param array $payload + */ + public static function from(array $payload): self + { + return new self( + itemId: $payload['item_id'], + outputIndex: $payload['output_index'], + summaryIndex: $payload['summary_index'], + part: $payload['part'], + )->setRaw($payload); + } +} diff --git a/src/SDK/OpenAI/Data/Responses/Streaming/Events/ResponseReasoningSummaryPartDone.php b/src/SDK/OpenAI/Data/Responses/Streaming/Events/ResponseReasoningSummaryPartDone.php new file mode 100644 index 0000000..667e76a --- /dev/null +++ b/src/SDK/OpenAI/Data/Responses/Streaming/Events/ResponseReasoningSummaryPartDone.php @@ -0,0 +1,33 @@ + $part + */ + public function __construct( + public string $itemId, + public int $outputIndex, + public int $summaryIndex, + public array $part, + ) {} + + /** + * @param array $payload + */ + public static function from(array $payload): self + { + return new self( + itemId: $payload['item_id'], + outputIndex: $payload['output_index'], + summaryIndex: $payload['summary_index'], + part: $payload['part'], + )->setRaw($payload); + } +} diff --git a/src/SDK/OpenAI/Data/Responses/Streaming/Events/ResponseReasoningSummaryTextDelta.php b/src/SDK/OpenAI/Data/Responses/Streaming/Events/ResponseReasoningSummaryTextDelta.php new file mode 100644 index 0000000..a15c972 --- /dev/null +++ b/src/SDK/OpenAI/Data/Responses/Streaming/Events/ResponseReasoningSummaryTextDelta.php @@ -0,0 +1,30 @@ + $payload + */ + public static function from(array $payload): self + { + return new self( + itemId: $payload['item_id'], + outputIndex: $payload['output_index'], + summaryIndex: $payload['summary_index'], + delta: $payload['delta'], + )->setRaw($payload); + } +} diff --git a/src/SDK/OpenAI/Data/Responses/Streaming/Events/ResponseReasoningSummaryTextDone.php b/src/SDK/OpenAI/Data/Responses/Streaming/Events/ResponseReasoningSummaryTextDone.php new file mode 100644 index 0000000..aaddf47 --- /dev/null +++ b/src/SDK/OpenAI/Data/Responses/Streaming/Events/ResponseReasoningSummaryTextDone.php @@ -0,0 +1,30 @@ + $payload + */ + public static function from(array $payload): self + { + return new self( + itemId: $payload['item_id'], + outputIndex: $payload['output_index'], + summaryIndex: $payload['summary_index'], + text: $payload['text'], + )->setRaw($payload); + } +} diff --git a/src/SDK/OpenAI/Data/Responses/Streaming/Events/ResponseReasoningTextDelta.php b/src/SDK/OpenAI/Data/Responses/Streaming/Events/ResponseReasoningTextDelta.php new file mode 100644 index 0000000..c418541 --- /dev/null +++ b/src/SDK/OpenAI/Data/Responses/Streaming/Events/ResponseReasoningTextDelta.php @@ -0,0 +1,32 @@ + $payload + */ + public static function from(array $payload): self + { + return new self( + itemId: $payload['item_id'], + outputIndex: $payload['output_index'], + contentIndex: $payload['content_index'], + delta: $payload['delta'], + sequenceNumber: $payload['sequence_number'], + )->setRaw($payload); + } +} diff --git a/src/SDK/OpenAI/Data/Responses/Streaming/Events/ResponseReasoningTextDone.php b/src/SDK/OpenAI/Data/Responses/Streaming/Events/ResponseReasoningTextDone.php new file mode 100644 index 0000000..59fcc9c --- /dev/null +++ b/src/SDK/OpenAI/Data/Responses/Streaming/Events/ResponseReasoningTextDone.php @@ -0,0 +1,30 @@ + $payload + */ + public static function from(array $payload): self + { + return new self( + itemId: $payload['item_id'], + outputIndex: $payload['output_index'], + contentIndex: $payload['content_index'], + text: $payload['text'], + )->setRaw($payload); + } +} diff --git a/src/SDK/OpenAI/Data/Responses/Streaming/Events/ResponseRefusalDelta.php b/src/SDK/OpenAI/Data/Responses/Streaming/Events/ResponseRefusalDelta.php new file mode 100644 index 0000000..4459d07 --- /dev/null +++ b/src/SDK/OpenAI/Data/Responses/Streaming/Events/ResponseRefusalDelta.php @@ -0,0 +1,32 @@ + $payload + */ + public static function from(array $payload): self + { + return new self( + itemId: $payload['item_id'], + outputIndex: $payload['output_index'], + contentIndex: $payload['content_index'], + delta: $payload['delta'], + sequenceNumber: $payload['sequence_number'], + )->setRaw($payload); + } +} diff --git a/src/SDK/OpenAI/Data/Responses/Streaming/Events/ResponseRefusalDone.php b/src/SDK/OpenAI/Data/Responses/Streaming/Events/ResponseRefusalDone.php new file mode 100644 index 0000000..a0b605a --- /dev/null +++ b/src/SDK/OpenAI/Data/Responses/Streaming/Events/ResponseRefusalDone.php @@ -0,0 +1,30 @@ + $payload + */ + public static function from(array $payload): self + { + return new self( + itemId: $payload['item_id'], + outputIndex: $payload['output_index'], + contentIndex: $payload['content_index'], + refusal: $payload['refusal'], + )->setRaw($payload); + } +} diff --git a/src/SDK/OpenAI/Data/Responses/Streaming/Events/ResponseWebSearchCallCompleted.php b/src/SDK/OpenAI/Data/Responses/Streaming/Events/ResponseWebSearchCallCompleted.php new file mode 100644 index 0000000..61c06d0 --- /dev/null +++ b/src/SDK/OpenAI/Data/Responses/Streaming/Events/ResponseWebSearchCallCompleted.php @@ -0,0 +1,31 @@ + $results + */ + public function __construct( + public string $itemId, + public int $outputIndex, + public array $results, + ) {} + + /** + * @param array $payload + */ + public static function from(array $payload): self + { + return new self( + itemId: $payload['item_id'], + outputIndex: $payload['output_index'], + results: $payload['results'] ?? [], + )->setRaw($payload); + } +} diff --git a/src/SDK/OpenAI/Data/Responses/Streaming/Events/ResponseWebSearchCallInProgress.php b/src/SDK/OpenAI/Data/Responses/Streaming/Events/ResponseWebSearchCallInProgress.php new file mode 100644 index 0000000..aae3f24 --- /dev/null +++ b/src/SDK/OpenAI/Data/Responses/Streaming/Events/ResponseWebSearchCallInProgress.php @@ -0,0 +1,26 @@ + $payload + */ + public static function from(array $payload): self + { + return new self( + itemId: $payload['item_id'], + outputIndex: $payload['output_index'], + )->setRaw($payload); + } +} diff --git a/src/SDK/OpenAI/Data/Responses/Streaming/Events/ResponseWebSearchCallSearching.php b/src/SDK/OpenAI/Data/Responses/Streaming/Events/ResponseWebSearchCallSearching.php new file mode 100644 index 0000000..8a0689c --- /dev/null +++ b/src/SDK/OpenAI/Data/Responses/Streaming/Events/ResponseWebSearchCallSearching.php @@ -0,0 +1,26 @@ + $payload + */ + public static function from(array $payload): self + { + return new self( + itemId: $payload['item_id'], + outputIndex: $payload['output_index'], + )->setRaw($payload); + } +} diff --git a/src/SDK/OpenAI/Data/Responses/Streaming/Events/Unknown.php b/src/SDK/OpenAI/Data/Responses/Streaming/Events/Unknown.php new file mode 100644 index 0000000..1a26369 --- /dev/null +++ b/src/SDK/OpenAI/Data/Responses/Streaming/Events/Unknown.php @@ -0,0 +1,18 @@ + $payload + */ + public static function from(array $payload): self + { + return new self()->setRaw($payload); + } +} diff --git a/src/SDK/OpenAI/Data/Responses/Usage.php b/src/SDK/OpenAI/Data/Responses/Usage.php new file mode 100644 index 0000000..c52c434 --- /dev/null +++ b/src/SDK/OpenAI/Data/Responses/Usage.php @@ -0,0 +1,34 @@ + $inputTokensDetails + * @param array $outputTokensDetails + */ + public function __construct( + public int $inputTokens, + public int $outputTokens, + public int $totalTokens, + public array $inputTokensDetails, + public array $outputTokensDetails, + ) {} + + /** + * @param array $payload + */ + public static function from(array $payload): self + { + return new self( + inputTokens: $payload['input_tokens'] ?? 0, + outputTokens: $payload['output_tokens'] ?? 0, + totalTokens: $payload['total_tokens'] ?? 0, + inputTokensDetails: $payload['input_token_details'] ?? [], + outputTokensDetails: $payload['output_token_details'] ?? [], + ); + } +} diff --git a/src/SDK/OpenAI/OpenAI.php b/src/SDK/OpenAI/OpenAI.php new file mode 100644 index 0000000..8b392bf --- /dev/null +++ b/src/SDK/OpenAI/OpenAI.php @@ -0,0 +1,79 @@ +cacheStore, + $this->cacheExpiryInSeconds, + ); + } + + public function resolveBaseUrl(): string + { + return $this->baseUri ?? static::defaultBaseUri(); + } + + protected function defaultAuth(): TokenAuthenticator + { + return new TokenAuthenticator($this->apiKey); + } + + protected function defaultHeaders(): array + { + $headers = []; + + if ($this->organization) { + $headers['OpenAI-Organization'] = $this->organization; + } + + if ($this->project) { + $headers['OpenAI-Project'] = $this->project; + } + + return $headers; + } + + protected static function defaultBaseUri(): string + { + return 'https://api.openai.com/v1'; + } + + /** + * @param array $responses + */ + public static function fake(array $responses, ?string $apiKey = null, ?string $baseUri = null): self + { + MockClient::global($responses); + + return new self($apiKey ?? 'test-api-key', $baseUri); + } +} diff --git a/src/SDK/OpenAI/Requests/CreateResponse.php b/src/SDK/OpenAI/Requests/CreateResponse.php new file mode 100644 index 0000000..ed66b5d --- /dev/null +++ b/src/SDK/OpenAI/Requests/CreateResponse.php @@ -0,0 +1,102 @@ + $parameters + */ + public function __construct( + protected array $parameters, + protected ?Repository $cacheStore = null, + protected ?int $cacheExpiryInSeconds = null, + ) {} + + protected Method $method = Method::POST; + + public function resolveEndpoint(): string + { + return '/responses'; + } + + /** + * @return array + */ + protected function defaultBody(): array + { + return $this->parameters; + } + + protected function defaultConfig(): array + { + return [ + 'stream' => $this->parameters['stream'] ?? false, + ]; + } + + public function createDtoFromResponse(SaloonResponse $response): Response|ResponseStream + { + return $this->parameters['stream'] ?? false + ? ResponseStream::fromResponse($response) + : Response::fromResponse($response); + } + + public function resolveCacheDriver(): Driver + { + return new LaravelCacheDriver($this->cacheStore ?? Cache::store()); + } + + public function cacheExpiryInSeconds(): int + { + return $this->cacheExpiryInSeconds ?? 3600; + } + + /** + * @return array<\Saloon\Enums\Method> + */ + protected function getCacheableMethods(): array + { + return [$this->method]; + } + + protected function cacheKey(PendingRequest $pendingRequest): ?string + { + $className = $pendingRequest->getRequest()::class; + $requestUrl = $pendingRequest->getUrl(); + $query = $pendingRequest->query()->all(); + $headers = $pendingRequest->headers()->all(); + $body = $pendingRequest->body()->all(); + + return hash( + 'sha256', + json_encode([ + 'className' => $className, + 'requestUrl' => $requestUrl, + 'query' => $query, + 'headers' => $headers, + 'body' => $body, + ], JSON_THROW_ON_ERROR), + ); + } +} diff --git a/src/SDK/OpenAI/Resources/Responses.php b/src/SDK/OpenAI/Resources/Responses.php new file mode 100644 index 0000000..5035102 --- /dev/null +++ b/src/SDK/OpenAI/Resources/Responses.php @@ -0,0 +1,59 @@ + $parameters + */ + public function create(array $parameters, bool $cacheEnabled = false): Response + { + $request = new CreateResponse( + $parameters, + $this->cacheStore, + $this->cacheExpiryInSeconds, + ); + + if ($cacheEnabled) { + $request->enableCaching(); + } else { + $request->disableCaching(); + } + + $response = $this->connector->send($request); + + return $response->throw(); + } + + /** + * Create a streamed message. + * + * @param array $parameters + */ + public function stream(array $parameters, bool $cacheEnabled = false): Response + { + return $this->create([ + ...$parameters, + 'stream' => true, + ], $cacheEnabled); + } +} diff --git a/src/Skills/Skill.php b/src/Skills/Skill.php new file mode 100644 index 0000000..74abda0 --- /dev/null +++ b/src/Skills/Skill.php @@ -0,0 +1,49 @@ + $metadata Additional metadata from frontmatter + */ + public function __construct( + public string $name, + public string $description, + public string $instructions, + public ?string $path = null, + public array $metadata = [], + ) {} + + /** + * Get a metadata value using dot notation. + */ + public function get(string $key, mixed $default = null): mixed + { + return data_get($this->metadata, $key, $default); + } + + /** + * Get the full content of the skill (instructions). + */ + public function getContent(): string + { + return $this->instructions; + } + + /** + * Get a summary of the skill for listing purposes. + * + * @return array{name: string, description: string, path: string|null} + */ + public function toSummary(): array + { + return [ + 'name' => $this->name, + 'description' => $this->description, + 'path' => $this->path, + ]; + } +} diff --git a/src/Skills/SkillLoader.php b/src/Skills/SkillLoader.php new file mode 100644 index 0000000..028d649 --- /dev/null +++ b/src/Skills/SkillLoader.php @@ -0,0 +1,109 @@ +matter('name') ?? $this->inferNameFromPath($path); + + /** @var string $description */ + $description = $document->matter('description') ?? ''; + + $instructions = trim($document->body()); + + // Collect all frontmatter as metadata, excluding name and description + /** @var array $allMatter */ + $allMatter = $document->matter(); + + /** @var array $metadata */ + $metadata = collect($allMatter) + ->except(['name', 'description']) + ->all(); + + return new Skill( + name: $name, + description: $description, + instructions: $instructions, + path: $path, + metadata: $metadata, + ); + } + + /** + * Load all skills from a directory. + * + * @return array + */ + public function loadFromDirectory(string $directory, string $pattern = 'SKILL.md'): array + { + if (! is_dir($directory)) { + throw new GenericException(sprintf('Skills directory not found: %s', $directory)); + } + + $skills = []; + + $this->scanDirectory($directory, $pattern, function (string $path) use (&$skills): void { + $skill = $this->load($path); + $skills[$skill->name] = $skill; + }); + + return $skills; + } + + /** + * Recursively scan a directory for skill files. + * + * @param callable(string): void $callback + */ + protected function scanDirectory(string $directory, string $pattern, callable $callback): void + { + /** @var \RecursiveIteratorIterator<\RecursiveDirectoryIterator> $iterator */ + $iterator = new RecursiveIteratorIterator( + new RecursiveDirectoryIterator($directory, RecursiveDirectoryIterator::SKIP_DOTS), + RecursiveIteratorIterator::SELF_FIRST, + ); + + /** @var SplFileInfo $file */ + foreach ($iterator as $file) { + if ($file->isFile() && $file->getFilename() === $pattern) { + $callback($file->getPathname()); + } + } + } + + /** + * Infer a skill name from its file path. + */ + protected function inferNameFromPath(string $path): string + { + // Get the parent directory name as the skill name + $directory = dirname($path); + $name = basename($directory); + + // Convert to kebab-case if needed + return Str::kebab($name); + } +} diff --git a/src/Skills/SkillRegistry.php b/src/Skills/SkillRegistry.php new file mode 100644 index 0000000..30fee18 --- /dev/null +++ b/src/Skills/SkillRegistry.php @@ -0,0 +1,136 @@ + + */ + protected array $skills = []; + + public function __construct( + protected ?SkillLoader $loader = new SkillLoader(), + ) {} + + /** + * Register a skill. + */ + public function register(Skill $skill): self + { + $this->skills[$skill->name] = $skill; + + return $this; + } + + /** + * Register a skill from a file path. + */ + public function registerFromFile(string $path): self + { + return $this->register($this->loader->load($path)); + } + + /** + * Register all skills from a directory. + */ + public function registerFromDirectory(string $directory, string $pattern = 'SKILL.md'): self + { + $skills = $this->loader->loadFromDirectory($directory, $pattern); + + foreach ($skills as $skill) { + $this->register($skill); + } + + return $this; + } + + /** + * Get a skill by name. + * + * @throws GenericException + */ + public function get(string $name): Skill + { + if (! $this->has($name)) { + throw new GenericException(sprintf('Skill not found: %s', $name)); + } + + return $this->skills[$name]; + } + + /** + * Check if a skill exists. + */ + public function has(string $name): bool + { + return isset($this->skills[$name]); + } + + /** + * Get all registered skills. + * + * @return array + */ + public function all(): array + { + return $this->skills; + } + + /** + * Get all skill names. + * + * @return array + */ + public function names(): array + { + return array_keys($this->skills); + } + + /** + * Get summaries of all skills. + * + * @return array + */ + public function summaries(): array + { + return array_values( + array_map( + fn(Skill $skill): array => $skill->toSummary(), + $this->skills, + ), + ); + } + + /** + * Get the count of registered skills. + */ + public function count(): int + { + return count($this->skills); + } + + /** + * Remove a skill from the registry. + */ + public function remove(string $name): self + { + unset($this->skills[$name]); + + return $this; + } + + /** + * Clear all skills from the registry. + */ + public function clear(): self + { + $this->skills = []; + + return $this; + } +} diff --git a/src/Support/Events/InternalEventDispatcher.php b/src/Support/Events/InternalEventDispatcher.php new file mode 100644 index 0000000..821a819 --- /dev/null +++ b/src/Support/Events/InternalEventDispatcher.php @@ -0,0 +1,74 @@ +> + */ + private array $listeners = []; + + /** + * @var array + */ + private array $anyListeners = []; + + /** + * @var array + */ + private array $subscribers = []; + + private static ?self $instance = null; + + private function __construct() {} + + public static function instance(): self + { + return self::$instance ??= new self(); + } + + public function dispatch(object $event): void + { + foreach ($this->anyListeners as $listener) { + $listener($event); + } + + foreach ($this->listeners as $eventClass => $listeners) { + if ($event instanceof $eventClass) { + foreach ($listeners as $listener) { + $listener($event); + } + } + } + } + + public function hasListeners(): bool + { + return $this->anyListeners !== [] || $this->listeners !== []; + } + + public function listen(string $eventClass, callable $listener): void + { + $this->listeners[$eventClass][] = $listener; + } + + public function listenToAll(callable $listener): void + { + $this->anyListeners[] = $listener; + } + + public function subscribe(InternalEventSubscriber $subscriber): void + { + $id = spl_object_id($subscriber); + + if (isset($this->subscribers[$id])) { + return; + } + + $this->subscribers[$id] = $subscriber; + $subscriber->subscribe($this); + } +} diff --git a/src/Support/Events/InternalEventSubscriber.php b/src/Support/Events/InternalEventSubscriber.php new file mode 100644 index 0000000..7b003be --- /dev/null +++ b/src/Support/Events/InternalEventSubscriber.php @@ -0,0 +1,10 @@ +listenToAll(function (CortexEvent $event): void { + $eventsToIgnore = [ + 'pipeline.*', + 'stage.*', + 'agent.stream_chunk', + 'runtime_config.*', + 'chat_model.stream', + 'output_parser.*', + ]; + + if (Str::is($eventsToIgnore, $event->eventId())) { + return; + } + + $context = [ + // 'run_id' => $event->config?->runId, + ...$event->toArray(), + ]; + + $this->logger->log($this->level, $event->eventId(), $context); + }); + } +} diff --git a/src/Support/Traits/DispatchesEvents.php b/src/Support/Traits/DispatchesEvents.php index 318efa3..49444b4 100644 --- a/src/Support/Traits/DispatchesEvents.php +++ b/src/Support/Traits/DispatchesEvents.php @@ -6,6 +6,7 @@ use Closure; use Psr\EventDispatcher\EventDispatcherInterface; +use Cortex\Support\Events\InternalEventDispatcher; trait DispatchesEvents { @@ -52,6 +53,9 @@ public function dispatchEvent(object $event, bool $dispatchToGlobalDispatcher = } } + // Dispatch to internal subscribers + InternalEventDispatcher::instance()->dispatch($event); + // Then dispatch to global dispatcher if ($dispatchToGlobalDispatcher) { $this->getEventDispatcher()?->dispatch($event); diff --git a/src/Tools/AgentTool.php b/src/Tools/AgentTool.php new file mode 100644 index 0000000..9e33e69 --- /dev/null +++ b/src/Tools/AgentTool.php @@ -0,0 +1,54 @@ +name; + } + + public function description(): string + { + return $this->description ?? ''; + } + + public function schema(): ObjectSchema + { + return $this->schema ?? $this->agent->getInputSchema(); + } + + /** + * @param ToolCall|array $toolCall + */ + public function invoke(ToolCall|array $toolCall = [], ?RuntimeConfig $config = null): mixed + { + // Get the arguments from the given tool call. + $arguments = $this->getArguments($toolCall); + + // Ensure arguments are valid as per the tool's schema. + if ($arguments !== []) { + $this->schema()->validate($arguments); + } + + // Invoke the agent with the arguments. + $result = $this->agent->invoke(input: $arguments); + + return $result->generation->parsedOutput ?? $result->text(); + } +} diff --git a/src/Tools/FrontendTool.php b/src/Tools/FrontendTool.php new file mode 100644 index 0000000..4a4268a --- /dev/null +++ b/src/Tools/FrontendTool.php @@ -0,0 +1,54 @@ + $schema + */ + public function __construct( + protected string $name, + protected ?string $description = null, + protected array $schema = [], + ) {} + + public function name(): string + { + return $this->name; + } + + public function description(): string + { + return $this->description ?? ''; + } + + public function schema(): ObjectSchema + { + $schema = Schema::from($this->schema); + + if (! $schema instanceof ObjectSchema) { + throw new GenericException(sprintf('Schema for tool [%s] is not an object', $this->name)); + } + + return $schema; + } + + /** + * @param ToolCall|array $toolCall + */ + public function invoke(ToolCall|array $toolCall = [], ?RuntimeConfig $config = null): mixed + { + throw new GenericException( + 'The Frontend tool does not support invocation. It is only used for frontend output.', + ); + } +} diff --git a/src/Tools/Prebuilt/FetchUrlTool.php b/src/Tools/Prebuilt/FetchUrlTool.php new file mode 100644 index 0000000..53750cc --- /dev/null +++ b/src/Tools/Prebuilt/FetchUrlTool.php @@ -0,0 +1,52 @@ +properties( + Schema::string('url') + ->description('The URL to fetch') + ->required(), + ); + } + + public function invoke(ToolCall|array $toolCall = [], ?RuntimeConfig $config = null): mixed + { + $arguments = $this->getArguments($toolCall); + + $url = $arguments['url']; + + $response = file_get_contents($url); + + if ($response === false) { + return sprintf('Failed to fetch URL: %s', $url); + } + + return [ + 'url' => $url, + 'contents' => $response, + ]; + } +} diff --git a/src/Tools/Prebuilt/OpenMeteoWeatherTool.php b/src/Tools/Prebuilt/GetCurrentWeatherTool.php similarity index 80% rename from src/Tools/Prebuilt/OpenMeteoWeatherTool.php rename to src/Tools/Prebuilt/GetCurrentWeatherTool.php index 6033ccc..599dd5c 100644 --- a/src/Tools/Prebuilt/OpenMeteoWeatherTool.php +++ b/src/Tools/Prebuilt/GetCurrentWeatherTool.php @@ -8,10 +8,11 @@ use Cortex\LLM\Data\ToolCall; use Cortex\Tools\AbstractTool; use Cortex\Pipeline\RuntimeConfig; +use Illuminate\Support\Facades\Date; use Illuminate\Support\Facades\Http; use Cortex\JsonSchema\Types\ObjectSchema; -class OpenMeteoWeatherTool extends AbstractTool +class GetCurrentWeatherTool extends AbstractTool { public function name(): string { @@ -41,7 +42,6 @@ public function invoke(ToolCall|array $toolCall = [], ?RuntimeConfig $config = n $location = $arguments['location']; /** @var \Illuminate\Http\Client\Response $geocodeResponse */ - // @phpstan-ignore staticMethod.void $geocodeResponse = Http::get('https://geocoding-api.open-meteo.com/v1/search', [ 'name' => $location, 'count' => 1, @@ -49,34 +49,43 @@ public function invoke(ToolCall|array $toolCall = [], ?RuntimeConfig $config = n 'format' => 'json', ]); + $name = $geocodeResponse->json('results.0.name'); $latitude = $geocodeResponse->json('results.0.latitude'); $longitude = $geocodeResponse->json('results.0.longitude'); + $timezone = $geocodeResponse->json('results.0.timezone'); if (! $latitude || ! $longitude) { return 'Could not find location for: ' . $location; } $windSpeedUnit = $config?->context?->get('wind_speed_unit') ?? 'mph'; + $temperatureUnit = $config?->context?->get('temperature_unit') ?? 'celsius'; /** @var \Illuminate\Http\Client\Response $weatherResponse */ - // @phpstan-ignore staticMethod.void $weatherResponse = Http::get('https://api.open-meteo.com/v1/forecast', [ 'latitude' => $latitude, 'longitude' => $longitude, 'current' => 'temperature_2m,apparent_temperature,relative_humidity_2m,wind_speed_10m,wind_gusts_10m,weather_code', - 'wind_speed_unit' => $config?->context?->get('wind_speed_unit') ?? 'mph', + 'wind_speed_unit' => $windSpeedUnit, + 'temperature_unit' => $temperatureUnit, ]); $data = $weatherResponse->collect('current'); + $time = $data->get('time'); + $time = Date::parse($time, 'UTC')->setTimezone($timezone)->toDateTimeString(); return [ + 'time' => $time, 'temperature' => $data->get('temperature_2m'), 'feels_like' => $data->get('apparent_temperature'), + 'temperature_unit' => $temperatureUnit, 'humidity' => $data->get('relative_humidity_2m'), - 'wind_speed' => $data->get('wind_speed_10m') . $windSpeedUnit, - 'wind_gusts' => $data->get('wind_gusts_10m') . $windSpeedUnit, + 'wind_speed' => $data->get('wind_speed_10m'), + 'wind_gusts' => $data->get('wind_gusts_10m'), + 'wind_speed_unit' => $windSpeedUnit, 'conditions' => $this->getWeatherConditions($data->get('weather_code')), - 'location' => $location, + 'conditions_code' => $data->get('weather_code'), + 'location' => $name, ]; } diff --git a/src/Tools/Prebuilt/ListDirectoryTool.php b/src/Tools/Prebuilt/ListDirectoryTool.php new file mode 100644 index 0000000..186404e --- /dev/null +++ b/src/Tools/Prebuilt/ListDirectoryTool.php @@ -0,0 +1,168 @@ +properties( + Schema::string('path') + ->description('The path to the directory to list') + ->required(), + ); + } + + public function invoke(ToolCall|array $toolCall = [], ?RuntimeConfig $config = null): mixed + { + /** @var array $arguments */ + $arguments = $this->getArguments($toolCall); + + if ($arguments !== []) { + $this->schema()->validate($arguments); + } + + /** @var string $path */ + $path = $arguments['path']; + + // Resolve the full path + $fullPath = $this->resolvePath($path); + + // Validate the path + $validationError = $this->validatePath($fullPath); + + if ($validationError !== null) { + return $validationError; + } + + if (! is_dir($fullPath)) { + return sprintf('Directory not found: %s', $path); + } + + if (! is_readable($fullPath)) { + return sprintf('Directory is not readable: %s', $path); + } + + $entries = $this->listDirectory($fullPath); + + return [ + 'path' => $path, + 'count' => count($entries), + 'entries' => $entries, + ]; + } + + /** + * List directory contents. + * + * @return array + */ + protected function listDirectory(string $path): array + { + $entries = []; + $iterator = new DirectoryIterator($path); + + foreach ($iterator as $item) { + if ($item->isDot()) { + continue; + } + + $size = null; + + if ($item->isFile()) { + $fileSize = $item->getSize(); + $size = $fileSize !== false ? $fileSize : null; + } + + $entry = [ + 'name' => $item->getFilename(), + 'type' => $item->isDir() ? 'directory' : 'file', + 'size' => $size, + ]; + + $entries[] = $entry; + } + + // Sort entries: directories first, then files, both alphabetically + usort($entries, function (array $a, array $b): int { + if ($a['type'] !== $b['type']) { + return $a['type'] === 'directory' ? -1 : 1; + } + + return strcasecmp($a['name'], $b['name']); + }); + + return $entries; + } + + /** + * Resolve the full path from a relative or absolute path. + */ + protected function resolvePath(string $path): string + { + if ($this->basePath === null) { + return $path; + } + + // If the path is absolute, use it directly (but validate against basePath) + if (str_starts_with($path, '/')) { + return $path; + } + + // Otherwise, resolve relative to basePath + return rtrim($this->basePath, '/') . '/' . $path; + } + + /** + * Validate the path for security. + */ + protected function validatePath(string $fullPath): ?string + { + // Check for directory traversal attempts + $realPath = realpath($fullPath); + + if ($realPath === false) { + // Directory doesn't exist, but we'll handle that later + return null; + } + + // If basePath is set, ensure the directory is within it + if ($this->basePath !== null) { + $realBasePath = realpath($this->basePath); + + if ($realBasePath !== false && ! str_starts_with($realPath, $realBasePath)) { + return 'Access denied: path is outside the allowed directory'; + } + } + + return null; + } +} diff --git a/src/Tools/Prebuilt/ListSkillsTool.php b/src/Tools/Prebuilt/ListSkillsTool.php new file mode 100644 index 0000000..d16d5a6 --- /dev/null +++ b/src/Tools/Prebuilt/ListSkillsTool.php @@ -0,0 +1,48 @@ +registry->summaries(); + + if ($summaries === []) { + return 'No skills are currently registered.'; + } + + return [ + 'count' => count($summaries), + 'skills' => $summaries, + ]; + } +} diff --git a/src/Tools/Prebuilt/ReadFileTool.php b/src/Tools/Prebuilt/ReadFileTool.php new file mode 100644 index 0000000..f496719 --- /dev/null +++ b/src/Tools/Prebuilt/ReadFileTool.php @@ -0,0 +1,141 @@ + $allowedExtensions Optional list of allowed file extensions + */ + public function __construct( + protected ?string $basePath = null, + protected array $allowedExtensions = [], + ) {} + + public function name(): string + { + return 'read_file'; + } + + public function description(): string + { + return 'Read the contents of a file at the specified path.'; + } + + public function schema(): ObjectSchema + { + return Schema::object()->properties( + Schema::string('path') + ->description('The path to the file to read') + ->required(), + ); + } + + public function invoke(ToolCall|array $toolCall = [], ?RuntimeConfig $config = null): mixed + { + /** @var array $arguments */ + $arguments = $this->getArguments($toolCall); + + if ($arguments !== []) { + $this->schema()->validate($arguments); + } + + /** @var string $path */ + $path = $arguments['path']; + + // Resolve the full path + $fullPath = $this->resolvePath($path); + + // Validate the path + $validationError = $this->validatePath($fullPath); + + if ($validationError !== null) { + return $validationError; + } + + if (! file_exists($fullPath)) { + return sprintf('File not found: %s', $path); + } + + if (! is_readable($fullPath)) { + return sprintf('File is not readable: %s', $path); + } + + $contents = file_get_contents($fullPath); + + if ($contents === false) { + return sprintf('Failed to read file: %s', $path); + } + + return [ + 'path' => $path, + 'contents' => $contents, + 'size' => strlen($contents), + ]; + } + + /** + * Resolve the full path from a relative or absolute path. + */ + protected function resolvePath(string $path): string + { + if ($this->basePath === null) { + return $path; + } + + // If the path is absolute, use it directly (but validate against basePath) + if (str_starts_with($path, '/')) { + return $path; + } + + // Otherwise, resolve relative to basePath + return rtrim($this->basePath, '/') . '/' . $path; + } + + /** + * Validate the path for security. + */ + protected function validatePath(string $fullPath): ?string + { + // Check for directory traversal attempts + $realPath = realpath($fullPath); + + if ($realPath === false) { + // File doesn't exist, but we'll handle that later + return null; + } + + // If basePath is set, ensure the file is within it + if ($this->basePath !== null) { + $realBasePath = realpath($this->basePath); + + if ($realBasePath !== false && ! str_starts_with($realPath, $realBasePath)) { + return 'Access denied: path is outside the allowed directory'; + } + } + + // Check file extension if restrictions are set + if ($this->allowedExtensions !== []) { + $extension = pathinfo($realPath, PATHINFO_EXTENSION); + + if (! in_array(strtolower($extension), array_map(strtolower(...), $this->allowedExtensions), true)) { + return sprintf( + 'Access denied: file extension "%s" is not allowed. Allowed extensions: %s', + $extension, + implode(', ', $this->allowedExtensions), + ); + } + } + + return null; + } +} diff --git a/src/Tools/Prebuilt/ReadSkillTool.php b/src/Tools/Prebuilt/ReadSkillTool.php new file mode 100644 index 0000000..02d2458 --- /dev/null +++ b/src/Tools/Prebuilt/ReadSkillTool.php @@ -0,0 +1,64 @@ +properties( + Schema::string('name') + ->description('The name of the skill to read') + ->required(), + ); + } + + public function invoke(ToolCall|array $toolCall = [], ?RuntimeConfig $config = null): mixed + { + /** @var array $arguments */ + $arguments = $this->getArguments($toolCall); + + if ($arguments !== []) { + $this->schema()->validate($arguments); + } + + /** @var string $name */ + $name = $arguments['name']; + + if (! $this->registry->has($name)) { + return sprintf('Skill not found: %s. Use list_skills to see available skills.', $name); + } + + $skill = $this->registry->get($name); + + return [ + 'name' => $skill->name, + 'description' => $skill->description, + 'instructions' => $skill->instructions, + 'path' => $skill->path, + ]; + } +} diff --git a/src/Tools/Prebuilt/UseSkillTool.php b/src/Tools/Prebuilt/UseSkillTool.php new file mode 100644 index 0000000..7c13030 --- /dev/null +++ b/src/Tools/Prebuilt/UseSkillTool.php @@ -0,0 +1,78 @@ +properties( + Schema::string('name') + ->description('The name of the skill to activate') + ->required(), + ); + } + + public function invoke(ToolCall|array $toolCall = [], ?RuntimeConfig $config = null): mixed + { + /** @var array $arguments */ + $arguments = $this->getArguments($toolCall); + + if ($arguments !== []) { + $this->schema()->validate($arguments); + } + + /** @var string $name */ + $name = $arguments['name']; + + if (! $this->registry->has($name)) { + return sprintf('Skill not found: %s. Use list_skills to see available skills.', $name); + } + + $skill = $this->registry->get($name); + + // Store the active skill in the runtime context + if ($config !== null) { + /** @var array $activeSkills */ + $activeSkills = $config->context->get(self::ACTIVE_SKILLS_KEY, []); + $activeSkills[$name] = true; + $config->context->set(self::ACTIVE_SKILLS_KEY, $activeSkills); + } + + return [ + 'activated' => true, + 'name' => $skill->name, + 'description' => $skill->description, + 'instructions' => $skill->instructions, + 'message' => sprintf('Skill "%s" has been activated. Follow the instructions provided.', $name), + ]; + } +} diff --git a/src/Tools/ToolKits/SkillToolKit.php b/src/Tools/ToolKits/SkillToolKit.php new file mode 100644 index 0000000..08e61f8 --- /dev/null +++ b/src/Tools/ToolKits/SkillToolKit.php @@ -0,0 +1,57 @@ +registry = new SkillRegistry(new SkillLoader()) + ->registerFromDirectory($registry, $this->pattern); + } else { + $this->registry = $registry; + } + } + + /** + * Get the skill registry. + */ + public function getRegistry(): SkillRegistry + { + return $this->registry; + } + + /** + * @return array + */ + public function getTools(): array + { + return [ + new ListSkillsTool($this->registry), + new ReadSkillTool($this->registry), + new UseSkillTool($this->registry), + new FetchUrlTool(), + ]; + } +} diff --git a/testbench.yaml b/testbench.yaml index b73b245..0cf02a8 100644 --- a/testbench.yaml +++ b/testbench.yaml @@ -9,7 +9,7 @@ seeders: - Workbench\Database\Seeders\DatabaseSeeder workbench: - start: "/" + start: "/cortex" install: true health: false discovers: @@ -17,7 +17,9 @@ workbench: api: true commands: false components: false - views: false + views: true build: [] assets: [] - sync: [] + sync: + - from: public/build + to: public/build diff --git a/tests/ArchitectureTest.php b/tests/ArchitectureTest.php index 0501ec3..8e3b059 100644 --- a/tests/ArchitectureTest.php +++ b/tests/ArchitectureTest.php @@ -6,13 +6,31 @@ use Throwable; use Cortex\Contracts\OutputParser; +use Cortex\Memory\Contracts\Store; +use Cortex\Contracts\CortexException; use Illuminate\Support\Facades\Facade; +use Cortex\Agents\AbstractAgentBuilder; +use Cortex\Embeddings\Contracts\Embeddings; +use Cortex\Prompts\Contracts\PromptFactory; +use Cortex\Prompts\Contracts\PromptCompiler; // arch()->preset()->php(); arch()->preset()->security(); arch()->expect('Cortex\Contracts')->toBeInterfaces(); + arch()->expect('Cortex\Enums')->toBeEnums(); +arch()->expect('Cortex\LLM\Enums')->toBeEnums(); +arch()->expect('Cortex\Prompts\Enums')->toBeEnums(); +arch()->expect('Cortex\AGUI\Enums')->toBeEnums(); + arch()->expect('Cortex\Exceptions')->toExtend(Throwable::class); arch()->expect('Cortex\Facades')->toExtend(Facade::class); +arch()->expect('Cortex\Agents\Prebuilt')->toExtend(AbstractAgentBuilder::class); + +arch()->expect('Cortex\Exceptions')->toImplement(CortexException::class); arch()->expect('Cortex\OutputParsers')->toImplement(OutputParser::class); +arch()->expect('Cortex\Embeddings\Drivers')->toImplement(Embeddings::class); +arch()->expect('Cortex\Memory\Stores')->toImplement(Store::class); +arch()->expect('Cortex\Prompts\Compilers')->toImplement(PromptCompiler::class); +arch()->expect('Cortex\Prompts\Factories')->toImplement(PromptFactory::class); diff --git a/tests/Pest.php b/tests/Pest.php index 90d1a71..5f7a053 100644 --- a/tests/Pest.php +++ b/tests/Pest.php @@ -2,6 +2,17 @@ declare(strict_types=1); +use Saloon\MockConfig; use Cortex\Tests\TestCase; +use Saloon\Http\Faking\MockClient; -uses(TestCase::class)->in('Unit'); +// Config::preventStrayRequests(); +MockConfig::setFixturePath('tests/fixtures/sdk'); + +if (env('CI')) { + MockConfig::throwOnMissingFixtures(); +} + +uses(TestCase::class) + ->in('Unit') + ->beforeEach(fn() => MockClient::destroyGlobal()); diff --git a/tests/Unit/Agents/AgentMiddlewareTest.php b/tests/Unit/Agents/AgentMiddlewareTest.php index 2de99df..49c55e7 100644 --- a/tests/Unit/Agents/AgentMiddlewareTest.php +++ b/tests/Unit/Agents/AgentMiddlewareTest.php @@ -48,7 +48,7 @@ }); $agent = new Agent( - name: 'TestAgent', + id: 'TestAgent', prompt: 'Say hello', llm: $llm, middleware: [$beforeMiddleware], @@ -84,7 +84,7 @@ }); $agent = new Agent( - name: 'TestAgent', + id: 'TestAgent', prompt: 'Say hello', llm: $llm, middleware: [$afterMiddleware], @@ -125,7 +125,7 @@ }); $agent = new Agent( - name: 'TestAgent', + id: 'TestAgent', prompt: 'Say hello', llm: $llm, middleware: [$beforeMiddleware, $afterMiddleware], @@ -167,7 +167,7 @@ public function beforeModel(mixed $payload, RuntimeConfig $config, Closure $next }; $agent = new Agent( - name: 'TestAgent', + id: 'TestAgent', prompt: 'Say hello', llm: $llm, middleware: [$middleware], @@ -206,7 +206,7 @@ public function afterModel(mixed $payload, RuntimeConfig $config, Closure $next) }; $agent = new Agent( - name: 'TestAgent', + id: 'TestAgent', prompt: 'Say hello', llm: $llm, middleware: [$middleware], @@ -243,7 +243,7 @@ public function afterModel(mixed $payload, RuntimeConfig $config, Closure $next) }); $agent = new Agent( - name: 'TestAgent', + id: 'TestAgent', prompt: 'Say hello', llm: $llm, middleware: [$beforeMiddleware], @@ -288,7 +288,7 @@ public function afterModel(mixed $payload, RuntimeConfig $config, Closure $next) }); $agent = new Agent( - name: 'TestAgent', + id: 'TestAgent', prompt: 'Say hello', llm: $llm, middleware: [$beforeMiddleware], @@ -330,7 +330,7 @@ public function afterModel(mixed $payload, RuntimeConfig $config, Closure $next) }); $agent = new Agent( - name: 'TestAgent', + id: 'TestAgent', prompt: 'Say hello', llm: $llm, middleware: [$before1, $before2], @@ -376,7 +376,7 @@ public function afterModel(mixed $payload, RuntimeConfig $config, Closure $next) }); $agent = new Agent( - name: 'TestAgent', + id: 'TestAgent', prompt: 'Say hello', llm: $llm, middleware: [$after1, $after2], @@ -406,7 +406,7 @@ public function afterModel(mixed $payload, RuntimeConfig $config, Closure $next) }); $agent = new Agent( - name: 'TestAgent', + id: 'TestAgent', prompt: 'Say hello', llm: $llm, middleware: [$beforeMiddleware], @@ -473,7 +473,7 @@ function (int $x, int $y): int { }); $agent = new Agent( - name: 'TestAgent', + id: 'TestAgent', prompt: 'Calculate 3 * 4', llm: $llm, tools: [$multiplyTool], @@ -532,7 +532,7 @@ function (int $x, int $y): int { }); $agent = new Agent( - name: 'TestAgent', + id: 'TestAgent', prompt: 'Say hello', llm: $llm, middleware: [$beforeMiddleware, $afterMiddleware, $finalAfterMiddleware], @@ -576,7 +576,7 @@ function (int $x, int $y): int { }); $agent = new Agent( - name: 'TestAgent', + id: 'TestAgent', prompt: 'Say hello', llm: $llm, middleware: [$afterMiddleware], @@ -636,7 +636,7 @@ function (int $x, int $y): int { }); $agent = new Agent( - name: 'TestAgent', + id: 'TestAgent', prompt: 'Say hello', llm: $llm, middleware: [$before1, $before2, $after1, $after2], @@ -706,7 +706,7 @@ function (int $x, int $y): int { }); $agent = new Agent( - name: 'TestAgent', + id: 'TestAgent', prompt: 'Calculate 3 * 4', llm: $llm, tools: [$multiplyTool], @@ -750,7 +750,7 @@ function (int $x, int $y): int { }); $agent = new Agent( - name: 'TestAgent', + id: 'TestAgent', prompt: 'Say hello', llm: $llm, middleware: [$beforePromptMiddleware, $beforeModelMiddleware], @@ -796,7 +796,7 @@ function (int $x, int $y): int { }); $agent = new Agent( - name: 'TestAgent', + id: 'TestAgent', prompt: 'Say hello', llm: $llm, middleware: [$beforePromptMiddleware], @@ -840,7 +840,7 @@ function (int $x, int $y): int { }); $agent = new Agent( - name: 'TestAgent', + id: 'TestAgent', prompt: 'Say hello', llm: $llm, middleware: [$beforePromptMiddleware, $beforeModelMiddleware], @@ -886,7 +886,7 @@ public function beforePrompt(mixed $payload, RuntimeConfig $config, Closure $nex }; $agent = new Agent( - name: 'TestAgent', + id: 'TestAgent', prompt: 'Say hello', llm: $llm, middleware: [$middleware], @@ -923,7 +923,7 @@ public function beforePrompt(mixed $payload, RuntimeConfig $config, Closure $nex }); $agent = new Agent( - name: 'TestAgent', + id: 'TestAgent', prompt: 'Say hello', llm: $llm, middleware: [$beforePromptMiddleware], @@ -969,7 +969,7 @@ public function beforePrompt(mixed $payload, RuntimeConfig $config, Closure $nex }); $agent = new Agent( - name: 'TestAgent', + id: 'TestAgent', prompt: 'Say hello', llm: $llm, middleware: [$beforeMiddleware], @@ -1027,7 +1027,7 @@ public function beforePrompt(mixed $payload, RuntimeConfig $config, Closure $nex }); $agent = new Agent( - name: 'TestAgent', + id: 'TestAgent', prompt: 'Say hello', llm: $llm, middleware: [$beforeMiddleware], @@ -1101,7 +1101,7 @@ function (int $x, int $y): int { }); $agent = new Agent( - name: 'TestAgent', + id: 'TestAgent', prompt: 'Calculate 3 * 4', llm: $llm, tools: [$multiplyTool], @@ -1144,7 +1144,7 @@ function (int $x, int $y): int { }); $agent = new Agent( - name: 'TestAgent', + id: 'TestAgent', prompt: 'Say hello', llm: $llm, middleware: [$beforeMiddleware], @@ -1192,7 +1192,7 @@ function (int $x, int $y): int { }); $agent = new Agent( - name: 'TestAgent', + id: 'TestAgent', prompt: 'Say hello', llm: $llm, middleware: [$beforeMiddleware], @@ -1223,7 +1223,7 @@ function (int $x, int $y): int { }); $agent = new Agent( - name: 'TestAgent', + id: 'TestAgent', prompt: 'Say hello', llm: $llm, middleware: [$beforeMiddleware], @@ -1267,7 +1267,7 @@ function (int $x, int $y): int { }); $agent = new Agent( - name: 'TestAgent', + id: 'TestAgent', prompt: 'Say hello', llm: $llm, middleware: [$before1, $before2], @@ -1308,7 +1308,7 @@ function (int $x, int $y): int { }); $agent = new Agent( - name: 'TestAgent', + id: 'TestAgent', prompt: 'Say hello', llm: $llm, middleware: [$beforeMiddleware], @@ -1356,7 +1356,7 @@ function (int $x, int $y): int { }); $agent = new Agent( - name: 'TestAgent', + id: 'TestAgent', prompt: 'Say hello', llm: $llm, middleware: [$beforeMiddleware], @@ -1438,7 +1438,7 @@ function (int $x, int $y): int { }); $agent = new Agent( - name: 'TestAgent', + id: 'TestAgent', prompt: 'Calculate 3 * 4', llm: $llm, tools: [$multiplyTool], @@ -1499,7 +1499,7 @@ function (int $x, int $y): int { }); $agent = new Agent( - name: 'TestAgent', + id: 'TestAgent', prompt: 'Say hello', llm: $llm, middleware: [$beforeMiddleware], @@ -1593,7 +1593,7 @@ function (int $x, int $y): int { }); $agent = new Agent( - name: 'TestAgent', + id: 'TestAgent', prompt: 'Calculate 3 * 4', llm: $llm, tools: [$multiplyTool], @@ -1684,7 +1684,7 @@ function (int $x, int $y): int { }); $agent = new Agent( - name: 'TestAgent', + id: 'TestAgent', prompt: 'Calculate 3 * 4', llm: $llm, tools: [$multiplyTool], diff --git a/tests/Unit/Agents/AgentOldTest.php b/tests/Unit/Agents/AgentOldTest.php deleted file mode 100644 index d04bbee..0000000 --- a/tests/Unit/Agents/AgentOldTest.php +++ /dev/null @@ -1,214 +0,0 @@ -messages([ - new SystemMessage('You are a comedian.'), - new UserMessage('Tell me a joke about {topic}.'), - ]) - ->metadata( - provider: 'ollama', - model: 'ministral-3:14b', - structuredOutput: Schema::object()->properties( - Schema::string('setup')->required(), - Schema::string('punchline')->required(), - ), - ), - ); - - $result = $agent->stream(input: [ - 'topic' => 'dragons', - ]); - - foreach ($result->text() as $chunk) { - dump([ - 'type' => $chunk->type, - 'content' => $chunk->content(), - ]); - } - - dd($agent->getMemory()->getMessages()->toArray()); -})->todo(); - -test('it can create an agent with tools', function (): void { - // Event::listen(ChatModelStart::class, function (ChatModelStart $event): void { - // dump('llm start: ', $event->parameters); - // }); - - // Event::listen(ChatModelEnd::class, function (ChatModelEnd $event): void { - // dump('llm end: ', $event->result); - // }); - - $agent = new Agent( - name: 'Weather Forecaster', - prompt: 'You are a weather forecaster. Use the tool to get the weather for a given location.', - llm: llm('ollama', 'qwen2.5:14b')->ignoreFeatures(), - tools: [ - tool( - 'get_weather', - 'Get the current weather for a given location', - fn(string $location): string => - vsprintf('{"location": "%s", "conditions": "%s", "temperature": %s, "unit": "celsius"}', [ - $location, - Arr::random(['sunny', 'cloudy', 'rainy', 'snowing']), - 14, - ]), - ), - ], - ); - - // $result = $agent->invoke([ - // new UserMessage('What is the weather in London?'), - // ]); - - // dump($result->generation->message->content()); - // dump($agent->getMemory()->getMessages()->toArray()); - // dd($agent->getTotalUsage()->toArray()); - - // $result = $agent->invoke([ - // new UserMessage('What about Manchester?'), - // ]); - - // dump($result->generation->message->content()); - // dump($agent->getMemory()->getMessages()->toArray()); - - $result = $agent->stream([ - new UserMessage('What is the weather in London?'), - ]); - - foreach ($result as $chunk) { - dump($chunk->toArray()); - } - - dump($agent->getMemory()->getMessages()->toArray()); -})->todo(); - -test('it can create an agent with a prompt instance', function (): void { - Event::listen(ChatModelStart::class, function (ChatModelStart $event): void { - dump('llm start: ', $event->parameters); - }); - - Event::listen(ChatModelEnd::class, function (ChatModelEnd $event): void { - dump('llm end: ', $event->result); - }); - - $londonWeatherTool = tool( - 'london-weather-tool', - 'Returns year-to-date historical weather data for London', - function (): string { - $url = vsprintf('https://archive-api.open-meteo.com/v1/archive?latitude=51.5072&longitude=-0.1276&start_date=%s&end_date=%s&daily=temperature_2m_max,temperature_2m_min,precipitation_sum,windspeed_10m_max,snowfall_sum&timezone=auto', [ - today()->startOfYear()->format('Y-m-d'), - today()->format('Y-m-d'), - ]); - - $response = Http::get($url)->collect(); - - dd($response); - - return $response->mapWithKeys(fn(array $item): array => [ - 'date' => $item['daily']['time'], - 'temp_max' => $item['daily']['temperature_2m_max'], - 'temp_min' => $item['daily']['temperature_2m_min'], - 'rainfall' => $item['daily']['precipitation_sum'], - 'windspeed' => $item['daily']['windspeed_10m_max'], - 'snowfall' => $item['daily']['snowfall_sum'], - ])->toJson(); - }, - ); - - $weatherAgent = new Agent( - name: 'london-weather-agent', - prompt: <<ignoreFeatures(), - tools: [$londonWeatherTool], - ); - - $result = $weatherAgent->invoke([ - new UserMessage('How many times has it rained this year?'), - ]); - - // dd($result); - dump($result->generation->message->content()); - dump($weatherAgent->getMemory()->getMessages()->toArray()); - dd($weatherAgent->getTotalUsage()->toArray()); -})->todo(); - -test('it can pipe agents', function (): void { - // $result = WeatherAgent::make()->invoke(input: [ - // 'location' => 'Manchester', - // ]); - - $weatherAgent = new Agent( - name: 'weather', - prompt: Cortex::prompt([ - new SystemMessage('You are a weather assistant. Call the tool to get the weather for a given location.'), - new UserMessage('What is the weather in {location}?'), - ]), - // llm: 'lmstudio/gpt-oss:20b', - llm: 'openai/gpt-4.1-mini', - tools: [ - OpenMeteoWeatherTool::class, - ], - output: [ - Schema::string('summary')->required(), - ], - ); - $umbrellaAgent = new Agent( - name: 'umbrella-agent', - prompt: Cortex::prompt([ - new SystemMessage("You are a helpful assistant that determines if an umbrella is needed based on the following information:\n{summary}"), - ]), - // llm: 'lmstudio/gpt-oss:20b', - llm: 'openai/gpt-4.1-mini', - output: [ - Schema::boolean('umbrella_needed')->required(), - Schema::string('reasoning')->required(), - ], - ); - - // dd(array_map(fn(object $stage): string => get_class($stage), $weatherAgent->pipe($umbrellaAgent)->getStages())); - - $umbrellaNeededResult = $weatherAgent - ->pipe($umbrellaAgent) - ->pipe(new JsonOutputParser()) - ->stream([ - 'location' => 'Manchester', - ]); - - foreach ($umbrellaNeededResult as $chunk) { - dump($chunk->message->content()); - } - - // dump($umbrellaAgent->getMemory()->getMessages()->toArray()); - // dd($umbrellaNeededResult); -})->todo(); diff --git a/tests/Unit/Agents/AgentTest.php b/tests/Unit/Agents/AgentTest.php index 7438359..fba119a 100644 --- a/tests/Unit/Agents/AgentTest.php +++ b/tests/Unit/Agents/AgentTest.php @@ -5,9 +5,11 @@ namespace Cortex\Tests\Unit\Agents; use Cortex\Cortex; +use RuntimeException; use Cortex\Agents\Agent; use Cortex\LLM\Data\Usage; use Cortex\Events\AgentEnd; +use Illuminate\Support\Str; use Cortex\Agents\Data\Step; use Cortex\Events\AgentStart; use Cortex\JsonSchema\Schema; @@ -15,7 +17,9 @@ use Cortex\Events\AgentStepEnd; use Cortex\LLM\Data\ChatResult; use Cortex\LLM\Enums\ChunkType; +use Cortex\Events\AgentStepError; use Cortex\Events\AgentStepStart; +use Cortex\Pipeline\RuntimeConfig; use Cortex\LLM\Data\ChatStreamResult; use Cortex\LLM\Data\ToolCallCollection; use Cortex\LLM\Data\ChatGenerationChunk; @@ -48,7 +52,7 @@ ], 'gpt-4o'); $agent = new Agent( - name: 'Comedian', + id: 'Comedian', prompt: 'You are a comedian. Tell me a joke about {topic}.', llm: $llm, output: [ @@ -101,7 +105,7 @@ ], 'gpt-4o'); $agent = new Agent( - name: 'Helper', + id: 'Helper', prompt: 'You are a helpful assistant.', llm: $llm, ); @@ -183,7 +187,7 @@ function (int $x, int $y) use (&$toolCalled, &$toolArguments): int { $llm->addFeature(ModelFeature::ToolCalling); $agent = new Agent( - name: 'Calculator', + id: 'Calculator', prompt: 'You are a helpful calculator assistant. Use the multiply tool to calculate the answer.', llm: $llm, tools: [$multiplyTool], @@ -261,7 +265,7 @@ function (int $x, int $y): int { $llm->addFeature(ModelFeature::ToolCalling); $agent = new Agent( - name: 'Calculator', + id: 'Calculator', prompt: 'You are a helpful calculator assistant. Use the multiply tool to calculate the answer.', llm: $llm, tools: [$multiplyTool], @@ -344,7 +348,7 @@ function (int $x, int $y): int { ], 'gpt-4o'); $agent = new Agent( - name: 'Helper', + id: 'Helper', prompt: 'You are a helpful assistant.', llm: $llm, maxSteps: 3, @@ -391,7 +395,7 @@ function (int $x, int $y): int { ], 'gpt-4o'); $agent = new Agent( - name: 'Helper', + id: 'Helper', prompt: 'You are a helpful assistant.', llm: $llm, ); @@ -432,7 +436,7 @@ function (int $x, int $y): int { ], 'gpt-4o'); $agent = new Agent( - name: 'Helper', + id: 'Helper', prompt: 'You are a helpful assistant.', llm: $llm, ); @@ -474,7 +478,7 @@ function (int $x, int $y): int { ], 'gpt-4o'); $agent = new Agent( - name: 'Helper', + id: 'Helper', prompt: 'You are a helpful assistant.', llm: $llm, ); @@ -548,7 +552,7 @@ function (int $x, int $y): int { $llm->addFeature(ModelFeature::ToolCalling); $agent = new Agent( - name: 'Calculator', + id: 'Calculator', prompt: 'You are a helpful calculator assistant. Use the multiply tool to calculate the answer.', llm: $llm, tools: [$multiplyTool], @@ -584,7 +588,7 @@ function (int $x, int $y): int { ], 'gpt-4o'); $agent = new Agent( - name: 'Helper', + id: 'Helper', prompt: 'You are a helpful assistant.', llm: $llm, ); @@ -642,9 +646,9 @@ function (int $x, int $y): int { $finalChunk = array_find($llmChunks, fn($chunk): bool => $chunk instanceof ChatGenerationChunk && $chunk->isFinal); expect($finalChunk)->not->toBeNull('Should have a final chunk') - ->and($finalChunk->contentSoFar)->toContain('Hello!') - ->and($finalChunk->contentSoFar)->toContain('program') - ->and($finalChunk->contentSoFar)->toContain('assist you today'); + ->and($finalChunk->textSoFar())->toContain('Hello!') + ->and($finalChunk->textSoFar())->toContain('program') + ->and($finalChunk->textSoFar())->toContain('assist you today'); }); test('it dispatches AgentStart and AgentEnd events only once when streaming', function (): void { @@ -653,7 +657,7 @@ function (int $x, int $y): int { ], 'gpt-4o'); $agent = new Agent( - name: 'Helper', + id: 'Helper', prompt: 'You are a helpful assistant.', llm: $llm, ); @@ -708,7 +712,7 @@ function (int $x, int $y): int { ], 'gpt-4o'); $agent = new Agent( - name: 'Helper', + id: 'Helper', prompt: 'You are a helpful assistant.', llm: $llm, ); @@ -768,7 +772,7 @@ function (int $x, int $y): int { $llm->addFeature(ModelFeature::ToolCalling); $agent = new Agent( - name: 'Calculator', + id: 'Calculator', prompt: 'You are a helpful calculator assistant. Use the multiply tool to calculate the answer.', llm: $llm, tools: [$multiplyTool], @@ -836,7 +840,7 @@ function (int $x, int $y): int { ], 'gpt-4o'); $agent = new Agent( - name: 'Helper', + id: 'Helper', prompt: 'You are a helpful assistant.', llm: $llm, ); @@ -906,7 +910,7 @@ function (int $x, int $y): int { $llm->addFeature(ModelFeature::ToolCalling); $agent = new Agent( - name: 'Calculator', + id: 'Calculator', prompt: 'You are a helpful calculator assistant. Use the multiply tool to calculate the answer.', llm: $llm, tools: [$multiplyTool], @@ -978,7 +982,7 @@ function (int $x, int $y): int { ], 'gpt-4o-mini'); $agent = new Agent( - name: 'Test Agent', + id: 'Test Agent', prompt: Cortex::prompt([ new SystemMessage('You are a helpful assistant.'), new UserMessage('What is the weather in {location}?'), @@ -1068,3 +1072,397 @@ function (int $x, int $y): int { ->and($messageHistory[2]->role()->value)->toBe('assistant') ->and($messageHistory[2]->content())->toBe('Hello there!'); }); + +test('thread id from RuntimeConfig is passed through to memory instance', function (): void { + $llm = OpenAIChat::fake([ + ChatCreateResponse::fake([ + 'model' => 'gpt-4o', + 'choices' => [ + [ + 'message' => [ + 'role' => 'assistant', + 'content' => 'Hello!', + ], + ], + ], + ]), + ], 'gpt-4o'); + + $agent = new Agent( + id: 'Helper', + prompt: 'You are a helpful assistant.', + llm: $llm, + ); + + // Create a RuntimeConfig with a specific thread ID + $customThreadId = 'test-thread-' . Str::uuid7()->toString(); + $runtimeConfig = new RuntimeConfig(threadId: $customThreadId); + + // Invoke the agent with the custom RuntimeConfig + $agent->invoke( + input: [ + 'query' => 'Hello', + ], + config: $runtimeConfig, + ); + + // Verify the memory instance has the correct thread ID + $memory = $agent->getMemory(); + expect($memory->getThreadId())->toBe($customThreadId, 'Memory should have the thread ID from RuntimeConfig'); + + // Verify the RuntimeConfig also maintains the same thread ID + expect($agent->getRuntimeConfig()->threadId)->toBe($customThreadId, 'RuntimeConfig should maintain the thread ID'); +}); + +test('thread id is generated when not provided in RuntimeConfig', function (): void { + $llm = OpenAIChat::fake([ + ChatCreateResponse::fake([ + 'model' => 'gpt-4o', + 'choices' => [ + [ + 'message' => [ + 'role' => 'assistant', + 'content' => 'Hello!', + ], + ], + ], + ]), + ], 'gpt-4o'); + + $agent = new Agent( + id: 'Helper', + prompt: 'You are a helpful assistant.', + llm: $llm, + ); + + // Invoke without providing a RuntimeConfig (or with one without a threadId) + $agent->invoke(input: [ + 'query' => 'Hello', + ]); + + // Verify the memory instance has a thread ID (should be auto-generated) + $memory = $agent->getMemory(); + $threadId = $memory->getThreadId(); + + expect($threadId)->not->toBeEmpty('Memory should have a generated thread ID') + ->and($threadId)->toBeString(); + + // Verify the RuntimeConfig has the same thread ID + $runtimeConfig = $agent->getRuntimeConfig(); + expect($runtimeConfig->threadId)->toBe($threadId, 'RuntimeConfig should have the same thread ID as memory'); +}); + +test('thread id persists across multiple invocations with same RuntimeConfig', function (): void { + $llm = OpenAIChat::fake([ + ChatCreateResponse::fake([ + 'model' => 'gpt-4o', + 'choices' => [ + [ + 'message' => [ + 'role' => 'assistant', + 'content' => 'First response', + ], + ], + ], + ]), + ChatCreateResponse::fake([ + 'model' => 'gpt-4o', + 'choices' => [ + [ + 'message' => [ + 'role' => 'assistant', + 'content' => 'Second response', + ], + ], + ], + ]), + ], 'gpt-4o'); + + $agent = new Agent( + id: 'Helper', + prompt: 'You are a helpful assistant.', + llm: $llm, + ); + + // Create a RuntimeConfig with a specific thread ID + $customThreadId = 'persistent-thread-' . Str::uuid7()->toString(); + $runtimeConfig = new RuntimeConfig(threadId: $customThreadId); + + // First invocation + $agent->invoke( + input: [ + 'query' => 'First message', + ], + config: $runtimeConfig, + ); + + $threadIdAfterFirst = $agent->getMemory()->getThreadId(); + expect($threadIdAfterFirst)->toBe($customThreadId, 'Thread ID should match after first invocation'); + + // Second invocation with the same RuntimeConfig + $agent->invoke( + input: [ + 'query' => 'Second message', + ], + config: $runtimeConfig, + ); + + $threadIdAfterSecond = $agent->getMemory()->getThreadId(); + expect($threadIdAfterSecond)->toBe($customThreadId, 'Thread ID should persist across invocations') + ->and($threadIdAfterSecond)->toBe($threadIdAfterFirst, 'Thread ID should remain the same') + ->and($runtimeConfig->threadId)->toBe($customThreadId, 'RuntimeConfig thread ID should remain unchanged'); +}); + +test('it can use an agent as a tool with asTool method', function (): void { + // Create LLM for the sub-agent (translator) + $translatorLlm = OpenAIChat::fake([ + ChatCreateResponse::fake([ + 'model' => 'gpt-4o', + 'choices' => [ + [ + 'message' => [ + 'role' => 'assistant', + 'content' => 'Bonjour le monde!', + ], + ], + ], + ]), + ], 'gpt-4o'); + + // Create a sub-agent that will be used as a tool + $translatorAgent = new Agent( + id: 'translator', + prompt: 'Translate the following text to {target_language}: {text}', + llm: $translatorLlm, + ); + + // Wrap the agent as a tool + $translatorTool = $translatorAgent->asTool( + name: 'translate', + description: 'Translate text to another language.', + ); + + // Create LLM for the main agent + $mainLlm = OpenAIChat::fake([ + // First response: Main agent decides to call the translator tool + ChatCreateResponse::fake([ + 'model' => 'gpt-4o', + 'choices' => [ + [ + 'message' => [ + 'role' => 'assistant', + 'content' => '', + 'tool_calls' => [ + [ + 'id' => 'call_translate_123', + 'type' => 'function', + 'function' => [ + 'name' => 'translate', + 'arguments' => '{"target_language":"French","text":"Hello world!"}', + ], + ], + ], + ], + ], + ], + ]), + // Second response: Main agent responds with the translation result + ChatCreateResponse::fake([ + 'model' => 'gpt-4o', + 'choices' => [ + [ + 'message' => [ + 'role' => 'assistant', + 'content' => 'The translation of "Hello world!" to French is: Bonjour le monde!', + ], + ], + ], + ]), + ], 'gpt-4o'); + + $mainLlm->addFeature(ModelFeature::ToolCalling); + + // Create the main agent that uses the translator as a tool + $mainAgent = new Agent( + id: 'Assistant', + prompt: 'You are a helpful assistant that can translate text using the translate tool.', + llm: $mainLlm, + tools: [$translatorTool], + ); + + $result = $mainAgent->invoke(input: [ + 'query' => 'Please translate "Hello world!" to French.', + ]); + + expect($result)->toBeInstanceOf(ChatResult::class) + ->and($result->content())->toBe('The translation of "Hello world!" to French is: Bonjour le monde!'); + + // Verify the translator agent was invoked (check its memory has messages) + $translatorMemory = $translatorAgent->getMemory()->getMessages(); + expect($translatorMemory)->toHaveCount(2, 'Translator agent should have user message and assistant response') + ->and($translatorMemory[0]->content())->toContain('Hello world!') + ->and($translatorMemory[0]->content())->toContain('French') + ->and($translatorMemory[1]->content())->toBe('Bonjour le monde!'); + + // Verify the main agent tracked the tool call in its steps + $runtimeConfig = $mainAgent->getRuntimeConfig(); + $steps = $runtimeConfig->context->getSteps(); + expect($steps)->toHaveCount(2, 'Main agent should have 2 steps (tool call + final response)'); + + $step1 = $steps[0]; + expect($step1->hasToolCalls())->toBeTrue('Step 1 should have tool calls') + ->and($step1->toolCalls[0]->function->name)->toBe('translate'); + + // Check custom schema capability + $translatorTool = $translatorAgent->asTool( + name: 'translate', + description: 'Translate text to another language.', + schema: Schema::object() + ->properties( + Schema::string('target_language')->required(), + Schema::string('text')->required(), + ), + ); + + $schema = $translatorTool->schema()->toArray(); + + expect($schema)->toBe([ + 'type' => 'object', + '$schema' => 'https://json-schema.org/draft/2020-12/schema', + 'properties' => [ + 'target_language' => [ + 'type' => 'string', + ], + 'text' => [ + 'type' => 'string', + ], + ], + 'required' => ['target_language', 'text'], + ]); +}); + +test('it emits error chunk with exception when exception is thrown during streaming', function (): void { + // Create a tool that throws an exception (named 'multiply' to match the fixture) + $failingTool = tool( + 'multiply', + 'Multiply two numbers together', + function (int $x, int $y): never { + throw new RuntimeException('Tool execution failed'); + }, + ); + + $llm = OpenAIChat::fake([ + // First response: LLM decides to call the tool (streaming with tool calls) + ChatCreateStreamedResponse::fake(fopen(__DIR__ . '/../../fixtures/openai/chat-stream-tool-calls.txt', 'r')), + ], 'gpt-4o'); + + $llm->addFeature(ModelFeature::ToolCalling); + + $agent = new Agent( + id: 'TestAgent', + prompt: 'You are a helpful assistant. Use the multiply tool when asked.', + llm: $llm, + tools: [$failingTool], + ); + + $result = $agent->stream(input: [ + 'query' => 'What is 3 times 4?', + ]); + + expect($result)->toBeInstanceOf(ChatStreamResult::class); + + // Collect all chunks and look for error chunk + $chunks = []; + $errorChunk = null; + $exceptionThrown = false; + + try { + foreach ($result as $chunk) { + $chunks[] = $chunk; + + if ($chunk->type === ChunkType::Error) { + $errorChunk = $chunk; + } + } + } catch (RuntimeException $e) { + $exceptionThrown = true; + expect($e->getMessage())->toBe('Tool execution failed'); + } + + // The exception should be thrown (this is current behavior) + // But we want to verify that an error chunk with the exception is also emitted + expect($exceptionThrown)->toBeTrue('Exception should be thrown when tool fails'); + + // Verify that an error chunk was emitted before the exception was thrown + expect($errorChunk)->not->toBeNull('Error chunk should be emitted when exception occurs during streaming'); + + if ($errorChunk !== null) { + expect($errorChunk->type)->toBe(ChunkType::Error); + expect($errorChunk->exception)->not->toBeNull('Error chunk should have the exception attached'); + expect($errorChunk->exception)->toBeInstanceOf(RuntimeException::class); + expect($errorChunk->exception->getMessage())->toBe('Tool execution failed'); + } +}); + +test('it dispatches AgentStepError event when exception is thrown during streaming', function (): void { + // Create a tool that throws an exception + $failingTool = tool( + 'multiply', + 'Multiply two numbers together', + function (int $x, int $y): never { + throw new RuntimeException('Tool execution failed'); + }, + ); + + $llm = OpenAIChat::fake([ + // First response: LLM decides to call the tool (streaming with tool calls) + ChatCreateStreamedResponse::fake(fopen(__DIR__ . '/../../fixtures/openai/chat-stream-tool-calls.txt', 'r')), + ], 'gpt-4o'); + + $llm->addFeature(ModelFeature::ToolCalling); + + $agent = new Agent( + id: 'TestAgent', + prompt: 'You are a helpful assistant. Use the multiply tool when asked.', + llm: $llm, + tools: [$failingTool], + ); + + $stepErrorCalled = false; + $stepErrorEvent = null; + + $agent->onStepError(function (AgentStepError $event) use (&$stepErrorCalled, &$stepErrorEvent): void { + $stepErrorCalled = true; + $stepErrorEvent = $event; + }); + + $result = $agent->stream(input: [ + 'query' => 'What is 3 times 4?', + ]); + + expect($result)->toBeInstanceOf(ChatStreamResult::class); + + // Collect chunks + $exceptionThrown = false; + + try { + foreach ($result as $chunk) { + // Just consume the stream + } + } catch (RuntimeException $e) { + $exceptionThrown = true; + expect($e->getMessage())->toBe('Tool execution failed'); + } + + // The exception should be thrown + expect($exceptionThrown)->toBeTrue('Exception should be thrown when tool fails'); + + // Verify that the AgentStepError event was dispatched + expect($stepErrorCalled)->toBeTrue('AgentStepError event should be dispatched when exception occurs during streaming'); + + if ($stepErrorEvent !== null) { + expect($stepErrorEvent->exception)->not->toBeNull('AgentStepError event should have the exception'); + expect($stepErrorEvent->exception)->toBeInstanceOf(RuntimeException::class); + expect($stepErrorEvent->exception->getMessage())->toBe('Tool execution failed'); + } +}); diff --git a/tests/Unit/Agents/GenericAgentBuilderTest.php b/tests/Unit/Agents/GenericAgentBuilderTest.php index a49f270..d27a96d 100644 --- a/tests/Unit/Agents/GenericAgentBuilderTest.php +++ b/tests/Unit/Agents/GenericAgentBuilderTest.php @@ -31,7 +31,7 @@ $agent = $builder->build(); expect($agent)->toBeInstanceOf(Agent::class) - ->and($agent->getName())->toBe('generic_agent') + ->and($agent->getId())->toBe('generic_agent') ->and($agent->getPrompt())->toBeInstanceOf(ChatPromptTemplate::class); }); @@ -491,11 +491,11 @@ test('it can use withName to change the agent name', function (): void { $builder = new GenericAgentBuilder(); $agent = $builder - ->withName('custom_agent') + ->withId('custom_agent') ->withPrompt('Say hello') ->build(); - expect($agent->getName())->toBe('custom_agent'); + expect($agent->getId())->toBe('custom_agent'); }); test('it can use withTools with tool choice', function (): void { diff --git a/tests/Unit/Agents/SkillMiddlewareTest.php b/tests/Unit/Agents/SkillMiddlewareTest.php new file mode 100644 index 0000000..fdd3180 --- /dev/null +++ b/tests/Unit/Agents/SkillMiddlewareTest.php @@ -0,0 +1,255 @@ +fixturesPath = __DIR__ . '/../../fixtures/skills'; + $this->registry = new SkillRegistry(); + $this->registry->registerFromDirectory($this->fixturesPath); +}); + +test('it passes through when no skills are active', function (): void { + $middleware = new SkillMiddleware($this->registry); + $config = new RuntimeConfig(context: new Context()); + + $payload = [ + 'messages' => new MessageCollection(), + ]; + $nextCalled = false; + + $result = $middleware->beforePrompt($payload, $config, function ($p, $c) use (&$nextCalled) { + $nextCalled = true; + + return $p; + }); + + expect($nextCalled)->toBeTrue(); + expect($result)->toBe($payload); +}); + +test('it injects skill instructions when skills are active', function (): void { + $middleware = new SkillMiddleware($this->registry); + $config = new RuntimeConfig(context: new Context([ + UseSkillTool::ACTIVE_SKILLS_KEY => [ + 'create-rule' => true, + ], + ])); + + $payload = [ + 'messages' => new MessageCollection(), + ]; + $resultPayload = null; + + $middleware->beforePrompt($payload, $config, function ($p, $c) use (&$resultPayload) { + $resultPayload = $p; + + return $p; + }); + + expect($resultPayload)->toHaveKey('skill_instructions'); + expect($resultPayload['skill_instructions'])->toContain(''); + expect($resultPayload['skill_instructions'])->toContain(''); + expect($resultPayload['skill_instructions'])->toContain('# Create Rule Skill'); +}); + +test('it auto-activates configured skills', function (): void { + $middleware = new SkillMiddleware($this->registry, ['code-review']); + $config = new RuntimeConfig(context: new Context()); + + $payload = [ + 'messages' => new MessageCollection(), + ]; + $resultPayload = null; + + $middleware->beforePrompt($payload, $config, function ($p, $c) use (&$resultPayload) { + $resultPayload = $p; + + return $p; + }); + + expect($resultPayload)->toHaveKey('skill_instructions'); + expect($resultPayload['skill_instructions'])->toContain(''); +}); + +test('it combines auto-activated and manually activated skills', function (): void { + $middleware = new SkillMiddleware($this->registry, ['code-review']); + $config = new RuntimeConfig(context: new Context([ + UseSkillTool::ACTIVE_SKILLS_KEY => [ + 'create-rule' => true, + ], + ])); + + $payload = [ + 'messages' => new MessageCollection(), + ]; + $resultPayload = null; + + $middleware->beforePrompt($payload, $config, function ($p, $c) use (&$resultPayload) { + $resultPayload = $p; + + return $p; + }); + + expect($resultPayload['skill_instructions'])->toContain(''); + expect($resultPayload['skill_instructions'])->toContain(''); +}); + +test('it ignores non-existent auto-activated skills', function (): void { + $middleware = new SkillMiddleware($this->registry, ['nonexistent-skill']); + $config = new RuntimeConfig(context: new Context()); + + $payload = [ + 'messages' => new MessageCollection(), + ]; + $resultPayload = null; + + $middleware->beforePrompt($payload, $config, function ($p, $c) use (&$resultPayload) { + $resultPayload = $p; + + return $p; + }); + + // Should pass through without modifications since skill doesn't exist + expect($resultPayload)->toBe($payload); +}); + +test('it inserts skill message after existing system messages', function (): void { + $middleware = new SkillMiddleware($this->registry); + $config = new RuntimeConfig(context: new Context([ + UseSkillTool::ACTIVE_SKILLS_KEY => [ + 'create-rule' => true, + ], + ])); + + $messages = new MessageCollection([ + new SystemMessage('You are a helpful assistant.'), + new UserMessage('Hello'), + ]); + $payload = [ + 'messages' => $messages, + ]; + $resultPayload = null; + + $middleware->beforePrompt($payload, $config, function ($p, $c) use (&$resultPayload) { + $resultPayload = $p; + + return $p; + }); + + expect($resultPayload['messages'])->toBeInstanceOf(MessageCollection::class); + expect($resultPayload['messages']->count())->toBe(3); + + // First message should be the original system message + expect($resultPayload['messages'][0])->toBeInstanceOf(SystemMessage::class); + expect($resultPayload['messages'][0]->content)->toBe('You are a helpful assistant.'); + + // Second message should be the skill instructions + expect($resultPayload['messages'][1])->toBeInstanceOf(SystemMessage::class); + expect($resultPayload['messages'][1]->content)->toContain(''); + + // Third message should be the user message + expect($resultPayload['messages'][2])->toBeInstanceOf(UserMessage::class); + expect($resultPayload['messages'][2]->content)->toBe('Hello'); +}); + +test('it inserts at beginning when no system messages exist', function (): void { + $middleware = new SkillMiddleware($this->registry); + $config = new RuntimeConfig(context: new Context([ + UseSkillTool::ACTIVE_SKILLS_KEY => [ + 'create-rule' => true, + ], + ])); + + $messages = new MessageCollection([ + new UserMessage('Hello'), + ]); + $payload = [ + 'messages' => $messages, + ]; + $resultPayload = null; + + $middleware->beforePrompt($payload, $config, function ($p, $c) use (&$resultPayload) { + $resultPayload = $p; + + return $p; + }); + + expect($resultPayload['messages'])->toBeInstanceOf(MessageCollection::class); + expect($resultPayload['messages']->count())->toBe(2); + + // First message should be the skill instructions + expect($resultPayload['messages'][0])->toBeInstanceOf(SystemMessage::class); + expect($resultPayload['messages'][0]->content)->toContain(''); + + // Second message should be the user message + expect($resultPayload['messages'][1])->toBeInstanceOf(UserMessage::class); +}); + +test('it inserts after multiple system messages', function (): void { + $middleware = new SkillMiddleware($this->registry); + $config = new RuntimeConfig(context: new Context([ + UseSkillTool::ACTIVE_SKILLS_KEY => [ + 'create-rule' => true, + ], + ])); + + $messages = new MessageCollection([ + new SystemMessage('System instruction 1'), + new SystemMessage('System instruction 2'), + new UserMessage('Hello'), + ]); + $payload = [ + 'messages' => $messages, + ]; + $resultPayload = null; + + $middleware->beforePrompt($payload, $config, function ($p, $c) use (&$resultPayload) { + $resultPayload = $p; + + return $p; + }); + + expect($resultPayload['messages']->count())->toBe(4); + + // First two should be original system messages + expect($resultPayload['messages'][0]->content)->toBe('System instruction 1'); + expect($resultPayload['messages'][1]->content)->toBe('System instruction 2'); + + // Third should be skill instructions + expect($resultPayload['messages'][2]->content)->toContain(''); + + // Fourth should be user message + expect($resultPayload['messages'][3])->toBeInstanceOf(UserMessage::class); +}); + +test('it handles non-array payloads gracefully', function (): void { + $middleware = new SkillMiddleware($this->registry); + $config = new RuntimeConfig(context: new Context([ + UseSkillTool::ACTIVE_SKILLS_KEY => [ + 'create-rule' => true, + ], + ])); + + $payload = 'string payload'; + $resultPayload = null; + + $middleware->beforePrompt($payload, $config, function ($p, $c) use (&$resultPayload) { + $resultPayload = $p; + + return $p; + }); + + // Non-array payloads should pass through unchanged + expect($resultPayload)->toBe('string payload'); +}); diff --git a/tests/Unit/Agents/SkillsAgentTest.php b/tests/Unit/Agents/SkillsAgentTest.php new file mode 100644 index 0000000..10f60c1 --- /dev/null +++ b/tests/Unit/Agents/SkillsAgentTest.php @@ -0,0 +1,96 @@ +fixturesPath = __DIR__ . '/../../fixtures/skills'; +}); + +test('it can build an agent with skills directory', function (): void { + $agent = new SkillsAgent() + ->withSkillsDirectory($this->fixturesPath) + ->build(); + + expect($agent)->toBeInstanceOf(Agent::class); + expect($agent->getId())->toBe('skills_agent'); + expect($agent->getName())->toBe('Skills Agent'); +}); + +test('it can build an agent with custom registry', function (): void { + $registry = new SkillRegistry(); + $registry->registerFromDirectory($this->fixturesPath); + + $agent = new SkillsAgent() + ->withRegistry($registry) + ->build(); + + expect($agent)->toBeInstanceOf(Agent::class); + expect($agent->getTools())->toHaveCount(4); +}); + +test('it provides skill tools', function (): void { + $agent = new SkillsAgent() + ->withSkillsDirectory($this->fixturesPath) + ->build(); + + $tools = $agent->getTools(); + + expect($tools)->toHaveCount(4); + + $toolNames = array_map(fn(Closure|Tool|string $tool): string => $tool->name(), $tools); + expect($toolNames)->toContain('list_skills'); + expect($toolNames)->toContain('read_skill'); + expect($toolNames)->toContain('use_skill'); +}); + +test('it has skill middleware configured', function (): void { + $builder = new SkillsAgent() + ->withSkillsDirectory($this->fixturesPath); + + $middleware = $builder->middleware(); + + expect($middleware)->toHaveCount(1); + expect($middleware[0])->toBeInstanceOf(SkillMiddleware::class); +}); + +test('it can configure auto-activate skills', function (): void { + $builder = new SkillsAgent() + ->withSkillsDirectory($this->fixturesPath) + ->withAutoActivateSkills(['create-rule']); + + $middleware = $builder->middleware(); + + expect($middleware)->toHaveCount(1); + expect($middleware[0])->toBeInstanceOf(SkillMiddleware::class); +}); + +test('it has higher max steps for skill workflows', function (): void { + $builder = new SkillsAgent(); + + expect($builder->maxSteps())->toBe(10); +}); + +test('it can be created via static make method', function (): void { + $agent = SkillsAgent::make([ + 'skillsDirectory' => $this->fixturesPath, + ]); + + expect($agent)->toBeInstanceOf(Agent::class); +}); + +test('builder methods return self for fluent interface', function (): void { + $builder = new SkillsAgent(); + + expect($builder->withSkillsDirectory($this->fixturesPath))->toBe($builder); + expect($builder->withAutoActivateSkills(['test']))->toBe($builder); + expect($builder->withRegistry(new SkillRegistry()))->toBe($builder); +}); diff --git a/tests/Unit/CortexTest.php b/tests/Unit/CortexTest.php index c1ad688..f1d19b8 100644 --- a/tests/Unit/CortexTest.php +++ b/tests/Unit/CortexTest.php @@ -9,11 +9,11 @@ use Cortex\Prompts\Prompt; use Cortex\LLM\Contracts\LLM; use Cortex\LLM\Data\Messages\UserMessage; -use Cortex\LLM\Drivers\OpenAI\Chat\OpenAIChat; use Cortex\Prompts\Builders\ChatPromptBuilder; use Cortex\Prompts\Builders\TextPromptBuilder; use Cortex\Agents\Prebuilt\GenericAgentBuilder; use Cortex\LLM\Data\Messages\MessageCollection; +use Cortex\LLM\Drivers\OpenAI\Responses\OpenAIResponses; use Cortex\Embeddings\Contracts\Embeddings as EmbeddingsContract; describe('Cortex', function (): void { @@ -49,20 +49,30 @@ describe('llm()', function (): void { test('it can get an LLM instance with provider and model', function (): void { - expect(Cortex::llm('openai', 'gpt-4o'))->toBeInstanceOf(OpenAIChat::class); + expect(Cortex::llm('openai', 'gpt-5'))->toBeInstanceOf(OpenAIResponses::class); }); test('it can get an LLM instance via shortcut string', function (): void { - expect(Cortex::llm('openai/gpt-4o'))->toBeInstanceOf(OpenAIChat::class); + expect(Cortex::llm('openai/gpt-5'))->toBeInstanceOf(OpenAIResponses::class); }); test('it can get an LLM instance with a closure', function (): void { $llm = Cortex::llm('openai', function (LLM $llm): LLM { - return $llm->withModel('gpt-4o'); + return $llm->withModel('gpt-5'); }); - expect($llm)->toBeInstanceOf(OpenAIChat::class) - ->and($llm->getModel())->toBe('gpt-4o'); + expect($llm)->toBeInstanceOf(OpenAIResponses::class) + ->and($llm->getModel())->toBe('gpt-5'); + }); + + test('it can get an LLM instance with a closure and model', function (): void { + $llm = Cortex::llm('openai/gpt-5', function (LLM $llm): LLM { + return $llm->withTemperature(0.5); + }); + + expect($llm)->toBeInstanceOf(OpenAIResponses::class) + ->and($llm->getModel())->toBe('gpt-5') + ->and($llm->getTemperature())->toBe(0.5); }); test('it can get an LLM instance with null provider', function (): void { @@ -124,7 +134,7 @@ test('it can get an agent from registry by name', function (): void { // Register a test agent first $testAgent = new Agent( - name: 'test-agent', + id: 'test-agent', prompt: 'You are a test agent.', ); @@ -133,14 +143,14 @@ $agent = Cortex::agent('test-agent'); expect($agent)->toBeInstanceOf(Agent::class); - expect($agent->getName())->toBe('test-agent'); + expect($agent->getId())->toBe('test-agent'); }); }); describe('registerAgent()', function (): void { test('it can register an agent instance', function (): void { $agent = new Agent( - name: 'registered-agent', + id: 'registered-agent', prompt: 'You are a registered agent.', ); @@ -151,14 +161,14 @@ test('it can register an agent with a name override', function (): void { $agent = new Agent( - name: 'original-name', + id: 'original-name', prompt: 'You are an agent.', ); Cortex::registerAgent($agent, 'override-name'); expect(Cortex::agent('override-name'))->toBeInstanceOf(Agent::class); - expect(Cortex::agent('override-name')->getName())->toBe('original-name'); + expect(Cortex::agent('override-name')->getId())->toBe('original-name'); }); }); }); diff --git a/tests/Unit/LLM/Data/Messages/AssistantMessageTest.php b/tests/Unit/LLM/Data/Messages/AssistantMessageTest.php index e9af039..562a98b 100644 --- a/tests/Unit/LLM/Data/Messages/AssistantMessageTest.php +++ b/tests/Unit/LLM/Data/Messages/AssistantMessageTest.php @@ -9,6 +9,9 @@ use Cortex\LLM\Enums\MessageRole; use Cortex\LLM\Data\ToolCallCollection; use Cortex\LLM\Data\Messages\AssistantMessage; +use Cortex\LLM\Data\Messages\Content\TextContent; +use Cortex\LLM\Data\Messages\Content\ImageContent; +use Cortex\LLM\Data\Messages\Content\ReasoningContent; test('it can create an assistant message', function (): void { $message = new AssistantMessage('Hello, how are you?'); @@ -43,3 +46,73 @@ expect($message->toolCalls)->toBe($toolCalls); expect($message->hasToolCalls())->toBeTrue(); }); + +test('it concatenates multiple text content blocks', function (): void { + $message = new AssistantMessage([ + new TextContent('First part'), + new TextContent('Second part'), + new TextContent('Third part'), + ]); + + expect($message->text())->toBe("First part\nSecond part\nThird part"); +}); + +test('it extracts text from mixed content array', function (): void { + $message = new AssistantMessage([ + new TextContent('Text content'), + new ImageContent('data:image/png;base64,abc123'), + new TextContent('More text'), + ]); + + expect($message->text())->toBe("Text content\nMore text"); +}); + +test('it returns null for text when content is null', function (): void { + $message = new AssistantMessage(); + + expect($message->text())->toBeNull(); +}); + +test('it returns null for text when no text content exists', function (): void { + $message = new AssistantMessage([ + new ImageContent('data:image/png;base64,abc123'), + ]); + + expect($message->text())->toBeNull(); +}); + +test('it concatenates multiple reasoning content blocks', function (): void { + $message = new AssistantMessage([ + new ReasoningContent('First reasoning'), + new ReasoningContent('Second reasoning'), + new ReasoningContent('Third reasoning'), + ]); + + expect($message->reasoning())->toBe("First reasoning\nSecond reasoning\nThird reasoning"); +}); + +test('it extracts reasoning from mixed content array', function (): void { + $message = new AssistantMessage([ + new TextContent('Text content'), + new ReasoningContent('Reasoning content'), + new ImageContent('data:image/png;base64,abc123'), + new ReasoningContent('More reasoning'), + ]); + + expect($message->reasoning())->toBe("Reasoning content\nMore reasoning"); +}); + +test('it returns null for reasoning when content is null', function (): void { + $message = new AssistantMessage(); + + expect($message->reasoning())->toBeNull(); +}); + +test('it returns null for reasoning when no reasoning content exists', function (): void { + $message = new AssistantMessage([ + new TextContent('Text content'), + new ImageContent('data:image/png;base64,abc123'), + ]); + + expect($message->reasoning())->toBeNull(); +}); diff --git a/tests/Unit/LLM/Drivers/Anthropic/AnthropicChatTest.php b/tests/Unit/LLM/Drivers/Anthropic/AnthropicChatTest.php index e06d13e..938cd30 100644 --- a/tests/Unit/LLM/Drivers/Anthropic/AnthropicChatTest.php +++ b/tests/Unit/LLM/Drivers/Anthropic/AnthropicChatTest.php @@ -4,107 +4,259 @@ namespace Cortex\Tests\Unit\LLM\Drivers\Anthropic; -use Cortex\Cortex; use Cortex\LLM\Data\Usage; use Cortex\Attributes\Tool; -use Cortex\Tools\SchemaTool; use Cortex\JsonSchema\Schema; use Cortex\LLM\Data\ToolCall; use Cortex\LLM\Data\ChatResult; +use Cortex\LLM\Enums\ChunkType; use Cortex\LLM\Data\FunctionCall; +use Saloon\Http\Faking\MockClient; use Cortex\LLM\Data\ChatGeneration; +use Saloon\Http\Faking\MockResponse; use Cortex\LLM\Data\ChatStreamResult; use Cortex\LLM\Data\ToolCallCollection; use Cortex\LLM\Data\ChatGenerationChunk; use Cortex\ModelInfo\Enums\ModelFeature; +use Cortex\LLM\Data\Messages\ToolMessage; use Cortex\LLM\Data\Messages\UserMessage; -use Cortex\LLM\Enums\StructuredOutputMode; -use Anthropic\Responses\Meta\MetaInformation; use Cortex\LLM\Data\Messages\AssistantMessage; use Cortex\LLM\Drivers\Anthropic\AnthropicChat; +use Cortex\SDK\Anthropic\Requests\CreateMessage; use Cortex\LLM\Data\Messages\Content\TextContent; -use Anthropic\Responses\Messages\CreateResponse as ChatCreateResponse; -use Anthropic\Responses\Messages\CreateStreamedResponse as ChatCreateStreamedResponse; +use Cortex\LLM\Data\Messages\Content\ImageContent; +use Cortex\LLM\Data\Messages\Content\ReasoningContent; test('it responds to messages', function (): void { $llm = AnthropicChat::fake([ - ChatCreateResponse::fake([ - 'content' => [ - [ - 'type' => 'text', - 'text' => 'I am doing well, thank you for asking!', - ], - ], - ]), + CreateMessage::class => MockResponse::fixture('anthropic/messages/simple'), ]); - $result = $llm->invoke([ + $result = $llm->includeRaw()->invoke([ new UserMessage('Hello, how are you?'), ]); expect($result)->toBeInstanceOf(ChatResult::class) - ->and($result->rawResponse) - ->toBeArray()->not->toBeEmpty() - ->and($result->generation) - ->toBeInstanceOf(ChatGeneration::class) - ->and($result->generation->message) - ->toBeInstanceOf(AssistantMessage::class) - ->and($result->generation->message->content) - ->toBeArray() - ->toContainOnlyInstancesOf(TextContent::class) - ->and($result->generation->message->content[0]->text) - ->toBe('I am doing well, thank you for asking!'); + ->and($result->rawResponse)->toBeArray()->not->toBeEmpty() + ->and($result->generation)->toBeInstanceOf(ChatGeneration::class) + ->and($result->generation->message)->toBeInstanceOf(AssistantMessage::class) + ->and($result->generation->message->content)->toBeArray()->toContainOnlyInstancesOf(TextContent::class) + ->and($result->generation->message->content[0]->text)->toBe("Hello! I'm doing well, thank you for asking. How are you doing today? Is there anything I can help you with?"); }); test('it can stream', function (): void { $llm = AnthropicChat::fake([ - ChatCreateStreamedResponse::fake(fopen(__DIR__ . '/../../../../fixtures/anthropic/chat-stream.txt', 'r')), + CreateMessage::class => MockResponse::fixture('anthropic/messages/simple-streamed'), ]); $llm->withStreaming(); - $chunks = $llm->invoke([ + $chunks = $llm->includeRaw()->invoke([ new UserMessage('Hello, how are you?'), ]); expect($chunks)->toBeInstanceOf(ChatStreamResult::class); - $output = $chunks->reduce(function (string $carry, ChatGenerationChunk $chunk) { + $allChunks = []; + $output = $chunks->reduce(function (string $carry, ChatGenerationChunk $chunk) use (&$allChunks): string { + $allChunks[] = $chunk; + expect($chunk)->toBeInstanceOf(ChatGenerationChunk::class) - ->and($chunk->message)->toBeInstanceOf(AssistantMessage::class); + ->and($chunk->message)->toBeInstanceOf(AssistantMessage::class) + ->and($chunk->contentSoFar)->toBeArray(); + + // Verify contentSoFar contains content objects + foreach ($chunk->contentSoFar as $content) { + expect($content instanceof TextContent || $content instanceof ReasoningContent)->toBeTrue(); + } - return $carry . ($chunk->message->content ?? ''); + return $carry . ($chunk->message->text() ?? ''); }, ''); - expect($output)->toBe('Hello!'); + expect($output)->toBe("Hello! I'm doing well, thank you for asking. How are you doing today? Is there anything I can help you with?"); + + // Verify final chunk has complete content in contentSoFar + $finalChunk = null; + foreach ($allChunks as $chunk) { + if ($chunk->isFinal) { + $finalChunk = $chunk; + } + } + + // If still no chunks, something is wrong + expect(count($allChunks))->toBeGreaterThan(0, 'Expected at least one chunk to be yielded') + ->and($finalChunk)->not->toBeNull() + ->and($finalChunk->contentSoFar)->toBeArray() + ->and($finalChunk->contentSoFar)->toHaveCount(1) + ->and($finalChunk->contentSoFar[0])->toBeInstanceOf(TextContent::class) + ->and($finalChunk->contentSoFar[0]->text)->toBe("Hello! I'm doing well, thank you for asking. How are you doing today? Is there anything I can help you with?"); +}); + +test('it can stream with reasoning content', function (): void { + $llm = AnthropicChat::fake([ + CreateMessage::class => MockResponse::fixture('anthropic/messages/thinking-streamed'), + ]); + + $llm->withStreaming(); + + $chunks = $llm->includeRaw()->stream([ + new UserMessage('What is the mass of the Moon?'), + ]); + + expect($chunks)->toBeInstanceOf(ChatStreamResult::class); + + $hasReasoningContent = false; + $hasTextContent = false; + $finalChunk = null; + + foreach ($chunks as $chunk) { + expect($chunk)->toBeInstanceOf(ChatGenerationChunk::class) + ->and($chunk->contentSoFar)->toBeArray(); + + // Verify contentSoFar contains content objects + foreach ($chunk->contentSoFar as $content) { + expect($content instanceof TextContent || $content instanceof ReasoningContent)->toBeTrue(); + + if ($content instanceof ReasoningContent) { + $hasReasoningContent = true; + expect($content->reasoning)->toBeString(); + } + + if ($content instanceof TextContent) { + $hasTextContent = true; + } + } + + if ($chunk->isFinal) { + $finalChunk = $chunk; + } + } + + expect($hasReasoningContent)->toBeTrue('Expected at least one chunk to have reasoning content') + ->and($hasTextContent)->toBeTrue('Expected at least one chunk to have text content') + ->and($finalChunk)->not->toBeNull() + ->and($finalChunk->contentSoFar)->toBeArray() + ->and($finalChunk->contentSoFar)->toHaveCount(2) // Should have both reasoning and text content + ->and($finalChunk->contentSoFar[0])->toBeInstanceOf(ReasoningContent::class) + ->and($finalChunk->contentSoFar[1])->toBeInstanceOf(TextContent::class); +}); + +test('it emits correct chunk types for content block end events', function (): void { + $llm = AnthropicChat::fake([ + CreateMessage::class => MockResponse::fixture('anthropic/messages/thinking-streamed'), + ]); + + $llm->withStreaming(); + + $chunks = $llm->stream([ + new UserMessage('What is the mass of the Moon?'), + ]); + + expect($chunks)->toBeInstanceOf(ChatStreamResult::class); + + $chunkTypes = []; + + foreach ($chunks as $chunk) { + $chunkTypes[] = $chunk->type; + } + + // Verify we have the correct sequence of chunk types + // The thinking-streamed fixture has: thinking block (index 0) then text block (index 1) + expect($chunkTypes)->toContain(ChunkType::MessageStart) + ->and($chunkTypes)->toContain(ChunkType::ReasoningStart) + ->and($chunkTypes)->toContain(ChunkType::ReasoningDelta) + ->and($chunkTypes)->toContain(ChunkType::ReasoningEnd) + ->and($chunkTypes)->toContain(ChunkType::TextStart) + ->and($chunkTypes)->toContain(ChunkType::TextDelta) + ->and($chunkTypes)->toContain(ChunkType::TextEnd); + + // Verify the order: ReasoningEnd should come before TextStart + $reasoningEndIndex = array_search(ChunkType::ReasoningEnd, $chunkTypes, true); + $textStartIndex = array_search(ChunkType::TextStart, $chunkTypes, true); + + expect($reasoningEndIndex)->toBeLessThan($textStartIndex); +}); + +test('it emits TextEnd for text-only streaming', function (): void { + $llm = AnthropicChat::fake([ + CreateMessage::class => MockResponse::fixture('anthropic/messages/simple-streamed'), + ]); + + $llm->withStreaming(); + + $chunks = $llm->stream([ + new UserMessage('Hello, how are you?'), + ]); + + expect($chunks)->toBeInstanceOf(ChatStreamResult::class); + + $chunkTypes = []; + + foreach ($chunks as $chunk) { + $chunkTypes[] = $chunk->type; + } + + // Verify we have the correct chunk types for text-only streaming + expect($chunkTypes)->toContain(ChunkType::MessageStart) + ->and($chunkTypes)->toContain(ChunkType::TextStart) + ->and($chunkTypes)->toContain(ChunkType::TextDelta) + ->and($chunkTypes)->toContain(ChunkType::TextEnd); + + // Verify we don't have any reasoning chunk types + expect($chunkTypes)->not->toContain(ChunkType::ReasoningStart) + ->and($chunkTypes)->not->toContain(ChunkType::ReasoningDelta) + ->and($chunkTypes)->not->toContain(ChunkType::ReasoningEnd); +}); + +test('it tracks contentSoFar incrementally during streaming', function (): void { + $llm = AnthropicChat::fake([ + CreateMessage::class => MockResponse::fixture('anthropic/messages/simple-streamed'), + ]); + + $llm->withStreaming(); + + $chunks = $llm->invoke([ + new UserMessage('Hello, how are you?'), + ]); + + $contentSoFarLengths = []; + $hasPartialContent = false; + + foreach ($chunks as $chunk) { + expect($chunk->contentSoFar)->toBeArray(); + + $contentSoFarLengths[] = count($chunk->contentSoFar); + + // During streaming, contentSoFar should include partial content being built + if ($chunk->contentSoFar !== []) { + $hasPartialContent = true; + // Verify the last item is a content object + $contentArray = $chunk->contentSoFar; + $lastContent = $contentArray[count($contentArray) - 1]; + expect($lastContent instanceof TextContent || $lastContent instanceof ReasoningContent)->toBeTrue(); + } + } + + // Verify that contentSoFar grows during streaming + expect($hasPartialContent)->toBeTrue('Expected contentSoFar to include partial content during streaming') + ->and($contentSoFarLengths)->toContain(1) // Should have at least one content item at some point + ->and(max($contentSoFarLengths))->toBeGreaterThanOrEqual(1); // Should reach at least 1 item }); test('it can use tools', function (): void { $llm = AnthropicChat::fake([ - createAnthropicResponse([ - 'content' => [ - [ - 'type' => 'tool_use', - 'id' => 'call_123', - 'name' => 'multiply', - 'input' => [ - 'x' => 3, - 'y' => 4, - ], - ], - ], - ]), + CreateMessage::class => MockResponse::fixture('anthropic/messages/tool-use'), ]); $llm->addFeature(ModelFeature::ToolCalling); $llm->withTools([ - #[Tool(name: 'multiply', description: 'Multiply two numbers')] - fn(int $x, int $y): int => $x * $y, + #[Tool(name: 'get_weather', description: 'Get the current weather in a given location')] + fn(string $location): string => sprintf('The weather in %s is raining', $location), ]); $result = $llm->invoke([ - new UserMessage('What is 3 times 4?'), + new UserMessage('What is the weather in Manchester?'), ]); expect($result->generation->message->toolCalls) @@ -116,127 +268,71 @@ ->and($result->generation->message->toolCalls[0]->function) ->toBeInstanceOf(FunctionCall::class) ->and($result->generation->message->toolCalls[0]->function->name) - ->toBe('multiply') + ->toBe('get_weather') ->and($result->generation->message->toolCalls[0]->function->arguments) ->toBe([ - 'x' => 3, - 'y' => 4, + 'location' => 'Manchester, UK', ]); }); test('it can use structured output', function (): void { - $response = createAnthropicResponse([ - 'stop_reason' => 'end_turn', - 'content' => [ - [ - 'type' => 'text', - 'text' => '{"name":"John Doe","age":30}', - ], - ], - ]); - $llm = AnthropicChat::fake([ - $response, - ], 'claude-3-5-sonnet-20241022'); + CreateMessage::class => MockResponse::fixture('anthropic/messages/structured-output'), + ]); $llm->addFeature(ModelFeature::StructuredOutput); - $llm->withStructuredOutput( - Schema::object()->properties( - Schema::string('name'), - Schema::integer('age'), - ), + Schema::object() + ->properties( + Schema::string('name')->required(), + Schema::string('email')->required(), + Schema::string('plan_interest')->required(), + Schema::boolean('demo_requested')->required(), + ) + ->additionalProperties(false), name: 'Person', description: 'A person with a name and age', ); $result = $llm->invoke([ - new UserMessage('Tell me about a person'), + new UserMessage('Extract the key information from this email: John Smith (john@example.com) is interested in our Enterprise plan and wants to schedule a demo for next Tuesday at 2pm.'), ]); expect($result->generation->message->content) ->toBeArray() ->toContainOnlyInstancesOf(TextContent::class) ->and($result->generation->message->content[0]->text) - ->toBe('{"name":"John Doe","age":30}'); + ->toBe('{"name":"John Smith","email":"john@example.com","plan_interest":"Enterprise","demo_requested":true}'); expect($result->generation->parsedOutput)->toBe([ - 'name' => 'John Doe', - 'age' => 30, + 'name' => 'John Smith', + 'email' => 'john@example.com', + 'plan_interest' => 'Enterprise', + 'demo_requested' => true, ]); }); -test('it can use structured output using the schema tool', function (): void { - $response = createAnthropicResponse([ - 'stop_reason' => 'tool_use', - 'content' => [ - [ - 'type' => 'tool_use', - 'id' => 'call_123', - 'name' => SchemaTool::NAME, - 'input' => [ - 'name' => 'John Doe', - 'age' => 30, - ], - ], - ], - ]); - - $llm = AnthropicChat::fake([ - $response, - ], 'claude-3-5-sonnet-20241022'); - - $llm->addFeature(ModelFeature::ToolCalling); - - $schema = Schema::object('Person')->properties( - Schema::string('name'), - Schema::integer('age'), - ); - - $llm->withStructuredOutput($schema, outputMode: StructuredOutputMode::Tool); - - $result = $llm->invoke([ - new UserMessage('Tell me about a person'), - ]); - - expect($result->parsedOutput) - ->toBe([ - 'name' => 'John Doe', - 'age' => 30, - ]); - - /** @var \Anthropic\Testing\ClientFake $client */ - $client = $llm->getClient(); - - $client->messages()->assertSent(function (string $method, array $parameters): bool { - return $parameters['model'] === 'claude-3-5-sonnet-20241022' - && $parameters['messages'][0]['role'] === 'user' - && $parameters['messages'][0]['content'] === 'Tell me about a person' - && $parameters['tools'][0]['type'] === 'custom' - && $parameters['tools'][0]['name'] === SchemaTool::NAME - && $parameters['tools'][0]['input_schema']['properties']['name']['type'] === 'string' - && $parameters['tools'][0]['input_schema']['properties']['age']['type'] === 'integer'; - }); -}); - test('it can stream with structured output', function (): void { $llm = AnthropicChat::fake([ - ChatCreateStreamedResponse::fake(fopen(__DIR__ . '/../../../../fixtures/anthropic/chat-stream-json.txt', 'r')), - ], 'claude-3-5-sonnet-20241022'); + CreateMessage::class => MockResponse::fixture('anthropic/messages/structured-output-streamed'), + ]); $llm->addFeature(ModelFeature::StructuredOutput); $llm->withStreaming(); - class AnthropicJoke - { - public function __construct( - public ?string $setup = null, - public ?string $punchline = null, - ) {} - } + $llm->withStructuredOutput( + Schema::object() + ->properties( + Schema::string('name')->required(), + Schema::string('email')->required(), + Schema::string('plan_interest')->required(), + Schema::boolean('demo_requested')->required(), + ) + ->additionalProperties(false), + ); - $result = $llm->withStructuredOutput(AnthropicJoke::class)->invoke([ - new UserMessage('Tell me a joke about dogs'), + $result = $llm->invoke([ + new UserMessage('Extract the key information from this email: John Smith (john@example.com) is interested in our Enterprise plan and wants to schedule a demo for next Tuesday at 2pm.'), ]); expect($result)->toBeInstanceOf(ChatStreamResult::class); @@ -248,257 +344,602 @@ public function __construct( // Check if this chunk has the parsed output we expect if ($chunk->isFinal) { - expect($chunk->parsedOutput->setup)->toBe('Why did the dog sit in the shade?') - ->and($chunk->parsedOutput->punchline)->toBe("Because he didn't want to be a hot dog!"); + expect($chunk->parsedOutput)->toBe([ + 'name' => 'John Smith', + 'email' => 'john@example.com', + 'plan_interest' => 'Enterprise', + 'demo_requested' => true, + ]); $hasValidParsedOutput = true; } }); expect($hasValidParsedOutput)->toBeTrue('Expected at least one chunk to have valid parsed output'); - - /** @var \Anthropic\Testing\ClientFake $client */ - $client = $llm->getClient(); - - $client->messages()->assertSent(function (string $method, array $parameters): bool { - return $parameters['model'] === 'claude-3-5-sonnet-20241022' - && $parameters['messages'][0]['role'] === 'user' - && $parameters['messages'][0]['content'] === 'Tell me a joke about dogs'; - }); }); test('it can stream with tool calls', function (): void { $llm = AnthropicChat::fake([ - ChatCreateStreamedResponse::fake(fopen(__DIR__ . '/../../../../fixtures/anthropic/chat-stream-tool-calls.txt', 'r')), - ], 'claude-3-5-sonnet-20241022'); + CreateMessage::class => MockResponse::fixture('anthropic/messages/tool-use-streamed'), + ]); $llm->addFeature(ModelFeature::ToolCalling); $llm->withStreaming(); $llm->withTools([ - #[Tool(name: 'multiply', description: 'Multiply two numbers')] - fn(int $x, int $y): int => $x * $y, + #[Tool(name: 'get_weather', description: 'Get the current weather in a given location')] + fn(string $location): string => sprintf('The weather in %s is raining', $location), ]); $result = $llm->invoke([ - new UserMessage('What is 3 times 4?'), + new UserMessage('What is the weather in Manchester?'), ]); expect($result)->toBeInstanceOf(ChatStreamResult::class); - $hasToolCalls = false; + $chunkTypes = []; + + /** @var \Cortex\LLM\Data\ChatGenerationChunk|null $toolInputEndChunk */ + $toolInputEndChunk = null; $toolCallsAccumulated = false; - $result->each(function (ChatGenerationChunk $chunk) use (&$hasToolCalls, &$toolCallsAccumulated): void { + $result->each(function (ChatGenerationChunk $chunk) use (&$chunkTypes, &$toolInputEndChunk, &$toolCallsAccumulated): void { expect($chunk)->toBeInstanceOf(ChatGenerationChunk::class) ->and($chunk->message)->toBeInstanceOf(AssistantMessage::class); - // Check if we have tool calls in any chunk - if ($chunk->message->toolCalls?->isNotEmpty()) { - $hasToolCalls = true; - - // Check if tool calls are properly accumulated - if ($chunk->isFinal) { - expect($chunk->message->toolCalls) - ->toBeInstanceOf(ToolCallCollection::class) - ->and($chunk->message->toolCalls) - ->toHaveCount(1) - ->and($chunk->message->toolCalls[0]) - ->toBeInstanceOf(ToolCall::class) - ->and($chunk->message->toolCalls[0]->id) - ->toBe('call_multiply_123') - ->and($chunk->message->toolCalls[0]->function) - ->toBeInstanceOf(FunctionCall::class) - ->and($chunk->message->toolCalls[0]->function->name) - ->toBe('multiply') - ->and($chunk->message->toolCalls[0]->function->arguments) - ->toBe([ - 'x' => 3, - 'y' => 4, - ]); - - $toolCallsAccumulated = true; - } - } - }); - - expect($hasToolCalls)->toBeTrue('Expected at least one chunk to have tool calls') - ->and($toolCallsAccumulated)->toBeTrue('Expected final chunk to have complete tool call data'); + $chunkTypes[] = $chunk->type; - /** @var \Anthropic\Testing\ClientFake $client */ - $client = $llm->getClient(); + // Check if tool calls are being accumulated + if ($chunk->message->toolCalls !== null && $chunk->message->toolCalls->isNotEmpty()) { + $toolCallsAccumulated = true; + } - $client->messages()->assertSent(function (string $method, array $parameters): bool { - return $parameters['model'] === 'claude-3-5-sonnet-20241022' - && $parameters['messages'][0]['role'] === 'user' - && $parameters['messages'][0]['content'] === 'What is 3 times 4?' - && $parameters['tools'][0]['type'] === 'custom' - && $parameters['tools'][0]['name'] === 'multiply'; + if ($chunk->type === ChunkType::ToolInputEnd) { + $toolInputEndChunk = $chunk; + } }); + + // Verify we have the correct chunk types for tool call streaming + expect($chunkTypes)->toContain(ChunkType::MessageStart) + ->and($chunkTypes)->toContain(ChunkType::ToolInputStart) + ->and($chunkTypes)->toContain(ChunkType::ToolInputDelta) + ->and($chunkTypes)->toContain(ChunkType::ToolInputEnd); + + // Verify tool calls were accumulated during streaming + expect($toolCallsAccumulated)->toBeTrue('Expected tool calls to be accumulated during streaming'); + + // Verify the ToolInputEnd chunk has the complete tool call + expect($toolInputEndChunk)->not->toBeNull('Expected a ToolInputEnd chunk') + ->and($toolInputEndChunk->message->toolCalls)->toBeInstanceOf(ToolCallCollection::class) + ->and($toolInputEndChunk->message->toolCalls)->toHaveCount(1) + ->and($toolInputEndChunk->message->toolCalls[0])->toBeInstanceOf(ToolCall::class) + ->and($toolInputEndChunk->message->toolCalls[0]->id)->toBe('toolu_017cgnjBthRJC2WTaB6As9FB') + ->and($toolInputEndChunk->message->toolCalls[0]->function)->toBeInstanceOf(FunctionCall::class) + ->and($toolInputEndChunk->message->toolCalls[0]->function->name)->toBe('get_weather') + ->and($toolInputEndChunk->message->toolCalls[0]->function->arguments)->toBe([ + 'location' => 'Manchester, UK', + ]); }); -test('it can use structured output with an enum', function (): void { +test('it can use structured output with a class', function (): void { + // structured-output.json returns: + // {"name":"John Smith","email":"john@example.com","plan_interest":"Enterprise","demo_requested":true} $llm = AnthropicChat::fake([ - createAnthropicResponse([ - 'content' => [ - [ - 'type' => 'text', - 'text' => '{"AnthropicSentiment":"positive"}', - ], - ], - ]), - createAnthropicResponse([ - 'content' => [ - [ - 'type' => 'text', - 'text' => '{"AnthropicSentiment":"neutral"}', - ], - ], - ]), - createAnthropicResponse([ - 'content' => [ - [ - 'type' => 'text', - 'text' => '{"AnthropicSentiment":"negative"}', - ], - ], - ]), - createAnthropicResponse([ - 'content' => [ - [ - 'type' => 'text', - 'text' => '{"AnthropicSentiment":"neutral"}', - ], - ], - ]), - ], 'claude-3-5-sonnet-20241022'); + CreateMessage::class => MockResponse::fixture('anthropic/messages/structured-output'), + ]); $llm->addFeature(ModelFeature::StructuredOutput); - enum AnthropicSentiment: string + class AnthropicProspect { - case Positive = 'positive'; - case Negative = 'negative'; - case Neutral = 'neutral'; + public function __construct( + public string $name, + public string $email, + public string $plan_interest, + public bool $demo_requested, + ) {} } - $llm->withStructuredOutput(AnthropicSentiment::class); + $llm->withStructuredOutput(AnthropicProspect::class); - expect($llm->invoke('Analyze the sentiment of this text: This pizza is awesome')->parsedOutput) - ->toBe(AnthropicSentiment::Positive); + $result = $llm->invoke('Extract the key information from this email'); - expect($llm->invoke('Analyze the sentiment of this text: This pizza is okay')->parsedOutput) - ->toBe(AnthropicSentiment::Neutral); - - expect($llm->invoke('Analyze the sentiment of this text: This pizza is terrible')->parsedOutput) - ->toBe(AnthropicSentiment::Negative); + expect($result)->toBeInstanceOf(ChatResult::class) + ->and($result->parsedOutput)->toBeInstanceOf(AnthropicProspect::class) + ->and($result->parsedOutput->name)->toBe('John Smith') + ->and($result->parsedOutput->email)->toBe('john@example.com') + ->and($result->parsedOutput->plan_interest)->toBe('Enterprise') + ->and($result->parsedOutput->demo_requested)->toBeTrue(); +}); - $getSentiment = Cortex::prompt('Analyze the sentiment of this text: {input}')->llm($llm); +test('it can set temperature and max tokens', function (): void { + $llm = AnthropicChat::fake([ + CreateMessage::class => MockResponse::fixture('anthropic/messages/simple'), + ]); - $result = $getSentiment->invoke('This pizza is average'); + $result = $llm->withTemperature(0.7)->withMaxTokens(100)->invoke([ + new UserMessage('Hello'), + ]); + // Verify the request completes successfully with the configured parameters expect($result)->toBeInstanceOf(ChatResult::class) - ->and($result->parsedOutput)->toBe(AnthropicSentiment::Neutral); + ->and($result->generation->message->content)->toBeArray() + ->toContainOnlyInstancesOf(TextContent::class); + + MockClient::getGlobal()->assertSent(function (CreateMessage $request): bool { + return $request->body()->get('temperature') === 0.7 && $request->body()->get('max_tokens') === 100; + }); }); -test('it can force json output', function (): void { - $response = createAnthropicResponse([ - 'content' => [ - [ - 'type' => 'text', - 'text' => '{"setup":"Why did the scarecrow win an award?","punchline":"Because he was outstanding in his field!"}', - ], - ], +test('it tracks token usage', function (): void { + // The simple.json fixture includes usage: input_tokens: 13, output_tokens: 30 + $llm = AnthropicChat::fake([ + CreateMessage::class => MockResponse::fixture('anthropic/messages/simple'), + ]); + + $result = $llm->invoke([ + new UserMessage('Hello'), ]); + expect($result->usage) + ->toBeInstanceOf(Usage::class) + ->and($result->usage->promptTokens)->toBe(13) + ->and($result->usage->completionTokens)->toBe(30); +}); + +test('it formats tool messages as tool_result content blocks', function (): void { $llm = AnthropicChat::fake([ - $response, - ], 'claude-3-5-sonnet-20241022'); + CreateMessage::class => MockResponse::fixture('anthropic/messages/simple'), + ]); - $llm->addFeature(ModelFeature::JsonOutput); - $llm->forceJsonOutput(); + $llm->invoke([ + new UserMessage('What is the weather?'), + new AssistantMessage( + toolCalls: new ToolCallCollection([ + new ToolCall('tool_123', new FunctionCall('get_weather', [ + 'location' => 'London', + ])), + ]), + ), + new ToolMessage('The weather in London is sunny and 22°C', 'tool_123'), + ]); - $result = $llm->invoke([ - new UserMessage('Tell me a joke'), + MockClient::getGlobal()->assertSent(function (CreateMessage $request): bool { + $messages = $request->body()->get('messages'); + + // The third message should be the tool result + $toolResultMessage = $messages[2]; + + return $toolResultMessage['role'] === 'user' + && $toolResultMessage['content'][0]['type'] === 'tool_result' + && $toolResultMessage['content'][0]['tool_use_id'] === 'tool_123' + && $toolResultMessage['content'][0]['content'] === 'The weather in London is sunny and 22°C'; + }); +}); + +test('it formats assistant messages with tool calls as tool_use content blocks', function (): void { + $llm = AnthropicChat::fake([ + CreateMessage::class => MockResponse::fixture('anthropic/messages/simple'), ]); - expect($result->generation->message->content) - ->toBeArray() - ->toContainOnlyInstancesOf(TextContent::class) - ->and($result->generation->message->content[0]->text) - ->toBe('{"setup":"Why did the scarecrow win an award?","punchline":"Because he was outstanding in his field!"}'); + $llm->invoke([ + new UserMessage('What is the weather?'), + new AssistantMessage( + content: 'Let me check the weather for you.', + toolCalls: new ToolCallCollection([ + new ToolCall('tool_abc', new FunctionCall('get_weather', [ + 'location' => 'Paris', + ])), + ]), + ), + new ToolMessage('Rainy, 15°C', 'tool_abc'), + ]); + + MockClient::getGlobal()->assertSent(function (CreateMessage $request): bool { + $messages = $request->body()->get('messages'); + + // The second message should be the assistant message with tool call + $assistantMessage = $messages[1]; + + // Should have text content and tool_use content + $hasTextContent = false; + $hasToolUse = false; + + foreach ($assistantMessage['content'] as $block) { + if ($block['type'] === 'text' && $block['text'] === 'Let me check the weather for you.') { + $hasTextContent = true; + } + + if ($block['type'] === 'tool_use' + && $block['id'] === 'tool_abc' + && $block['name'] === 'get_weather' + && (array) $block['input'] === [ + 'location' => 'Paris', + ]) { + $hasToolUse = true; + } + } + + return $assistantMessage['role'] === 'assistant' && $hasTextContent && $hasToolUse; + }); }); -test('it can set temperature and max tokens', function (): void { +test('it formats assistant messages with multiple tool calls', function (): void { $llm = AnthropicChat::fake([ - createAnthropicResponse([ - 'content' => [ - [ - 'type' => 'text', - 'text' => 'Hello!', - ], - ], + CreateMessage::class => MockResponse::fixture('anthropic/messages/simple'), + ]); + + $llm->invoke([ + new UserMessage('Compare weather in London and Paris'), + new AssistantMessage( + toolCalls: new ToolCallCollection([ + new ToolCall('tool_1', new FunctionCall('get_weather', [ + 'location' => 'London', + ])), + new ToolCall('tool_2', new FunctionCall('get_weather', [ + 'location' => 'Paris', + ])), + ]), + ), + new ToolMessage('Sunny, 20°C', 'tool_1'), + new ToolMessage('Rainy, 15°C', 'tool_2'), + ]); + + MockClient::getGlobal()->assertSent(function (CreateMessage $request): bool { + $messages = $request->body()->get('messages'); + + $assistantMessage = $messages[1]; + + // Should have two tool_use blocks + $toolUseBlocks = array_filter( + $assistantMessage['content'], + fn(array $block): bool => $block['type'] === 'tool_use', + ); + + return count($toolUseBlocks) === 2; + }); +}); + +test('it formats user messages with text content', function (): void { + $llm = AnthropicChat::fake([ + CreateMessage::class => MockResponse::fixture('anthropic/messages/simple'), + ]); + + $llm->invoke([ + new UserMessage([ + new TextContent('Hello, how are you?'), ]), ]); - $llm->withTemperature(0.7)->withMaxTokens(100)->invoke([ - new UserMessage('Hello'), + MockClient::getGlobal()->assertSent(function (CreateMessage $request): bool { + $messages = $request->body()->get('messages'); + $userMessage = $messages[0]; + + return $userMessage['role'] === 'user' + && $userMessage['content'][0]['type'] === 'text' + && $userMessage['content'][0]['text'] === 'Hello, how are you?'; + }); +}); + +test('it formats image content with URL', function (): void { + $llm = AnthropicChat::fake([ + CreateMessage::class => MockResponse::fixture('anthropic/messages/simple'), ]); - /** @var \Anthropic\Testing\ClientFake $client */ - $client = $llm->getClient(); + $llm->addFeature(ModelFeature::Vision); + + $llm->invoke([ + new UserMessage([ + new TextContent('What is in this image?'), + new ImageContent('https://example.com/image.jpg'), + ]), + ]); + + MockClient::getGlobal()->assertSent(function (CreateMessage $request): bool { + $messages = $request->body()->get('messages'); + $userMessage = $messages[0]; + + $hasText = false; + $hasImage = false; + + foreach ($userMessage['content'] as $block) { + if ($block['type'] === 'text' && $block['text'] === 'What is in this image?') { + $hasText = true; + } + + if ($block['type'] === 'image' + && $block['source']['type'] === 'url' + && $block['source']['url'] === 'https://example.com/image.jpg') { + $hasImage = true; + } + } - $client->messages()->assertSent(function (string $method, array $parameters): bool { - return $parameters['temperature'] === 0.7 && $parameters['max_tokens'] === 100; + return $hasText && $hasImage; }); }); -test('it tracks token usage', function (): void { - $response = createAnthropicResponse([ - 'usage' => [ - 'input_tokens' => 10, - 'output_tokens' => 20, - ], - 'content' => [ - [ - 'type' => 'text', - 'text' => 'Hello!', - ], - ], +test('it formats image content with base64 data URL', function (): void { + $llm = AnthropicChat::fake([ + CreateMessage::class => MockResponse::fixture('anthropic/messages/simple'), + ]); + + $llm->addFeature(ModelFeature::Vision); + + $base64Data = base64_encode('fake-image-data'); + $dataUrl = 'data:image/png;base64,' . $base64Data; + + $llm->invoke([ + new UserMessage([ + new TextContent('Describe this image'), + new ImageContent($dataUrl), + ]), ]); + MockClient::getGlobal()->assertSent(function (CreateMessage $request) use ($base64Data): bool { + $messages = $request->body()->get('messages'); + $userMessage = $messages[0]; + + $hasImage = false; + + foreach ($userMessage['content'] as $block) { + if ($block['type'] === 'image' + && $block['source']['type'] === 'base64' + && $block['source']['media_type'] === 'image/png' + && $block['source']['data'] === $base64Data) { + $hasImage = true; + } + } + + return $hasImage; + }); +}); + +test('it handles string content in user messages', function (): void { $llm = AnthropicChat::fake([ - $response, + CreateMessage::class => MockResponse::fixture('anthropic/messages/simple'), ]); - $result = $llm->invoke([ + $llm->invoke([ + new UserMessage('Simple string message'), + ]); + + MockClient::getGlobal()->assertSent(function (CreateMessage $request): bool { + $messages = $request->body()->get('messages'); + $userMessage = $messages[0]; + + return $userMessage['role'] === 'user' + && $userMessage['content'] === 'Simple string message'; + }); +}); + +test('it handles assistant messages without tool calls', function (): void { + $llm = AnthropicChat::fake([ + CreateMessage::class => MockResponse::fixture('anthropic/messages/simple'), + ]); + + $llm->invoke([ new UserMessage('Hello'), + new AssistantMessage('Hi there!'), + new UserMessage('How are you?'), ]); - expect($result->usage) - ->toBeInstanceOf(Usage::class) - ->and($result->usage->promptTokens)->toBe(10) - ->and($result->usage->completionTokens)->toBe(20); + MockClient::getGlobal()->assertSent(function (CreateMessage $request): bool { + $messages = $request->body()->get('messages'); + $assistantMessage = $messages[1]; + + // Should have text content block + return $assistantMessage['role'] === 'assistant' + && $assistantMessage['content'][0]['type'] === 'text' + && $assistantMessage['content'][0]['text'] === 'Hi there!'; + }); +}); + +test('it merges consecutive tool messages into a single user message', function (): void { + $llm = AnthropicChat::fake([ + CreateMessage::class => MockResponse::fixture('anthropic/messages/simple'), + ]); + + $llm->invoke([ + new UserMessage('Compare weather in London and Paris'), + new AssistantMessage( + toolCalls: new ToolCallCollection([ + new ToolCall('tool_1', new FunctionCall('get_weather', [ + 'location' => 'London', + ])), + new ToolCall('tool_2', new FunctionCall('get_weather', [ + 'location' => 'Paris', + ])), + ]), + ), + new ToolMessage('Sunny, 20°C', 'tool_1'), + new ToolMessage('Rainy, 15°C', 'tool_2'), + ]); + + MockClient::getGlobal()->assertSent(function (CreateMessage $request): bool { + $messages = $request->body()->get('messages'); + + // Should have exactly 3 messages: user, assistant, user (merged tool results) + if (count($messages) !== 3) { + return false; + } + + // Third message should be a single user message with both tool results + $toolResultsMessage = $messages[2]; + + if ($toolResultsMessage['role'] !== 'user') { + return false; + } + + // Should have 2 tool_result content blocks + if (count($toolResultsMessage['content']) !== 2) { + return false; + } + + $firstResult = $toolResultsMessage['content'][0]; + $secondResult = $toolResultsMessage['content'][1]; + + return $firstResult['type'] === 'tool_result' + && $firstResult['tool_use_id'] === 'tool_1' + && $firstResult['content'] === 'Sunny, 20°C' + && $secondResult['type'] === 'tool_result' + && $secondResult['tool_use_id'] === 'tool_2' + && $secondResult['content'] === 'Rainy, 15°C'; + }); +}); + +test('it does not merge non-consecutive tool messages', function (): void { + $llm = AnthropicChat::fake([ + CreateMessage::class => MockResponse::fixture('anthropic/messages/simple'), + ]); + + $llm->invoke([ + new UserMessage('What is the weather?'), + new AssistantMessage( + toolCalls: new ToolCallCollection([ + new ToolCall('tool_1', new FunctionCall('get_weather', [ + 'location' => 'London', + ])), + ]), + ), + new ToolMessage('Sunny', 'tool_1'), + new AssistantMessage('The weather in London is sunny. Want to check another city?'), + new UserMessage('Yes, check Paris'), + new AssistantMessage( + toolCalls: new ToolCallCollection([ + new ToolCall('tool_2', new FunctionCall('get_weather', [ + 'location' => 'Paris', + ])), + ]), + ), + new ToolMessage('Rainy', 'tool_2'), + ]); + + MockClient::getGlobal()->assertSent(function (CreateMessage $request): bool { + $messages = $request->body()->get('messages'); + + // Should have 7 messages (tool messages not merged because they're not consecutive) + if (count($messages) !== 7) { + return false; + } + + // First tool result at index 2 + $firstToolResult = $messages[2]; + // Second tool result at index 6 + $secondToolResult = $messages[6]; + + return $firstToolResult['role'] === 'user' + && count($firstToolResult['content']) === 1 + && $firstToolResult['content'][0]['tool_use_id'] === 'tool_1' + && $secondToolResult['role'] === 'user' + && count($secondToolResult['content']) === 1 + && $secondToolResult['content'][0]['tool_use_id'] === 'tool_2'; + }); }); -// Helper function to create Anthropic responses without fake() merging issues -function createAnthropicResponse(array $attributes): ChatCreateResponse -{ - $defaults = [ - 'id' => 'msg_test_' . uniqid(), - 'type' => 'message', - 'role' => 'assistant', - 'model' => 'claude-3-5-sonnet-20241022', - 'stop_sequence' => null, - 'stop_reason' => 'end_turn', - 'usage' => [ - 'input_tokens' => 10, - 'output_tokens' => 20, +test('it passes thinking blocks back unmodified in multi-turn conversations', function (): void { + $llm = AnthropicChat::fake([ + CreateMessage::class => MockResponse::fixture('anthropic/messages/simple'), + ]); + + // Simulate a previous assistant response with thinking content + $previousAssistantMessage = new AssistantMessage( + content: [ + new ReasoningContent( + reasoning: 'Let me think about this carefully...', + metadata: [ + 'signature' => 'sha256:abc123', + 'type' => 'thinking', + ], + ), + new TextContent('The answer is 42.'), ], - ]; + ); + + $llm->invoke([ + new UserMessage('What is the meaning of life?'), + $previousAssistantMessage, + new UserMessage('Can you explain more?'), + ]); + + MockClient::getGlobal()->assertSent(function (CreateMessage $request): bool { + $messages = $request->body()->get('messages'); + + // The second message should be the assistant message with thinking block + $assistantMessage = $messages[1]; + + if ($assistantMessage['role'] !== 'assistant') { + return false; + } + + // Should have thinking block and text block + $hasThinking = false; + $hasText = false; + + foreach ($assistantMessage['content'] as $block) { + if ($block['type'] === 'thinking' + && $block['thinking'] === 'Let me think about this carefully...' + && $block['signature'] === 'sha256:abc123') { + $hasThinking = true; + } + + if ($block['type'] === 'text' && $block['text'] === 'The answer is 42.') { + $hasText = true; + } + } + + return $hasThinking && $hasText; + }); +}); - return ChatCreateResponse::from( - array_merge($defaults, $attributes), - MetaInformation::from([]), +test('it passes redacted thinking blocks back correctly', function (): void { + $llm = AnthropicChat::fake([ + CreateMessage::class => MockResponse::fixture('anthropic/messages/simple'), + ]); + + // Simulate a previous assistant response with redacted thinking content + $previousAssistantMessage = new AssistantMessage( + content: [ + new ReasoningContent( + reasoning: 'REDACTED_DATA', + metadata: [ + 'type' => 'redacted_thinking', + 'signature' => 'sha256:abc123', + ], + ), + new TextContent('Based on my analysis, the result is positive.'), + ], ); -} + + $llm->invoke([ + new UserMessage('Analyze this data'), + $previousAssistantMessage, + new UserMessage('Tell me more'), + ]); + + MockClient::getGlobal()->assertSent(function (CreateMessage $request): bool { + $messages = $request->body()->get('messages'); + + // The second message should be the assistant message with redacted thinking block + $assistantMessage = $messages[1]; + + if ($assistantMessage['role'] !== 'assistant') { + return false; + } + + // Should have redacted_thinking block and text block + $hasRedactedThinking = false; + $hasText = false; + + foreach ($assistantMessage['content'] as $block) { + if ($block['type'] === 'redacted_thinking' && $block['data'] === 'REDACTED_DATA') { + $hasRedactedThinking = true; + } + + if ($block['type'] === 'text' && $block['text'] === 'Based on my analysis, the result is positive.') { + $hasText = true; + } + } + + return $hasRedactedThinking && $hasText; + }); +}); diff --git a/tests/Unit/LLM/Drivers/OpenAI/OpenAIChatTest.php b/tests/Unit/LLM/Drivers/OpenAI/OpenAIChatTest.php index 5020ecd..67e4374 100644 --- a/tests/Unit/LLM/Drivers/OpenAI/OpenAIChatTest.php +++ b/tests/Unit/LLM/Drivers/OpenAI/OpenAIChatTest.php @@ -29,6 +29,7 @@ use Cortex\LLM\Data\Messages\AssistantMessage; use Cortex\LLM\Drivers\OpenAI\Chat\OpenAIChat; use Cortex\LLM\Data\Messages\MessageCollection; +use Cortex\LLM\Data\Messages\Content\TextContent; use OpenAI\Responses\Chat\CreateResponse as ChatCreateResponse; use OpenAI\Responses\Chat\CreateStreamedResponse as ChatCreateStreamedResponse; @@ -71,12 +72,21 @@ $expectedOutput = 'Hello! I’m just a program, so I don’t have feelings, but I’m here and ready to help you with whatever you need. How can I assist you today?'; $chunkTypes = []; - $output = $chunks->reduce(function (string $carry, ChatGenerationChunk $chunk) use (&$chunkTypes, $expectedOutput) { + $allChunks = []; + $output = $chunks->reduce(function (string $carry, ChatGenerationChunk $chunk) use (&$chunkTypes, &$allChunks, $expectedOutput) { $chunkTypes[] = $chunk->type; + $allChunks[] = $chunk; + expect($chunk)->toBeInstanceOf(ChatGenerationChunk::class) ->and($chunk->message)->toBeInstanceOf(AssistantMessage::class) + ->and($chunk->contentSoFar)->toBeArray() ->and($expectedOutput)->toContain($chunk->message->content); + // Verify contentSoFar contains TextContent objects + foreach ($chunk->contentSoFar as $content) { + expect($content)->toBeInstanceOf(TextContent::class); + } + return $carry . $chunk->message->content; }, ''); @@ -87,7 +97,49 @@ ->and($chunkTypes[0])->toBe(ChunkType::TextStart) // First text content ->and($chunkTypes[1])->toBe(ChunkType::TextDelta) // Subsequent text ->and($chunkTypes[37])->toBe(ChunkType::TextDelta) // Last text content - ->and($chunkTypes[38])->toBe(ChunkType::TextEnd); // Text end in flush + ->and($chunkTypes[38])->toBe(ChunkType::TextEnd); + $finalChunk = array_find($allChunks, fn($chunk) => $chunk->isFinal); + + expect($finalChunk)->not->toBeNull() + ->and($finalChunk->contentSoFar)->toBeArray() + ->and($finalChunk->contentSoFar)->toHaveCount(1) + ->and($finalChunk->contentSoFar[0])->toBeInstanceOf(TextContent::class) + ->and($finalChunk->contentSoFar[0]->text)->toBe($expectedOutput); +}); + +test('it tracks contentSoFar incrementally during streaming', function (): void { + $llm = OpenAIChat::fake([ + ChatCreateStreamedResponse::fake(fopen(__DIR__ . '/../../../../fixtures/openai/chat-stream.txt', 'r')), + ]); + + $llm->withStreaming(); + + $chunks = $llm->invoke([ + new UserMessage('Hello, how are you?'), + ]); + + $contentSoFarLengths = []; + $hasPartialContent = false; + + foreach ($chunks as $chunk) { + expect($chunk->contentSoFar)->toBeArray(); + + $contentSoFarLengths[] = count($chunk->contentSoFar); + + // During streaming, contentSoFar should include partial content being built + if ($chunk->contentSoFar !== []) { + $hasPartialContent = true; + // Verify the last item is a TextContent object + $contentArray = $chunk->contentSoFar; + $lastContent = $contentArray[count($contentArray) - 1]; + expect($lastContent instanceof TextContent)->toBeTrue(); + } + } + + // Verify that contentSoFar grows during streaming + expect($hasPartialContent)->toBeTrue('Expected contentSoFar to include partial content during streaming') + ->and($contentSoFarLengths)->toContain(1) // Should have at least one content item at some point + ->and(max($contentSoFarLengths))->toBeGreaterThanOrEqual(1); // Should reach at least 1 item }); test('it can use tools', function (): void { diff --git a/tests/Unit/LLM/Drivers/OpenAI/OpenAIResponsesTest.php b/tests/Unit/LLM/Drivers/OpenAI/OpenAIResponsesTest.php index d2acb27..0bb7478 100644 --- a/tests/Unit/LLM/Drivers/OpenAI/OpenAIResponsesTest.php +++ b/tests/Unit/LLM/Drivers/OpenAI/OpenAIResponsesTest.php @@ -4,7 +4,7 @@ namespace Cortex\Tests\Unit\LLM\Drivers\OpenAI; -use Exception; +use Throwable; use Cortex\LLM\Data\Usage; use Cortex\Attributes\Tool; use Cortex\JsonSchema\Schema; @@ -16,53 +16,169 @@ use Cortex\Events\ChatModelStart; use Cortex\LLM\Data\FunctionCall; use Cortex\Events\ChatModelStream; -use Cortex\Exceptions\LLMException; +use Cortex\LLM\Enums\FinishReason; +use Saloon\Http\Faking\MockClient; use Cortex\LLM\Data\ChatGeneration; +use Saloon\Http\Faking\MockResponse; use Cortex\Events\ChatModelStreamEnd; use Cortex\LLM\Data\ChatStreamResult; use Cortex\LLM\Data\ToolCallCollection; use Cortex\LLM\Data\ChatGenerationChunk; use Cortex\ModelInfo\Enums\ModelFeature; +use Cortex\LLM\Data\Messages\ToolMessage; use Cortex\LLM\Data\Messages\UserMessage; use Cortex\LLM\Data\Messages\AssistantMessage; -use OpenAI\Responses\Responses\CreateResponse; +use Cortex\SDK\OpenAI\Requests\CreateResponse; use Cortex\LLM\Data\Messages\MessageCollection; -use OpenAI\Responses\Responses\CreateStreamedResponse; +use Cortex\LLM\Data\Messages\Content\TextContent; +use Cortex\LLM\Data\Messages\Content\ReasoningContent; use Cortex\LLM\Drivers\OpenAI\Responses\OpenAIResponses; +/** + * @return array + */ +function collectOpenAIResponseStreamChunks(ChatStreamResult $chunks): array +{ + $collected = []; + + foreach ($chunks as $chunk) { + expect($chunk)->toBeInstanceOf(ChatGenerationChunk::class) + ->and($chunk->message)->toBeInstanceOf(AssistantMessage::class) + ->and($chunk->contentSoFar)->toBeArray(); + + $collected[] = $chunk; + } + + return $collected; +} + +/** + * @param array $chunks + * + * @return array + */ +function collectChunkTypes(array $chunks): array +{ + return array_values(array_map( + static fn(ChatGenerationChunk $chunk): ChunkType => $chunk->type, + $chunks, + )); +} + +/** + * @return array + */ +function repeatChunkType(ChunkType $type, int $count): array +{ + return array_fill(0, $count, $type); +} + +/** + * @param array $actual + * @param array $expected + */ +function expectExactChunkTypeSequence(array $actual, array $expected, string $scenario): void +{ + $actualValues = array_map(static fn(ChunkType $type): string => $type->value, $actual); + $expectedValues = array_map(static fn(ChunkType $type): string => $type->value, $expected); + + expect($actualValues)->toBe( + $expectedValues, + sprintf( + '[%s] exact chunk sequence mismatch. actual=%s expected=%s', + $scenario, + json_encode($actualValues), + json_encode($expectedValues), + ), + ); +} + +/** + * @param array $chunkTypes + */ +function expectValidChunkLifecycleOrder(array $chunkTypes, string $scenario): void +{ + $isMessageOpen = false; + $isTextOpen = false; + $isReasoningOpen = false; + $isToolInputOpen = false; + + foreach ($chunkTypes as $index => $chunkType) { + switch ($chunkType) { + case ChunkType::MessageStart: + expect($isMessageOpen)->toBeFalse( + sprintf('[%s] duplicate MessageStart at index %d', $scenario, $index), + ); + $isMessageOpen = true; + break; + + case ChunkType::MessageEnd: + expect($isMessageOpen)->toBeTrue( + sprintf('[%s] MessageEnd without MessageStart at index %d', $scenario, $index), + ); + $isMessageOpen = false; + break; + + case ChunkType::TextStart: + expect($isTextOpen)->toBeFalse( + sprintf('[%s] duplicate TextStart at index %d', $scenario, $index), + ); + $isTextOpen = true; + break; + + case ChunkType::TextEnd: + expect($isTextOpen)->toBeTrue( + sprintf('[%s] TextEnd without TextStart at index %d', $scenario, $index), + ); + $isTextOpen = false; + break; + + case ChunkType::ReasoningStart: + expect($isReasoningOpen)->toBeFalse( + sprintf('[%s] duplicate ReasoningStart at index %d', $scenario, $index), + ); + $isReasoningOpen = true; + break; + + case ChunkType::ReasoningEnd: + expect($isReasoningOpen)->toBeTrue( + sprintf('[%s] ReasoningEnd without ReasoningStart at index %d', $scenario, $index), + ); + $isReasoningOpen = false; + break; + + case ChunkType::ToolInputStart: + expect($isToolInputOpen)->toBeFalse( + sprintf('[%s] duplicate ToolInputStart at index %d', $scenario, $index), + ); + $isToolInputOpen = true; + break; + + case ChunkType::ToolInputEnd: + expect($isToolInputOpen)->toBeTrue( + sprintf('[%s] ToolInputEnd without ToolInputStart at index %d', $scenario, $index), + ); + $isToolInputOpen = false; + break; + + default: + break; + } + } + + expect($isMessageOpen)->toBeFalse(sprintf('[%s] unclosed MessageStart at end of stream', $scenario)); + expect($isTextOpen)->toBeFalse(sprintf('[%s] unclosed TextStart at end of stream', $scenario)); + expect($isReasoningOpen)->toBeFalse(sprintf('[%s] unclosed ReasoningStart at end of stream', $scenario)); + expect($isToolInputOpen)->toBeFalse(sprintf('[%s] unclosed ToolInputStart at end of stream', $scenario)); +} + +// ───────────────────────────────────────────────────────── +// Non-streaming tests +// ───────────────────────────────────────────────────────── + test('it responds to messages', function (): void { $llm = OpenAIResponses::fake([ - CreateResponse::fake([ - 'id' => 'resp_123', - 'model' => 'gpt-4o', - 'created_at' => 1234567890, - 'status' => 'completed', - 'output' => [ - [ - 'type' => 'message', - 'id' => 'msg_123', - 'role' => 'assistant', - 'content' => [ - [ - 'type' => 'output_text', - 'text' => 'I am doing well, thank you for asking!', - 'annotations' => [], - ], - ], - ], - ], - 'usage' => [ - 'input_tokens' => 10, - 'output_tokens' => 20, - 'total_tokens' => 30, - 'input_token_details' => [ - 'cached_tokens' => 0, - ], - 'output_token_details' => [ - 'reasoning_tokens' => 0, - ], - ], - ]), + CreateResponse::class => MockResponse::fixture('openai/responses/simple'), ]); $result = $llm->includeRaw()->invoke([ @@ -73,61 +189,27 @@ ->and($result->rawResponse)->toBeArray()->not->toBeEmpty() ->and($result->generation)->toBeInstanceOf(ChatGeneration::class) ->and($result->generation->message)->toBeInstanceOf(AssistantMessage::class) - ->and($result->generation->message->text())->toContain('I am doing well, thank you for asking!'); + ->and($result->generation->message->text())->toBe("Hello! I'm doing well, thank you. How about you?") + ->and($result->generation->message->id)->not->toBeNull() + ->and($result->generation->message->metadata->id)->not->toBeNull() + ->and($result->generation->message->metadata->model)->toBe('gpt-4o-mini-2024-07-18') + ->and($result->generation->finishReason)->toBe(FinishReason::Stop); }); test('it can use tools', function (): void { $llm = OpenAIResponses::fake([ - CreateResponse::fake([ - 'id' => 'resp_123', - 'model' => 'gpt-4o', - 'created_at' => 1234567890, - 'status' => 'completed', - 'output' => [ - [ - 'type' => 'message', - 'id' => 'msg_123', - 'role' => 'assistant', - 'content' => [ - [ - 'type' => 'output_text', - 'text' => '', - 'annotations' => [], - ], - ], - ], - [ - 'type' => 'function_call', - 'id' => 'call_123', - 'call_id' => 'call_123', - 'name' => 'multiply', - 'arguments' => '{"x":3,"y":4}', - 'status' => 'completed', - ], - ], - 'usage' => [ - 'input_tokens' => 10, - 'output_tokens' => 20, - 'total_tokens' => 30, - 'input_token_details' => [ - 'cached_tokens' => 0, - ], - 'output_token_details' => [ - 'reasoning_tokens' => 0, - ], - ], - ]), + CreateResponse::class => MockResponse::fixture('openai/responses/function-call'), ], 'gpt-4o'); $llm->addFeature(ModelFeature::ToolCalling); $llm->withTools([ - #[Tool(name: 'multiply', description: 'Multiply two numbers')] - fn(int $x, int $y): int => $x * $y, + #[Tool(name: 'get_weather', description: 'Get the current weather in a given location')] + fn(string $location, string $unit = 'celsius'): string => 'sunny', ]); $result = $llm->invoke([ - new UserMessage('What is 3 times 4?'), + new UserMessage('What is the weather in Manchester?'), ]); expect($result->generation->message->toolCalls) @@ -139,47 +221,65 @@ ->and($result->generation->message->toolCalls[0]->function) ->toBeInstanceOf(FunctionCall::class) ->and($result->generation->message->toolCalls[0]->function->name) - ->toBe('multiply') + ->toBe('get_weather') ->and($result->generation->message->toolCalls[0]->function->arguments) ->toBe([ - 'x' => 3, - 'y' => 4, - ]); + 'location' => 'Manchester, UK', + 'unit' => 'celsius', + ]) + ->and($result->generation->finishReason)->toBe(FinishReason::Stop); +}); + +test('it maps assistant tool calls and tool outputs for the responses api input schema', function (): void { + $llm = OpenAIResponses::fake([ + CreateResponse::class => MockResponse::fixture('openai/responses/simple'), + ], 'gpt-4o'); + + $llm->addFeature(ModelFeature::ToolCalling); + + $llm->invoke([ + new UserMessage('What is the weather in London?'), + new AssistantMessage( + content: null, + toolCalls: new ToolCallCollection([ + new ToolCall( + 'call_weather_london', + new FunctionCall('get_weather', [ + 'location' => 'London, UK', + 'unit' => 'celsius', + ]), + ), + ]), + ), + new ToolMessage( + 'The weather in London is cloudy and 8 C', + 'call_weather_london', + ), + ]); + + MockClient::getGlobal()->assertSent(function (CreateResponse $request): bool { + $input = $request->body()->get('input'); + + if (! is_array($input) || ! isset($input[1], $input[2])) { + return false; + } + + $functionCallInput = $input[1]; + $functionCallOutputInput = $input[2]; + + return ($functionCallInput['type'] ?? null) === 'function_call' + && ($functionCallInput['call_id'] ?? null) === 'call_weather_london' + && ($functionCallInput['name'] ?? null) === 'get_weather' + && ($functionCallInput['arguments'] ?? null) === '{"location":"London, UK","unit":"celsius"}' + && ($functionCallOutputInput['type'] ?? null) === 'function_call_output' + && ($functionCallOutputInput['call_id'] ?? null) === 'call_weather_london' + && ($functionCallOutputInput['output'] ?? null) === 'The weather in London is cloudy and 8 C'; + }); }); test('it can use structured output', function (): void { $llm = OpenAIResponses::fake([ - CreateResponse::fake([ - 'id' => 'resp_123', - 'model' => 'gpt-4o', - 'created_at' => 1234567890, - 'status' => 'completed', - 'output' => [ - [ - 'type' => 'message', - 'id' => 'msg_123', - 'role' => 'assistant', - 'content' => [ - [ - 'type' => 'output_text', - 'text' => $expected = '{"name":"John Doe","age":30}', - 'annotations' => [], - ], - ], - ], - ], - 'usage' => [ - 'input_tokens' => 10, - 'output_tokens' => 20, - 'total_tokens' => 30, - 'input_token_details' => [ - 'cached_tokens' => 0, - ], - 'output_token_details' => [ - 'reasoning_tokens' => 0, - ], - ], - ]), + CreateResponse::class => MockResponse::fixture('openai/responses/structured-output'), ], 'gpt-4o'); $llm->addFeature(ModelFeature::StructuredOutput); @@ -187,57 +287,31 @@ $llm->withStructuredOutput( Schema::object()->properties( Schema::string('name'), - Schema::integer('age'), + Schema::string('email'), + Schema::string('plan_interest'), + Schema::boolean('demo_requested'), ), - name: 'Person', - description: 'A person with a name and age', + name: 'contact_info', + description: 'Extract contact info', ); $result = $llm->invoke([ - new UserMessage('Tell me about a person'), + new UserMessage('Extract the key information from this email: John Smith (john@example.com) is interested in our Enterprise plan.'), ]); expect($result->generation->message->text()) - ->toContain('John Doe') + ->toContain('John Smith') ->and($result->generation->parsedOutput)->toBe([ - 'name' => 'John Doe', - 'age' => 30, + 'name' => 'John Smith', + 'email' => 'john@example.com', + 'plan_interest' => 'Enterprise', + 'demo_requested' => true, ]); }); -test('it tracks token usage with reasoning tokens', function (): void { +test('it tracks token usage', function (): void { $llm = OpenAIResponses::fake([ - CreateResponse::fake([ - 'id' => 'resp_123', - 'model' => 'gpt-4o', - 'created_at' => 1234567890, - 'status' => 'completed', - 'output' => [ - [ - 'type' => 'message', - 'id' => 'msg_123', - 'role' => 'assistant', - 'content' => [ - [ - 'type' => 'output_text', - 'text' => 'Hello!', - 'annotations' => [], - ], - ], - ], - ], - 'usage' => [ - 'input_tokens' => 10, - 'output_tokens' => 25, - 'total_tokens' => 35, - 'input_token_details' => [ - 'cached_tokens' => 5, - ], - 'output_token_details' => [ - 'reasoning_tokens' => 15, - ], - ], - ]), + CreateResponse::class => MockResponse::fixture('openai/responses/simple'), ]); $result = $llm->invoke([ @@ -246,190 +320,36 @@ expect($result->usage) ->toBeInstanceOf(Usage::class) - ->and($result->usage->promptTokens)->toBe(10) - ->and($result->usage->completionTokens)->toBe(25) - ->and($result->usage->totalTokens)->toBe(35) - // Note: The OpenAI fake response doesn't properly set nested token details - // This would work with real API responses where inputTokensDetails and outputTokensDetails are properly set - ->and($result->usage->cachedTokens)->toBeInt() - ->and($result->usage->reasoningTokens)->toBeInt(); + ->and($result->usage->promptTokens)->toBe(13) + ->and($result->usage->completionTokens)->toBe(14) + ->and($result->usage->totalTokens)->toBe(27) + ->and($result->usage->cachedTokens)->toBe(0) + ->and($result->usage->reasoningTokens)->toBe(0); }); -test('it handles reasoning content', function (): void { +test('it handles reasoning content in non-streaming response', function (): void { $llm = OpenAIResponses::fake([ - CreateResponse::fake([ - 'id' => 'resp_123', - 'model' => 'gpt-4o', - 'created_at' => 1234567890, - 'status' => 'completed', - 'output' => [ - [ - 'type' => 'reasoning', - 'id' => 'reasoning_123', - 'summary' => [ - [ - 'type' => 'output_text', - 'text' => 'Let me think about this step by step...', - 'annotations' => [], - ], - ], - ], - [ - 'type' => 'message', - 'id' => 'msg_123', - 'role' => 'assistant', - 'content' => [ - [ - 'type' => 'output_text', - 'text' => 'The answer is 42.', - 'annotations' => [], - ], - ], - ], - ], - 'usage' => [ - 'input_tokens' => 10, - 'output_tokens' => 20, - 'total_tokens' => 30, - 'input_token_details' => [ - 'cached_tokens' => 0, - ], - 'output_token_details' => [ - 'reasoning_tokens' => 10, - ], - ], - ]), + CreateResponse::class => MockResponse::fixture('openai/responses/reasoning'), ]); $result = $llm->invoke([ - new UserMessage('What is the meaning of life?'), - ]); - - expect($result->generation->message->content())->toBeArray() - ->and($result->generation->message->content())->toHaveCount(2) - ->and($result->generation->message->text())->toContain('The answer is 42.'); -}); - -test('it handles refusal responses', function (): void { - $llm = OpenAIResponses::fake([ - CreateResponse::fake([ - 'id' => 'resp_123', - 'model' => 'gpt-4o', - 'created_at' => 1234567890, - 'status' => 'completed', - 'output' => [ - [ - 'type' => 'message', - 'id' => 'msg_123', - 'role' => 'assistant', - 'content' => [ - [ - 'type' => 'refusal', - 'refusal' => 'I cannot help with that request.', - ], - ], - ], - ], - 'usage' => [ - 'input_tokens' => 10, - 'output_tokens' => 5, - 'total_tokens' => 15, - 'input_token_details' => [ - 'cached_tokens' => 0, - ], - 'output_token_details' => [ - 'reasoning_tokens' => 0, - ], - ], - ]), - ]); - - expect(fn(): ChatResult|ChatStreamResult => $llm->invoke([ - new UserMessage('Help me do something bad'), - ]))->toThrow(LLMException::class, 'LLM refusal'); -}); - -test('it converts max_tokens to max_output_tokens', function (): void { - $llm = OpenAIResponses::fake([ - CreateResponse::fake([ - 'id' => 'resp_123', - 'model' => 'gpt-4o', - 'created_at' => 1234567890, - 'status' => 'completed', - 'output' => [ - [ - 'type' => 'message', - 'id' => 'msg_123', - 'role' => 'assistant', - 'content' => [ - [ - 'type' => 'output_text', - 'text' => 'Hello!', - 'annotations' => [], - ], - ], - ], - ], - 'usage' => [ - 'input_tokens' => 10, - 'output_tokens' => 5, - 'total_tokens' => 15, - 'input_token_details' => [ - 'cached_tokens' => 0, - ], - 'output_token_details' => [ - 'reasoning_tokens' => 0, - ], - ], - ]), - ]); - - $llm->withMaxTokens(100)->invoke([ - new UserMessage('Hello'), + new UserMessage('What is the weight of the moon?'), ]); - /** @var \OpenAI\Testing\ClientFake $client */ - $client = $llm->getClient(); + $content = $result->generation->message->content(); - $client->responses()->assertSent(function (string $method, array $parameters): bool { - return $parameters['max_output_tokens'] === 100 - && ! array_key_exists('max_tokens', $parameters); - }); + expect($content)->toBeArray() + ->and($content)->toHaveCount(2) + ->and($content[0])->toBeInstanceOf(ReasoningContent::class) + ->and($content[0]->metadata)->toHaveKey('id') + ->and($content[0]->metadata)->toHaveKey('status') + ->and($content[1])->toBeInstanceOf(TextContent::class) + ->and($result->generation->message->text())->toContain('Mass of the Moon'); }); test('LLM instance-specific listeners work correctly', function (): void { $llm = OpenAIResponses::fake([ - CreateResponse::fake([ - 'id' => 'resp_123', - 'model' => 'gpt-4o', - 'created_at' => 1234567890, - 'status' => 'completed', - 'output' => [ - [ - 'type' => 'message', - 'id' => 'msg_123', - 'role' => 'assistant', - 'content' => [ - [ - 'type' => 'output_text', - 'text' => 'Hello World!', - 'annotations' => [], - ], - ], - ], - ], - 'usage' => [ - 'input_tokens' => 10, - 'output_tokens' => 20, - 'total_tokens' => 30, - 'input_token_details' => [ - 'cached_tokens' => 0, - ], - 'output_token_details' => [ - 'reasoning_tokens' => 0, - ], - ], - ]), + CreateResponse::class => MockResponse::fixture('openai/responses/simple'), ]); $startCalled = false; @@ -458,7 +378,7 @@ test('LLM instance-specific error listeners work correctly', function (): void { $llm = OpenAIResponses::fake([ - new Exception('API Error'), + CreateResponse::class => MockResponse::make('', 500), ]); $errorCalled = false; @@ -466,279 +386,490 @@ $llm->onError(function (ChatModelError $event) use ($llm, &$errorCalled): void { $errorCalled = true; expect($event->llm)->toBe($llm); - expect($event->exception)->toBeInstanceOf(Exception::class); - expect($event->exception->getMessage())->toBe('API Error'); }); try { $llm->invoke([new UserMessage('Hello')]); - } catch (Exception) { + } catch (Throwable) { // Expected } expect($errorCalled)->toBeTrue(); }); -test('LLM instance-specific stream listeners work correctly', function (): void { +// ───────────────────────────────────────────────────────── +// Streaming: simple text response +// ───────────────────────────────────────────────────────── + +test('streaming simple text: chunk types are in the correct order', function (): void { $llm = OpenAIResponses::fake([ - CreateStreamedResponse::fake(fopen(__DIR__ . '/../../../../fixtures/openai/responses-stream.txt', 'r')), + CreateResponse::class => MockResponse::fixture('openai/responses/simple-streamed'), ]); $llm->withStreaming(); - $streamCalls = []; - $streamEndCalled = false; - $eventOrder = []; + $chunks = $llm->invoke([ + new UserMessage('Hello, how are you?'), + ]); - $llm->onStream(function (ChatModelStream $event) use ($llm, &$streamCalls, &$eventOrder): void { - expect($event->llm)->toBe($llm); - expect($event->chunk)->toBeInstanceOf(ChatGenerationChunk::class); - $streamCalls[] = $event->chunk; - $eventOrder[] = 'stream'; - }); + expect($chunks)->toBeInstanceOf(ChatStreamResult::class); + + $collectedChunks = collectOpenAIResponseStreamChunks($chunks); + $chunkTypes = collectChunkTypes($collectedChunks); + + // Fixture facts from tests/fixtures/sdk/openai/responses/simple-streamed.json: + // - 38 response.output_text.delta events + // - single assistant output item + $expectedSequence = [ + ChunkType::MessageStart, + ChunkType::TextStart, + ...repeatChunkType(ChunkType::TextDelta, 38), + ChunkType::TextEnd, + ChunkType::MessageEnd, + ]; + + expectExactChunkTypeSequence( + actual: $chunkTypes, + expected: $expectedSequence, + scenario: 'simple-streamed', + ); - $llm->onStreamEnd(function (ChatModelStreamEnd $event) use ($llm, &$streamEndCalled, &$eventOrder): void { - $streamEndCalled = true; - expect($event->llm)->toBe($llm); - expect($event->chunk)->toBeInstanceOf(ChatGenerationChunk::class); - $eventOrder[] = 'streamEnd'; - }); + expectValidChunkLifecycleOrder($chunkTypes, 'simple-streamed'); - $result = $llm->invoke([ - new UserMessage('Hello'), + expect($chunkTypes)->not->toContain(ChunkType::ReasoningStart) + ->and($chunkTypes)->not->toContain(ChunkType::ReasoningDelta) + ->and($chunkTypes)->not->toContain(ChunkType::ReasoningEnd) + ->and($chunkTypes)->not->toContain(ChunkType::ToolInputStart) + ->and($chunkTypes)->not->toContain(ChunkType::ToolInputDelta) + ->and($chunkTypes)->not->toContain(ChunkType::ToolInputEnd); +}); + +test('streaming simple text: final chunk has complete accumulated content and usage', function (): void { + $llm = OpenAIResponses::fake([ + CreateResponse::class => MockResponse::fixture('openai/responses/simple-streamed'), ]); - expect($result)->toBeInstanceOf(ChatStreamResult::class); + $llm->withStreaming(); - // Iterate over the stream to trigger stream events - $chunkTypes = []; - foreach ($result as $chunk) { - $chunkTypes[] = $chunk->type; + $chunks = $llm->invoke([ + new UserMessage('Hello, how are you?'), + ]); + + $allChunks = []; + foreach ($chunks as $chunk) { + $allChunks[] = $chunk; } - // Verify stream events were dispatched - expect($streamCalls)->not->toBeEmpty() - ->and($streamCalls)->toBeArray() - ->and(count($streamCalls))->toBeGreaterThan(0); + // Find the final chunk + $finalChunk = collect($allChunks)->last(fn(ChatGenerationChunk $c): bool => $c->isFinal); + expect($finalChunk)->not->toBeNull(); + + // Final chunk should have usage + expect($finalChunk->usage)->toBeInstanceOf(Usage::class) + ->and($finalChunk->usage->promptTokens)->toBe(12) + ->and($finalChunk->usage->completionTokens)->toBe(300) + ->and($finalChunk->usage->totalTokens)->toBe(312); + + // Final chunk should have finish reason + expect($finalChunk->finishReason)->toBe(FinishReason::Stop); + + // Final contentSoFar should have assembled text + $textContents = collect($finalChunk->contentSoFar) + ->filter(fn(mixed $c): bool => $c instanceof TextContent) + ->values(); + expect($textContents)->toHaveCount(1); + expect($textContents[0]->text)->toContain('Hello!') + ->and($textContents[0]->text)->toContain('How can I assist you today?'); + + // Metadata should be populated + expect($finalChunk->message->metadata->id)->not->toBeNull() + ->and($finalChunk->message->metadata->model)->toBe('gpt-5-nano-2025-08-07'); +}); - // Verify stream end event was dispatched after streaming completes - expect($streamEndCalled)->toBeTrue(); +test('streaming simple text: text deltas accumulate progressively in contentSoFar', function (): void { + $llm = OpenAIResponses::fake([ + CreateResponse::class => MockResponse::fixture('openai/responses/simple-streamed'), + ]); + + $llm->withStreaming(); + + $chunks = $llm->invoke([new UserMessage('Hello')]); + + $previousTextLength = 0; + $textDeltasSeen = 0; - // Verify chunk types are correctly mapped - expect($chunkTypes)->toContain(ChunkType::MessageStart) - ->and($chunkTypes)->toContain(ChunkType::TextDelta) - ->and($chunkTypes)->toContain(ChunkType::Done); + foreach ($chunks as $chunk) { + if ($chunk->type !== ChunkType::TextDelta && $chunk->type !== ChunkType::TextStart) { + continue; + } + + $textDeltasSeen++; + + // Extract current accumulated text from contentSoFar + $currentText = collect($chunk->contentSoFar) + ->filter(fn(mixed $c): bool => $c instanceof TextContent) + ->map(fn(TextContent $c): string => $c->text) + ->implode(''); + + // Text should be growing with each delta + $currentLength = mb_strlen($currentText); + expect($currentLength)->toBeGreaterThanOrEqual( + $previousTextLength, + sprintf('contentSoFar text should grow monotonically (was %d, now %d)', $previousTextLength, $currentLength), + ); + + $previousTextLength = $currentLength; + } - // Verify that stream end event is the final event - expect($eventOrder)->not->toBeEmpty() - ->and(end($eventOrder))->toBe('streamEnd'); + expect($textDeltasSeen)->toBeGreaterThan(5); }); -test('multiple LLM instances have separate listeners', function (): void { - $llm1 = OpenAIResponses::fake([ - CreateResponse::fake([ - 'id' => 'resp_1', - 'model' => 'gpt-4o', - 'created_at' => 1234567890, - 'status' => 'completed', - 'output' => [ - [ - 'type' => 'message', - 'id' => 'msg_1', - 'role' => 'assistant', - 'content' => [ - [ - 'type' => 'output_text', - 'text' => 'Response 1', - 'annotations' => [], - ], - ], - ], - ], - 'usage' => [ - 'input_tokens' => 10, - 'output_tokens' => 20, - 'total_tokens' => 30, - 'input_token_details' => [ - 'cached_tokens' => 0, - ], - 'output_token_details' => [ - 'reasoning_tokens' => 0, - ], - ], - ]), +// ───────────────────────────────────────────────────────── +// Streaming: tool calls +// ───────────────────────────────────────────────────────── + +test('streaming tool calls: chunk types are in the correct order', function (): void { + $llm = OpenAIResponses::fake([ + CreateResponse::class => MockResponse::fixture('openai/responses/function-call-streamed'), + ], 'gpt-4o'); + + $llm->addFeature(ModelFeature::ToolCalling); + $llm->withStreaming(); + + $chunks = $llm->invoke([ + new UserMessage('What is the weather in Manchester?'), ]); - $llm2 = OpenAIResponses::fake([ - CreateResponse::fake([ - 'id' => 'resp_2', - 'model' => 'gpt-4o', - 'created_at' => 1234567890, - 'status' => 'completed', - 'output' => [ - [ - 'type' => 'message', - 'id' => 'msg_2', - 'role' => 'assistant', - 'content' => [ - [ - 'type' => 'output_text', - 'text' => 'Response 2', - 'annotations' => [], - ], - ], - ], - ], - 'usage' => [ - 'input_tokens' => 10, - 'output_tokens' => 20, - 'total_tokens' => 30, - 'input_token_details' => [ - 'cached_tokens' => 0, - ], - 'output_token_details' => [ - 'reasoning_tokens' => 0, - ], - ], - ]), + $collectedChunks = collectOpenAIResponseStreamChunks($chunks); + $chunkTypes = collectChunkTypes($collectedChunks); + + // Fixture facts from tests/fixtures/sdk/openai/responses/function-call-streamed.json: + // - 12 response.function_call_arguments.delta events + // - single function_call output item + $expectedSequence = [ + ChunkType::MessageStart, + ChunkType::ToolInputStart, + ...repeatChunkType(ChunkType::ToolInputDelta, 12), + ChunkType::ToolInputEnd, + ChunkType::MessageEnd, + ]; + + expectExactChunkTypeSequence( + actual: $chunkTypes, + expected: $expectedSequence, + scenario: 'function-call-streamed', + ); + + expectValidChunkLifecycleOrder($chunkTypes, 'function-call-streamed'); + + expect($chunkTypes)->not->toContain(ChunkType::TextStart) + ->and($chunkTypes)->not->toContain(ChunkType::TextDelta) + ->and($chunkTypes)->not->toContain(ChunkType::TextEnd) + ->and($chunkTypes)->not->toContain(ChunkType::ReasoningStart) + ->and($chunkTypes)->not->toContain(ChunkType::ReasoningDelta) + ->and($chunkTypes)->not->toContain(ChunkType::ReasoningEnd); +}); + +test('streaming tool calls: final chunk has complete tool call data and usage', function (): void { + $llm = OpenAIResponses::fake([ + CreateResponse::class => MockResponse::fixture('openai/responses/function-call-streamed'), + ], 'gpt-4o'); + + $llm->addFeature(ModelFeature::ToolCalling); + $llm->withStreaming(); + + $chunks = $llm->invoke([ + new UserMessage('What is the weather in Manchester?'), ]); - $llm1StartCalled = false; - $llm1EndCalled = false; - $llm2StartCalled = false; - $llm2EndCalled = false; + $allChunks = []; + foreach ($chunks as $chunk) { + $allChunks[] = $chunk; + } - $llm1->onStart(function (ChatModelStart $event) use ($llm1, &$llm1StartCalled): void { - expect($event->llm)->toBe($llm1); - $llm1StartCalled = true; - }); + $finalChunk = collect($allChunks)->last(fn(ChatGenerationChunk $c): bool => $c->isFinal); + expect($finalChunk)->not->toBeNull(); + + // Tool calls should be fully assembled + expect($finalChunk->message->toolCalls)->toBeInstanceOf(ToolCallCollection::class) + ->and($finalChunk->message->toolCalls)->toHaveCount(1) + ->and($finalChunk->message->toolCalls[0]->function->name)->toBe('get_weather') + ->and($finalChunk->message->toolCalls[0]->function->arguments)->toBe([ + 'location' => 'Manchester, UK', + 'unit' => 'celsius', + ]) + ->and($finalChunk->message->toolCalls[0]->function->delta)->toBe('{"location":"Manchester, UK","unit":"celsius"}'); + + // Usage should be present + expect($finalChunk->usage)->toBeInstanceOf(Usage::class) + ->and($finalChunk->usage->promptTokens)->toBe(86) + ->and($finalChunk->usage->completionTokens)->toBe(22) + ->and($finalChunk->usage->totalTokens)->toBe(108); + + // Finish reason should indicate tool calls + expect($finalChunk->finishReason)->toBe(FinishReason::ToolCalls); +}); - $llm1->onEnd(function (ChatModelEnd $event) use ($llm1, &$llm1EndCalled): void { - expect($event->llm)->toBe($llm1); - $llm1EndCalled = true; - }); +test('streaming tool calls: tool call arguments accumulate progressively', function (): void { + $llm = OpenAIResponses::fake([ + CreateResponse::class => MockResponse::fixture('openai/responses/function-call-streamed'), + ], 'gpt-4o'); - $llm2->onStart(function (ChatModelStart $event) use ($llm2, &$llm2StartCalled): void { - expect($event->llm)->toBe($llm2); - $llm2StartCalled = true; - }); + $llm->addFeature(ModelFeature::ToolCalling); + $llm->withStreaming(); - $llm2->onEnd(function (ChatModelEnd $event) use ($llm2, &$llm2EndCalled): void { - expect($event->llm)->toBe($llm2); - $llm2EndCalled = true; - }); + $chunks = $llm->invoke([new UserMessage('What is the weather?')]); + + $previousArgsLength = 0; + + foreach ($chunks as $chunk) { + if ($chunk->type !== ChunkType::ToolInputDelta) { + continue; + } + + // Tool calls should be present and growing + expect($chunk->message->toolCalls)->toBeInstanceOf(ToolCallCollection::class) + ->and($chunk->message->toolCalls)->toHaveCount(1); + + $rawArgs = $chunk->message->toolCalls[0]->function->delta; + $currentLength = strlen((string) $rawArgs); - $result1 = $llm1->invoke([new UserMessage('Hello')]); - $result2 = $llm2->invoke([new UserMessage('Hello')]); + expect($currentLength)->toBeGreaterThanOrEqual( + $previousArgsLength, + 'Tool call arguments should grow monotonically', + ); - expect($result1)->toBeInstanceOf(ChatResult::class); - expect($result2)->toBeInstanceOf(ChatResult::class); - expect($llm1StartCalled)->toBeTrue(); - expect($llm1EndCalled)->toBeTrue(); - expect($llm2StartCalled)->toBeTrue(); - expect($llm2EndCalled)->toBeTrue(); + $previousArgsLength = $currentLength; + } + + expect($previousArgsLength)->toBeGreaterThan(0, 'Should have seen tool call arguments'); }); -test('LLM can chain multiple instance-specific listeners', function (): void { +// ───────────────────────────────────────────────────────── +// Streaming: reasoning + text +// ───────────────────────────────────────────────────────── + +test('streaming with reasoning: chunk types are in the correct order', function (): void { $llm = OpenAIResponses::fake([ - CreateResponse::fake([ - 'id' => 'resp_123', - 'model' => 'gpt-4o', - 'created_at' => 1234567890, - 'status' => 'completed', - 'output' => [ - [ - 'type' => 'message', - 'id' => 'msg_123', - 'role' => 'assistant', - 'content' => [ - [ - 'type' => 'output_text', - 'text' => 'Hello World!', - 'annotations' => [], - ], - ], - ], - ], - 'usage' => [ - 'input_tokens' => 10, - 'output_tokens' => 20, - 'total_tokens' => 30, - 'input_token_details' => [ - 'cached_tokens' => 0, - ], - 'output_token_details' => [ - 'reasoning_tokens' => 0, - ], - ], - ]), + CreateResponse::class => MockResponse::fixture('openai/responses/reasoning-streamed'), ]); - $callOrder = []; - - $llm - ->onStart(function (ChatModelStart $event) use (&$callOrder): void { - $callOrder[] = 'start1'; - }) - ->onStart(function (ChatModelStart $event) use (&$callOrder): void { - $callOrder[] = 'start2'; - }) - ->onEnd(function (ChatModelEnd $event) use (&$callOrder): void { - $callOrder[] = 'end1'; - }) - ->onEnd(function (ChatModelEnd $event) use (&$callOrder): void { - $callOrder[] = 'end2'; - }); - - $llm->invoke([new UserMessage('Hello')]); - - expect($callOrder)->toBe(['start1', 'start2', 'end1', 'end2']); + $llm->withStreaming(); + + $chunks = $llm->invoke([ + new UserMessage('What is the weight of the moon?'), + ]); + + $collectedChunks = collectOpenAIResponseStreamChunks($chunks); + $chunkTypes = collectChunkTypes($collectedChunks); + + // Fixture facts from tests/fixtures/sdk/openai/responses/reasoning-streamed.json: + // - 206 response.output_text.delta events (sequence_number 6..211) + // - reasoning output item contains empty summary array, so no reasoning text deltas + $expectedSequence = [ + ChunkType::MessageStart, + ChunkType::TextStart, + ...repeatChunkType(ChunkType::TextDelta, 206), + ChunkType::TextEnd, + ChunkType::MessageEnd, + ]; + + expectExactChunkTypeSequence( + actual: $chunkTypes, + expected: $expectedSequence, + scenario: 'reasoning-streamed', + ); + + expectValidChunkLifecycleOrder($chunkTypes, 'reasoning-streamed'); + + expect($chunkTypes)->not->toContain(ChunkType::ReasoningStart) + ->and($chunkTypes)->not->toContain(ChunkType::ReasoningDelta) + ->and($chunkTypes)->not->toContain(ChunkType::ReasoningEnd) + ->and($chunkTypes)->not->toContain(ChunkType::ToolInputStart) + ->and($chunkTypes)->not->toContain(ChunkType::ToolInputDelta) + ->and($chunkTypes)->not->toContain(ChunkType::ToolInputEnd); }); -test('it correctly maps chunk types for streaming with tool calls', function (): void { +test('streaming with reasoning: final chunk has complete content, usage and reasoning tokens', function (): void { $llm = OpenAIResponses::fake([ - CreateStreamedResponse::fake(fopen(__DIR__ . '/../../../../fixtures/openai/responses-stream-tool-calls.txt', 'r')), - ], 'gpt-4o'); + CreateResponse::class => MockResponse::fixture('openai/responses/reasoning-streamed'), + ]); - $llm->addFeature(ModelFeature::ToolCalling); $llm->withStreaming(); $chunks = $llm->invoke([ - new UserMessage('What is 3 times 4?'), + new UserMessage('What is the weight of the moon?'), ]); - $chunkTypes = []; + $allChunks = []; foreach ($chunks as $chunk) { - $chunkTypes[] = $chunk->type; + $allChunks[] = $chunk; } - // Verify chunk types for tool calls - expect($chunkTypes)->toContain(ChunkType::ToolInputStart) // When output_item.added with function_call - ->and($chunkTypes)->toContain(ChunkType::ToolInputDelta) // function_call_arguments.delta - ->and($chunkTypes)->toContain(ChunkType::ToolInputEnd) // function_call_arguments.done - ->and($chunkTypes)->toContain(ChunkType::Done); // response.completed + $finalChunk = collect($allChunks)->last(fn(ChatGenerationChunk $c): bool => $c->isFinal); + expect($finalChunk)->not->toBeNull(); + + // Usage with reasoning tokens + expect($finalChunk->usage)->toBeInstanceOf(Usage::class) + ->and($finalChunk->usage->promptTokens)->toBe(14) + ->and($finalChunk->usage->completionTokens)->toBe(727) + ->and($finalChunk->usage->totalTokens)->toBe(741); + + // Content should contain assembled text + $textContents = collect($finalChunk->contentSoFar) + ->filter(fn(mixed $c): bool => $c instanceof TextContent) + ->values(); + expect($textContents)->not->toBeEmpty(); + expect($textContents[0]->text)->toContain('Mass of the Moon') + ->and($textContents[0]->text)->toContain('7.35'); + + // Finish reason + expect($finalChunk->finishReason)->toBe(FinishReason::Stop); }); -test('it correctly maps chunk types for streaming with reasoning', function (): void { +// ───────────────────────────────────────────────────────── +// Streaming: listeners +// ───────────────────────────────────────────────────────── + +test('stream listeners fire in correct order and receive correct data', function (): void { $llm = OpenAIResponses::fake([ - CreateStreamedResponse::fake(fopen(__DIR__ . '/../../../../fixtures/openai/responses-stream-reasoning.txt', 'r')), + CreateResponse::class => MockResponse::fixture('openai/responses/simple-streamed'), ]); $llm->withStreaming(); + $streamChunkTypes = []; + $streamEndCalled = false; + $streamEndChunk = null; + $eventOrder = []; + + $llm->onStream(function (ChatModelStream $event) use ($llm, &$streamChunkTypes, &$eventOrder): void { + expect($event->llm)->toBe($llm); + expect($event->chunk)->toBeInstanceOf(ChatGenerationChunk::class); + $streamChunkTypes[] = $event->chunk->type; + $eventOrder[] = 'stream'; + }); + + $llm->onStreamEnd(function (ChatModelStreamEnd $event) use ($llm, &$streamEndCalled, &$streamEndChunk, &$eventOrder): void { + $streamEndCalled = true; + $streamEndChunk = $event->chunk; + expect($event->llm)->toBe($llm); + expect($event->chunk)->toBeInstanceOf(ChatGenerationChunk::class); + $eventOrder[] = 'streamEnd'; + }); + + $result = $llm->invoke([new UserMessage('Hello')]); + expect($result)->toBeInstanceOf(ChatStreamResult::class); + + // Must iterate to trigger events + $yieldedChunkTypes = []; + foreach ($result as $chunk) { + $yieldedChunkTypes[] = $chunk->type; + } + + // Stream events should match yielded chunks exactly + expect($streamChunkTypes)->toBe($yieldedChunkTypes); + + // Stream end must fire exactly once, as the very last event + expect($streamEndCalled)->toBeTrue(); + expect(end($eventOrder))->toBe('streamEnd'); + expect(count(array_filter($eventOrder, fn($e): bool => $e === 'streamEnd')))->toBe(1); + + // Stream end chunk should be the final chunk + expect($streamEndChunk->type)->toBe(ChunkType::MessageEnd); +}); + +// ───────────────────────────────────────────────────────── +// Streaming: structured output +// ───────────────────────────────────────────────────────── + +test('streaming structured output: produces correct chunk type sequence', function (): void { + $llm = OpenAIResponses::fake([ + CreateResponse::class => MockResponse::fixture('openai/responses/structured-output-streamed'), + ], 'gpt-4o'); + + $llm->addFeature(ModelFeature::StructuredOutput); + $llm->withStreaming(); + + $llm->withStructuredOutput( + Schema::object()->properties( + Schema::string('name'), + Schema::string('email'), + Schema::string('plan_interest'), + Schema::boolean('demo_requested'), + ), + name: 'contact_info', + description: 'Extract contact info', + ); + $chunks = $llm->invoke([ - new UserMessage('What is the meaning of life?'), + new UserMessage('Extract the key information from this email: John Smith (john@example.com) is interested in our Enterprise plan.'), + ]); + + expect($chunks)->toBeInstanceOf(ChatStreamResult::class); + + $collectedChunks = collectOpenAIResponseStreamChunks($chunks); + $chunkTypes = collectChunkTypes($collectedChunks); + + // Fixture facts from tests/fixtures/sdk/openai/responses/structured-output-streamed.json: + // - 22 response.output_text.delta events (sequence_number 4..25) + // - single assistant output item + $expectedSequence = [ + ChunkType::MessageStart, + ChunkType::TextStart, + ...repeatChunkType(ChunkType::TextDelta, 22), + ChunkType::TextEnd, + ChunkType::MessageEnd, + ]; + + expectExactChunkTypeSequence( + actual: $chunkTypes, + expected: $expectedSequence, + scenario: 'structured-output-streamed', + ); + + expectValidChunkLifecycleOrder($chunkTypes, 'structured-output-streamed'); +}); + +// ───────────────────────────────────────────────────────── +// Streaming: includeRaw +// ───────────────────────────────────────────────────────── + +test('streaming with includeRaw populates rawChunk on every chunk', function (): void { + $llm = OpenAIResponses::fake([ + CreateResponse::class => MockResponse::fixture('openai/responses/simple-streamed'), ]); - $chunkTypes = []; + $llm->withStreaming()->includeRaw(); + + $chunks = $llm->invoke([new UserMessage('Hello')]); + + $chunksWithRaw = 0; + $chunksWithoutRaw = 0; + foreach ($chunks as $chunk) { - $chunkTypes[] = $chunk->type; + if ($chunk->rawChunk !== null) { + expect($chunk->rawChunk)->toBeArray()->not->toBeEmpty(); + $chunksWithRaw++; + } else { + $chunksWithoutRaw++; + } } - // Verify chunk types for reasoning - expect($chunkTypes)->toContain(ChunkType::ReasoningStart) // reasoning_summary_part.added - ->and($chunkTypes)->toContain(ChunkType::ReasoningDelta) // reasoning_summary_text.delta - ->and($chunkTypes)->toContain(ChunkType::ReasoningEnd) // reasoning_summary_text.done - ->and($chunkTypes)->toContain(ChunkType::TextDelta) // output_text.delta - ->and($chunkTypes)->toContain(ChunkType::Done); // response.completed + expect($chunksWithRaw)->toBeGreaterThan(0, 'At least some chunks should have rawChunk when includeRaw is enabled'); + expect($chunksWithoutRaw)->toBe(0, 'All chunks should have rawChunk when includeRaw is enabled'); +}); + +test('streaming without includeRaw has null rawChunk', function (): void { + $llm = OpenAIResponses::fake([ + CreateResponse::class => MockResponse::fixture('openai/responses/simple-streamed'), + ]); + + $llm->withStreaming(); + + $chunks = $llm->invoke([new UserMessage('Hello')]); + + foreach ($chunks as $chunk) { + expect($chunk->rawChunk)->toBeNull(); + } }); diff --git a/tests/Unit/LLM/StreamBufferTest.php b/tests/Unit/LLM/StreamBufferTest.php index c5d771f..62bd765 100644 --- a/tests/Unit/LLM/StreamBufferTest.php +++ b/tests/Unit/LLM/StreamBufferTest.php @@ -3,9 +3,10 @@ declare(strict_types=1); use Cortex\Pipeline\RuntimeConfig; +use Saloon\Http\Faking\MockResponse; use Cortex\LLM\Data\ChatGenerationChunk; -use Cortex\LLM\Drivers\OpenAI\Chat\OpenAIChat; -use OpenAI\Responses\Chat\CreateStreamedResponse; +use Cortex\LLM\Drivers\Anthropic\AnthropicChat; +use Cortex\SDK\Anthropic\Requests\CreateMessage; it('interleaves stream buffer items with chat stream result', function (): void { // Setup @@ -14,26 +15,16 @@ // Push initial item $config->stream->push('start'); - // Create a minimal stream response file for our test chunks - $streamContent = "data: {\"id\":\"chatcmpl-1\",\"object\":\"chat.completion.chunk\",\"created\":1234567890,\"model\":\"gpt-4\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"A\"},\"finish_reason\":null}]}\n\n"; - $streamContent .= "data: {\"id\":\"chatcmpl-1\",\"object\":\"chat.completion.chunk\",\"created\":1234567890,\"model\":\"gpt-4\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"B\"},\"finish_reason\":\"stop\"}],\"usage\":{\"prompt_tokens\":10,\"completion_tokens\":2,\"total_tokens\":12}}\n\n"; - $streamContent .= "data: [DONE]\n\n"; - - $streamHandle = fopen('php://memory', 'r+'); - fwrite($streamHandle, $streamContent); - rewind($streamHandle); - - // Use real OpenAIChat with MapsStreamResponse - it will handle buffer draining - $llm = OpenAIChat::fake([ - CreateStreamedResponse::fake($streamHandle), - ]); - $llm->withStreaming(); + $llm = AnthropicChat::fake([ + CreateMessage::class => MockResponse::fixture('anthropic/messages/simple-streamed'), + ])->withStreaming(); // Next closure (simulating next stage in pipeline that pushes to buffer) $next = function (mixed $payload, RuntimeConfig $config): mixed { if ($payload instanceof ChatGenerationChunk) { // Push item during stream - $config->stream->push('mid-' . $payload->contentSoFar); + $textSoFar = $payload->textSoFar() ?? ''; + $config->stream->push('mid-' . $textSoFar); } return $payload; @@ -42,43 +33,68 @@ // Execute $result = $llm->handlePipeable('input', $config, $next); - // Collect results - $items = []; - foreach ($result as $item) { - $items[] = $item; - } + $items = iterator_to_array($result, false); - expect($items)->toHaveCount(5); + // Expected sequence based on fixture: + // 1. Buffer drain: 'start' + // 2. MessageStart chunk (no text) + // 3. Buffer drain: 'mid-' (empty string from MessageStart) + // 4. TextStart chunk (no text) + // 5. Buffer drain: 'mid-' (empty string from TextStart) + // 6. TextDelta chunk: "Hello! I'm doing well, thank" + // 7. Buffer drain: 'mid-Hello! I'm doing well, thank' + // 8. TextDelta chunk: "Hello! I'm doing well, thank you for asking. How" + // 9. Buffer drain: 'mid-Hello! I'm doing well, thank you for asking. How' + // 10. TextDelta chunk: "Hello! I'm doing well, thank you for asking. How are you doing today? Is" + // 11. Buffer drain: 'mid-Hello! I'm doing well, thank you for asking. How are you doing today? Is' + // 12. TextDelta chunk: "Hello! I'm doing well, thank you for asking. How are you doing today? Is there anything I can help you with?" + // 13. Buffer drain: 'mid-Hello! I'm doing well, thank you for asking. How are you doing today? Is there anything I can help you with?' + // 14. TextEnd chunk (full text) + // 15. Buffer drain: 'mid-Hello! I'm doing well, thank you for asking. How are you doing today? Is there anything I can help you with?' + // 16. MessageEnd chunk (full text) + // 17. Buffer drain: 'mid-Hello! I'm doing well, thank you for asking. How are you doing today? Is there anything I can help you with?' + // 18. Final buffer drain (empty) + + expect($items)->toHaveCount(17); expect($items[0])->toBe('start'); + // First chunk is MessageStart (no text) expect($items[1])->toBeInstanceOf(ChatGenerationChunk::class); - expect($items[1]->contentSoFar)->toBe('A'); // First chunk has contentSoFar = 'A' + expect($items[1]->textSoFar())->toBeNull(); - expect($items[2])->toBe('mid-A'); + // Buffer item from MessageStart (empty text) + expect($items[2])->toBe('mid-'); + // Second chunk is TextStart (no text yet) expect($items[3])->toBeInstanceOf(ChatGenerationChunk::class); - expect($items[3]->contentSoFar)->toBe('AB'); // Second chunk accumulates: 'A' + 'B' = 'AB' + expect($items[3]->textSoFar())->toBe(''); + + // Buffer item from TextStart (empty text) + expect($items[4])->toBe('mid-'); + + // Third chunk is first TextDelta + expect($items[5])->toBeInstanceOf(ChatGenerationChunk::class); + expect($items[5]->textSoFar())->toBe("Hello! I'm doing well, thank"); + + // Buffer item from first TextDelta + expect($items[6])->toBe("mid-Hello! I'm doing well, thank"); - expect($items[4])->toBe('mid-AB'); // Buffer item uses contentSoFar from the chunk ('AB') + // Fourth chunk is second TextDelta + expect($items[7])->toBeInstanceOf(ChatGenerationChunk::class); + expect($items[7]->textSoFar())->toBe("Hello! I'm doing well, thank you for asking. How"); + + // Buffer item from second TextDelta + expect($items[8])->toBe("mid-Hello! I'm doing well, thank you for asking. How"); }); it('handles buffer items pushed after stream completion', function (): void { $config = new RuntimeConfig(); - // Create a stream response with one chunk - $streamContent = "data: {\"id\":\"chatcmpl-1\",\"object\":\"chat.completion.chunk\",\"created\":1234567890,\"model\":\"gpt-4\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"Hello\"},\"finish_reason\":\"stop\"}],\"usage\":{\"prompt_tokens\":10,\"completion_tokens\":1,\"total_tokens\":11}}\n\n"; - $streamContent .= "data: [DONE]\n\n"; - - $streamHandle = fopen('php://memory', 'r+'); - fwrite($streamHandle, $streamContent); - rewind($streamHandle); - - // Use real OpenAIChat with MapsStreamResponse - it will handle buffer draining - $llm = OpenAIChat::fake([ - CreateStreamedResponse::fake($streamHandle), - ]); - $llm->withStreaming(); + // Use AnthropicChat with MapStreamResponse - it will handle buffer draining + $llm = AnthropicChat::fake([ + CreateMessage::class => MockResponse::fixture('anthropic/messages/simple-streamed'), + ])->withStreaming(); // Push item to buffer AFTER the stream completes (during iteration, after last chunk) $next = function (mixed $payload, RuntimeConfig $config): mixed { @@ -94,10 +110,27 @@ $items = iterator_to_array($result, false); - // MapsStreamResponse drains buffer after stream completes (line 179-181) - // So items pushed after the final chunk should be drained and yielded - expect($items)->toHaveCount(2); - expect($items[0])->toBeInstanceOf(ChatGenerationChunk::class); - expect($items[0]->isFinal)->toBeTrue(); // Final chunk - expect($items[1])->toBe('after-completion'); // Buffer item drained after stream completion + // MapStreamResponse drains buffer: + // 1. At the start (empty, doesn't yield anything) + // 2. Yields chunks from the stream + // 3. After stream completes, drains buffer (yields items pushed during chunk processing) + // The final chunk has isFinal=true when it contains usage information (MessageEnd chunk) + // The buffer item 'after-completion' is pushed when processing the final chunk, + // then drained after the stream completes + + // Find the final chunk and buffer item + $finalChunk = null; + $bufferItem = null; + + foreach ($items as $item) { + if ($item instanceof ChatGenerationChunk && $item->isFinal) { + $finalChunk = $item; + } elseif ($item === 'after-completion') { + $bufferItem = $item; + } + } + + expect($finalChunk)->not->toBeNull('Expected to find final chunk'); + expect($finalChunk->isFinal)->toBeTrue(); + expect($bufferItem)->toBe('after-completion'); }); diff --git a/tests/Unit/LLM/Streaming/AgUiDataStreamTest.php b/tests/Unit/LLM/Streaming/AgUiDataStreamTest.php index 49a605e..a9d6c55 100644 --- a/tests/Unit/LLM/Streaming/AgUiDataStreamTest.php +++ b/tests/Unit/LLM/Streaming/AgUiDataStreamTest.php @@ -3,304 +3,3 @@ declare(strict_types=1); namespace Tests\Unit\LLM\Streaming; - -use Closure; -use ArrayIterator; -use ReflectionClass; -use DateTimeImmutable; -use Cortex\LLM\Data\Usage; -use Cortex\LLM\Enums\ChunkType; -use Cortex\LLM\Enums\FinishReason; -use Cortex\LLM\Data\ChatStreamResult; -use Cortex\LLM\Data\ChatGenerationChunk; -use Cortex\LLM\Streaming\AgUiDataStream; -use Cortex\LLM\Data\Messages\AssistantMessage; - -beforeEach(function (): void { - $this->stream = new AgUiDataStream(); -}); - -it('maps MessageStart chunk to RunStarted and TextMessageStart events', function (): void { - $chunk = new ChatGenerationChunk( - type: ChunkType::MessageStart, - id: 'msg_123', - message: new AssistantMessage(content: ''), - createdAt: new DateTimeImmutable('2024-01-01 12:00:00'), - ); - - $payload = $this->stream->mapChunkToPayload($chunk); - - expect($payload)->toHaveKey('type', 'RunStarted') - ->and($payload)->toHaveKey('runId') - ->and($payload)->toHaveKey('threadId') - ->and($payload)->toHaveKey('timestamp'); -}); - -it('maps TextDelta chunk to TextMessageContent event', function (): void { - $chunk = new ChatGenerationChunk( - type: ChunkType::TextDelta, - id: 'msg_123', - message: new AssistantMessage(content: 'Hello, '), - createdAt: new DateTimeImmutable('2024-01-01 12:00:00'), - ); - - $reflection = new ReflectionClass($this->stream); - $method = $reflection->getMethod('mapChunkToEvents'); - - $events = $method->invoke($this->stream, $chunk); - - expect($events)->toHaveCount(1) - ->and($events[0])->toHaveKey('type', 'TextMessageContent') - ->and($events[0])->toHaveKey('delta', 'Hello, ') - ->and($events[0])->toHaveKey('messageId') - ->and($events[0])->toHaveKey('timestamp'); -}); - -it('maps TextEnd chunk to TextMessageEnd event', function (): void { - $chunk = new ChatGenerationChunk( - type: ChunkType::TextEnd, - id: 'msg_123', - message: new AssistantMessage(content: ''), - createdAt: new DateTimeImmutable('2024-01-01 12:00:00'), - ); - - $reflection = new ReflectionClass($this->stream); - $method = $reflection->getMethod('mapChunkToEvents'); - - $events = $method->invoke($this->stream, $chunk); - - expect($events)->toHaveCount(1) - ->and($events[0])->toHaveKey('type', 'TextMessageEnd') - ->and($events[0])->toHaveKey('messageId') - ->and($events[0])->toHaveKey('timestamp'); -}); - -it('maps ReasoningStart chunk to ReasoningStart event', function (): void { - $chunk = new ChatGenerationChunk( - type: ChunkType::ReasoningStart, - id: 'msg_123', - message: new AssistantMessage(content: ''), - createdAt: new DateTimeImmutable('2024-01-01 12:00:00'), - ); - - $reflection = new ReflectionClass($this->stream); - $method = $reflection->getMethod('mapChunkToEvents'); - - $events = $method->invoke($this->stream, $chunk); - - expect($events)->toHaveCount(1) - ->and($events[0])->toHaveKey('type', 'ReasoningStart') - ->and($events[0])->toHaveKey('messageId') - ->and($events[0])->toHaveKey('timestamp'); -}); - -it('maps ReasoningDelta chunk to ReasoningMessageContent event', function (): void { - $chunk = new ChatGenerationChunk( - type: ChunkType::ReasoningDelta, - id: 'msg_123', - message: new AssistantMessage(content: 'Thinking...'), - createdAt: new DateTimeImmutable('2024-01-01 12:00:00'), - ); - - $reflection = new ReflectionClass($this->stream); - $method = $reflection->getMethod('mapChunkToEvents'); - - $events = $method->invoke($this->stream, $chunk); - - expect($events)->toHaveCount(1) - ->and($events[0])->toHaveKey('type', 'ReasoningMessageContent') - ->and($events[0])->toHaveKey('delta', 'Thinking...') - ->and($events[0])->toHaveKey('messageId') - ->and($events[0])->toHaveKey('timestamp'); -}); - -it('maps ReasoningEnd chunk to ReasoningEnd event', function (): void { - $chunk = new ChatGenerationChunk( - type: ChunkType::ReasoningEnd, - id: 'msg_123', - message: new AssistantMessage(content: ''), - createdAt: new DateTimeImmutable('2024-01-01 12:00:00'), - ); - - $reflection = new ReflectionClass($this->stream); - $method = $reflection->getMethod('mapChunkToEvents'); - - $events = $method->invoke($this->stream, $chunk); - - expect($events)->toHaveCount(1) - ->and($events[0])->toHaveKey('type', 'ReasoningEnd') - ->and($events[0])->toHaveKey('messageId') - ->and($events[0])->toHaveKey('timestamp'); -}); - -it('maps StepStart chunk to StepStarted event', function (): void { - $chunk = new ChatGenerationChunk( - type: ChunkType::StepStart, - id: 'msg_123', - message: new AssistantMessage(content: ''), - createdAt: new DateTimeImmutable('2024-01-01 12:00:00'), - ); - - $reflection = new ReflectionClass($this->stream); - $method = $reflection->getMethod('mapChunkToEvents'); - - $events = $method->invoke($this->stream, $chunk); - - expect($events)->toHaveCount(1) - ->and($events[0])->toHaveKey('type', 'StepStarted') - ->and($events[0])->toHaveKey('stepName', 'step_msg_123') - ->and($events[0])->toHaveKey('timestamp'); -}); - -it('maps StepEnd chunk to StepFinished event', function (): void { - $chunk = new ChatGenerationChunk( - type: ChunkType::StepEnd, - id: 'msg_123', - message: new AssistantMessage(content: ''), - createdAt: new DateTimeImmutable('2024-01-01 12:00:00'), - ); - - $reflection = new ReflectionClass($this->stream); - $method = $reflection->getMethod('mapChunkToEvents'); - - $events = $method->invoke($this->stream, $chunk); - - expect($events)->toHaveCount(1) - ->and($events[0])->toHaveKey('type', 'StepFinished') - ->and($events[0])->toHaveKey('stepName', 'step_msg_123') - ->and($events[0])->toHaveKey('timestamp'); -}); - -it('maps Error chunk to RunError event', function (): void { - $chunk = new ChatGenerationChunk( - type: ChunkType::Error, - id: 'msg_123', - message: new AssistantMessage(content: 'Something went wrong'), - createdAt: new DateTimeImmutable('2024-01-01 12:00:00'), - ); - - $reflection = new ReflectionClass($this->stream); - $method = $reflection->getMethod('mapChunkToEvents'); - - $events = $method->invoke($this->stream, $chunk); - - expect($events)->toHaveCount(1) - ->and($events[0])->toHaveKey('type', 'RunError') - ->and($events[0])->toHaveKey('message', 'Something went wrong') - ->and($events[0])->toHaveKey('timestamp'); -}); - -it('maps MessageEnd final chunk to TextMessageEnd and RunFinished events', function (): void { - $chunk = new ChatGenerationChunk( - type: ChunkType::MessageEnd, - id: 'msg_123', - message: new AssistantMessage(content: ''), - createdAt: new DateTimeImmutable('2024-01-01 12:00:00'), - finishReason: FinishReason::Stop, - isFinal: true, - ); - - $reflection = new ReflectionClass($this->stream); - $method = $reflection->getMethod('mapChunkToEvents'); - - // Simulate that message was started - $messageStartedProperty = $reflection->getProperty('messageStarted'); - $messageStartedProperty->setValue($this->stream, true); - - $runStartedProperty = $reflection->getProperty('runStarted'); - $runStartedProperty->setValue($this->stream, true); - - $currentMessageIdProperty = $reflection->getProperty('currentMessageId'); - $currentMessageIdProperty->setValue($this->stream, 'msg_123'); - - $events = $method->invoke($this->stream, $chunk); - - expect($events)->toHaveCount(2) - ->and($events[0])->toHaveKey('type', 'TextMessageEnd') - ->and($events[1])->toHaveKey('type', 'RunFinished') - ->and($events[1])->toHaveKey('runId') - ->and($events[1])->toHaveKey('threadId'); -}); - -it('includes usage information in RunFinished result when available', function (): void { - $usage = new Usage( - promptTokens: 10, - completionTokens: 20, - ); - - $chunk = new ChatGenerationChunk( - type: ChunkType::MessageEnd, - id: 'msg_123', - message: new AssistantMessage(content: ''), - createdAt: new DateTimeImmutable('2024-01-01 12:00:00'), - finishReason: FinishReason::Stop, - usage: $usage, - isFinal: true, - ); - - $reflection = new ReflectionClass($this->stream); - $method = $reflection->getMethod('mapChunkToEvents'); - - // Simulate that run was started - $runStartedProperty = $reflection->getProperty('runStarted'); - $runStartedProperty->setValue($this->stream, true); - - $events = $method->invoke($this->stream, $chunk); - - $runFinishedEvent = collect($events)->firstWhere('type', 'RunFinished'); - - expect($runFinishedEvent)->toHaveKey('result') - ->and($runFinishedEvent['result'])->toHaveKey('usage'); -}); - -it('does not emit text content events for empty deltas', function (): void { - $chunk = new ChatGenerationChunk( - type: ChunkType::TextDelta, - id: 'msg_123', - message: new AssistantMessage(content: ''), - createdAt: new DateTimeImmutable('2024-01-01 12:00:00'), - ); - - $reflection = new ReflectionClass($this->stream); - $method = $reflection->getMethod('mapChunkToEvents'); - - $events = $method->invoke($this->stream, $chunk); - - expect($events)->toBeEmpty(); -}); - -it('returns streamResponse closure that can be invoked', function (): void { - $chunks = [ - new ChatGenerationChunk( - type: ChunkType::MessageStart, - id: 'msg_123', - message: new AssistantMessage(content: ''), - createdAt: new DateTimeImmutable('2024-01-01 12:00:00'), - ), - new ChatGenerationChunk( - type: ChunkType::TextDelta, - id: 'msg_123', - message: new AssistantMessage(content: 'Hello'), - createdAt: new DateTimeImmutable('2024-01-01 12:00:00'), - ), - ]; - - $result = new ChatStreamResult( - new ArrayIterator($chunks), - ); - - $closure = $this->stream->streamResponse($result); - - expect($closure)->toBeInstanceOf(Closure::class); - - // Note: We cannot reliably test the actual output here because flush() - // bypasses output buffering. The closure invocation is sufficient to - // verify it works without errors. - ob_start(); - - try { - $closure(); - } finally { - ob_end_clean(); - } -})->group('stream'); diff --git a/tests/Unit/LLM/Streaming/VercelDataStreamTest.php b/tests/Unit/LLM/Streaming/VercelDataStreamTest.php index ea02bbd..a9d6c55 100644 --- a/tests/Unit/LLM/Streaming/VercelDataStreamTest.php +++ b/tests/Unit/LLM/Streaming/VercelDataStreamTest.php @@ -3,577 +3,3 @@ declare(strict_types=1); namespace Tests\Unit\LLM\Streaming; - -use Closure; -use ArrayIterator; -use DateTimeImmutable; -use Cortex\LLM\Data\Usage; -use Cortex\LLM\Data\ToolCall; -use Cortex\LLM\Enums\ChunkType; -use Cortex\LLM\Data\FunctionCall; -use Cortex\LLM\Enums\FinishReason; -use Cortex\LLM\Data\ChatStreamResult; -use Cortex\LLM\Data\ResponseMetadata; -use Cortex\LLM\Data\ToolCallCollection; -use Cortex\LLM\Data\ChatGenerationChunk; -use Cortex\ModelInfo\Enums\ModelProvider; -use Cortex\LLM\Streaming\VercelDataStream; -use Cortex\LLM\Data\Messages\AssistantMessage; - -beforeEach(function (): void { - $this->stream = new VercelDataStream(); -}); - -it('maps MessageStart chunk to start type with messageId', function (): void { - $chunk = new ChatGenerationChunk( - type: ChunkType::MessageStart, - id: 'msg_123', - message: new AssistantMessage(content: ''), - createdAt: new DateTimeImmutable('2024-01-01 12:00:00'), - ); - - $payload = $this->stream->mapChunkToPayload($chunk); - - expect($payload)->toHaveKey('type', 'start') - ->and($payload)->toHaveKey('messageId', 'msg_123'); -}); - -it('maps MessageEnd chunk to finish type', function (): void { - $chunk = new ChatGenerationChunk( - type: ChunkType::MessageEnd, - id: 'msg_123', - message: new AssistantMessage(content: ''), - createdAt: new DateTimeImmutable('2024-01-01 12:00:00'), - ); - - $payload = $this->stream->mapChunkToPayload($chunk); - - expect($payload)->toHaveKey('type', 'finish'); -}); - -it('maps TextStart chunk to text-start type with id', function (): void { - $metadata = new ResponseMetadata( - id: 'resp_456', - model: 'gpt-4', - provider: ModelProvider::OpenAI, - ); - $chunk = new ChatGenerationChunk( - type: ChunkType::TextStart, - id: 'msg_123', - message: new AssistantMessage(content: '', metadata: $metadata), - createdAt: new DateTimeImmutable('2024-01-01 12:00:00'), - ); - - $payload = $this->stream->mapChunkToPayload($chunk); - - expect($payload)->toHaveKey('type', 'text-start') - ->and($payload)->toHaveKey('id', 'resp_456'); -}); - -it('maps TextDelta chunk to text-delta type with delta and id', function (): void { - $chunk = new ChatGenerationChunk( - type: ChunkType::TextDelta, - id: 'msg_123', - message: new AssistantMessage(content: 'Hello, '), - createdAt: new DateTimeImmutable('2024-01-01 12:00:00'), - ); - - $payload = $this->stream->mapChunkToPayload($chunk); - - expect($payload)->toHaveKey('type', 'text-delta') - ->and($payload)->toHaveKey('delta', 'Hello, ') - ->and($payload)->toHaveKey('id', 'msg_123') - ->and($payload)->not->toHaveKey('content'); -}); - -it('maps TextEnd chunk to text-end type with id', function (): void { - $chunk = new ChatGenerationChunk( - type: ChunkType::TextEnd, - id: 'msg_123', - message: new AssistantMessage(content: ''), - createdAt: new DateTimeImmutable('2024-01-01 12:00:00'), - ); - - $payload = $this->stream->mapChunkToPayload($chunk); - - expect($payload)->toHaveKey('type', 'text-end') - ->and($payload)->toHaveKey('id', 'msg_123'); -}); - -it('maps ReasoningStart chunk to reasoning-start type with id', function (): void { - $chunk = new ChatGenerationChunk( - type: ChunkType::ReasoningStart, - id: 'msg_123', - message: new AssistantMessage(content: ''), - createdAt: new DateTimeImmutable('2024-01-01 12:00:00'), - ); - - $payload = $this->stream->mapChunkToPayload($chunk); - - expect($payload)->toHaveKey('type', 'reasoning-start') - ->and($payload)->toHaveKey('id', 'msg_123'); -}); - -it('maps ReasoningDelta chunk to reasoning-delta type with delta', function (): void { - $chunk = new ChatGenerationChunk( - type: ChunkType::ReasoningDelta, - id: 'msg_123', - message: new AssistantMessage(content: 'Thinking step 1...'), - createdAt: new DateTimeImmutable('2024-01-01 12:00:00'), - ); - - $payload = $this->stream->mapChunkToPayload($chunk); - - expect($payload)->toHaveKey('type', 'reasoning-delta') - ->and($payload)->toHaveKey('delta', 'Thinking step 1...') - ->and($payload)->toHaveKey('id', 'msg_123') - ->and($payload)->not->toHaveKey('content'); -}); - -it('maps ReasoningEnd chunk to reasoning-end type with id', function (): void { - $chunk = new ChatGenerationChunk( - type: ChunkType::ReasoningEnd, - id: 'msg_123', - message: new AssistantMessage(content: ''), - createdAt: new DateTimeImmutable('2024-01-01 12:00:00'), - ); - - $payload = $this->stream->mapChunkToPayload($chunk); - - expect($payload)->toHaveKey('type', 'reasoning-end') - ->and($payload)->toHaveKey('id', 'msg_123'); -}); - -it('maps ToolInputStart chunk to tool-input-start type', function (): void { - $chunk = new ChatGenerationChunk( - type: ChunkType::ToolInputStart, - id: 'msg_123', - message: new AssistantMessage(content: ''), - createdAt: new DateTimeImmutable('2024-01-01 12:00:00'), - ); - - $payload = $this->stream->mapChunkToPayload($chunk); - - expect($payload)->toHaveKey('type', 'tool-input-start'); -}); - -it('maps ToolInputDelta chunk to tool-input-delta type', function (): void { - $chunk = new ChatGenerationChunk( - type: ChunkType::ToolInputDelta, - id: 'msg_123', - message: new AssistantMessage(content: ''), - createdAt: new DateTimeImmutable('2024-01-01 12:00:00'), - ); - - $payload = $this->stream->mapChunkToPayload($chunk); - - expect($payload)->toHaveKey('type', 'tool-input-delta'); -}); - -it('maps ToolInputEnd chunk to tool-input-available type', function (): void { - $chunk = new ChatGenerationChunk( - type: ChunkType::ToolInputEnd, - id: 'msg_123', - message: new AssistantMessage(content: ''), - createdAt: new DateTimeImmutable('2024-01-01 12:00:00'), - ); - - $payload = $this->stream->mapChunkToPayload($chunk); - - expect($payload)->toHaveKey('type', 'tool-input-available'); -}); - -it('maps ToolOutputEnd chunk to tool-output-available type', function (): void { - $chunk = new ChatGenerationChunk( - type: ChunkType::ToolOutputEnd, - id: 'msg_123', - message: new AssistantMessage(content: ''), - createdAt: new DateTimeImmutable('2024-01-01 12:00:00'), - ); - - $payload = $this->stream->mapChunkToPayload($chunk); - - expect($payload)->toHaveKey('type', 'tool-output-available'); -}); - -it('maps StepStart chunk to start-step type', function (): void { - $chunk = new ChatGenerationChunk( - type: ChunkType::StepStart, - id: 'msg_123', - message: new AssistantMessage(content: ''), - createdAt: new DateTimeImmutable('2024-01-01 12:00:00'), - ); - - $payload = $this->stream->mapChunkToPayload($chunk); - - expect($payload)->toHaveKey('type', 'start-step'); -}); - -it('maps StepEnd chunk to finish-step type', function (): void { - $chunk = new ChatGenerationChunk( - type: ChunkType::StepEnd, - id: 'msg_123', - message: new AssistantMessage(content: ''), - createdAt: new DateTimeImmutable('2024-01-01 12:00:00'), - ); - - $payload = $this->stream->mapChunkToPayload($chunk); - - expect($payload)->toHaveKey('type', 'finish-step'); -}); - -it('maps Error chunk to error type', function (): void { - $chunk = new ChatGenerationChunk( - type: ChunkType::Error, - id: 'msg_123', - message: new AssistantMessage(content: 'An error occurred'), - createdAt: new DateTimeImmutable('2024-01-01 12:00:00'), - ); - - $payload = $this->stream->mapChunkToPayload($chunk); - - expect($payload)->toHaveKey('type', 'error') - ->and($payload)->toHaveKey('content', 'An error occurred'); -}); - -it('maps SourceDocument chunk to source-document type', function (): void { - $chunk = new ChatGenerationChunk( - type: ChunkType::SourceDocument, - id: 'msg_123', - message: new AssistantMessage(content: ''), - createdAt: new DateTimeImmutable('2024-01-01 12:00:00'), - ); - - $payload = $this->stream->mapChunkToPayload($chunk); - - expect($payload)->toHaveKey('type', 'source-document'); -}); - -it('maps File chunk to file type', function (): void { - $chunk = new ChatGenerationChunk( - type: ChunkType::File, - id: 'msg_123', - message: new AssistantMessage(content: ''), - createdAt: new DateTimeImmutable('2024-01-01 12:00:00'), - ); - - $payload = $this->stream->mapChunkToPayload($chunk); - - expect($payload)->toHaveKey('type', 'file'); -}); - -it('includes tool calls in payload when present', function (): void { - $toolCall = new ToolCall( - id: 'call_123', - function: new FunctionCall( - name: 'get_weather', - arguments: [ - 'city' => 'San Francisco', - ], - ), - ); - - $toolCalls = new ToolCallCollection([$toolCall]); - - $chunk = new ChatGenerationChunk( - type: ChunkType::ToolInputEnd, - id: 'msg_123', - message: new AssistantMessage(content: '', toolCalls: $toolCalls), - createdAt: new DateTimeImmutable('2024-01-01 12:00:00'), - ); - - $payload = $this->stream->mapChunkToPayload($chunk); - - expect($payload)->toHaveKey('toolCalls') - ->and($payload['toolCalls'])->toBeArray() - ->and($payload['toolCalls'])->toHaveCount(1) - ->and($payload['toolCalls'][0])->toHaveKey('id', 'call_123') - ->and($payload['toolCalls'][0])->toHaveKey('function'); -}); - -it('includes usage information when available', function (): void { - $usage = new Usage( - promptTokens: 10, - completionTokens: 20, - ); - - $chunk = new ChatGenerationChunk( - type: ChunkType::MessageEnd, - id: 'msg_123', - message: new AssistantMessage(content: ''), - createdAt: new DateTimeImmutable('2024-01-01 12:00:00'), - usage: $usage, - ); - - $payload = $this->stream->mapChunkToPayload($chunk); - - expect($payload)->toHaveKey('usage') - ->and($payload['usage'])->toHaveKey('prompt_tokens', 10) - ->and($payload['usage'])->toHaveKey('completion_tokens', 20) - ->and($payload['usage'])->toHaveKey('total_tokens', 30); -}); - -it('includes finish reason for final chunks', function (): void { - $chunk = new ChatGenerationChunk( - type: ChunkType::MessageEnd, - id: 'msg_123', - message: new AssistantMessage(content: ''), - createdAt: new DateTimeImmutable('2024-01-01 12:00:00'), - finishReason: FinishReason::Stop, - isFinal: true, - ); - - $payload = $this->stream->mapChunkToPayload($chunk); - - expect($payload)->toHaveKey('finishReason', 'stop'); -}); - -it('does not include finish reason for non-final chunks', function (): void { - $chunk = new ChatGenerationChunk( - type: ChunkType::TextDelta, - id: 'msg_123', - message: new AssistantMessage(content: 'Hello'), - createdAt: new DateTimeImmutable('2024-01-01 12:00:00'), - finishReason: null, - isFinal: false, - ); - - $payload = $this->stream->mapChunkToPayload($chunk); - - expect($payload)->not->toHaveKey('finishReason'); -}); - -it('uses metadata id over chunk id for text blocks when available', function (): void { - $metadata = new ResponseMetadata( - id: 'resp_meta_id', - model: 'gpt-4', - provider: ModelProvider::OpenAI, - ); - $chunk = new ChatGenerationChunk( - type: ChunkType::TextDelta, - id: 'msg_chunk_id', - message: new AssistantMessage(content: 'test', metadata: $metadata), - createdAt: new DateTimeImmutable('2024-01-01 12:00:00'), - ); - - $payload = $this->stream->mapChunkToPayload($chunk); - - expect($payload)->toHaveKey('id', 'resp_meta_id'); -}); - -it('falls back to chunk id when metadata id is not available', function (): void { - $chunk = new ChatGenerationChunk( - type: ChunkType::TextDelta, - id: 'msg_chunk_id', - message: new AssistantMessage(content: 'test'), - createdAt: new DateTimeImmutable('2024-01-01 12:00:00'), - ); - - $payload = $this->stream->mapChunkToPayload($chunk); - - expect($payload)->toHaveKey('id', 'msg_chunk_id'); -}); - -it('does not add content key when delta is present', function (): void { - $chunk = new ChatGenerationChunk( - type: ChunkType::TextDelta, - id: 'msg_123', - message: new AssistantMessage(content: 'Hello'), - createdAt: new DateTimeImmutable('2024-01-01 12:00:00'), - ); - - $payload = $this->stream->mapChunkToPayload($chunk); - - expect($payload)->toHaveKey('delta', 'Hello') - ->and($payload)->not->toHaveKey('content'); -}); - -it('adds content key when delta is not present and content is available', function (): void { - $chunk = new ChatGenerationChunk( - type: ChunkType::MessageEnd, - id: 'msg_123', - message: new AssistantMessage(content: 'Full message'), - createdAt: new DateTimeImmutable('2024-01-01 12:00:00'), - ); - - $payload = $this->stream->mapChunkToPayload($chunk); - - expect($payload)->toHaveKey('content', 'Full message') - ->and($payload)->not->toHaveKey('delta'); -}); - -it('does not add content or delta when content is null', function (): void { - $chunk = new ChatGenerationChunk( - type: ChunkType::MessageStart, - id: 'msg_123', - message: new AssistantMessage(), - createdAt: new DateTimeImmutable('2024-01-01 12:00:00'), - ); - - $payload = $this->stream->mapChunkToPayload($chunk); - - expect($payload)->not->toHaveKey('content') - ->and($payload)->not->toHaveKey('delta'); -}); - -it('returns streamResponse closure that can be invoked', function (): void { - $chunks = [ - new ChatGenerationChunk( - type: ChunkType::MessageStart, - id: 'msg_123', - message: new AssistantMessage(content: ''), - createdAt: new DateTimeImmutable('2024-01-01 12:00:00'), - ), - new ChatGenerationChunk( - type: ChunkType::TextDelta, - id: 'msg_123', - message: new AssistantMessage(content: 'Hello'), - createdAt: new DateTimeImmutable('2024-01-01 12:00:00'), - ), - ]; - - $result = new ChatStreamResult( - new ArrayIterator($chunks), - ); - - $closure = $this->stream->streamResponse($result); - - expect($closure)->toBeInstanceOf(Closure::class); - - // Note: We cannot reliably test the actual output here because flush() - // bypasses output buffering. The closure invocation is sufficient to - // verify it works without errors. - ob_start(); - - try { - $closure(); - } finally { - ob_end_clean(); - } -})->group('stream'); - -it('handles multiple tool calls in a single chunk', function (): void { - $toolCall1 = new ToolCall( - id: 'call_1', - function: new FunctionCall('tool_one', [ - 'arg' => 'value1', - ]), - ); - $toolCall2 = new ToolCall( - id: 'call_2', - function: new FunctionCall('tool_two', [ - 'arg' => 'value2', - ]), - ); - - $toolCalls = new ToolCallCollection([$toolCall1, $toolCall2]); - - $chunk = new ChatGenerationChunk( - type: ChunkType::ToolInputEnd, - id: 'msg_123', - message: new AssistantMessage(content: '', toolCalls: $toolCalls), - createdAt: new DateTimeImmutable('2024-01-01 12:00:00'), - ); - - $payload = $this->stream->mapChunkToPayload($chunk); - - expect($payload)->toHaveKey('toolCalls') - ->and($payload['toolCalls'])->toHaveCount(2) - ->and($payload['toolCalls'][0]['id'])->toBe('call_1') - ->and($payload['toolCalls'][1]['id'])->toBe('call_2'); -}); - -it('includes finish reason stop correctly', function (): void { - $chunk = new ChatGenerationChunk( - type: ChunkType::MessageEnd, - id: 'msg_123', - message: new AssistantMessage(content: ''), - createdAt: new DateTimeImmutable('2024-01-01 12:00:00'), - finishReason: FinishReason::Stop, - isFinal: true, - ); - - $payload = $this->stream->mapChunkToPayload($chunk); - - expect($payload)->toHaveKey('finishReason', 'stop'); -}); - -it('includes finish reason length correctly', function (): void { - $chunk = new ChatGenerationChunk( - type: ChunkType::MessageEnd, - id: 'msg_123', - message: new AssistantMessage(content: ''), - createdAt: new DateTimeImmutable('2024-01-01 12:00:00'), - finishReason: FinishReason::Length, - isFinal: true, - ); - - $payload = $this->stream->mapChunkToPayload($chunk); - - expect($payload)->toHaveKey('finishReason', 'length'); -}); - -it('includes finish reason tool_calls correctly', function (): void { - $chunk = new ChatGenerationChunk( - type: ChunkType::MessageEnd, - id: 'msg_123', - message: new AssistantMessage(content: ''), - createdAt: new DateTimeImmutable('2024-01-01 12:00:00'), - finishReason: FinishReason::ToolCalls, - isFinal: true, - ); - - $payload = $this->stream->mapChunkToPayload($chunk); - - expect($payload)->toHaveKey('finishReason', 'tool_calls'); -}); - -it('includes finish reason content_filter correctly', function (): void { - $chunk = new ChatGenerationChunk( - type: ChunkType::MessageEnd, - id: 'msg_123', - message: new AssistantMessage(content: ''), - createdAt: new DateTimeImmutable('2024-01-01 12:00:00'), - finishReason: FinishReason::ContentFilter, - isFinal: true, - ); - - $payload = $this->stream->mapChunkToPayload($chunk); - - expect($payload)->toHaveKey('finishReason', 'content_filter'); -}); - -it('handles unknown chunk types by using the enum value', function (): void { - $chunk = new ChatGenerationChunk( - type: ChunkType::Done, - id: 'msg_123', - message: new AssistantMessage(content: ''), - createdAt: new DateTimeImmutable('2024-01-01 12:00:00'), - ); - - $payload = $this->stream->mapChunkToPayload($chunk); - - expect($payload)->toHaveKey('type', 'done'); -}); - -it('only includes messageId for MessageStart events', function (): void { - $startChunk = new ChatGenerationChunk( - type: ChunkType::MessageStart, - id: 'msg_123', - message: new AssistantMessage(content: ''), - createdAt: new DateTimeImmutable('2024-01-01 12:00:00'), - ); - - $deltaChunk = new ChatGenerationChunk( - type: ChunkType::TextDelta, - id: 'msg_123', - message: new AssistantMessage(content: 'text'), - createdAt: new DateTimeImmutable('2024-01-01 12:00:00'), - ); - - $startPayload = $this->stream->mapChunkToPayload($startChunk); - $deltaPayload = $this->stream->mapChunkToPayload($deltaChunk); - - expect($startPayload)->toHaveKey('messageId', 'msg_123') - ->and($deltaPayload)->not->toHaveKey('messageId'); -}); diff --git a/tests/Unit/LLM/Streaming/VercelTextStreamTest.php b/tests/Unit/LLM/Streaming/VercelTextStreamTest.php index 97c76e3..a9d6c55 100644 --- a/tests/Unit/LLM/Streaming/VercelTextStreamTest.php +++ b/tests/Unit/LLM/Streaming/VercelTextStreamTest.php @@ -3,426 +3,3 @@ declare(strict_types=1); namespace Tests\Unit\LLM\Streaming; - -use Closure; -use ArrayIterator; -use ReflectionClass; -use DateTimeImmutable; -use Cortex\LLM\Data\Usage; -use Cortex\LLM\Enums\ChunkType; -use Cortex\LLM\Enums\FinishReason; -use Cortex\LLM\Data\ChatStreamResult; -use Cortex\LLM\Data\ChatGenerationChunk; -use Cortex\LLM\Streaming\VercelTextStream; -use Cortex\LLM\Data\Messages\AssistantMessage; - -beforeEach(function (): void { - $this->stream = new VercelTextStream(); -}); - -it('outputs text content for TextDelta chunks', function (): void { - $chunk = new ChatGenerationChunk( - type: ChunkType::TextDelta, - id: 'msg_123', - message: new AssistantMessage(content: 'Hello, '), - createdAt: new DateTimeImmutable('2024-01-01 12:00:00'), - ); - - $reflection = new ReflectionClass($this->stream); - $method = $reflection->getMethod('shouldOutputChunk'); - - expect($method->invoke($this->stream, $chunk))->toBeTrue(); -}); - -it('outputs text content for ReasoningDelta chunks', function (): void { - $chunk = new ChatGenerationChunk( - type: ChunkType::ReasoningDelta, - id: 'msg_123', - message: new AssistantMessage(content: 'Thinking...'), - createdAt: new DateTimeImmutable('2024-01-01 12:00:00'), - ); - - $reflection = new ReflectionClass($this->stream); - $method = $reflection->getMethod('shouldOutputChunk'); - - expect($method->invoke($this->stream, $chunk))->toBeTrue(); -}); - -it('does not output MessageStart chunks', function (): void { - $chunk = new ChatGenerationChunk( - type: ChunkType::MessageStart, - id: 'msg_123', - message: new AssistantMessage(content: ''), - createdAt: new DateTimeImmutable('2024-01-01 12:00:00'), - ); - - $reflection = new ReflectionClass($this->stream); - $method = $reflection->getMethod('shouldOutputChunk'); - - expect($method->invoke($this->stream, $chunk))->toBeFalse(); -}); - -it('does not output MessageEnd chunks', function (): void { - $chunk = new ChatGenerationChunk( - type: ChunkType::MessageEnd, - id: 'msg_123', - message: new AssistantMessage(content: ''), - createdAt: new DateTimeImmutable('2024-01-01 12:00:00'), - ); - - $reflection = new ReflectionClass($this->stream); - $method = $reflection->getMethod('shouldOutputChunk'); - - expect($method->invoke($this->stream, $chunk))->toBeFalse(); -}); - -it('does not output TextStart chunks', function (): void { - $chunk = new ChatGenerationChunk( - type: ChunkType::TextStart, - id: 'msg_123', - message: new AssistantMessage(content: ''), - createdAt: new DateTimeImmutable('2024-01-01 12:00:00'), - ); - - $reflection = new ReflectionClass($this->stream); - $method = $reflection->getMethod('shouldOutputChunk'); - - expect($method->invoke($this->stream, $chunk))->toBeFalse(); -}); - -it('does not output TextEnd chunks', function (): void { - $chunk = new ChatGenerationChunk( - type: ChunkType::TextEnd, - id: 'msg_123', - message: new AssistantMessage(content: ''), - createdAt: new DateTimeImmutable('2024-01-01 12:00:00'), - ); - - $reflection = new ReflectionClass($this->stream); - $method = $reflection->getMethod('shouldOutputChunk'); - - expect($method->invoke($this->stream, $chunk))->toBeFalse(); -}); - -it('does not output ReasoningStart chunks', function (): void { - $chunk = new ChatGenerationChunk( - type: ChunkType::ReasoningStart, - id: 'msg_123', - message: new AssistantMessage(content: ''), - createdAt: new DateTimeImmutable('2024-01-01 12:00:00'), - ); - - $reflection = new ReflectionClass($this->stream); - $method = $reflection->getMethod('shouldOutputChunk'); - - expect($method->invoke($this->stream, $chunk))->toBeFalse(); -}); - -it('does not output ReasoningEnd chunks', function (): void { - $chunk = new ChatGenerationChunk( - type: ChunkType::ReasoningEnd, - id: 'msg_123', - message: new AssistantMessage(content: ''), - createdAt: new DateTimeImmutable('2024-01-01 12:00:00'), - ); - - $reflection = new ReflectionClass($this->stream); - $method = $reflection->getMethod('shouldOutputChunk'); - - expect($method->invoke($this->stream, $chunk))->toBeFalse(); -}); - -it('does not output ToolInputStart chunks', function (): void { - $chunk = new ChatGenerationChunk( - type: ChunkType::ToolInputStart, - id: 'msg_123', - message: new AssistantMessage(content: ''), - createdAt: new DateTimeImmutable('2024-01-01 12:00:00'), - ); - - $reflection = new ReflectionClass($this->stream); - $method = $reflection->getMethod('shouldOutputChunk'); - - expect($method->invoke($this->stream, $chunk))->toBeFalse(); -}); - -it('does not output Error chunks', function (): void { - $chunk = new ChatGenerationChunk( - type: ChunkType::Error, - id: 'msg_123', - message: new AssistantMessage(content: 'Error occurred'), - createdAt: new DateTimeImmutable('2024-01-01 12:00:00'), - ); - - $reflection = new ReflectionClass($this->stream); - $method = $reflection->getMethod('shouldOutputChunk'); - - expect($method->invoke($this->stream, $chunk))->toBeFalse(); -}); - -it('mapChunkToPayload returns content', function (): void { - $chunk = new ChatGenerationChunk( - type: ChunkType::TextDelta, - id: 'msg_123', - message: new AssistantMessage(content: 'Hello, world!'), - createdAt: new DateTimeImmutable('2024-01-01 12:00:00'), - ); - - $payload = $this->stream->mapChunkToPayload($chunk); - - expect($payload)->toHaveKey('content', 'Hello, world!'); -}); - -it('mapChunkToPayload returns empty string for null content', function (): void { - $chunk = new ChatGenerationChunk( - type: ChunkType::MessageStart, - id: 'msg_123', - message: new AssistantMessage(), - createdAt: new DateTimeImmutable('2024-01-01 12:00:00'), - ); - - $payload = $this->stream->mapChunkToPayload($chunk); - - expect($payload)->toHaveKey('content', ''); -}); - -it('returns streamResponse closure that can be invoked', function (): void { - $chunks = [ - new ChatGenerationChunk( - type: ChunkType::MessageStart, - id: 'msg_123', - message: new AssistantMessage(content: ''), - createdAt: new DateTimeImmutable('2024-01-01 12:00:00'), - ), - new ChatGenerationChunk( - type: ChunkType::TextDelta, - id: 'msg_123', - message: new AssistantMessage(content: 'Hello'), - createdAt: new DateTimeImmutable('2024-01-01 12:00:00'), - ), - new ChatGenerationChunk( - type: ChunkType::TextDelta, - id: 'msg_123', - message: new AssistantMessage(content: ', world!'), - createdAt: new DateTimeImmutable('2024-01-01 12:00:00'), - ), - new ChatGenerationChunk( - type: ChunkType::MessageEnd, - id: 'msg_123', - message: new AssistantMessage(content: ''), - createdAt: new DateTimeImmutable('2024-01-01 12:00:00'), - ), - ]; - - $result = new ChatStreamResult( - new ArrayIterator($chunks), - ); - - $closure = $this->stream->streamResponse($result); - - expect($closure)->toBeInstanceOf(Closure::class); - - // Note: We cannot reliably test the actual output here because flush() - // bypasses output buffering. The closure invocation is sufficient to - // verify it works without errors. - ob_start(); - - try { - $closure(); - } finally { - ob_end_clean(); - } -})->group('stream'); - -it('streams only text content without metadata or JSON', function (): void { - $chunks = [ - new ChatGenerationChunk( - type: ChunkType::TextDelta, - id: 'msg_123', - message: new AssistantMessage(content: 'Part 1'), - createdAt: new DateTimeImmutable('2024-01-01 12:00:00'), - ), - new ChatGenerationChunk( - type: ChunkType::TextDelta, - id: 'msg_123', - message: new AssistantMessage(content: ' Part 2'), - createdAt: new DateTimeImmutable('2024-01-01 12:00:00'), - ), - ]; - - $result = new ChatStreamResult( - new ArrayIterator($chunks), - ); - - $closure = $this->stream->streamResponse($result); - - // Verify closure is created - expect($closure)->toBeInstanceOf(Closure::class); -}); - -it('ignores chunks with null content', function (): void { - $chunk = new ChatGenerationChunk( - type: ChunkType::TextDelta, - id: 'msg_123', - message: new AssistantMessage(), - createdAt: new DateTimeImmutable('2024-01-01 12:00:00'), - ); - - $reflection = new ReflectionClass($this->stream); - $method = $reflection->getMethod('shouldOutputChunk'); - - // Should not output (returns false for empty content in streamResponse logic) - expect($method->invoke($this->stream, $chunk))->toBeTrue(); // Type is correct - expect($chunk->message->content)->toBeNull(); // But content is null, so won't output -}); - -it('ignores chunks with empty string content', function (): void { - $chunk = new ChatGenerationChunk( - type: ChunkType::TextDelta, - id: 'msg_123', - message: new AssistantMessage(content: ''), - createdAt: new DateTimeImmutable('2024-01-01 12:00:00'), - ); - - $reflection = new ReflectionClass($this->stream); - $method = $reflection->getMethod('shouldOutputChunk'); - - // Type check passes, but empty content won't be output in streamResponse - expect($method->invoke($this->stream, $chunk))->toBeTrue(); - expect($chunk->message->content)->toBe(''); -}); - -it('handles whitespace content correctly', function (): void { - $chunk = new ChatGenerationChunk( - type: ChunkType::TextDelta, - id: 'msg_123', - message: new AssistantMessage(content: ' '), - createdAt: new DateTimeImmutable('2024-01-01 12:00:00'), - ); - - $reflection = new ReflectionClass($this->stream); - $method = $reflection->getMethod('shouldOutputChunk'); - - // Whitespace is valid content and should be output - expect($method->invoke($this->stream, $chunk))->toBeTrue(); - expect($chunk->message->content)->toBe(' '); -}); - -it('handles special characters in content', function (): void { - $specialContent = "Line 1\nLine 2\tTabbed\r\nWindows line"; - - $chunk = new ChatGenerationChunk( - type: ChunkType::TextDelta, - id: 'msg_123', - message: new AssistantMessage(content: $specialContent), - createdAt: new DateTimeImmutable('2024-01-01 12:00:00'), - ); - - $reflection = new ReflectionClass($this->stream); - $method = $reflection->getMethod('shouldOutputChunk'); - - expect($method->invoke($this->stream, $chunk))->toBeTrue(); - expect($chunk->message->content)->toBe($specialContent); -}); - -it('handles unicode characters in content', function (): void { - $unicodeContent = 'Hello 👋 世界 🌍'; - - $chunk = new ChatGenerationChunk( - type: ChunkType::TextDelta, - id: 'msg_123', - message: new AssistantMessage(content: $unicodeContent), - createdAt: new DateTimeImmutable('2024-01-01 12:00:00'), - ); - - $reflection = new ReflectionClass($this->stream); - $method = $reflection->getMethod('shouldOutputChunk'); - - expect($method->invoke($this->stream, $chunk))->toBeTrue(); - expect($chunk->message->content)->toBe($unicodeContent); -}); - -it('handles very long content strings', function (): void { - $longContent = str_repeat('A', 10000); - - $chunk = new ChatGenerationChunk( - type: ChunkType::TextDelta, - id: 'msg_123', - message: new AssistantMessage(content: $longContent), - createdAt: new DateTimeImmutable('2024-01-01 12:00:00'), - ); - - $reflection = new ReflectionClass($this->stream); - $method = $reflection->getMethod('shouldOutputChunk'); - - expect($method->invoke($this->stream, $chunk))->toBeTrue(); - expect(strlen($chunk->message->content))->toBe(10000); -}); - -it('ignores usage metadata when streaming text', function (): void { - $usage = new Usage( - promptTokens: 10, - completionTokens: 20, - ); - - $chunk = new ChatGenerationChunk( - type: ChunkType::TextDelta, - id: 'msg_123', - message: new AssistantMessage(content: 'Hello'), - createdAt: new DateTimeImmutable('2024-01-01 12:00:00'), - usage: $usage, - ); - - $reflection = new ReflectionClass($this->stream); - $method = $reflection->getMethod('shouldOutputChunk'); - - // Should still output, usage is just ignored - expect($method->invoke($this->stream, $chunk))->toBeTrue(); -}); - -it('ignores finish reason when streaming text', function (): void { - $chunk = new ChatGenerationChunk( - type: ChunkType::TextDelta, - id: 'msg_123', - message: new AssistantMessage(content: 'Final text'), - createdAt: new DateTimeImmutable('2024-01-01 12:00:00'), - finishReason: FinishReason::Stop, - ); - - $reflection = new ReflectionClass($this->stream); - $method = $reflection->getMethod('shouldOutputChunk'); - - // Should still output, finish reason is ignored - expect($method->invoke($this->stream, $chunk))->toBeTrue(); -}); - -it('streams mixed text and reasoning deltas', function (): void { - $chunks = [ - new ChatGenerationChunk( - type: ChunkType::TextDelta, - id: 'msg_123', - message: new AssistantMessage(content: 'Text part'), - createdAt: new DateTimeImmutable('2024-01-01 12:00:00'), - ), - new ChatGenerationChunk( - type: ChunkType::ReasoningDelta, - id: 'msg_123', - message: new AssistantMessage(content: 'Reasoning part'), - createdAt: new DateTimeImmutable('2024-01-01 12:00:01'), - ), - new ChatGenerationChunk( - type: ChunkType::TextDelta, - id: 'msg_123', - message: new AssistantMessage(content: 'More text'), - createdAt: new DateTimeImmutable('2024-01-01 12:00:02'), - ), - ]; - - $result = new ChatStreamResult( - new ArrayIterator($chunks), - ); - - $closure = $this->stream->streamResponse($result); - - expect($closure)->toBeInstanceOf(Closure::class); -}); diff --git a/tests/Unit/OutputParsers/JsonOutputParserTest.php b/tests/Unit/OutputParsers/JsonOutputParserTest.php index e28cdc7..2449db8 100644 --- a/tests/Unit/OutputParsers/JsonOutputParserTest.php +++ b/tests/Unit/OutputParsers/JsonOutputParserTest.php @@ -100,9 +100,7 @@ '{"foo": "bar", "baz": {"hel', [ 'foo' => 'bar', - 'baz' => [ - 'hel' => null, - ], + 'baz' => [], ], ], [ @@ -111,6 +109,51 @@ 'hello' => [], ], ], + [ + '{"foo": "bar", "baz": 1', + [ + 'foo' => 'bar', + 'baz' => 1, + ], + ], + [ + '{"array": [1, 2, 3', + [ + 'array' => [1, 2, 3], + ], + ], + [ + '{"numbers": [1, 2, ', + [ + 'numbers' => [1, 2], + ], + ], + [ + '{"open_object": {', + [ + 'open_object' => [], + ], + ], + [ + '{"nested": {"key": "val', + [ + 'nested' => [ + 'key' => 'val', + ], + ], + ], + [ + '{"array": [', + [ + 'array' => [], + ], + ], + [ + '{"missing_colon" "oops"}', + [ + 'missing_colon' => 'oops', + ], + ], ]); test('it throws an exception if it cannot parse json', function (): void { diff --git a/tests/Unit/OutputParsers/StructuredOutputParserTest.php b/tests/Unit/OutputParsers/StructuredOutputParserTest.php index 2201553..07683db 100644 --- a/tests/Unit/OutputParsers/StructuredOutputParserTest.php +++ b/tests/Unit/OutputParsers/StructuredOutputParserTest.php @@ -5,7 +5,6 @@ namespace Cortex\Tests\Unit\OutputParsers; use Cortex\JsonSchema\Schema; -use Cortex\Exceptions\OutputParserException; use Cortex\OutputParsers\StructuredOutputParser; use Cortex\JsonSchema\Exceptions\SchemaException; @@ -86,12 +85,13 @@ expect($result['items'][1]['value'])->toBe('second'); }); -test('it throws exception for invalid JSON', function (): void { +test('it handles invalid JSON', function (): void { $parser = new StructuredOutputParser( Schema::object()->properties( Schema::string('foo'), ), ); + $output = <<<'OUTPUT' ```json { @@ -101,7 +101,9 @@ ``` OUTPUT; - expect(fn(): array => $parser->parse($output))->toThrow(OutputParserException::class); + $result = $parser->parse($output); + + expect($result['foo'])->toBe('bar'); }); test('it handles different data types correctly', function (): void { diff --git a/tests/Unit/Prompts/Builders/ChatPromptBuilderTest.php b/tests/Unit/Prompts/Builders/ChatPromptBuilderTest.php index 4a92ad0..55aba99 100644 --- a/tests/Unit/Prompts/Builders/ChatPromptBuilderTest.php +++ b/tests/Unit/Prompts/Builders/ChatPromptBuilderTest.php @@ -44,7 +44,7 @@ expect($result->first())->toBeInstanceOf(UserMessage::class); expect($result->first()->text())->toBe('What is the capital of France?'); - $agentBuilder = $prompt->agentBuilder(); + $agentBuilder = $prompt->agent(); expect($agentBuilder)->toBeInstanceOf(GenericAgentBuilder::class) ->and($agentBuilder->prompt())->toBe($prompt); diff --git a/tests/Unit/Prompts/Factories/BladePromptFactoryTest.php b/tests/Unit/Prompts/Factories/BladePromptFactoryTest.php index 0673088..103f135 100644 --- a/tests/Unit/Prompts/Factories/BladePromptFactoryTest.php +++ b/tests/Unit/Prompts/Factories/BladePromptFactoryTest.php @@ -9,9 +9,9 @@ use Cortex\Prompts\Compilers\BladeCompiler; use Cortex\LLM\Drivers\OpenAI\Chat\OpenAIChat; use Cortex\LLM\Data\Messages\MessageCollection; -use Cortex\Tools\Prebuilt\OpenMeteoWeatherTool; use Cortex\Prompts\Factories\BladePromptFactory; use Cortex\Prompts\Templates\ChatPromptTemplate; +use Cortex\Tools\Prebuilt\GetCurrentWeatherTool; use Illuminate\Foundation\Testing\Concerns\InteractsWithViews; use OpenAI\Responses\Chat\CreateResponse as ChatCreateResponse; @@ -247,5 +247,5 @@ expect($template->metadata)->not->toBeNull(); expect($template->metadata->tools)->toHaveCount(2); - expect($template->metadata->tools)->toContain(OpenMeteoWeatherTool::class); + expect($template->metadata->tools)->toContain(GetCurrentWeatherTool::class); }); diff --git a/tests/Unit/Prompts/Templates/ChatPromptTemplateTest.php b/tests/Unit/Prompts/Templates/ChatPromptTemplateTest.php index dbc7108..954efed 100644 --- a/tests/Unit/Prompts/Templates/ChatPromptTemplateTest.php +++ b/tests/Unit/Prompts/Templates/ChatPromptTemplateTest.php @@ -11,11 +11,14 @@ use Cortex\Prompts\Data\PromptMetadata; use Cortex\JsonSchema\Types\ObjectSchema; use Cortex\LLM\Data\Messages\UserMessage; +use Cortex\LLM\Enums\StructuredOutputMode; use Cortex\OutputParsers\JsonOutputParser; use Cortex\LLM\Data\Messages\SystemMessage; +use Cortex\LLM\Data\StructuredOutputConfig; use Cortex\LLM\Data\Messages\AssistantMessage; use Cortex\LLM\Drivers\OpenAI\Chat\OpenAIChat; use Cortex\LLM\Data\Messages\MessageCollection; +use Cortex\LLM\Drivers\Anthropic\AnthropicChat; use Cortex\LLM\Data\Messages\MessagePlaceholder; use Cortex\Prompts\Templates\ChatPromptTemplate; use Cortex\JsonSchema\Exceptions\SchemaException; @@ -237,3 +240,85 @@ && $parameters['messages'][0]['content'] === 'Tell me a joke about dogs'; }); }); + +test('it can call an llm with a separate provider and model string', function (): void { + $prompt = new ChatPromptTemplate([ + new UserMessage('Tell me a joke about {topic}'), + ]); + + $pipeline = $prompt->llm('anthropic', 'claude-4-5-sonnet-20260219'); + + $stages = $pipeline->getStages(); + + expect($stages)->toHaveCount(2); + expect($stages[0])->toBeInstanceOf(ChatPromptTemplate::class); + expect($stages[1])->toBeInstanceOf(AnthropicChat::class); + + /** @var \Cortex\LLM\Contracts\LLM $llm */ + $llm = $stages[1]; + + expect($llm->getModel())->toBe('claude-4-5-sonnet-20260219'); +}); + +test('it can call an llm with a shortcut string', function (): void { + $prompt = new ChatPromptTemplate([ + new UserMessage('Tell me a joke about {topic}'), + ]); + + $pipeline = $prompt->llm('anthropic/claude-4-5-sonnet-20260219'); + + $stages = $pipeline->getStages(); + + expect($stages)->toHaveCount(2); + expect($stages[0])->toBeInstanceOf(ChatPromptTemplate::class); + expect($stages[1])->toBeInstanceOf(AnthropicChat::class); + + /** @var \Cortex\LLM\Contracts\LLM $llm */ + $llm = $stages[1]; + + expect($llm->getModel())->toBe('claude-4-5-sonnet-20260219'); +}); + +test('it can call an llm with from metadata', function (): void { + $prompt = new ChatPromptTemplate([ + new UserMessage('Tell me a joke about {topic}'), + ]); + + $prompt->withMetadata(new PromptMetadata( + provider: 'anthropic', + model: 'claude-4-5-sonnet-20260219', + parameters: [ + 'temperature' => 0.5, + 'max_tokens' => 100, + ], + structuredOutput: Schema::object() + ->properties( + Schema::string('setup')->required(), + Schema::string('punchline')->required(), + ), + structuredOutputMode: StructuredOutputMode::Auto, + )); + + $pipeline = $prompt->llm(); + + $stages = $pipeline->getStages(); + + expect($stages)->toHaveCount(2); + expect($stages[0])->toBeInstanceOf(ChatPromptTemplate::class); + expect($stages[1])->toBeInstanceOf(AnthropicChat::class); + + /** @var \Cortex\LLM\Contracts\LLM $llm */ + $llm = $stages[1]; + + expect($llm->getModel())->toBe('claude-4-5-sonnet-20260219'); + expect($llm->getParameters())->toBe([ + 'temperature' => 0.5, + 'max_tokens' => 100, + ]); + + $structuredOutputConfig = $llm->getStructuredOutputConfig(); + + expect($structuredOutputConfig)->toBeInstanceOf(StructuredOutputConfig::class); + expect($structuredOutputConfig->schema)->toBeInstanceOf(ObjectSchema::class); + expect($structuredOutputConfig->schema->getPropertyKeys())->toBe(['setup', 'punchline']); +}); diff --git a/tests/Unit/SDK/Anthropic/AnthropicTest.php b/tests/Unit/SDK/Anthropic/AnthropicTest.php new file mode 100644 index 0000000..c40a2ab --- /dev/null +++ b/tests/Unit/SDK/Anthropic/AnthropicTest.php @@ -0,0 +1,593 @@ + MockResponse::fixture('anthropic/messages/simple'), + ]); + + $anthropic = new Anthropic('test-api-key'); + + $message = $anthropic->messages()->create([ + 'model' => 'claude-sonnet-4-5-20250929', + 'max_tokens' => 1024, + 'messages' => [ + [ + 'role' => 'user', + 'content' => 'Hello, how are you?', + ], + ], + ])->dtoOrFail(); + + expect($message)->toBeInstanceOf(Message::class) + ->and($message->content)->toHaveCount(1) + ->and($message->content[0])->toBeInstanceOf(TextContentBlock::class) + ->and($message->content[0]->text)->toBe("Hello! I'm doing well, thank you for asking. How are you doing today? Is there anything I can help you with?") + ->and($message->usage)->toBeInstanceOf(Usage::class); + + $mockClient->assertSentCount(1, CreateMessage::class); + }); + + test('it can create a message with thinking', function (): void { + $mockClient = MockClient::global([ + CreateMessage::class => MockResponse::fixture('anthropic/messages/thinking'), + ]); + + $anthropic = new Anthropic('test-api-key'); + + $message = $anthropic->messages()->create([ + 'model' => 'claude-sonnet-4-5-20250929', + 'max_tokens' => 2048, + 'thinking' => [ + 'type' => 'enabled', + 'budget_tokens' => 1024, + ], + 'messages' => [ + [ + 'role' => 'user', + 'content' => 'What is the weight of the moon?', + ], + ], + ])->dtoOrFail(); + + expect($message)->toBeInstanceOf(Message::class) + ->and($message->content)->toHaveCount(2) + ->and($message->content[0])->toBeInstanceOf(ThinkingContentBlock::class) + ->and($message->content[0]->thinking)->toBeString() + ->and($message->content[0]->signature)->toBeString() + ->and($message->content[1])->toBeInstanceOf(TextContentBlock::class) + ->and($message->content[1]->text)->toBeString() + ->and($message->usage)->toBeInstanceOf(Usage::class); + + $mockClient->assertSentCount(1, CreateMessage::class); + }); + + test('it can create a message with redacted thinking', function (): void { + MockClient::global([ + CreateMessage::class => MockResponse::fixture('anthropic/messages/redacted-thinking'), + ]); + + $anthropic = new Anthropic('test-api-key'); + + $message = $anthropic->messages()->create([ + 'model' => 'claude-sonnet-4-5-20250929', + 'max_tokens' => 2048, + 'betas' => ['interleaved-thinking-2025-05-14'], + 'thinking' => [ + 'type' => 'enabled', + 'budget_tokens' => 1024, + ], + 'messages' => [ + [ + 'role' => 'user', + 'content' => 'ANTHROPIC_MAGIC_STRING_TRIGGER_REDACTED_THINKING_46C9A13E193C177646C7398A98432ECCCE4C1253D5E2D82641AC0E52CC2876CB', + ], + ], + ]); + + dd($message); + + // $response = $message->getResponse(); + + // expect($response->json())->toBeArray(); + // expect($response->status())->toBe(200); + + // dump($message); + })->todo('Does not trigger redacted thinking for some reason'); + + test('it can create a message with tool use', function (): void { + MockClient::global([ + CreateMessage::class => MockResponse::fixture('anthropic/messages/tool-use'), + ]); + + $anthropic = new Anthropic('test-api-key'); + + $message = $anthropic->messages()->create([ + 'model' => 'claude-sonnet-4-5-20250929', + 'max_tokens' => 1024, + 'tools' => [ + [ + 'name' => 'get_weather', + 'description' => 'Get the current weather in a given location', + 'input_schema' => [ + 'type' => 'object', + 'properties' => [ + 'location' => [ + 'type' => 'string', + 'description' => 'The city and country, e.g. Manchester, UK', + ], + 'unit' => [ + 'type' => 'string', + 'enum' => ['celsius', 'fahrenheit'], + 'description' => 'The unit of temperature, either "celsius" or "fahrenheit"', + ], + ], + 'required' => ['location'], + ], + ], + ], + 'messages' => [ + [ + 'role' => 'user', + 'content' => 'What is the weather in Manchester?', + ], + ], + ])->dtoOrFail(); + + expect($message)->toBeInstanceOf(Message::class) + ->and($message->content)->toHaveCount(1) + ->and($message->content[0])->toBeInstanceOf(ToolUseContentBlock::class) + ->and($message->content[0]->name)->toBe('get_weather') + ->and($message->content[0]->input)->toBe([ + 'location' => 'Manchester, UK', + ]) + ->and($message->usage)->toBeInstanceOf(Usage::class); + }); + + test('it can create a message with server tool use (web search)', function (): void { + MockClient::global([ + CreateMessage::class => MockResponse::fixture('anthropic/messages/tool-use-web-search'), + ]); + + $anthropic = new Anthropic('test-api-key'); + + $response = $anthropic->messages()->create([ + 'model' => 'claude-sonnet-4-5-20250929', + 'max_tokens' => 1024, + 'tools' => [ + [ + 'type' => 'web_search_20250305', + 'name' => 'web_search', + 'max_uses' => 1, + ], + ], + 'messages' => [ + [ + 'role' => 'user', + 'content' => 'What is a recent positive news story? Current date is ' . date('Y-m-d'), + ], + ], + ])->dtoOrFail(); + + expect($response)->toBeInstanceOf(Message::class) + ->and($response->content)->toHaveCount(12) + ->and($response->content[0])->toBeInstanceOf(ServerToolUseContentBlock::class) + ->and($response->content[0]->name)->toBe('web_search') + ->and($response->content[0]->input)->toBeArray() + ->and($response->content[1])->toBeInstanceOf(WebSearchToolResultContentBlock::class) + ->and($response->content[1]->toolUseId)->toBeString() + ->and($response->content[1]->content)->toBeArray() + ->and($response->usage)->toBeInstanceOf(Usage::class); + }); + + test('it can create a message with structured output', function (): void { + MockClient::global([ + CreateMessage::class => MockResponse::fixture('anthropic/messages/structured-output'), + ]); + + $anthropic = new Anthropic('test-api-key'); + + $message = $anthropic->messages()->create([ + 'model' => 'claude-sonnet-4-5-20250929', + 'max_tokens' => 1024, + 'betas' => ['structured-outputs-2025-11-13'], + 'output_format' => [ + 'type' => 'json_schema', + 'schema' => Schema::object() + ->properties( + Schema::string('name')->required(), + Schema::string('email')->required(), + Schema::string('plan_interest')->required(), + Schema::boolean('demo_requested')->required(), + ) + ->additionalProperties(false) + ->toArray(), + ], + 'messages' => [ + [ + 'role' => 'user', + 'content' => 'Extract the key information from this email: John Smith (john@example.com) is interested in our Enterprise plan and wants to schedule a demo for next Tuesday at 2pm.', + ], + ], + ])->dtoOrFail(); + + expect($message)->toBeInstanceOf(Message::class) + ->and($message->content)->toHaveCount(1) + ->and($message->content[0])->toBeInstanceOf(TextContentBlock::class) + ->and($message->content[0]->text)->toBe('{"name":"John Smith","email":"john@example.com","plan_interest":"Enterprise","demo_requested":true}') + ->and($message->usage)->toBeInstanceOf(Usage::class); + }); +}); + +describe('Streaming messages', function (): void { + test('it can create a streamed message', function (): void { + MockClient::global([ + CreateMessage::class => MockResponse::fixture('anthropic/messages/simple-streamed'), + ]); + + $anthropic = new Anthropic('test-api-key'); + + $response = $anthropic->messages()->stream([ + 'model' => 'claude-sonnet-4-5-20250929', + 'max_tokens' => 1024, + 'messages' => [ + [ + 'role' => 'user', + 'content' => 'Hello, how are you?', + ], + ], + ])->dtoOrFail(); + + expect($response)->toBeInstanceOf(MessageStream::class) + ->and($response->getIterator())->toBeInstanceOf(Generator::class); + + $events = iterator_to_array($response->getIterator()); + + expect($events)->toHaveCount(10) + ->and($events[0])->toBeInstanceOf(MessageStart::class) + ->and($events[1])->toBeInstanceOf(ContentBlockStart::class) + ->and($events[2])->toBeInstanceOf(ContentBlockDelta::class) + ->and($events[7])->toBeInstanceOf(ContentBlockStop::class) + ->and($events[8])->toBeInstanceOf(MessageDelta::class) + ->and($events[9])->toBeInstanceOf(MessageStop::class); + }); + + test('it can create a streamed message with thinking', function (): void { + MockClient::global([ + CreateMessage::class => MockResponse::fixture('anthropic/messages/thinking-streamed'), + ]); + + $anthropic = new Anthropic('test-api-key'); + + $response = $anthropic->messages()->stream([ + 'model' => 'claude-sonnet-4-5-20250929', + 'max_tokens' => 2048, + 'thinking' => [ + 'type' => 'enabled', + 'budget_tokens' => 1024, + ], + 'messages' => [ + [ + 'role' => 'user', + 'content' => 'What is the weight of the moon?', + ], + ], + ])->dtoOrFail(); + + expect($response)->toBeInstanceOf(MessageStream::class) + ->and($response->getIterator())->toBeInstanceOf(Generator::class); + + $events = iterator_to_array($response->getIterator()); + + expect($events)->toHaveCount(112) + ->and($events[0])->toBeInstanceOf(MessageStart::class) + ->and($events[1])->toBeInstanceOf(ContentBlockStart::class) + ->and($events[1]->contentBlock)->toBeInstanceOf(ThinkingContentBlock::class) + + ->and($events[2])->toBeInstanceOf(ContentBlockDelta::class) + ->and($events[2]->delta)->toBeInstanceOf(ThinkingDelta::class) + + ->and($events[60])->toBeInstanceOf(ContentBlockDelta::class) + ->and($events[60]->delta)->toBeInstanceOf(SignatureDelta::class) + ->and($events[60]->delta->signature)->toBeString() + + ->and($events[62])->toBeInstanceOf(ContentBlockStart::class) + ->and($events[62]->contentBlock)->toBeInstanceOf(TextContentBlock::class) + ->and($events[62]->contentBlock->text)->toBeString() + + ->and($events[110])->toBeInstanceOf(MessageDelta::class) + ->and($events[110]->stopReason)->toBe('end_turn') + ->and($events[110]->cumulativeUsage)->toBeInstanceOf(Usage::class) + + ->and($events[111])->toBeInstanceOf(MessageStop::class); + }); + + test('it can create a streamed message with tool use', function (): void { + MockClient::global([ + CreateMessage::class => MockResponse::fixture('anthropic/messages/tool-use-streamed'), + ]); + + $anthropic = new Anthropic('test-api-key'); + + $response = $anthropic->messages()->stream([ + 'model' => 'claude-sonnet-4-5-20250929', + 'max_tokens' => 1024, + 'tools' => [ + [ + 'name' => 'get_weather', + 'description' => 'Get the current weather in a given location', + 'input_schema' => [ + 'type' => 'object', + 'properties' => [ + 'location' => [ + 'type' => 'string', + 'description' => 'The city and country, e.g. Manchester, UK', + ], + 'unit' => [ + 'type' => 'string', + 'enum' => ['celsius', 'fahrenheit'], + 'description' => 'The unit of temperature, either "celsius" or "fahrenheit"', + ], + ], + 'required' => ['location'], + ], + ], + ], + 'messages' => [ + [ + 'role' => 'user', + 'content' => 'What is the weather in Manchester?', + ], + ], + ])->dtoOrFail(); + + expect($response)->toBeInstanceOf(MessageStream::class) + ->and($response->getIterator())->toBeInstanceOf(Generator::class); + + $events = iterator_to_array($response->getIterator()); + + expect($events)->toHaveCount(10) + ->and($events[0])->toBeInstanceOf(MessageStart::class) + ->and($events[1])->toBeInstanceOf(ContentBlockStart::class) + ->and($events[2])->toBeInstanceOf(ContentBlockDelta::class) + ->and($events[7])->toBeInstanceOf(ContentBlockStop::class) + ->and($events[8])->toBeInstanceOf(MessageDelta::class) + ->and($events[9])->toBeInstanceOf(MessageStop::class); + }); + + test('it can create a streamed message with server tool use (web search)', function (): void { + MockClient::global([ + CreateMessage::class => MockResponse::fixture('anthropic/messages/tool-use-web-search-streamed'), + ]); + + $anthropic = new Anthropic('test-api-key'); + + $response = $anthropic->messages()->stream([ + 'model' => 'claude-sonnet-4-5-20250929', + 'max_tokens' => 1024, + 'tools' => [ + [ + 'type' => 'web_search_20250305', + 'name' => 'web_search', + 'max_uses' => 3, + ], + ], + 'messages' => [ + [ + 'role' => 'user', + 'content' => 'What is a recent positive news story?', + ], + ], + ])->dtoOrFail(); + + expect($response)->toBeInstanceOf(MessageStream::class) + ->and($response->getIterator())->toBeInstanceOf(Generator::class); + + $events = iterator_to_array($response->getIterator()); + + expect($events)->toHaveCount(144) + ->and($events[0])->toBeInstanceOf(MessageStart::class) + + // First search + ->and($events[1])->toBeInstanceOf(ContentBlockStart::class) + ->and($events[1]->index)->toBe(0) + ->and($events[1]->contentBlock)->toBeInstanceOf(ServerToolUseContentBlock::class) + ->and($events[1]->contentBlock->name)->toBe('web_search') + ->and($events[1]->contentBlock->input)->toBeArray() + + // First search result + ->and($events[9])->toBeInstanceOf(ContentBlockStart::class) + ->and($events[9]->index)->toBe(1) + ->and($events[9]->contentBlock)->toBeInstanceOf(WebSearchToolResultContentBlock::class) + ->and($events[9]->contentBlock->toolUseId)->toBeString() + ->and($events[9]->contentBlock->content)->toBeArray() + + // Second search + ->and($events[11])->toBeInstanceOf(ContentBlockStart::class) + ->and($events[11]->index)->toBe(2) + ->and($events[11]->contentBlock)->toBeInstanceOf(ServerToolUseContentBlock::class) + ->and($events[11]->contentBlock->name)->toBe('web_search') + ->and($events[11]->contentBlock->input)->toBeArray() + + // Second search result + ->and($events[18])->toBeInstanceOf(ContentBlockStart::class) + ->and($events[18]->index)->toBe(3) + ->and($events[18]->contentBlock)->toBeInstanceOf(WebSearchToolResultContentBlock::class) + ->and($events[18]->contentBlock->toolUseId)->toBeString() + ->and($events[18]->contentBlock->content)->toBeArray() + + // First text block + ->and($events[20])->toBeInstanceOf(ContentBlockStart::class) + ->and($events[20]->index)->toBe(4) + ->and($events[20]->contentBlock)->toBeInstanceOf(TextContentBlock::class) + + // Second text block + ->and($events[29])->toBeInstanceOf(ContentBlockStart::class) + ->and($events[29]->index)->toBe(5) + ->and($events[29]->contentBlock)->toBeInstanceOf(TextContentBlock::class) + + // Third text block + ->and($events[44])->toBeInstanceOf(ContentBlockStart::class) + ->and($events[44]->index)->toBe(6) + ->and($events[44]->contentBlock)->toBeInstanceOf(TextContentBlock::class) + + // Final text block + ->and($events[135])->toBeInstanceOf(ContentBlockStart::class) + ->and($events[135]->index)->toBe(13) + ->and($events[135]->contentBlock)->toBeInstanceOf(TextContentBlock::class) + + // Usage + ->and($events[142])->toBeInstanceOf(MessageDelta::class) + ->and($events[142]->cumulativeUsage)->toBeInstanceOf(Usage::class) + ->and($events[142]->cumulativeUsage->serverToolUse)->toBeArray() + ->and($events[142]->cumulativeUsage->serverToolUse['web_search_requests'])->toBe(2) + + ->and($events[143])->toBeInstanceOf(MessageStop::class); + }); + + test('it can create a streamed message with structured output', function (): void { + MockClient::global([ + CreateMessage::class => MockResponse::fixture('anthropic/messages/structured-output-streamed'), + ]); + + $anthropic = new Anthropic('test-api-key'); + + $response = $anthropic->messages()->stream([ + 'model' => 'claude-sonnet-4-5-20250929', + 'max_tokens' => 1024, + 'betas' => ['structured-outputs-2025-11-13'], + 'output_format' => [ + 'type' => 'json_schema', + 'schema' => Schema::object() + ->properties( + Schema::string('name')->required(), + Schema::string('email')->required(), + Schema::string('plan_interest')->required(), + Schema::boolean('demo_requested')->required(), + ) + ->additionalProperties(false) + ->toArray(), + ], + 'messages' => [ + [ + 'role' => 'user', + 'content' => 'Extract the key information from this email: John Smith (john@example.com) is interested in our Enterprise plan and wants to schedule a demo for next Tuesday at 2pm.', + ], + ], + ])->dtoOrFail(); + + expect($response)->toBeInstanceOf(MessageStream::class) + ->and($response->getIterator())->toBeInstanceOf(Generator::class); + + $events = iterator_to_array($response->getIterator()); + + expect($events)->toHaveCount(11) + ->and($events[0])->toBeInstanceOf(MessageStart::class) + ->and($events[1])->toBeInstanceOf(ContentBlockStart::class) + ->and($events[2])->toBeInstanceOf(ContentBlockDelta::class) + ->and($events[8])->toBeInstanceOf(ContentBlockStop::class) + ->and($events[9])->toBeInstanceOf(MessageDelta::class) + ->and($events[10])->toBeInstanceOf(MessageStop::class); + }); + + test('it can create a streamed message with an image', function (): void { + MockClient::global([ + CreateMessage::class => MockResponse::fixture('anthropic/messages/image-streamed'), + ]); + + $anthropic = new Anthropic('test-api-key'); + + $dataUrl = base64_encode( + file_get_contents(__DIR__ . '/../../../fixtures/content/images/test.png'), + ); + + $response = $anthropic->messages()->stream([ + 'model' => 'claude-sonnet-4-5-20250929', + 'max_tokens' => 1024, + 'messages' => [ + [ + 'role' => 'user', + 'content' => [ + [ + 'type' => 'image', + 'source' => [ + 'type' => 'base64', + 'media_type' => 'image/png', + 'data' => $dataUrl, + ], + ], + [ + 'type' => 'text', + 'text' => 'What is in this image?', + ], + ], + ], + ], + ])->dtoOrFail(); + + expect($response)->toBeInstanceOf(MessageStream::class) + ->and($response->getIterator())->toBeInstanceOf(Generator::class); + + $events = iterator_to_array($response->getIterator()); + + expect($events)->toHaveCount(88) + ->and($events[0])->toBeInstanceOf(MessageStart::class) + ->and($events[1])->toBeInstanceOf(ContentBlockStart::class) + ->and($events[2])->toBeInstanceOf(Ping::class) + ->and($events[3])->toBeInstanceOf(ContentBlockDelta::class) + ->and($events[85])->toBeInstanceOf(ContentBlockStop::class) + ->and($events[86])->toBeInstanceOf(MessageDelta::class) + ->and($events[87])->toBeInstanceOf(MessageStop::class); + }); +}); + +describe('Counting tokens', function (): void { + test('it can count tokens', function (): void { + MockClient::global([ + CountTokens::class => MockResponse::fixture('anthropic/messages/count-tokens'), + ]); + + $anthropic = new Anthropic('test-api-key'); + + $tokens = $anthropic->messages()->countTokens([ + 'model' => 'claude-sonnet-4-5-20250929', + 'messages' => [ + [ + 'role' => 'user', + 'content' => 'Hello, how are you?', + ], + ], + ]); + + expect($tokens)->toBe(13); + }); +}); diff --git a/tests/Unit/SDK/OpenAI/OpenAIResponsesTest.php b/tests/Unit/SDK/OpenAI/OpenAIResponsesTest.php new file mode 100644 index 0000000..7b422bb --- /dev/null +++ b/tests/Unit/SDK/OpenAI/OpenAIResponsesTest.php @@ -0,0 +1,510 @@ + MockResponse::fixture('openai/responses/simple'), + ]); + + /** @var \Cortex\SDK\OpenAI\Data\Responses\Response $response */ + $response = $openai->responses()->create([ + 'model' => 'gpt-4o-mini', + 'max_output_tokens' => 1024, + 'input' => 'Hello, how are you?', + ])->dtoOrFail(); + + expect($response)->toBeInstanceOf(Response::class) + ->and($response->output)->toHaveCount(1) + ->and($response->output[0])->toBeInstanceOf(MessageOutputItem::class) + ->and($response->output[0]->content)->toHaveCount(1) + ->and($response->output[0]->content[0])->toBeInstanceOf(OutputText::class) + ->and($response->output[0]->content[0]->text)->toBe("Hello! I'm doing well, thank you. How about you?") + ->and($response->getMeta())->toBeInstanceOf(Meta::class) + ->and($response->usage)->toBeInstanceOf(Usage::class) + ->and($response->usage->inputTokens)->toBe(13) + ->and($response->usage->outputTokens)->toBe(14) + ->and($response->usage->totalTokens)->toBe(27); + + MockClient::getGlobal()->assertSentCount(1, CreateResponse::class); + }); + + test('it can create a response with reasoning', function (): void { + $openai = OpenAI::fake([ + CreateResponse::class => MockResponse::fixture('openai/responses/reasoning'), + ]); + + /** @var \Cortex\SDK\OpenAI\Data\Responses\Response $response */ + $response = $openai->responses()->create([ + 'model' => 'gpt-5-mini', + 'max_output_tokens' => 2048, + 'reasoning' => [ + 'effort' => 'low', + ], + 'input' => 'What is the weight of the moon?', + ])->dtoOrFail(); + + expect($response)->toBeInstanceOf(Response::class) + ->and($response->output)->toHaveCount(2) + ->and($response->output[0])->toBeInstanceOf(ReasoningOutputItem::class) + ->and($response->output[0]->id)->toBe('rs_0aac962cf73a606600696ec13b527c819589b98e8125e4a263') + ->and($response->output[0]->summary)->toBeArray() + ->and($response->output[0]->summary)->toBeEmpty() + ->and($response->output[0]->content)->toBeArray() + ->and($response->output[0]->status)->toBe('completed') + ->and($response->output[1])->toBeInstanceOf(MessageOutputItem::class) + ->and($response->output[1]->id)->toBe('msg_0aac962cf73a606600696ec142bf6c8195bcc3b061c651d79a') + ->and($response->output[1]->status)->toBe('completed') + ->and($response->output[1]->content)->toHaveCount(1) + ->and($response->output[1]->content[0])->toBeInstanceOf(OutputText::class) + ->and($response->output[1]->content[0]->text)->toContain('Mass of the Moon') + ->and($response->output[1]->content[0]->text)->toContain('7.3477') + ->and($response->usage)->toBeInstanceOf(Usage::class) + ->and($response->usage->inputTokens)->toBe(14) + ->and($response->usage->outputTokens)->toBe(729) + ->and($response->usage->totalTokens)->toBe(743); + + MockClient::getGlobal()->assertSentCount(1, CreateResponse::class); + }); + + test('it can create a response with function call', function (): void { + $openai = OpenAI::fake([ + CreateResponse::class => MockResponse::fixture('openai/responses/function-call'), + ]); + + /** @var \Cortex\SDK\OpenAI\Data\Responses\Response $response */ + $response = $openai->responses()->create([ + 'model' => 'gpt-4o-mini', + 'max_output_tokens' => 1024, + 'tools' => [ + [ + 'type' => 'function', + 'name' => 'get_weather', + 'description' => 'Get the current weather in a given location', + 'parameters' => [ + 'type' => 'object', + 'properties' => [ + 'location' => [ + 'type' => 'string', + 'description' => 'The city and country, e.g. Manchester, UK', + ], + 'unit' => [ + 'type' => 'string', + 'enum' => ['celsius', 'fahrenheit'], + 'description' => 'The unit of temperature, either "celsius" or "fahrenheit"', + ], + ], + 'required' => ['location'], + ], + ], + ], + 'input' => 'What is the weather in Manchester?', + ])->dtoOrFail(); + + expect($response)->toBeInstanceOf(Response::class) + ->and($response->output)->toHaveCount(1) + ->and($response->output[0])->toBeInstanceOf(FunctionToolCallOutputItem::class) + ->and($response->output[0]->id)->toBe('fc_00826c2ea443aee900696ec238c1348198b92b47a1894ce3b2') + ->and($response->output[0]->name)->toBe('get_weather') + ->and($response->output[0]->status)->toBe('completed') + ->and($response->output[0]->callId)->toBe('call_sXyT5eGTVisSbFWEBSjquORK') + ->and($response->output[0]->arguments)->toBe('{"location":"Manchester, UK","unit":"celsius"}') + ->and($response->usage)->toBeInstanceOf(Usage::class) + ->and($response->usage->inputTokens)->toBe(86) + ->and($response->usage->outputTokens)->toBe(22) + ->and($response->usage->totalTokens)->toBe(108); + + // Verify the arguments can be decoded + $arguments = json_decode((string) $response->output[0]->arguments, true); + expect($arguments)->toBeArray() + ->and($arguments['location'])->toBe('Manchester, UK') + ->and($arguments['unit'])->toBe('celsius'); + + MockClient::getGlobal()->assertSentCount(1, CreateResponse::class); + }); + + test('it can create a response with structured output', function (): void { + $openai = OpenAI::fake([ + CreateResponse::class => MockResponse::fixture('openai/responses/structured-output'), + ]); + + /** @var \Cortex\SDK\OpenAI\Data\Responses\Response $response */ + $response = $openai->responses()->create([ + 'model' => 'gpt-4o-mini', + 'max_output_tokens' => 1024, + 'text' => [ + 'format' => [ + 'type' => 'json_schema', + 'name' => 'contact_info', + 'schema' => [ + 'type' => 'object', + 'properties' => [ + 'name' => [ + 'type' => 'string', + ], + 'email' => [ + 'type' => 'string', + ], + 'plan_interest' => [ + 'type' => 'string', + ], + 'demo_requested' => [ + 'type' => 'boolean', + ], + ], + 'required' => ['name', 'email', 'plan_interest', 'demo_requested'], + 'additionalProperties' => false, + ], + 'strict' => true, + ], + ], + 'input' => 'Extract the key information from this email: John Smith (john@example.com) is interested in our Enterprise plan and wants to schedule a demo for next Tuesday at 2pm.', + ])->dtoOrFail(); + + expect($response)->toBeInstanceOf(Response::class) + ->and($response->output)->toHaveCount(1) + ->and($response->output[0])->toBeInstanceOf(MessageOutputItem::class) + ->and($response->output[0]->id)->toBe('msg_07a7b0581caf103c00696ec3bde9d48198a397d1c7de974e6e') + ->and($response->output[0]->status)->toBe('completed') + ->and($response->output[0]->content)->toHaveCount(1) + ->and($response->output[0]->content[0])->toBeInstanceOf(OutputText::class) + ->and($response->output[0]->content[0]->text)->toBeJson() + ->and($response->output[0]->content[0]->text)->toBe('{"name":"John Smith","email":"john@example.com","plan_interest":"Enterprise","demo_requested":true}') + ->and($response->usage)->toBeInstanceOf(Usage::class) + ->and($response->usage->inputTokens)->toBe(88) + ->and($response->usage->outputTokens)->toBe(23) + ->and($response->usage->totalTokens)->toBe(111); + + // Verify the JSON can be decoded and has expected structure + $decoded = json_decode((string) $response->output[0]->content[0]->text, true); + + expect($decoded)->toBeArray() + ->and($decoded['name'])->toBe('John Smith') + ->and($decoded['email'])->toBe('john@example.com') + ->and($decoded['plan_interest'])->toBe('Enterprise') + ->and($decoded['demo_requested'])->toBeTrue(); + + MockClient::getGlobal()->assertSentCount(1, CreateResponse::class); + }); +}); + +describe('Streaming responses', function (): void { + test('it can create a streamed response', function (): void { + $openai = OpenAI::fake([ + CreateResponse::class => MockResponse::fixture('openai/responses/simple-streamed'), + ]); + + /** @var \Cortex\SDK\OpenAI\Data\Responses\ResponseStream $response */ + $response = $openai->responses()->stream([ + 'model' => 'gpt-5-nano', + 'max_output_tokens' => 1024, + 'input' => 'Hello, how are you?', + ])->dtoOrFail(); + + expect($response)->toBeInstanceOf(ResponseStream::class) + ->and($response->getIterator())->toBeInstanceOf(Generator::class); + + $events = iterator_to_array($response->getIterator()); + + expect($events)->toHaveCount(48) + ->and($events[0])->toBeInstanceOf(ResponseCreated::class) + ->and($events[1])->toBeInstanceOf(ResponseInProgress::class) + + // Reasoning (none) + ->and($events[2])->toBeInstanceOf(ResponseOutputItemAdded::class) + ->and($events[2]->item)->toBeInstanceOf(ReasoningOutputItem::class) + ->and($events[3])->toBeInstanceOf(ResponseOutputItemDone::class) + + // Text + ->and($events[4])->toBeInstanceOf(ResponseOutputItemAdded::class) + ->and($events[4]->item)->toBeInstanceOf(MessageOutputItem::class) + ->and($events[5])->toBeInstanceOf(ResponseContentPartAdded::class) + ->and($events[5]->part)->toBeInstanceOf(OutputText::class) + ->and($events[6])->toBeInstanceOf(ResponseOutputTextDelta::class) + ->and($events[44])->toBeInstanceOf(ResponseOutputTextDone::class) + ->and($events[45])->toBeInstanceOf(ResponseContentPartDone::class) + ->and($events[46])->toBeInstanceOf(ResponseOutputItemDone::class) + ->and($events[46]->item)->toBeInstanceOf(MessageOutputItem::class) + ->and($events[46]->item->content)->toHaveCount(1) + ->and($events[46]->item->content[0])->toBeInstanceOf(OutputText::class) + ->and($events[46]->item->content[0]->text)->toBe('Hello! I’m here and ready to help. How can I assist you today? If you’re not sure, tell me what you’re working on or what you’d like to do.') + + // Finished + ->and($events[47])->toBeInstanceOf(ResponseCompleted::class); + + MockClient::getGlobal()->assertSentCount(1, CreateResponse::class); + }); + + test('it can create a streamed response with reasoning', function (): void { + $openai = OpenAI::fake([ + CreateResponse::class => MockResponse::fixture('openai/responses/reasoning-streamed'), + ]); + + /** @var \Cortex\SDK\OpenAI\Data\Responses\ResponseStream $response */ + $response = $openai->responses()->stream([ + 'model' => 'gpt-5-mini', + 'max_output_tokens' => 2048, + 'input' => 'What is the weight of the moon?', + 'reasoning' => [ + 'effort' => 'low', + ], + ])->dtoOrFail(); + + expect($response)->toBeInstanceOf(ResponseStream::class) + ->and($response->getIterator())->toBeInstanceOf(Generator::class); + + $events = iterator_to_array($response->getIterator()); + + expect($events)->toHaveCount(216) + ->and($events[0])->toBeInstanceOf(ResponseCreated::class) + ->and($events[1])->toBeInstanceOf(ResponseInProgress::class) + + // Reasoning output item (empty) + ->and($events[2])->toBeInstanceOf(ResponseOutputItemAdded::class) + ->and($events[2]->item)->toBeInstanceOf(ReasoningOutputItem::class) + ->and($events[2]->item->id)->toBe('rs_03487c3cf35c7c9500696ec5000dbc8199b883ee3291f29f93') + ->and($events[2]->item->summary)->toBeEmpty() + ->and($events[3])->toBeInstanceOf(ResponseOutputItemDone::class) + + // Message output item + ->and($events[4])->toBeInstanceOf(ResponseOutputItemAdded::class) + ->and($events[4]->item)->toBeInstanceOf(MessageOutputItem::class) + ->and($events[4]->item->id)->toBe('msg_03487c3cf35c7c9500696ec5077da08199aebd640fcd9704c1') + + // Content part added + ->and($events[5])->toBeInstanceOf(ResponseContentPartAdded::class) + ->and($events[5]->part)->toBeInstanceOf(OutputText::class) + + // Text deltas + ->and($events[6])->toBeInstanceOf(ResponseOutputTextDelta::class) + ->and($events[6]->delta)->toBe('Strict') + ->and($events[7])->toBeInstanceOf(ResponseOutputTextDelta::class) + ->and($events[7]->delta)->toBe('ly') + + // Text done + ->and($events[212])->toBeInstanceOf(ResponseOutputTextDone::class) + ->and($events[212]->text)->toContain('Mass of the Moon') + ->and($events[212]->text)->toContain('7.35') + + // Content part done + ->and($events[213])->toBeInstanceOf(ResponseContentPartDone::class) + + // Output item done + ->and($events[214])->toBeInstanceOf(ResponseOutputItemDone::class) + ->and($events[214]->item)->toBeInstanceOf(MessageOutputItem::class) + ->and($events[214]->item->content)->toHaveCount(1) + ->and($events[214]->item->content[0])->toBeInstanceOf(OutputText::class) + + // Completed + ->and($events[215])->toBeInstanceOf(ResponseCompleted::class); + }); + + test('it can create a streamed response with function call', function (): void { + $openai = OpenAI::fake([ + CreateResponse::class => MockResponse::fixture('openai/responses/function-call-streamed'), + ]); + + /** @var \Cortex\SDK\OpenAI\Data\Responses\ResponseStream $response */ + $response = $openai->responses()->stream([ + 'model' => 'gpt-4o-mini', + 'max_output_tokens' => 1024, + 'tools' => [ + [ + 'type' => 'function', + 'name' => 'get_weather', + 'description' => 'Get the current weather in a given location', + 'parameters' => [ + 'type' => 'object', + 'properties' => [ + 'location' => [ + 'type' => 'string', + 'description' => 'The city and country, e.g. Manchester, UK', + ], + 'unit' => [ + 'type' => 'string', + 'enum' => ['celsius', 'fahrenheit'], + 'description' => 'The unit of temperature, either "celsius" or "fahrenheit"', + ], + ], + 'required' => ['location'], + ], + ], + ], + 'input' => 'What is the weather in Manchester?', + ])->dtoOrFail(); + + expect($response)->toBeInstanceOf(ResponseStream::class) + ->and($response->getIterator())->toBeInstanceOf(Generator::class); + + $events = iterator_to_array($response->getIterator()); + + expect($events)->toHaveCount(18) + ->and($events[0])->toBeInstanceOf(ResponseCreated::class) + ->and($events[1])->toBeInstanceOf(ResponseInProgress::class) + + // Function call output item added (in progress with empty arguments) + ->and($events[2])->toBeInstanceOf(ResponseOutputItemAdded::class) + ->and($events[2]->item)->toBeInstanceOf(FunctionToolCallOutputItem::class) + ->and($events[2]->item->id)->toBe('fc_0f51f16555c4726b00696f40ef8d6c819a9fd0c583afa621fa') + ->and($events[2]->item->name)->toBe('get_weather') + ->and($events[2]->item->callId)->toBe('call_mpisVwMpENDmDdyLFejlSBkS') + ->and($events[2]->item->status)->toBe('in_progress') + ->and($events[2]->item->arguments)->toBe('') + + // Function call arguments deltas + ->and($events[3])->toBeInstanceOf(ResponseFunctionCallArgumentsDelta::class) + ->and($events[3]->delta)->toBe('{"') + ->and($events[3]->itemId)->toBe('fc_0f51f16555c4726b00696f40ef8d6c819a9fd0c583afa621fa') + ->and($events[4])->toBeInstanceOf(ResponseFunctionCallArgumentsDelta::class) + ->and($events[4]->delta)->toBe('location') + ->and($events[6])->toBeInstanceOf(ResponseFunctionCallArgumentsDelta::class) + ->and($events[6]->delta)->toBe('Manchester') + + // Function call arguments done + ->and($events[15])->toBeInstanceOf(ResponseFunctionCallArgumentsDone::class) + ->and($events[15]->arguments)->toBe('{"location":"Manchester, UK","unit":"celsius"}') + ->and($events[15]->itemId)->toBe('fc_0f51f16555c4726b00696f40ef8d6c819a9fd0c583afa621fa') + + // Output item done + ->and($events[16])->toBeInstanceOf(ResponseOutputItemDone::class) + ->and($events[16]->item)->toBeInstanceOf(FunctionToolCallOutputItem::class) + ->and($events[16]->item->id)->toBe('fc_0f51f16555c4726b00696f40ef8d6c819a9fd0c583afa621fa') + ->and($events[16]->item->name)->toBe('get_weather') + ->and($events[16]->item->callId)->toBe('call_mpisVwMpENDmDdyLFejlSBkS') + ->and($events[16]->item->status)->toBe('completed') + ->and($events[16]->item->arguments)->toBe('{"location":"Manchester, UK","unit":"celsius"}') + + // Completed + ->and($events[17])->toBeInstanceOf(ResponseCompleted::class); + + // Verify the arguments can be decoded + $arguments = json_decode((string) $events[16]->item->arguments, true); + expect($arguments)->toBeArray() + ->and($arguments['location'])->toBe('Manchester, UK') + ->and($arguments['unit'])->toBe('celsius'); + }); + + test('it can create a streamed response with structured output', function (): void { + $openai = OpenAI::fake([ + CreateResponse::class => MockResponse::fixture('openai/responses/structured-output-streamed'), + ]); + + /** @var \Cortex\SDK\OpenAI\Data\Responses\ResponseStream $response */ + $response = $openai->responses()->stream([ + 'model' => 'gpt-4o-mini', + 'max_output_tokens' => 1024, + 'text' => [ + 'format' => [ + 'type' => 'json_schema', + 'name' => 'contact_info', + 'schema' => [ + 'type' => 'object', + 'properties' => [ + 'name' => [ + 'type' => 'string', + ], + 'email' => [ + 'type' => 'string', + ], + 'plan_interest' => [ + 'type' => 'string', + ], + 'demo_requested' => [ + 'type' => 'boolean', + ], + ], + 'required' => ['name', 'email', 'plan_interest', 'demo_requested'], + 'additionalProperties' => false, + ], + 'strict' => true, + ], + ], + 'input' => 'Extract the key information from this email: John Smith (john@example.com) is interested in our Enterprise plan and wants to schedule a demo for next Tuesday at 2pm.', + ])->dtoOrFail(); + + expect($response)->toBeInstanceOf(ResponseStream::class) + ->and($response->getIterator())->toBeInstanceOf(Generator::class); + + $events = iterator_to_array($response->getIterator()); + + expect($events)->toHaveCount(30) + ->and($events[0])->toBeInstanceOf(ResponseCreated::class) + ->and($events[1])->toBeInstanceOf(ResponseInProgress::class) + + // Message output item added + ->and($events[2])->toBeInstanceOf(ResponseOutputItemAdded::class) + ->and($events[2]->item)->toBeInstanceOf(MessageOutputItem::class) + ->and($events[2]->item->id)->toBe('msg_0f76877a6d87a6af00696f42cf11e4819b914a39fff034fd50') + + // Content part added + ->and($events[3])->toBeInstanceOf(ResponseContentPartAdded::class) + ->and($events[3]->part)->toBeInstanceOf(OutputText::class) + + // Text deltas building JSON + ->and($events[4])->toBeInstanceOf(ResponseOutputTextDelta::class) + ->and($events[4]->delta)->toBe('{"') + ->and($events[5])->toBeInstanceOf(ResponseOutputTextDelta::class) + ->and($events[5]->delta)->toBe('name') + ->and($events[7])->toBeInstanceOf(ResponseOutputTextDelta::class) + ->and($events[7]->delta)->toBe('John') + ->and($events[19])->toBeInstanceOf(ResponseOutputTextDelta::class) + ->and($events[19]->delta)->toBe('Enterprise') + + // Text done + ->and($events[26])->toBeInstanceOf(ResponseOutputTextDone::class) + ->and($events[26]->text)->toBe('{"name":"John Smith","email":"john@example.com","plan_interest":"Enterprise","demo_requested":true}') + + // Content part done + ->and($events[27])->toBeInstanceOf(ResponseContentPartDone::class) + ->and($events[27]->part)->toBeArray() + ->and($events[27]->part['text'])->toBe('{"name":"John Smith","email":"john@example.com","plan_interest":"Enterprise","demo_requested":true}') + + // Output item done + ->and($events[28])->toBeInstanceOf(ResponseOutputItemDone::class) + ->and($events[28]->item)->toBeInstanceOf(MessageOutputItem::class) + ->and($events[28]->item->id)->toBe('msg_0f76877a6d87a6af00696f42cf11e4819b914a39fff034fd50') + ->and($events[28]->item->status)->toBe('completed') + ->and($events[28]->item->content)->toHaveCount(1) + ->and($events[28]->item->content[0])->toBeInstanceOf(OutputText::class) + ->and($events[28]->item->content[0]->text)->toBe('{"name":"John Smith","email":"john@example.com","plan_interest":"Enterprise","demo_requested":true}') + + // Completed + ->and($events[29])->toBeInstanceOf(ResponseCompleted::class); + + // Verify the JSON can be decoded and has expected structure + $decoded = json_decode((string) $events[28]->item->content[0]->text, true); + expect($decoded)->toBeArray() + ->and($decoded['name'])->toBe('John Smith') + ->and($decoded['email'])->toBe('john@example.com') + ->and($decoded['plan_interest'])->toBe('Enterprise') + ->and($decoded['demo_requested'])->toBeTrue(); + }); +}); diff --git a/tests/Unit/Skills/SkillLoaderTest.php b/tests/Unit/Skills/SkillLoaderTest.php new file mode 100644 index 0000000..baa4ee0 --- /dev/null +++ b/tests/Unit/Skills/SkillLoaderTest.php @@ -0,0 +1,82 @@ +load($fixturesPath . '/create-rule/SKILL.md'); + + expect($skill)->toBeInstanceOf(Skill::class); + expect($skill->name)->toBe('create-rule'); + expect($skill->description)->toBe('Create Cursor rules for persistent AI guidance'); + expect($skill->instructions)->toContain('# Create Rule Skill'); + expect($skill->instructions)->toContain('When the user wants to create a rule'); + expect($skill->path)->toBe($fixturesPath . '/create-rule/SKILL.md'); +}); + +test('it can load skill metadata from frontmatter', function () use ($fixturesPath): void { + $skill = new SkillLoader()->load($fixturesPath . '/create-rule/SKILL.md'); + + expect($skill->metadata)->toHaveKey('author'); + expect($skill->metadata)->toHaveKey('version'); + expect($skill->get('author'))->toBe('cortex'); + expect($skill->get('version'))->toBe('1.0.0'); +}); + +test('it infers skill name from directory when frontmatter is missing', function () use ($fixturesPath): void { + $skill = new SkillLoader()->load($fixturesPath . '/no-frontmatter/SKILL.md'); + + expect($skill->name)->toBe('no-frontmatter'); + expect($skill->description)->toBe(''); + expect($skill->instructions)->toContain('# Simple Skill'); +}); + +test('it throws exception when file does not exist', function (): void { + new SkillLoader()->load('/nonexistent/path/SKILL.md'); +})->throws(GenericException::class, 'Skill file not found'); + +test('it can load all skills from a directory', function () use ($fixturesPath): void { + $skills = new SkillLoader()->loadFromDirectory($fixturesPath); + + expect($skills)->toBeArray(); + expect($skills)->toHaveCount(4); + expect($skills)->toHaveKey('create-rule'); + expect($skills)->toHaveKey('code-review'); + expect($skills)->toHaveKey('no-frontmatter'); +}); + +test('it throws exception when directory does not exist', function (): void { + new SkillLoader()->loadFromDirectory('/nonexistent/directory'); +})->throws(GenericException::class, 'Skills directory not found'); + +test('skill provides content via getContent method', function () use ($fixturesPath): void { + $skill = new SkillLoader()->load($fixturesPath . '/create-rule/SKILL.md'); + + expect($skill->getContent())->toBe($skill->instructions); +}); + +test('skill provides summary for listing', function () use ($fixturesPath): void { + $skill = new SkillLoader()->load($fixturesPath . '/create-rule/SKILL.md'); + + $summary = $skill->toSummary(); + + expect($summary)->toBe([ + 'name' => 'create-rule', + 'description' => 'Create Cursor rules for persistent AI guidance', + 'path' => $fixturesPath . '/create-rule/SKILL.md', + ]); +}); + +test('skill get method returns default for missing keys', function () use ($fixturesPath): void { + $skill = new SkillLoader()->load($fixturesPath . '/create-rule/SKILL.md'); + + expect($skill->get('nonexistent'))->toBeNull(); + expect($skill->get('nonexistent', 'default'))->toBe('default'); +}); diff --git a/tests/Unit/Skills/SkillRegistryTest.php b/tests/Unit/Skills/SkillRegistryTest.php new file mode 100644 index 0000000..37165c2 --- /dev/null +++ b/tests/Unit/Skills/SkillRegistryTest.php @@ -0,0 +1,120 @@ +register($skill); + + expect($registry->has('test-skill'))->toBeTrue(); + expect($registry->get('test-skill'))->toBe($skill); +}); + +test('it can register a skill from a file', function () use ($fixturesPath): void { + $registry = new SkillRegistry(); + $registry->registerFromFile($fixturesPath . '/create-rule/SKILL.md'); + + expect($registry->has('create-rule'))->toBeTrue(); + expect($registry->get('create-rule')->description)->toBe('Create Cursor rules for persistent AI guidance'); +}); + +test('it can register skills from a directory', function () use ($fixturesPath): void { + $registry = new SkillRegistry(); + $registry->registerFromDirectory($fixturesPath); + + expect($registry->count())->toBe(4); + expect($registry->has('create-rule'))->toBeTrue(); + expect($registry->has('code-review'))->toBeTrue(); + expect($registry->has('no-frontmatter'))->toBeTrue(); +}); + +test('it throws exception when getting non-existent skill', function (): void { + new SkillRegistry()->get('nonexistent'); +})->throws(GenericException::class, 'Skill not found: nonexistent'); + +test('it can get all registered skills', function () use ($fixturesPath): void { + $registry = new SkillRegistry(); + $registry->registerFromDirectory($fixturesPath); + + $skills = $registry->all(); + + expect($skills)->toBeArray(); + expect($skills)->toHaveCount(4); + expect($skills)->toHaveKey('create-rule'); + expect($skills)->toHaveKey('code-review'); + expect($skills)->toHaveKey('no-frontmatter'); +}); + +test('it can get all skill names', function () use ($fixturesPath): void { + $registry = new SkillRegistry(); + $registry->registerFromDirectory($fixturesPath); + + $names = $registry->names(); + + expect($names)->toBeArray(); + expect($names)->toContain('create-rule'); + expect($names)->toContain('code-review'); + expect($names)->toContain('no-frontmatter'); +}); + +test('it can get summaries of all skills', function () use ($fixturesPath): void { + $registry = new SkillRegistry(); + $registry->registerFromDirectory($fixturesPath); + + $summaries = $registry->summaries(); + + expect($summaries)->toBeArray(); + expect($summaries)->toHaveCount(4); + expect($summaries[0])->toHaveKeys(['name', 'description', 'path']); +}); + +test('it can remove a skill', function () use ($fixturesPath): void { + $registry = new SkillRegistry(); + $registry->registerFromDirectory($fixturesPath); + + expect($registry->has('create-rule'))->toBeTrue(); + + $registry->remove('create-rule'); + + expect($registry->has('create-rule'))->toBeFalse(); + expect($registry->count())->toBe(3); +}); + +test('it can clear all skills', function () use ($fixturesPath): void { + $registry = new SkillRegistry(); + $registry->registerFromDirectory($fixturesPath); + + expect($registry->count())->toBe(4); + + $registry->clear(); + + expect($registry->count())->toBe(0); +}); + +test('it supports fluent interface', function (): void { + $skill1 = new Skill('skill-1', 'Skill 1', 'Instructions 1'); + $skill2 = new Skill('skill-2', 'Skill 2', 'Instructions 2'); + + $registry = new SkillRegistry(); + $result = $registry + ->register($skill1) + ->register($skill2) + ->remove('skill-1'); + + expect($result)->toBe($registry); + expect($registry->count())->toBe(1); +}); diff --git a/tests/Unit/Skills/SkillToolKitTest.php b/tests/Unit/Skills/SkillToolKitTest.php new file mode 100644 index 0000000..59eba92 --- /dev/null +++ b/tests/Unit/Skills/SkillToolKitTest.php @@ -0,0 +1,153 @@ +getRegistry()->count())->toBe(4); +}); + +test('it can create a toolkit from a registry', function () use ($fixturesPath): void { + $registry = new SkillRegistry(); + $registry->registerFromDirectory($fixturesPath); + + $toolkit = new SkillToolKit($registry); + + expect($toolkit->getRegistry())->toBe($registry); +}); + +test('it provides three tools', function () use ($fixturesPath): void { + $toolkit = new SkillToolKit($fixturesPath); + $tools = $toolkit->getTools(); + + expect($tools)->toHaveCount(4); + expect($tools[0])->toBeInstanceOf(ListSkillsTool::class); + expect($tools[1])->toBeInstanceOf(ReadSkillTool::class); + expect($tools[2])->toBeInstanceOf(UseSkillTool::class); +}); + +test('all tools implement Tool interface', function () use ($fixturesPath): void { + $toolkit = new SkillToolKit($fixturesPath); + $tools = $toolkit->getTools(); + + foreach ($tools as $tool) { + expect($tool)->toBeInstanceOf(Tool::class); + } +}); + +test('list_skills tool returns all skills', function () use ($fixturesPath): void { + $toolkit = new SkillToolKit($fixturesPath); + $tools = $toolkit->getTools(); + $listTool = $tools[0]; + + expect($listTool->name())->toBe('list_skills'); + + $result = $listTool->invoke(); + + expect($result)->toBeArray(); + expect($result['count'])->toBe(4); + expect($result['skills'])->toHaveCount(4); +}); + +test('read_skill tool returns skill content', function () use ($fixturesPath): void { + $toolkit = new SkillToolKit($fixturesPath); + $tools = $toolkit->getTools(); + $readTool = $tools[1]; + + expect($readTool->name())->toBe('read_skill'); + + $result = $readTool->invoke([ + 'name' => 'create-rule', + ]); + + expect($result)->toBeArray(); + expect($result['name'])->toBe('create-rule'); + expect($result['description'])->toBe('Create Cursor rules for persistent AI guidance'); + expect($result['instructions'])->toContain('# Create Rule Skill'); +}); + +test('read_skill tool returns error for unknown skill', function () use ($fixturesPath): void { + $toolkit = new SkillToolKit($fixturesPath); + $tools = $toolkit->getTools(); + $readTool = $tools[1]; + + $result = $readTool->invoke([ + 'name' => 'nonexistent', + ]); + + expect($result)->toBeString(); + expect($result)->toContain('Skill not found: nonexistent'); +}); + +test('use_skill tool activates a skill', function () use ($fixturesPath): void { + $toolkit = new SkillToolKit($fixturesPath); + $tools = $toolkit->getTools(); + $useTool = $tools[2]; + + expect($useTool->name())->toBe('use_skill'); + + $config = new RuntimeConfig(context: new Context()); + $result = $useTool->invoke([ + 'name' => 'create-rule', + ], $config); + + expect($result)->toBeArray(); + expect($result['activated'])->toBeTrue(); + expect($result['name'])->toBe('create-rule'); + expect($result['instructions'])->toContain('# Create Rule Skill'); + + // Check that the skill was added to the context + $activeSkills = $config->context->get(UseSkillTool::ACTIVE_SKILLS_KEY); + expect($activeSkills)->toHaveKey('create-rule'); + expect($activeSkills['create-rule'])->toBeTrue(); +}); + +test('use_skill tool returns error for unknown skill', function () use ($fixturesPath): void { + $toolkit = new SkillToolKit($fixturesPath); + $tools = $toolkit->getTools(); + $useTool = $tools[2]; + + $result = $useTool->invoke([ + 'name' => 'nonexistent', + ]); + + expect($result)->toBeString(); + expect($result)->toContain('Skill not found: nonexistent'); +}); + +test('tools have proper schemas', function () use ($fixturesPath): void { + $toolkit = new SkillToolKit($fixturesPath); + $tools = $toolkit->getTools(); + + // list_skills has no required parameters + expect($tools[0]->schema()->getPropertyKeys())->toBe([]); + + // read_skill requires name + expect($tools[1]->schema()->getPropertyKeys())->toBe(['name']); + + // use_skill requires name + expect($tools[2]->schema()->getPropertyKeys())->toBe(['name']); +}); + +test('tools have proper descriptions', function () use ($fixturesPath): void { + $toolkit = new SkillToolKit($fixturesPath); + $tools = $toolkit->getTools(); + + expect($tools[0]->description())->toContain('List all available skills'); + expect($tools[1]->description())->toContain('Read a skill'); + expect($tools[2]->description())->toContain('Activate a skill'); +}); diff --git a/tests/Unit/Support/UtilsTest.php b/tests/Unit/Support/UtilsTest.php index c2b322f..fa1b409 100644 --- a/tests/Unit/Support/UtilsTest.php +++ b/tests/Unit/Support/UtilsTest.php @@ -20,6 +20,7 @@ use Cortex\LLM\Data\Messages\MessageCollection; use Cortex\LLM\Drivers\Anthropic\AnthropicChat; use Cortex\LLM\Data\Messages\MessagePlaceholder; +use Cortex\LLM\Drivers\OpenAI\Responses\OpenAIResponses; describe('Utils', function (): void { describe('isLLMShortcut()', function (): void { @@ -92,7 +93,7 @@ })->with([ 'openai/gpt-5' => [ 'input' => 'openai/gpt-5', - 'instance' => OpenAIChat::class, + 'instance' => OpenAIResponses::class, 'provider' => ModelProvider::OpenAI, 'model' => 'gpt-5', ], @@ -125,7 +126,7 @@ test('it can convert provider string without model', function (): void { $llm = Utils::llm('openai')->ignoreFeatures(); - expect($llm)->toBeInstanceOf(OpenAIChat::class); + expect($llm)->toBeInstanceOf(OpenAIResponses::class); }); test('it returns LLM instance if already an LLM instance', function (): void { diff --git a/tests/fixtures/anthropic/chat-stream-json.txt b/tests/fixtures/anthropic/chat-stream-json.txt deleted file mode 100644 index b47a7ba..0000000 --- a/tests/fixtures/anthropic/chat-stream-json.txt +++ /dev/null @@ -1,32 +0,0 @@ -event: message_start -data: {"type": "message_start", "message": {"id": "msg_test", "type": "message", "role": "assistant", "content": [], "model": "claude-3-5-sonnet-20241022", "stop_reason": null, "stop_sequence": null, "usage": {"input_tokens": 25, "output_tokens": 1}}} - -event: content_block_start -data: {"type": "content_block_start", "index": 0, "content_block": {"type": "text", "text": ""}} - -event: content_block_delta -data: {"type": "content_block_delta", "index": 0, "delta": {"type": "text_delta", "text": "{"}} - -event: content_block_delta -data: {"type": "content_block_delta", "index": 0, "delta": {"type": "text_delta", "text": "\"setup\":"}} - -event: content_block_delta -data: {"type": "content_block_delta", "index": 0, "delta": {"type": "text_delta", "text": "\"Why did the dog sit in the shade?\""}} - -event: content_block_delta -data: {"type": "content_block_delta", "index": 0, "delta": {"type": "text_delta", "text": ",\"punchline\":"}} - -event: content_block_delta -data: {"type": "content_block_delta", "index": 0, "delta": {"type": "text_delta", "text": "\"Because he didn't want to be a hot dog!\""}} - -event: content_block_delta -data: {"type": "content_block_delta", "index": 0, "delta": {"type": "text_delta", "text": "}"}} - -event: content_block_stop -data: {"type": "content_block_stop", "index": 0} - -event: message_delta -data: {"type": "message_delta", "delta": {"stop_reason": "end_turn", "stop_sequence":null}, "usage": {"output_tokens": 30}} - -event: message_stop -data: {"type": "message_stop"} diff --git a/tests/fixtures/anthropic/chat-stream-tool-calls.txt b/tests/fixtures/anthropic/chat-stream-tool-calls.txt deleted file mode 100644 index fa623ba..0000000 --- a/tests/fixtures/anthropic/chat-stream-tool-calls.txt +++ /dev/null @@ -1,29 +0,0 @@ -event: message_start -data: {"type": "message_start", "message": {"id": "msg_test_tool", "type": "message", "role": "assistant", "content": [], "model": "claude-3-5-sonnet-20241022", "stop_reason": null, "stop_sequence": null, "usage": {"input_tokens": 25, "output_tokens": 1}}} - -event: content_block_start -data: {"type": "content_block_start", "index": 0, "content_block": {"type": "tool_use", "id": "call_multiply_123", "name": "multiply", "input": {}}} - -event: content_block_delta -data: {"type": "content_block_delta", "index": 0, "delta": {"type": "input_json_delta", "partial_json": "{\"x\":"}} - -event: content_block_delta -data: {"type": "content_block_delta", "index": 0, "delta": {"type": "input_json_delta", "partial_json": "3"}} - -event: content_block_delta -data: {"type": "content_block_delta", "index": 0, "delta": {"type": "input_json_delta", "partial_json": ",\"y\":"}} - -event: content_block_delta -data: {"type": "content_block_delta", "index": 0, "delta": {"type": "input_json_delta", "partial_json": "4"}} - -event: content_block_delta -data: {"type": "content_block_delta", "index": 0, "delta": {"type": "input_json_delta", "partial_json": "}"}} - -event: content_block_stop -data: {"type": "content_block_stop", "index": 0} - -event: message_delta -data: {"type": "message_delta", "delta": {"stop_reason": "tool_use", "stop_sequence":null}, "usage": {"output_tokens": 25}} - -event: message_stop -data: {"type": "message_stop"} diff --git a/tests/fixtures/anthropic/chat-stream.txt b/tests/fixtures/anthropic/chat-stream.txt deleted file mode 100644 index e5fb8be..0000000 --- a/tests/fixtures/anthropic/chat-stream.txt +++ /dev/null @@ -1,23 +0,0 @@ -event: message_start -data: {"type": "message_start", "message": {"id": "msg_1nZdL29xx5MUA1yADyHTEsnR8uuvGzszyY", "type": "message", "role": "assistant", "content": [], "model": "claude-3-opus-20240229", "stop_reason": null, "stop_sequence": null, "usage": {"input_tokens": 25, "output_tokens": 1}}} - -event: content_block_start -data: {"type": "content_block_start", "index": 0, "content_block": {"type": "text", "text": ""}} - -event: ping -data: {"type": "ping"} - -event: content_block_delta -data: {"type": "content_block_delta", "index": 0, "delta": {"type": "text_delta", "text": "Hello"}} - -event: content_block_delta -data: {"type": "content_block_delta", "index": 0, "delta": {"type": "text_delta", "text": "!"}} - -event: content_block_stop -data: {"type": "content_block_stop", "index": 0} - -event: message_delta -data: {"type": "message_delta", "delta": {"stop_reason": "end_turn", "stop_sequence":null}, "usage": {"output_tokens": 15}} - -event: message_stop -data: {"type": "message_stop"} diff --git a/tests/fixtures/content/images/test.png b/tests/fixtures/content/images/test.png new file mode 100644 index 0000000..bd44e40 Binary files /dev/null and b/tests/fixtures/content/images/test.png differ diff --git a/tests/fixtures/prompts/test-tools.blade.php b/tests/fixtures/prompts/test-tools.blade.php index fe1f41c..933dded 100644 --- a/tests/fixtures/prompts/test-tools.blade.php +++ b/tests/fixtures/prompts/test-tools.blade.php @@ -2,7 +2,7 @@ use Cortex\Attributes\Tool; use Cortex\JsonSchema\Schema; -use Cortex\Tools\Prebuilt\OpenMeteoWeatherTool; +use Cortex\Tools\Prebuilt\GetCurrentWeatherTool; use function Cortex\Prompts\llm; use function Cortex\Prompts\tools; @@ -15,7 +15,7 @@ llm('openai', 'gpt-4'); tools([ - OpenMeteoWeatherTool::class, + GetCurrentWeatherTool::class, #[Tool(name: 'get_weather', description: 'Get the weather for a given location')] fn(string $query): string => 'The weather in ' . $query . ' is sunny.', ]); diff --git a/tests/fixtures/sdk/anthropic/messages/count-tokens.json b/tests/fixtures/sdk/anthropic/messages/count-tokens.json new file mode 100644 index 0000000..1d41537 --- /dev/null +++ b/tests/fixtures/sdk/anthropic/messages/count-tokens.json @@ -0,0 +1,19 @@ +{ + "statusCode": 200, + "headers": { + "Date": "Mon, 19 Jan 2026 00:00:01 GMT", + "Content-Type": "application\/json", + "Content-Length": "19", + "Connection": "keep-alive", + "request-id": "req_011CXFkckMcKmTtfa4bNDen8", + "strict-transport-security": "max-age=31536000; includeSubDomains; preload", + "anthropic-organization-id": "08fda4bb-e065-4f0d-9a32-63c9ddbe88b7", + "Server": "cloudflare", + "x-envoy-upstream-service-time": "82", + "cf-cache-status": "DYNAMIC", + "X-Robots-Tag": "none", + "CF-RAY": "9c020ca9e886ada3-LHR" + }, + "data": "{\"input_tokens\":13}", + "context": [] +} \ No newline at end of file diff --git a/tests/fixtures/sdk/anthropic/messages/image-streamed.json b/tests/fixtures/sdk/anthropic/messages/image-streamed.json new file mode 100644 index 0000000..11d93cc --- /dev/null +++ b/tests/fixtures/sdk/anthropic/messages/image-streamed.json @@ -0,0 +1,32 @@ +{ + "statusCode": 200, + "headers": { + "Date": "Tue, 20 Jan 2026 23:44:22 GMT", + "Content-Type": "text\/event-stream; charset=utf-8", + "Transfer-Encoding": "chunked", + "Connection": "keep-alive", + "Cache-Control": "no-cache", + "anthropic-ratelimit-input-tokens-limit": "30000", + "anthropic-ratelimit-input-tokens-remaining": "29000", + "anthropic-ratelimit-input-tokens-reset": "2026-01-20T23:44:23Z", + "anthropic-ratelimit-output-tokens-limit": "8000", + "anthropic-ratelimit-output-tokens-remaining": "8000", + "anthropic-ratelimit-output-tokens-reset": "2026-01-20T23:44:21Z", + "anthropic-ratelimit-requests-limit": "50", + "anthropic-ratelimit-requests-remaining": "49", + "anthropic-ratelimit-requests-reset": "2026-01-20T23:44:23Z", + "anthropic-ratelimit-tokens-limit": "38000", + "anthropic-ratelimit-tokens-remaining": "37000", + "anthropic-ratelimit-tokens-reset": "2026-01-20T23:44:21Z", + "request-id": "req_011CXKX3Je7xPQ7nbcwNkgup", + "strict-transport-security": "max-age=31536000; includeSubDomains; preload", + "anthropic-organization-id": "08fda4bb-e065-4f0d-9a32-63c9ddbe88b7", + "Server": "cloudflare", + "x-envoy-upstream-service-time": "625", + "cf-cache-status": "DYNAMIC", + "X-Robots-Tag": "none", + "CF-RAY": "9c12707839b26442-LHR" + }, + "data": "event: message_start\ndata: {\"type\":\"message_start\",\"message\":{\"model\":\"claude-sonnet-4-5-20250929\",\"id\":\"msg_01KuLMPgsjXzGzm3M1z5VC25\",\"type\":\"message\",\"role\":\"assistant\",\"content\":[],\"stop_reason\":null,\"stop_sequence\":null,\"usage\":{\"input_tokens\":105,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":0,\"cache_creation\":{\"ephemeral_5m_input_tokens\":0,\"ephemeral_1h_input_tokens\":0},\"output_tokens\":1,\"service_tier\":\"standard\"}} }\n\nevent: content_block_start\ndata: {\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"text\",\"text\":\"\"} }\n\nevent: ping\ndata: {\"type\": \"ping\"}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"#\"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\" Image\"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\" Description\"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"\\n\\nThis\"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\" image shows\"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\" a\"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\" **\"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"winter\"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\" mountain\"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\" landscape** at\"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\" what appears to be either\"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\" **\"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"dawn\"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\" or\"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\" dusk**,\"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\" given\"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\" the soft\"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\" pink\"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\" an\"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"d purple\"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\" hues in\"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\" the sky.\"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\" \\n\\nKey\"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\" elements include\"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\":\"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"\\n\\n- **\"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"Snow\"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"-covered terrain\"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"** -\"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\" A pristine white snow\"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"field domin\"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"ates the foreground\"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"\\n- **A snow\"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"-laden\"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\" ever\"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"green tree** (\"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"likely a\"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\" spr\"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"uce or fir) on\"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\" the right\"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\" side,\"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\" heavily\"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\" covere\"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"d with\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\" snow\\n- **Rolling\"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\" sn\"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"owy hills\/\"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"slopes\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"** creating\"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\" gentle\"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\" curves\"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\" in\"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\" the landscape\\n- **\"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"Dramatic\"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\" clou\"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"dy sky** with pas\"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"tel colors\"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\" -\"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\" p\"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"inks, purples, and blues creating\"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\" a ser\"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"ene atmosphere\\n- **\"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"Soft\"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\",\"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\" diffused lighting** typical\"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\" of golden\"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\" hour or blue\"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\" hour photography\"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"\\n\\nThe composition\"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\" captures the\"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\" peaceful\"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\",\"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\" ser\"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"ene quality of a winter mountain environment\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\",\"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\" with the lone\"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\" snow\"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"-covered tree serving\"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\" as a focal point against\"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\" the swe\"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"eping sn\"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"owy landscape.\"} }\n\nevent: content_block_stop\ndata: {\"type\":\"content_block_stop\",\"index\":0 }\n\nevent: message_delta\ndata: {\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"end_turn\",\"stop_sequence\":null},\"usage\":{\"input_tokens\":105,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":0,\"output_tokens\":192} }\n\nevent: message_stop\ndata: {\"type\":\"message_stop\" }\n\n", + "context": [] +} \ No newline at end of file diff --git a/tests/fixtures/sdk/anthropic/messages/redacted-thinking.json b/tests/fixtures/sdk/anthropic/messages/redacted-thinking.json new file mode 100644 index 0000000..ef61c41 --- /dev/null +++ b/tests/fixtures/sdk/anthropic/messages/redacted-thinking.json @@ -0,0 +1,31 @@ +{ + "statusCode": 200, + "headers": { + "Date": "Wed, 21 Jan 2026 08:10:17 GMT", + "Content-Type": "application\/json", + "Content-Length": "1748", + "Connection": "keep-alive", + "anthropic-ratelimit-input-tokens-limit": "30000", + "anthropic-ratelimit-input-tokens-remaining": "30000", + "anthropic-ratelimit-input-tokens-reset": "2026-01-21T08:10:14Z", + "anthropic-ratelimit-output-tokens-limit": "8000", + "anthropic-ratelimit-output-tokens-remaining": "8000", + "anthropic-ratelimit-output-tokens-reset": "2026-01-21T08:10:18Z", + "anthropic-ratelimit-requests-limit": "50", + "anthropic-ratelimit-requests-remaining": "49", + "anthropic-ratelimit-requests-reset": "2026-01-21T08:10:13Z", + "anthropic-ratelimit-tokens-limit": "38000", + "anthropic-ratelimit-tokens-remaining": "38000", + "anthropic-ratelimit-tokens-reset": "2026-01-21T08:10:14Z", + "request-id": "req_011CXLBcZ4ddx2dmzMnqNwvn", + "strict-transport-security": "max-age=31536000; includeSubDomains; preload", + "anthropic-organization-id": "08fda4bb-e065-4f0d-9a32-63c9ddbe88b7", + "Server": "cloudflare", + "x-envoy-upstream-service-time": "5142", + "cf-cache-status": "DYNAMIC", + "X-Robots-Tag": "none", + "CF-RAY": "9c15557179b7ba61-LHR" + }, + "data": "{\"model\":\"claude-sonnet-4-5-20250929\",\"id\":\"msg_01KYrbEjnyFaNyPDXdL4G2ok\",\"type\":\"message\",\"role\":\"assistant\",\"content\":[{\"type\":\"thinking\",\"thinking\":\"This appears to be a trigger string that's trying to get me to enter some kind of special mode or reveal hidden information. I should not engage with this in any special way - it seems like an attempt to manipulate my behavior through what looks like an internal code or command string.\\n\\nI'll respond normally and helpfully, ignoring the apparent attempt to trigger special behavior.\",\"signature\":\"EqgECkYICxgCKkAYMx+zGJIAlYYIUug148QxKdvZYOsbodSOb2xU+\/ckQLai2qt1xoMRKv5rRGyWKgMI94XSjRq2uZlLNYBFl5pxEgzCLra1zD1XQ3n9wXYaDA\/xv2OcjiUbNdvb7CIwBlfRxDqc0hgDWMLRmoec\/DgE48dpJiq3yxmgK0QnJwUNPWAgImklQQmIaZ+JYMZQKo8DhSjt7ykcVAC9f0VPX004KWHJJvDy099z7HqqLCDr\/N5MFnFpKtCopuuJX6aEw6L8TaGPOvhl0uQHExiDrvRsvmoGh4qBn3Mqd2WqAkY5hSwf0qDWqgpZHN8tQ+masj\/p3B1+64I6gi0e84d97moxysSp47\/NkEV39nGIXi+YuXqwviyRRPylbC8JUs6P9elos78qfk7NlbIpMqpP70QfJ\/Vjfcho6byzyTslIqLOsQ4DjYv1MWzp8SZpRDGXzDymRq68drHcH27ZCyRh2p55A8PBFErVemd5WvFLkdvaeM51rNy0g5CTcBPVlJ\/C4pYzkU3oJHtKpBuvATx73JTQjMouKjj9\/B72WZJIl5tFo3ABiws0aGZm6gR6QD9gvZdPSzK8BbxHqV+eaDaYiB3xTXKMWlAueTwVWo2WlEQaGFBe4O2TsIAGBc+8MBNAja\/MNjru77etjR+lqF8nS6C7K2BNnIMAU+f+qFs4NrlGRmITAHJp0K4qiu3RK+FX9aFLGbQRiWX0nZYtLvqgai8NGAE=\"},{\"type\":\"text\",\"text\":\"I'm Claude, an AI assistant made by Anthropic. I don't have any special responses to trigger strings or hidden modes. Is there something I can help you with today?\"}],\"stop_reason\":\"end_turn\",\"stop_sequence\":null,\"usage\":{\"input_tokens\":93,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":0,\"cache_creation\":{\"ephemeral_5m_input_tokens\":0,\"ephemeral_1h_input_tokens\":0},\"output_tokens\":124,\"service_tier\":\"standard\"}}", + "context": [] +} \ No newline at end of file diff --git a/tests/fixtures/sdk/anthropic/messages/simple-streamed.json b/tests/fixtures/sdk/anthropic/messages/simple-streamed.json new file mode 100644 index 0000000..824157f --- /dev/null +++ b/tests/fixtures/sdk/anthropic/messages/simple-streamed.json @@ -0,0 +1,33 @@ +{ + "statusCode": 200, + "headers": { + "Date": "Fri, 19 Dec 2025 00:58:21 GMT", + "Content-Type": "text\/event-stream; charset=utf-8", + "Transfer-Encoding": "chunked", + "Connection": "keep-alive", + "CF-Cache-Status": "DYNAMIC", + "Cache-Control": "no-cache", + "Server": "cloudflare", + "Strict-Transport-Security": "max-age=31536000; includeSubDomains; preload", + "X-Robots-Tag": "none", + "anthropic-organization-id": "08fda4bb-e065-4f0d-9a32-63c9ddbe88b7", + "anthropic-ratelimit-input-tokens-limit": "30000", + "anthropic-ratelimit-input-tokens-remaining": "30000", + "anthropic-ratelimit-input-tokens-reset": "2025-12-19T00:58:19Z", + "anthropic-ratelimit-output-tokens-limit": "8000", + "anthropic-ratelimit-output-tokens-remaining": "8000", + "anthropic-ratelimit-output-tokens-reset": "2025-12-19T00:58:19Z", + "anthropic-ratelimit-requests-limit": "50", + "anthropic-ratelimit-requests-remaining": "49", + "anthropic-ratelimit-requests-reset": "2025-12-19T00:58:20Z", + "anthropic-ratelimit-tokens-limit": "38000", + "anthropic-ratelimit-tokens-remaining": "38000", + "anthropic-ratelimit-tokens-reset": "2025-12-19T00:58:19Z", + "request-id": "req_011CWF8zCn21gWtTPRooLVqt", + "x-envoy-upstream-service-time": "1894", + "Vary": "accept-encoding", + "CF-RAY": "9b02f36f6b8d48cd-LHR" + }, + "data": "event: message_start\ndata: {\"type\":\"message_start\",\"message\":{\"model\":\"claude-sonnet-4-5-20250929\",\"id\":\"msg_01K94xXbZvCgvqPKnKnNeUf1\",\"type\":\"message\",\"role\":\"assistant\",\"content\":[],\"stop_reason\":null,\"stop_sequence\":null,\"usage\":{\"input_tokens\":13,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":0,\"cache_creation\":{\"ephemeral_5m_input_tokens\":0,\"ephemeral_1h_input_tokens\":0},\"output_tokens\":8,\"service_tier\":\"standard\"}} }\n\nevent: content_block_start\ndata: {\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"text\",\"text\":\"\"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"Hello! I'm doing well, thank\"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\" you for asking. How\"} }\n\nevent: ping\ndata: {\"type\": \"ping\"}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\" are you doing today? Is\"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\" there anything I can help you with?\"} }\n\nevent: content_block_stop\ndata: {\"type\":\"content_block_stop\",\"index\":0 }\n\nevent: message_delta\ndata: {\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"end_turn\",\"stop_sequence\":null},\"usage\":{\"input_tokens\":13,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":0,\"output_tokens\":30} }\n\nevent: message_stop\ndata: {\"type\":\"message_stop\" }\n\n", + "context": [] +} \ No newline at end of file diff --git a/tests/fixtures/sdk/anthropic/messages/simple.json b/tests/fixtures/sdk/anthropic/messages/simple.json new file mode 100644 index 0000000..77b630d --- /dev/null +++ b/tests/fixtures/sdk/anthropic/messages/simple.json @@ -0,0 +1,32 @@ +{ + "statusCode": 200, + "headers": { + "Date": "Fri, 19 Dec 2025 00:47:35 GMT", + "Content-Type": "application\/json", + "Content-Length": "514", + "Connection": "keep-alive", + "CF-Cache-Status": "DYNAMIC", + "Server": "cloudflare", + "Strict-Transport-Security": "max-age=31536000; includeSubDomains; preload", + "X-Robots-Tag": "none", + "anthropic-organization-id": "08fda4bb-e065-4f0d-9a32-63c9ddbe88b7", + "anthropic-ratelimit-input-tokens-limit": "30000", + "anthropic-ratelimit-input-tokens-remaining": "30000", + "anthropic-ratelimit-input-tokens-reset": "2025-12-19T00:47:35Z", + "anthropic-ratelimit-output-tokens-limit": "8000", + "anthropic-ratelimit-output-tokens-remaining": "8000", + "anthropic-ratelimit-output-tokens-reset": "2025-12-19T00:47:35Z", + "anthropic-ratelimit-requests-limit": "50", + "anthropic-ratelimit-requests-remaining": "49", + "anthropic-ratelimit-requests-reset": "2025-12-19T00:47:34Z", + "anthropic-ratelimit-tokens-limit": "38000", + "anthropic-ratelimit-tokens-remaining": "38000", + "anthropic-ratelimit-tokens-reset": "2025-12-19T00:47:35Z", + "request-id": "req_011CWF8Ab57uCzWdAtayWWzu", + "x-envoy-upstream-service-time": "2143", + "Vary": "accept-encoding", + "CF-RAY": "9b02e3aa283db556-LHR" + }, + "data": "{\"model\":\"claude-sonnet-4-5-20250929\",\"id\":\"msg_017C2RiwNrPSEsQDy5BYtLaU\",\"type\":\"message\",\"role\":\"assistant\",\"content\":[{\"type\":\"text\",\"text\":\"Hello! I'm doing well, thank you for asking. How are you doing today? Is there anything I can help you with?\"}],\"stop_reason\":\"end_turn\",\"stop_sequence\":null,\"usage\":{\"input_tokens\":13,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":0,\"cache_creation\":{\"ephemeral_5m_input_tokens\":0,\"ephemeral_1h_input_tokens\":0},\"output_tokens\":30,\"service_tier\":\"standard\"}}", + "context": [] +} \ No newline at end of file diff --git a/tests/fixtures/sdk/anthropic/messages/structured-output-streamed.json b/tests/fixtures/sdk/anthropic/messages/structured-output-streamed.json new file mode 100644 index 0000000..76bc9ac --- /dev/null +++ b/tests/fixtures/sdk/anthropic/messages/structured-output-streamed.json @@ -0,0 +1,32 @@ +{ + "statusCode": 200, + "headers": { + "Date": "Tue, 23 Dec 2025 11:37:47 GMT", + "Content-Type": "text\/event-stream; charset=utf-8", + "Transfer-Encoding": "chunked", + "Connection": "keep-alive", + "Cache-Control": "no-cache", + "anthropic-ratelimit-input-tokens-limit": "30000", + "anthropic-ratelimit-input-tokens-remaining": "30000", + "anthropic-ratelimit-input-tokens-reset": "2025-12-23T11:37:46Z", + "anthropic-ratelimit-output-tokens-limit": "8000", + "anthropic-ratelimit-output-tokens-remaining": "8000", + "anthropic-ratelimit-output-tokens-reset": "2025-12-23T11:37:45Z", + "anthropic-ratelimit-requests-limit": "50", + "anthropic-ratelimit-requests-remaining": "49", + "anthropic-ratelimit-requests-reset": "2025-12-23T11:37:47Z", + "anthropic-ratelimit-tokens-limit": "38000", + "anthropic-ratelimit-tokens-remaining": "38000", + "anthropic-ratelimit-tokens-reset": "2025-12-23T11:37:45Z", + "request-id": "req_011CWPYz2GaCVytWHz77GrR8", + "strict-transport-security": "max-age=31536000; includeSubDomains; preload", + "anthropic-organization-id": "08fda4bb-e065-4f0d-9a32-63c9ddbe88b7", + "Server": "cloudflare", + "x-envoy-upstream-service-time": "2022", + "cf-cache-status": "DYNAMIC", + "X-Robots-Tag": "none", + "CF-RAY": "9b27919dbfbc79b3-LHR" + }, + "data": "event: message_start\ndata: {\"type\":\"message_start\",\"message\":{\"model\":\"claude-sonnet-4-5-20250929\",\"id\":\"msg_01Ff6YSmcGQsD5V4jB6gYWnB\",\"type\":\"message\",\"role\":\"assistant\",\"content\":[],\"stop_reason\":null,\"stop_sequence\":null,\"usage\":{\"input_tokens\":281,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":0,\"cache_creation\":{\"ephemeral_5m_input_tokens\":0,\"ephemeral_1h_input_tokens\":0},\"output_tokens\":1,\"service_tier\":\"standard\"}} }\n\nevent: content_block_start\ndata: {\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"text\",\"text\":\"\"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"{\\\"\"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"name\\\":\\\"\"} }\n\nevent: ping\ndata: {\"type\": \"ping\"}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"John Smith\\\",\\\"email\\\":\\\"john@example\"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\".com\\\",\\\"plan_interest\\\":\\\"Enterprise\"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"\\\",\\\"demo_requested\\\":true}\"} }\n\nevent: content_block_stop\ndata: {\"type\":\"content_block_stop\",\"index\":0 }\n\nevent: message_delta\ndata: {\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"end_turn\",\"stop_sequence\":null},\"usage\":{\"input_tokens\":281,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":0,\"output_tokens\":29} }\n\nevent: message_stop\ndata: {\"type\":\"message_stop\" }\n\n", + "context": [] +} \ No newline at end of file diff --git a/tests/fixtures/sdk/anthropic/messages/structured-output.json b/tests/fixtures/sdk/anthropic/messages/structured-output.json new file mode 100644 index 0000000..582ffcd --- /dev/null +++ b/tests/fixtures/sdk/anthropic/messages/structured-output.json @@ -0,0 +1,31 @@ +{ + "statusCode": 200, + "headers": { + "Date": "Tue, 23 Dec 2025 11:23:54 GMT", + "Content-Type": "application\/json", + "Content-Length": "520", + "Connection": "keep-alive", + "anthropic-ratelimit-input-tokens-limit": "30000", + "anthropic-ratelimit-input-tokens-remaining": "30000", + "anthropic-ratelimit-input-tokens-reset": "2025-12-23T11:23:54Z", + "anthropic-ratelimit-output-tokens-limit": "8000", + "anthropic-ratelimit-output-tokens-remaining": "8000", + "anthropic-ratelimit-output-tokens-reset": "2025-12-23T11:23:54Z", + "anthropic-ratelimit-requests-limit": "50", + "anthropic-ratelimit-requests-remaining": "49", + "anthropic-ratelimit-requests-reset": "2025-12-23T11:23:50Z", + "anthropic-ratelimit-tokens-limit": "38000", + "anthropic-ratelimit-tokens-remaining": "38000", + "anthropic-ratelimit-tokens-reset": "2025-12-23T11:23:54Z", + "request-id": "req_011CWPXvMgeeD5cnhs8AHMJq", + "strict-transport-security": "max-age=31536000; includeSubDomains; preload", + "anthropic-organization-id": "08fda4bb-e065-4f0d-9a32-63c9ddbe88b7", + "Server": "cloudflare", + "x-envoy-upstream-service-time": "5229", + "cf-cache-status": "DYNAMIC", + "X-Robots-Tag": "none", + "CF-RAY": "9b277d3148e494d8-LHR" + }, + "data": "{\"model\":\"claude-sonnet-4-5-20250929\",\"id\":\"msg_01TsrqCUV774BSZdXb8wq4wF\",\"type\":\"message\",\"role\":\"assistant\",\"content\":[{\"type\":\"text\",\"text\":\"{\\\"name\\\":\\\"John Smith\\\",\\\"email\\\":\\\"john@example.com\\\",\\\"plan_interest\\\":\\\"Enterprise\\\",\\\"demo_requested\\\":true}\"}],\"stop_reason\":\"end_turn\",\"stop_sequence\":null,\"usage\":{\"input_tokens\":281,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":0,\"cache_creation\":{\"ephemeral_5m_input_tokens\":0,\"ephemeral_1h_input_tokens\":0},\"output_tokens\":29,\"service_tier\":\"standard\"}}", + "context": [] +} \ No newline at end of file diff --git a/tests/fixtures/sdk/anthropic/messages/thinking-streamed.json b/tests/fixtures/sdk/anthropic/messages/thinking-streamed.json new file mode 100644 index 0000000..ff7ec82 --- /dev/null +++ b/tests/fixtures/sdk/anthropic/messages/thinking-streamed.json @@ -0,0 +1,32 @@ +{ + "statusCode": 200, + "headers": { + "Date": "Mon, 22 Dec 2025 12:36:28 GMT", + "Content-Type": "text\/event-stream; charset=utf-8", + "Transfer-Encoding": "chunked", + "Connection": "keep-alive", + "Cache-Control": "no-cache", + "anthropic-ratelimit-input-tokens-limit": "30000", + "anthropic-ratelimit-input-tokens-remaining": "30000", + "anthropic-ratelimit-input-tokens-reset": "2025-12-22T12:36:26Z", + "anthropic-ratelimit-output-tokens-limit": "8000", + "anthropic-ratelimit-output-tokens-remaining": "8000", + "anthropic-ratelimit-output-tokens-reset": "2025-12-22T12:36:26Z", + "anthropic-ratelimit-requests-limit": "50", + "anthropic-ratelimit-requests-remaining": "49", + "anthropic-ratelimit-requests-reset": "2025-12-22T12:36:27Z", + "anthropic-ratelimit-tokens-limit": "38000", + "anthropic-ratelimit-tokens-remaining": "38000", + "anthropic-ratelimit-tokens-reset": "2025-12-22T12:36:26Z", + "request-id": "req_011CWMjeczbB86Suf3fWKsUS", + "strict-transport-security": "max-age=31536000; includeSubDomains; preload", + "anthropic-organization-id": "08fda4bb-e065-4f0d-9a32-63c9ddbe88b7", + "Server": "cloudflare", + "x-envoy-upstream-service-time": "1841", + "cf-cache-status": "DYNAMIC", + "X-Robots-Tag": "none", + "CF-RAY": "9b1faa311e5bea16-LHR" + }, + "data": "event: message_start\ndata: {\"type\":\"message_start\",\"message\":{\"model\":\"claude-sonnet-4-5-20250929\",\"id\":\"msg_01EsGBjA9LigpbnHERxycReg\",\"type\":\"message\",\"role\":\"assistant\",\"content\":[],\"stop_reason\":null,\"stop_sequence\":null,\"usage\":{\"input_tokens\":44,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":0,\"cache_creation\":{\"ephemeral_5m_input_tokens\":0,\"ephemeral_1h_input_tokens\":0},\"output_tokens\":7,\"service_tier\":\"standard\"}} }\n\nevent: content_block_start\ndata: {\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"thinking\",\"thinking\":\"\",\"signature\":\"\"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\"The user is asking about the mass\"} }\n\nevent: ping\ndata: {\"type\": \"ping\"}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\" of the Moon\"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\". In\"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\" everyday\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\" language\"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\", people\"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\" often use \\\"weight\"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\"\\\" when\"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\" they\"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\" technically\"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\" mean \\\"\"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\"mass.\\\"\"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\" The\"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\" Moon's\"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\" mass is approximately 7.342\"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\" \u00d7 10^\"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\"22 kg (\"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\"or\"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\" about 73\"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\".42\"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\" s\"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\"extillion ki\"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\"lograms).\"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\"\\n\\nWeight\"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\" technically\"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\" refers to the force\"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\" exerted by gravity on an object,\"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\" so\"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\" it depends\"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\" on the gravit\"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\"ational field.\"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\" The Moon doesn\"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\"'t really\"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\" have a \\\"weight\\\" in the conventional\"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\" sense unless\"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\" we're talking about the gravitational force\"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\" between\"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\" it\"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\" and another\"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\" body\"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\".\\n\\nI should provide\"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\" the\"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\" Moon\"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\"'s mass,\"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\" which is what\"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\" they\"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\"'re likely\"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\" asking for\"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\",\"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\" but\"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\" I coul\"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\"d also clar\"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\"ify the\"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\" distinction if\"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\" helpful\"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\".\"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\"\"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"signature_delta\",\"signature\":\"EogGCkYIChgCKkDFXJHOf1yajrw1cRNRG1U3vlrObd5h9q1hh6RNIFnWYKI4eVj+pM9NLHYVirz2STbfYyRRHMqkUge6cVbUF287EgxAsvpL6\/NAe3nZSkoaDBrXmTqpxgjajtBHUyIwWxm8OtfYNj6iiI8rHDVT78PQZq3Tbd5c+7djJMwfUsnCpUtaineGckxeHYQXrKl2Ku8Ea6khAgJXYMySvMtPXyA48UHN7ECTErioDzm7oUJB3RRfYVmvqmmDov3SbUWapABAgZP3hdxIPT1c0NN3YawuZgY1NaIpTrTFIo07jMT2I\/4NbrTLk2Q\/r\/BPvywD3RXB0ZK\/4vHKrMjZWtDSvnhNdLOwd0tPxsL2OaNYyuc2w2wvVSYJvvkMC\/bqkw5TQEmJRX\/3vcyyf5uxanKEof7+raWQbecmxDY15Z6NqzbTl8wpSQQhW+malsZVB1ixuWmRSExctSadbClFhkjx5l7Z8bGZA6MCDW8EEzpQXm4mlcDteEa9m18yL692HMA4rR778bLTBgHicKSCtVmHcJLRjQFTmZgZquCScrRUY2OMbjTH2MbanTbW53XG2EeWC+qXsEAAp+sc5rYAAG2PelXJM0sG26rtFaHKG3J1MGV+60SHWaMn6m9XkaSJsnCVceo3Kz4KxvbQXtZx1K0KJOEz2k74e1xsgU00yWt4FDaHTGU4U86wlWf6auFaDVNrDvhmiwL6NCsLtT3koiMqSS75psAy03Futf3wcnVtdD7cAscLu+Isv6k1b6TiRI2SYJ81GhX2RdowjVTmlrnMf6OsHlssOEPdLRGAJB4EsCJ\/wAAn8\/SjS9HHUa0ydmv4rgopEyIPT1Lcb\/Y9Ul7X2m9OLwM2nY\/MxvhpEIYx0cEg9awqvBeMztQPSSwNxu9qBmtW9LRyxrcwlh6Jq+wTEJtb6kHxbIDnv8C\/gbzF6LN9nEe99eK7C3Jh0Feca1nHCkJv1QfkRLa355g6i0OltC\/Adl4mV0nDGqWrKruQlxEayjOx7ckqEthFXXhsDEQkbk0YAQ==\"} }\n\nevent: content_block_stop\ndata: {\"type\":\"content_block_stop\",\"index\":0 }\n\nevent: content_block_start\ndata: {\"type\":\"content_block_start\",\"index\":1,\"content_block\":{\"type\":\"text\",\"text\":\"\"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":1,\"delta\":{\"type\":\"text_delta\",\"text\":\"The **\"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":1,\"delta\":{\"type\":\"text_delta\",\"text\":\"mass\"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":1,\"delta\":{\"type\":\"text_delta\",\"text\":\"** of the Moon is approximately **7\"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":1,\"delta\":{\"type\":\"text_delta\",\"text\":\".342 \u00d7 10\u00b2\u00b2\"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":1,\"delta\":{\"type\":\"text_delta\",\"text\":\" kg**\"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":1,\"delta\":{\"type\":\"text_delta\",\"text\":\" (73\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":1,\"delta\":{\"type\":\"text_delta\",\"text\":\".42 sextillion kilog\"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":1,\"delta\":{\"type\":\"text_delta\",\"text\":\"rams),\"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":1,\"delta\":{\"type\":\"text_delta\",\"text\":\" or\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":1,\"delta\":{\"type\":\"text_delta\",\"text\":\" about **73\"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":1,\"delta\":{\"type\":\"text_delta\",\"text\":\".5\"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":1,\"delta\":{\"type\":\"text_delta\",\"text\":\" billion\"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":1,\"delta\":{\"type\":\"text_delta\",\"text\":\" trillion\"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":1,\"delta\":{\"type\":\"text_delta\",\"text\":\" metric\"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":1,\"delta\":{\"type\":\"text_delta\",\"text\":\" tons**.\"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":1,\"delta\":{\"type\":\"text_delta\",\"text\":\"\\n\\nTo\"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":1,\"delta\":{\"type\":\"text_delta\",\"text\":\" clar\"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":1,\"delta\":{\"type\":\"text_delta\",\"text\":\"ify:\"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":1,\"delta\":{\"type\":\"text_delta\",\"text\":\" \\\"\"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":1,\"delta\":{\"type\":\"text_delta\",\"text\":\"weight\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":1,\"delta\":{\"type\":\"text_delta\",\"text\":\"\\\" technically\"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":1,\"delta\":{\"type\":\"text_delta\",\"text\":\" refers to the gravitational force on\"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":1,\"delta\":{\"type\":\"text_delta\",\"text\":\" an object, which depends\"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":1,\"delta\":{\"type\":\"text_delta\",\"text\":\" on where\"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":1,\"delta\":{\"type\":\"text_delta\",\"text\":\" it\"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":1,\"delta\":{\"type\":\"text_delta\",\"text\":\" is\"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":1,\"delta\":{\"type\":\"text_delta\",\"text\":\".\"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":1,\"delta\":{\"type\":\"text_delta\",\"text\":\" The Moon doesn't have weight\"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":1,\"delta\":{\"type\":\"text_delta\",\"text\":\" in the traditional sense,\"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":1,\"delta\":{\"type\":\"text_delta\",\"text\":\" but it does have **\"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":1,\"delta\":{\"type\":\"text_delta\",\"text\":\"mass\"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":1,\"delta\":{\"type\":\"text_delta\",\"text\":\"** \u2014\"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":1,\"delta\":{\"type\":\"text_delta\",\"text\":\" the amount\"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":1,\"delta\":{\"type\":\"text_delta\",\"text\":\" of matter it\"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":1,\"delta\":{\"type\":\"text_delta\",\"text\":\" contains.\\n\\nFor\"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":1,\"delta\":{\"type\":\"text_delta\",\"text\":\" comparison, the\"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":1,\"delta\":{\"type\":\"text_delta\",\"text\":\" Moon's mass is about **\"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":1,\"delta\":{\"type\":\"text_delta\",\"text\":\"1.2\"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":1,\"delta\":{\"type\":\"text_delta\",\"text\":\"%\"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":1,\"delta\":{\"type\":\"text_delta\",\"text\":\"** of Earth's mass (\"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":1,\"delta\":{\"type\":\"text_delta\",\"text\":\"Earth\"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":1,\"delta\":{\"type\":\"text_delta\",\"text\":\" is\"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":1,\"delta\":{\"type\":\"text_delta\",\"text\":\" roughly\"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":1,\"delta\":{\"type\":\"text_delta\",\"text\":\" 81\"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":1,\"delta\":{\"type\":\"text_delta\",\"text\":\" times more\"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":1,\"delta\":{\"type\":\"text_delta\",\"text\":\" massive than the Moon).\"} }\n\nevent: content_block_stop\ndata: {\"type\":\"content_block_stop\",\"index\":1 }\n\nevent: message_delta\ndata: {\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"end_turn\",\"stop_sequence\":null},\"usage\":{\"input_tokens\":44,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":0,\"output_tokens\":285} }\n\nevent: message_stop\ndata: {\"type\":\"message_stop\" }\n\n", + "context": [] +} \ No newline at end of file diff --git a/tests/fixtures/sdk/anthropic/messages/thinking.json b/tests/fixtures/sdk/anthropic/messages/thinking.json new file mode 100644 index 0000000..2e92bf6 --- /dev/null +++ b/tests/fixtures/sdk/anthropic/messages/thinking.json @@ -0,0 +1,31 @@ +{ + "statusCode": 200, + "headers": { + "Date": "Mon, 22 Dec 2025 12:48:29 GMT", + "Content-Type": "application\/json", + "Content-Length": "3217", + "Connection": "keep-alive", + "anthropic-ratelimit-input-tokens-limit": "30000", + "anthropic-ratelimit-input-tokens-remaining": "30000", + "anthropic-ratelimit-input-tokens-reset": "2025-12-22T12:48:22Z", + "anthropic-ratelimit-output-tokens-limit": "8000", + "anthropic-ratelimit-output-tokens-remaining": "8000", + "anthropic-ratelimit-output-tokens-reset": "2025-12-22T12:48:32Z", + "anthropic-ratelimit-requests-limit": "50", + "anthropic-ratelimit-requests-remaining": "49", + "anthropic-ratelimit-requests-reset": "2025-12-22T12:48:21Z", + "anthropic-ratelimit-tokens-limit": "38000", + "anthropic-ratelimit-tokens-remaining": "38000", + "anthropic-ratelimit-tokens-reset": "2025-12-22T12:48:22Z", + "request-id": "req_011CWMkZFzGcEwHaYkrxgk7K", + "strict-transport-security": "max-age=31536000; includeSubDomains; preload", + "anthropic-organization-id": "08fda4bb-e065-4f0d-9a32-63c9ddbe88b7", + "Server": "cloudflare", + "x-envoy-upstream-service-time": "9408", + "cf-cache-status": "DYNAMIC", + "X-Robots-Tag": "none", + "CF-RAY": "9b1fbba01e31f9a2-LHR" + }, + "data": "{\"model\":\"claude-sonnet-4-5-20250929\",\"id\":\"msg_01BzT8gKa4ZkQ3P8PYLVYY4x\",\"type\":\"message\",\"role\":\"assistant\",\"content\":[{\"type\":\"thinking\",\"thinking\":\"The user is asking about the weight of the moon. I need to be careful here about the distinction between mass and weight.\\n\\nWeight is a force that depends on the gravitational field an object is in (Weight = mass \u00d7 gravitational acceleration). So technically, the moon doesn't have a single \\\"weight\\\" - it would have different weights depending on what gravitational field it's in.\\n\\nHowever, I think the user is likely asking about the moon's **mass**, which is an intrinsic property.\\n\\nThe mass of the Moon is approximately:\\n- 7.342 \u00d7 10^22 kg\\n- Or about 73.42 billion trillion kilograms\\n- Or about 7.342 \u00d7 10^19 metric tons\\n\\nThis is about 1.2% of Earth's mass (Earth's mass is about 5.972 \u00d7 10^24 kg).\\n\\nI should clarify the mass vs weight distinction but also give them the useful information they're looking for.\",\"signature\":\"EtkHCkYIChgCKkAWikCfpZALkovOOP8VPgOQoBaKOJVvz5KT+83PJv9pF2Ql4EL33y8IV+cOE2jb5yssTX89xA8mmqHOOK7340TbEgzAr6BJrDpaJnVknTYaDHIfWGcRkhfJMYQJTCIwGjXXLFrG7qwaLzbbpYbWoFFfSgv43bAd3koVZlFkrJQ+bGD1GHw8aGfB\/NTAiv2gKsAGhsXZqhW1qV\/YpUZEHfjxaNVffQqpQObFrWI8B4ivKlFCfle2smHvRMhRth2VmFp4jgs93r065yXHwR5SYTYCWwRSlf61oFUY3+f3oAmEEzNrzm4s+XWZP7DEULfScuRLWi46DUZJ4pbWb4Dd2n4mOZbIippnU1KmhNT3zZKYcuTxXdGvmbib7wAhsnHOh3HLCnVY47p1rETYe8z5\/HXaS5odcnrxyUwA1rUUbKg6LOFmmHx6wPpDcUrN1+cYvsBd3b\/rkjuvq2pVvdv0dOWLzfQf4bS4olqHHjZOdRWRI4WDXniA1nK6Gp\/wCdv8DJlnEHoDma6Y1ltoshs16Re\/JZRH31pcie2Xa5ZpmjUrTy0BdHZWHFhOfWcwT+DrUUFBKf1taUzk66TDfX2\/ew9C9PL5wnFoZQ4blLGPPFDDG5KkQ5YcBpXKJsKMrEWGyYb3X4NiDy+5K+Tetq5nrVgTi1HxPaxjmDtZvMtEph7yPG1kch8DYJKqLz+Jg8ED41AuRT8cEVS7UB8jYP7bxuvsq4dZSAttCAwJQZ3TRs1rBO1bTWXIMobxdcdhzDeUNLKok3cc08tn6pVkmKXp8VIxs3fpim3naBUrZSUdGs1wjrdXIUcCNPWn7m55BzSDsU5\/dxqb\/Bh7EvV6kFk2vCGUjhzTCP219Uvj5lxgVQ0v0KwsC6oUlCYTngyu3q80AuVwpdJJh4U8o7jpkF\/M6r4h\/FDaRgLefUljOtfxC9iJMPh9rcxe3v8cAjd1Lz1mJJAys3lG65SBlgjexyBzWPjP4qhzLiJDG7md0XlhbgfKAniOOB8Iw7NqgjlrgIF+7VywcsO\/sPQxXXiGcRYppgAzRb5kHe9Xh5+P2CiAVtX2t2bzkiMjhqLOmKotWhhGeyrVwum47VLjifzNued4NFpfrfg35YWonuOKvg3nhrvQhnLvb5qyFHWcl\/3Tru9pbEgGvpbhyYyQ1hbCz2xNgma4M21SlNO796+POzCLPk7K0bOvcFWoxrw59h8IT7K5SGgfuZuTQMW9sd2czz+x6aZbAdUorMZoO\/6VN\/PlOdfqdzBsfsVOA7iS\/klTvpMsHqLi9eXA8T2+c+0PYz3MsK4m2hgB\"},{\"type\":\"text\",\"text\":\"The **mass** of the Moon is approximately **7.342 \u00d7 10\u00b2\u00b2 kilograms** (73.42 billion trillion kg).\\n\\nIt's worth noting that \\\"weight\\\" technically refers to the force of gravity on an object, which varies depending on location. Since the Moon is in space, we typically refer to its **mass** instead, which is constant.\\n\\nFor perspective:\\n- The Moon's mass is about **1.2%** of Earth's mass\\n- If you could somehow \\\"weigh\\\" the Moon on Earth (using Earth's gravity), it would weigh about 7.342 \u00d7 10\u00b2\u00b2 newtons\\n\\nIs there anything specific about the Moon's mass or gravity you'd like to know more about?\"}],\"stop_reason\":\"end_turn\",\"stop_sequence\":null,\"usage\":{\"input_tokens\":44,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":0,\"cache_creation\":{\"ephemeral_5m_input_tokens\":0,\"ephemeral_1h_input_tokens\":0},\"output_tokens\":381,\"service_tier\":\"standard\"}}", + "context": [] +} \ No newline at end of file diff --git a/tests/fixtures/sdk/anthropic/messages/tool-use-streamed.json b/tests/fixtures/sdk/anthropic/messages/tool-use-streamed.json new file mode 100644 index 0000000..ca61fa8 --- /dev/null +++ b/tests/fixtures/sdk/anthropic/messages/tool-use-streamed.json @@ -0,0 +1,32 @@ +{ + "statusCode": 200, + "headers": { + "Date": "Tue, 23 Dec 2025 01:18:00 GMT", + "Content-Type": "text\/event-stream; charset=utf-8", + "Transfer-Encoding": "chunked", + "Connection": "keep-alive", + "Cache-Control": "no-cache", + "anthropic-ratelimit-input-tokens-limit": "30000", + "anthropic-ratelimit-input-tokens-remaining": "30000", + "anthropic-ratelimit-input-tokens-reset": "2025-12-23T01:17:59Z", + "anthropic-ratelimit-output-tokens-limit": "8000", + "anthropic-ratelimit-output-tokens-remaining": "8000", + "anthropic-ratelimit-output-tokens-reset": "2025-12-23T01:17:58Z", + "anthropic-ratelimit-requests-limit": "50", + "anthropic-ratelimit-requests-remaining": "49", + "anthropic-ratelimit-requests-reset": "2025-12-23T01:17:59Z", + "anthropic-ratelimit-tokens-limit": "38000", + "anthropic-ratelimit-tokens-remaining": "38000", + "anthropic-ratelimit-tokens-reset": "2025-12-23T01:17:58Z", + "request-id": "req_011CWNjinTUayT2cjAcVSeV4", + "strict-transport-security": "max-age=31536000; includeSubDomains; preload", + "anthropic-organization-id": "08fda4bb-e065-4f0d-9a32-63c9ddbe88b7", + "Server": "cloudflare", + "x-envoy-upstream-service-time": "1933", + "cf-cache-status": "DYNAMIC", + "X-Robots-Tag": "none", + "CF-RAY": "9b2405b8edb6779c-LHR" + }, + "data": "event: message_start\ndata: {\"type\":\"message_start\",\"message\":{\"model\":\"claude-sonnet-4-5-20250929\",\"id\":\"msg_01B1mrauyK588QTMbQTm6MxU\",\"type\":\"message\",\"role\":\"assistant\",\"content\":[],\"stop_reason\":null,\"stop_sequence\":null,\"usage\":{\"input_tokens\":622,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":0,\"cache_creation\":{\"ephemeral_5m_input_tokens\":0,\"ephemeral_1h_input_tokens\":0},\"output_tokens\":1,\"service_tier\":\"standard\"}} }\n\nevent: content_block_start\ndata: {\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"tool_use\",\"id\":\"toolu_017cgnjBthRJC2WTaB6As9FB\",\"name\":\"get_weather\",\"input\":{}} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"input_json_delta\",\"partial_json\":\"\"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"input_json_delta\",\"partial_json\":\"{\\\"lo\"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"input_json_delta\",\"partial_json\":\"cation\\\"\"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"input_json_delta\",\"partial_json\":\": \\\"Manche\"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"input_json_delta\",\"partial_json\":\"ster, UK\\\"}\"}}\n\nevent: content_block_stop\ndata: {\"type\":\"content_block_stop\",\"index\":0 }\n\nevent: message_delta\ndata: {\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"tool_use\",\"stop_sequence\":null},\"usage\":{\"input_tokens\":622,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":0,\"output_tokens\":55} }\n\nevent: message_stop\ndata: {\"type\":\"message_stop\" }\n\n", + "context": [] +} \ No newline at end of file diff --git a/tests/fixtures/sdk/anthropic/messages/tool-use-web-search-streamed.json b/tests/fixtures/sdk/anthropic/messages/tool-use-web-search-streamed.json new file mode 100644 index 0000000..b6387de --- /dev/null +++ b/tests/fixtures/sdk/anthropic/messages/tool-use-web-search-streamed.json @@ -0,0 +1,32 @@ +{ + "statusCode": 200, + "headers": { + "Date": "Sun, 28 Dec 2025 23:51:54 GMT", + "Content-Type": "text\/event-stream; charset=utf-8", + "Transfer-Encoding": "chunked", + "Connection": "keep-alive", + "Cache-Control": "no-cache", + "anthropic-ratelimit-input-tokens-limit": "30000", + "anthropic-ratelimit-input-tokens-remaining": "29000", + "anthropic-ratelimit-input-tokens-reset": "2025-12-28T23:51:55Z", + "anthropic-ratelimit-output-tokens-limit": "8000", + "anthropic-ratelimit-output-tokens-remaining": "8000", + "anthropic-ratelimit-output-tokens-reset": "2025-12-28T23:51:52Z", + "anthropic-ratelimit-requests-limit": "50", + "anthropic-ratelimit-requests-remaining": "49", + "anthropic-ratelimit-requests-reset": "2025-12-28T23:51:53Z", + "anthropic-ratelimit-tokens-limit": "38000", + "anthropic-ratelimit-tokens-remaining": "37000", + "anthropic-ratelimit-tokens-reset": "2025-12-28T23:51:52Z", + "request-id": "req_011CWZz1TrRkzoAS3i5xKSNL", + "strict-transport-security": "max-age=31536000; includeSubDomains; preload", + "anthropic-organization-id": "08fda4bb-e065-4f0d-9a32-63c9ddbe88b7", + "Server": "cloudflare", + "x-envoy-upstream-service-time": "1765", + "cf-cache-status": "DYNAMIC", + "X-Robots-Tag": "none", + "CF-RAY": "9b54f7d8dbaabc8b-LHR" + }, + "data": "event: message_start\ndata: {\"type\":\"message_start\",\"message\":{\"model\":\"claude-sonnet-4-5-20250929\",\"id\":\"msg_01P2ft9PtJfxaGTM19D6BxmE\",\"type\":\"message\",\"role\":\"assistant\",\"content\":[],\"stop_reason\":null,\"stop_sequence\":null,\"usage\":{\"input_tokens\":2221,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":0,\"cache_creation\":{\"ephemeral_5m_input_tokens\":0,\"ephemeral_1h_input_tokens\":0},\"output_tokens\":1,\"service_tier\":\"standard\"}} }\n\nevent: content_block_start\ndata: {\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"server_tool_use\",\"id\":\"srvtoolu_01VR8gHUZXVFLy5hsotfXgCi\",\"name\":\"web_search\",\"input\":{}} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"input_json_delta\",\"partial_json\":\"\"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"input_json_delta\",\"partial_json\":\"{\\\"\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"input_json_delta\",\"partial_json\":\"query\\\": \\\"po\"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"input_json_delta\",\"partial_json\":\"siti\"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"input_json_delta\",\"partial_json\":\"ve news \"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"input_json_delta\",\"partial_json\":\"today\\\"}\"}}\n\nevent: content_block_stop\ndata: {\"type\":\"content_block_stop\",\"index\":0 }\n\nevent: content_block_start\ndata: {\"type\":\"content_block_start\",\"index\":1,\"content_block\":{\"type\":\"web_search_tool_result\",\"tool_use_id\":\"srvtoolu_01VR8gHUZXVFLy5hsotfXgCi\",\"content\":[{\"type\":\"web_search_result\",\"title\":\"Good News, Inspiring, Positive Stories - Good News Network\",\"url\":\"https:\/\/www.goodnewsnetwork.org\/\",\"encrypted_content\":\"EqwJCioICxgCIiQwOGZkYTRiYi1lMDY1LTRmMGQtOWEzMi02M2M5ZGRiZTg4YjcSDPrqY4+KJnGvpVbHeRoM7BDPCfdH8UkvuNrlIjA05zfN920+7wY\/zZNwCsoT2tloGWvz5PyRckp1AGY3+vAJqirGhUOM2\/dk1Uj4\/Q8qrwhSyJhSDFqeaC9SKZ1ONKZaT4mD8UtvUHd9HSCp676i2ehb7OSKExs+j5InikOIA4dXVPl+NVcXEhGlmpS9h5yip0vUUliI5ZdOAmNPWXKRzjVpnxVAjFdbn7RKtAcckbDfemzBpekZ\/VAWWjlnf02mnkUQ3eh5Q+MCbUATdcdEGsfjHi4Mpjx1pY6d00udUpaTOZ\/nbOf++Hr5cnRwWTXYp+isoNW32VEfOuioJCwxkKFYbSii1uih+QAVN9OSMEadtsbZ5dMKYiwsIn12bcOJ1hKI5gJWI2CdgmrJBGK1mImKcj7wPW8TNuUG4ScWssE+502OS+1HKet0OeTXvf1XV9XPWpJkD5eDEFXn8D+KCTJaAzrFbWzgOn6iY97K0GKMXfbTAezse4UGJk+w2sywTGguVnSGT\/QIv3erssZ6AOzMsi5u2CAndPvJSWSXvbP4I+Fp5cEcJd\/3ukfqLIQoXc7jUM8m5qa\/bRNieNubS4KyxodfnpEj+hk06T+k7qTCGp9\/wvlVwqhjhTJYCojOiXiJFhXlgkdaOoqtVIUZ2GjA4FcmIb2nUgoN6puOFkN+GN2ziII7OMKB8imGtD4QL1XtBnZABiGJJ6GUO9lnyYgyM3QrbRmnx6j704Y4NTfkpS+8jju68XtvZregxarPJLd7WadHwcZwq03DqsCwqysm6ZpyU9TlpP5Rx54dJ6jXQqM3hcDMPLGQfudQvGVDSXI3NRVaBb7oEglk9Vn0Z7bBASuPo+IKX2w39qff9VYSlKLGJzXs4X6TFin\/aOcemOQdXki3mp9tkuXb3UTVCHHNh\/xzlWqCLTvEf5XF1KkzinRdHK+WDjLe2aX\/2\/RMTnt8W6jBs6lts4TrI2qfl3t0f5+FIBdykjlWKQ1bNyn8OizMI7hkqleRIgbaXV2Avi1WaGKAFlASleq0Tz5VeDM67xFsFYv\/I3Grn\/9zM9LxjnzX1zohPgZJozEuOq4OlLiBvVoGkNJCuk005ECyJlVlwFyCSEYl0lNqbYnIFjbzuLmstqN\/R+wkzSlGnQf7Xkq+Cpa4po8e3dc2QioCHu9k1KFAwRl0bVLr4IhY7KnQ+HtlA61bjCtVssBup1k13Sf3IMlY1SmRIdeoa3LlItw7mAYV5Xz+lb0ghjIiuImY9cAY78ovM6ODe7FIKEubum2hF5dJB1t2OCMvxS5JnkS2XVLHLKIHAbN73sfZNGizYwMAXOetNuAJTBq4gF1FqPugH\/SN8hs3gEjLGcDrnoqHeiw08xdLrJQBCyy\/ny6syWnPYsqTGZo0xr\/cUVNS6MYVv9qbkEKsFjJv2aRs57yDYwhuhbmgz63NFOdElusqrLFinL7+6tas15LcOuHfu6pfDeHtR6KZ6jev9q0kj+9z38Qh4CxQWJEU2kxBqX3Ju9B4WJCDVu\/qhvtrNE8YAw==\",\"page_age\":\"6 days ago\"},{\"type\":\"web_search_result\",\"title\":\"Positive News | Good journalism about good things - Positive News\",\"url\":\"https:\/\/www.positive.news\/\",\"encrypted_content\":\"EusDCioICxgCIiQwOGZkYTRiYi1lMDY1LTRmMGQtOWEzMi02M2M5ZGRiZTg4YjcSDFNq5NpzFviEaQDAxxoMkxpXIfwU3pF+5PlaIjAwE9IEzDWosWGmAF0jX3H0c2lNlMIXKLsP8uuxWiuB1TON3kjtER++6XgpcLUdY60q7gKlQkYPIhp\/gi+mv973n1VpogQz4ThG\/eiqFA3qnkkpxdudbz0ARMohlYstecCE7UX2veYenElIJHkFIE+Jo+oHGrcL7KQXzyinFhB9XeAyCcl8LYjR6rwSz\/HBaWkiUI5Cnn5CyX\/8sxt5T7CDEnRvvwm+h0DkprRZVMgLqgmc4cwEOWwUYHbdkApebmIGMDhs5vuKU9Vy+l\/ZAG9ddrpN1YnoCfZNKuikyQ2eQ8283r2RZRPy\/iiTTupHYw6K50kU\/AnISJ9+7qpxGIZTAhRIegWfik7Wa5EUIDYvCBWpKWxQTdxyy4l1Aybaa0w5teKavYTArqyOPyBFkyxKXbspfPmEj2YcVKkngJeFsNL\/cz0EJADhuD59DT0WibwJLNu15eQsrbGyAKx8rCdIMynkECCYYfmj3SV2pDl1hQZylShdtcnCDdKXZ3GOIFvJvJ+sMcv5ehGA0Ig6PoQe3+Jk+iHNTFAVAYFGlpjsEzkYAw==\",\"page_age\":\"2 weeks ago\"},{\"type\":\"web_search_result\",\"title\":\"Good News: Inspirational, Uplifting and Happy News | TODAY | TODAY\",\"url\":\"https:\/\/www.today.com\/news\/good-news\",\"encrypted_content\":\"EvgCCioICxgCIiQwOGZkYTRiYi1lMDY1LTRmMGQtOWEzMi02M2M5ZGRiZTg4YjcSDBsOZOGlt5KGKIBwSRoMUAi80id2kuQzOS20IjC6rerQFRMlK2cWTstg2Oym2DFHxqwqzVTLxnUh0m7XDIID4paiMhp47A56QIm1dfMq+wElIFws669nw7YXf0TSP4sgT9iqZz5wBVZ5DPk6Ps8nDiH23YsZFfaWIU3SBI3oMBimzNT5zRc2TmJg4k5fz0iqKip2im\/p9GfAZLCAdiac55NxDZBk3utpEbDhXoLEuRKpg2ZemNIFi644jFHJ7gSFvfhH8TJFZANPQQoD8K39e6y6HxZicB7UktQlVoLCoRAeTlTz1rG3BCzocE\/1MoHwBl\/1uxiyV+eNjEbAgTLomhkkPCYQDyDTaknsZ2s\/T8nNn9ux+Xfy\/y9XnNWtAccRsFtjzc7S2wlM8ylDllMZYXuGNJtLBbkwhupwfm4H5vi0Kki2OgQWGjIrfBgD\",\"page_age\":null},{\"type\":\"web_search_result\",\"title\":\"DailyGood | News That Inspires\",\"url\":\"https:\/\/www.dailygood.org\/\",\"encrypted_content\":\"EpQGCioICxgCIiQwOGZkYTRiYi1lMDY1LTRmMGQtOWEzMi02M2M5ZGRiZTg4YjcSDNeP4EU3ZehvqLjPyRoMGQ8uRyKec61LSZWtIjDFVSO+L5KXF4CsU7egkVpP51f7LZiHtes3\/igs2W0TlkUG2JHUdYoddMeanklK3WAqlwU0UVV7vYWC1pUz37gtN+MyNieF46T8DG0qrjFVu4TG0Py1vwpCrlZmPYSOZDiAIUyJJPNw1XRfK0Flt1FKM5BEinpoqd\/9CVFDb0Aby9Oh1j\/lUqEhBzY+nY6vW2lgdHQFwDWFlZcwfmbHc\/wy\/pC7XYb+STkuoGcXfIMlnwTuJDdFUgBX0g+OiEmvqW278q+J91PIrY6Qv7MSz+Ys4BVDU3WnwdTQ9gMZ7GZNmJi7qMSERU3Q3wn0LWwPIHpIA8a3abtYkwRxGUyanuLg7qqsmQDSKIOE6sOtm6TLAtuw+\/i\/WZglSQFIp+i\/PWGWJQ3VJ5A1wlMny3dIfZ0ZOLbhDuO9VskcHWxBQm+5SOmor6drFY3zUkgFWW8X9s1jLQAcQn0EqWGh\/dHdQPPCQJVkku\/wwNsOic1GFSzYvHyPjqdUonKVpEX1v0QIb2+2\/OtjqhgpvBOn0OtKGMclIkjNxbA8eQqfdi3udQC4a604aFc0R4nGn6HIQkfNznwVincputwOhqh3wws5TzZi\/uwbmbhMVrsiNtAp4vkDzV6aaOuxKYvGkAYY0vBFEjFNoHiOfWMlaUKuhet+tHUVeAMEfgk6VvV36TMiUvKn0+M\/ktM7GtPR8XesjM\/vuHyR\/0CYYGeAz+45SH5uMdu2\/vXhwzQse71B8LS7N8fBEZ48pOtru+fORYbAXfpd8RcU0liGLdy9MjzZqfv9j9Iip3oAcWOcbgtfa6CBzKEdiC9sQO8HSiJifDnwvaaTCm4qfZNeHSwI3RAx+FXs2VgnQPRFSP3NhPyuoHFhpXszX7MLcfJowdLRRPmzPheEkikHqHXz9G0sUAM7BKa1vWi51lU5gyIHEY+IFycV7te4XUxX+JrDVme7pNsYAw==\",\"page_age\":\"1 week ago\"},{\"type\":\"web_search_result\",\"title\":\"Only Good News Daily\",\"url\":\"https:\/\/www.onlygoodnewsdaily.com\/\",\"encrypted_content\":\"EpYLCioICxgCIiQwOGZkYTRiYi1lMDY1LTRmMGQtOWEzMi02M2M5ZGRiZTg4YjcSDFiRednjFUm2R6fBbBoMdmL420i4JaOOGB00IjDPHMRUu8RPj1YPo475yOJSehOreA7qmqKY+JlalLNV6Y5NlGEOoqC2JnT\/T4nwt4oqmQofDkek2cp7fS3Rs7\/p\/bQukd5\/afRK8iRbJvV8Jou5SKbWDYr0vBFXS7qCRwYGA7fjb08KXERKfi4CZbj+hkFt8+g7rLxTc5CYLI9XgLOcuEDZaXUnX7X3QYm9hKFFEPC+dzZ2R\/WmvoBfngXhol+LDaAInzHNuqgcNQK8zpwx05LjyiYZY1tZ7NxEiCVphsoiEkcriKPOULsLq9Vh\/AWlMWW+Wcm80tNMfkYkzNdm\/CcL8l7eVqrpbVZmRHd7KRwbJnzzIFLpKgVdfSUbwvjp2b4z\/raxMdOyZHoYkkd3BUS1kIKMBvJkDJ2Ds8U7Tf4PfPRPecDcwD7gMchzcYRNbpgy\/z6VDpVOprm0IXEb3Dl6ipMEfEeEaCFmzJzyQKs83yUHfsK44R+oTnKuOM4F9H30enfBBbnMckNpc7gWx+XFkQnsVAa3oseXtrFXp6Lm4A8nXxQIheKlZdFMoTtPT\/KAA4HtjNJ37XwqYAXsMPMvEWXbOQV5g3rEiq4Z3aN\/mTFCGFoplDC6WrwWQREKOyTeYCAPtTQYa9cBvfyVS9uKGiNCpD\/kiCYwKC1PeL2tSNim+SFSLX6I2WkIKYL+XU6XSrOYrsFfIQ6p\/NpxdJWuhoMp5nPXqlKaCQpp6cq7yrs\/5yrPTjsIDqC\/i2i4GaBjW0UXzYn9ih2ewgQGWV2s\/jiGCq99Jff5q4DGTnkOFM\/ylEiGfstBA5IFYxKdtTBFvjexx5JxzLVe5GTK+uQdn+zbj5fDa9sf4hfErSOiO6z18+F1MnoUvqvH\/Zbdm5xscAdJY32dspnnawvBi2FcTwufUbY\/dL07mx7Y0T1CA9Zpr0DQk7gVdEj71zH93mcDIzRtzywkYo7cY4eRsMUHSgAXu4ToP7s7CKqFl\/B3Qec1Kkn3VkGUVC1oVVWJUnTDOTLsVIV4egxOrBJThq\/NF48GTjfIHOtOwya4OOzT1IIq1bowJjgIiCO1t964FamlRvIRl8PhtPkD7B5q0mmQzYKydEMhk4jXvOfcGyhA5Y957waEVIeqY2nBUBtWi0nf0flfPBE\/AUHrID1p8gVKmtOKp+rXhI8vLEiH9N5gwzVudxOQ9W\/wXLKuv3nd1XKTImtceDysHx8YuMgxXCBCWUVTRLVSDBYfUTqPV185XpuHRjkrff5m1V5ImhrQU1FsI0PWXjwMhjNQqVAfIOnAnmxNwNZX0TRIB0Rd+ICTgH9HNW4GETKH5vWC5AsVO0S\/lYKplsEgpdV0L9fAax2avINtmb3xIRae78RO5pssId\/8tXBMYAsHNvuuOihBfFd3UwpaujY7eaf\/vcU83b6nLlpWuGNL8GmCG23n06C6NrPID9dVt9d098LN4AFl0PwuLttxQ0mbltTqEZmzGKPjoHYGCpSCX4qVFLx8v4xl6L4ltDeiLWTJEIZFT3YjTQS6SZl3m+bGA2LeHCjBGjW\/\/pUpalj5JLKi3myAAEFLWefw5dw+PBMHJgPqTYVZS2a3OF2VTZqfEhfXjqD24YzOqPTLY2jKH+gvR7vx\/9Iglxn8zh4ugtXw\/mLk7\/OJ+u3fHXQZ84Jlo3ZQ1DTk8qnG0OI2hyU7UsmfqgubsPlWDRELSYmngbDg1bMgbRDTK4DKvuBpzwAXslbvRjaGYZXKx6daqEKynfzvXVxeq0MLR\/yD6LqEyW0W2uas1NtTO7ysttwF1K9KE8V3ux\/ajY3jDD3CGbnix1ZsUfFOixpqhUajnj\/zyRsYAw==\",\"page_age\":null},{\"type\":\"web_search_result\",\"title\":\"Good News - Uplifting news & stories from around the world\",\"url\":\"https:\/\/goodnews.eu\/en\/\",\"encrypted_content\":\"Et0GCioICxgCIiQwOGZkYTRiYi1lMDY1LTRmMGQtOWEzMi02M2M5ZGRiZTg4YjcSDI9Ea41PkeLMTKT4CxoMGsyRhrKEXcrUIuOuIjCDwpX2\/UBeGmvrYhVwLKGrDEM2Bz1Qm3QVK+BsAwTf8dVdneaPJB4yg5XXtCEUE9Qq4AUMxzAOFJQGgpPkLw8rHDK2YoikktNt9Ww78EefUeNqrLhVZwCs1CVnqN9T5xVXrmcPJzhhqHBDjPtUgqtf1fYd7Ocy42\/GYMvD8xJxT\/U3UTaa2bIl9oJv9Xzkyjv3lNgD1eFMdiHdoZlGgfpLStZgdZ2uT2i7d2VMx0ulDhJtqhIHvG8qqtGrdXDkGW15qsYQpuDAwIbkQBE6gXoWOoJ1O7tY3u7lR54lBx1GZtuBO0sWfiVMkfMZX8sIkSij9BDpVBr0lPHRZJ6Edixzh7ZCjcS1ekpZr7asrg+72BcsyHnQtKaAFdo1I1NTjajA+KQ+BdO28+Jnz5X0VaxCd2PNE5m4G44dLl7rIz3s4WaxcU0KZnaWnvmLfA+YerTOP4VdBRXXGzmoaLy+tElUZf7ghCrmHLOawLw9SIrF2Z+\/Vav\/IHxVg5WueKMz1Utpg0WltkVRLfrLWteCFBnSS0ikRUVT\/X5n3+KmjX5xMyDkdEcl1uUpiLOyMCVtjaWZ8NYQ1vucW0jH\/EymM13eXND8QNZl\/SlK8CVHXr4h2rSB8\/PU8ClhoxWEF7p1uRKR\/p\/exTMi24Ll7st8HnWVcrK1AY6UskxJb7uQclj29WqsOkvfVCSG\/48SVIDs11DqNEQuw4L0QcCUadZMTJnRkQkzE4IKazt0PeNx0B57U0sLmOfrenPn0sjRXWCEtvIxhpFJ7JiUDcCP2j4Q+uOYhD015MZTQdTe2i99CbHgwq+59vxp4jlElNK1Tf7toH5RnEX7c7ZuzNkT8Ak7Cp75oKbp7rj2n\/p8v3v6j1wPd47igmf1W8kypsFK\/J5yjlDYBo0GRFROPkVTQj0EFxFNCbho4y80ytcbZehD9pS\/u9xzjDQjGk7XLrVdoT0duApbYWEgzo6bRh7+cxZKZ9z7UjWNqXlpBExtOY8dzPtO2sDMc+XA7c6ibTsaawGlMopo3qv\/vZ7UUJgDwvEHCAP1kxBXGAM=\",\"page_age\":\"3 weeks ago\"},{\"type\":\"web_search_result\",\"title\":\"Positive News - The Best Good News Today | All positive world news at one central place. In English only. Here you can read the best uplifting, happy news that is happening around us. Read and subscribe\",\"url\":\"https:\/\/positivenewsfoundation.org\/\",\"encrypted_content\":\"EsACCioICxgCIiQwOGZkYTRiYi1lMDY1LTRmMGQtOWEzMi02M2M5ZGRiZTg4YjcSDGufGCI4REPbuTn\/5BoMu2NXcB0AoVQmBzBjIjBtm9AxGkVwY4EQNQu2fK+mJ3mtzRPUshmnRCfr81oPlKNA0AoOtuTDiES\/t8cRpEcqwwHauPDNrgOYGoxndaV0HnTCXXnNUiW6V9rOE5nojGsImdXrk\/j3U0vZ2cmdq9FwI3XB41eO1L3cVjpRZcNI6XGx1wNoSMx+me\/88GMUrfHdbPyylOC2JIEVmeP7kgNuyIXOLrremJL91uUj\/k+tkG32\/hNuGGuVGznwhGJEv15pBRs+548S++r9O+S+ZZNKvn2JcwdPimkbZRthBnraR7uTD4A90OhT3KZwoVtGTtGRUWK4gWQGrZpQb9sCFv0XKne7elgYAw==\",\"page_age\":null},{\"type\":\"web_search_result\",\"title\":\"Google News\",\"url\":\"https:\/\/news.google.com\/\",\"encrypted_content\":\"EsEDCioICxgCIiQwOGZkYTRiYi1lMDY1LTRmMGQtOWEzMi02M2M5ZGRiZTg4YjcSDF\/q\/1fDt2qbkB0rIxoMMqGT9HQVXNccQ4BQIjAfnWy+CyLg00P6dF98sPm7HV6CI+vs7LoJJko7sH2q0mQ0\/+wCTx\/rxFZYQtfpJh0qxAJ0rDduegD0vvGDx+YelVZDSKkYShfXCnfnWyUkUcuKDAORyq6e9sGDfI4qJFUZkIj+22EJq6zOIfSkGMeMY83IwCYRaEnxmWQv2iXkLC02S21VULRoaZWz96tz5NKYDFe746iL1OBZ7oTHBxRopl71aD8pe4vVHUdT7qlAHjO+ADAv+kECUolOjJ46vDcOe28Gp\/MADhbN9\/OvoUrRGgkUZoWmslE9utMmG29ZVz41f5CB0Jl7aBF9+lha6tQ6kmCtb8BSEdXbfqwpz7mpoxh0vj3y\/21tq4Paci2NYMtYYaxb2fVBypNeyLUw+Yak8HLBEPmXA91UJtaYOFuwvr8Cjm9rWohNSyQsaVF7wlwrD0l6GrmBJE9WC4\/uW0mSre11hMG5Nkb+9mlYA160T\/Sfolne1gy1wREMpnaa23Wa1OJEEDQYAw==\",\"page_age\":\"4 hours ago\"},{\"type\":\"web_search_result\",\"title\":\"The Uplift - Good news and stories that lift you up from CBS News\",\"url\":\"https:\/\/www.cbsnews.com\/uplift\/\",\"encrypted_content\":\"Ep8LCioICxgCIiQwOGZkYTRiYi1lMDY1LTRmMGQtOWEzMi02M2M5ZGRiZTg4YjcSDETbMsiBf1kjKCqBNRoM2OnwmECVlF1iu+SVIjDad4xlPFVGhKyFI79yJnD4TL2wfa\/8MUT3+Os0V4\/MZgMqHmNye6kFFTMhuPCfcXgqogqkQ1z5520+TFsYFv1SODxQ+Sw6LZiyk\/tT4nFOpj0wnehQPeOFOJS3KcMQq3oah82P+d+6Z+j0XlpLbuqZO5GIIyHBmfHZJuLdEqegefDmQLhmaiKRN7ccmCCD5JEKbc\/y3Vs3xznpvzMuZKIo91TaQ8f11VjefQABI0dGRsG+yr9ZtXjrk3Fgk7kLvgGSnD+fxS9gljtISatozX4n0fVPRxTLHILbSRaOjZhBQ5ZXs8yiOFIa3ng4OgUrCdjVYxOwjmL7ZzvuwAJABW9RrDJ0l+NTCcKRjGJpoQtKLilmecMSwLz2VjFDX2z8QAbjp1jMz5l4XmzMBrB92wAMdnsc5R\/lJNm1Mgqa+3vJIiOXqGhntOQNSRGgEGtudmbuimG8yw6uDx9oJtzBAnaNCuyUTfkcCZ+c+5fBEqNtm+KMW\/+RRg0ptPe9KDQGQDaFRB+YHaWyrJAwzTJxPdbXq5l2MqU7vZU7WVL2QGk1PVHi46SzywuIoXfVuysfyeh4E2N7qnup2ev5Z0Z4MpES6g7TL8m3wrrzk6Y75nSbTTanrd7K8MgFbjVWxvjL+Ba++vmC67gV5KSrnb3cksVTsGWpC3n9+8sCOlGztNKzpfYbtDiaag2K73JA\/WJdl9ghOe0DbhRwRVFuJ5uMq0f08JI3pHijo9tGQ5UZ3uVPxWJOVPp0jHiQj7T9DPQAC4FPa4DRIRvvSozgVGhVG0G8r\/2Mbfl7YX59Nbxv9SYLBd2EPut85OxPqdtc4UBn3yeYaxHTXhdCAgsIY1ev7DsmuzlncQShCRxhreCoA1ueX9ChOk7qxYOMVz820gG+PEbAah3WTK4WNbBGH8b6sJXp6kU4w1JmnxDIhnnhXrAGffh3XC8oVly48I9kMotRsJDQvkj\/o84PbKYYkCj9F9TS38JTf\/\/7AGybZh7j6+jlavtYsMatyYV2BKPRRi9xZq1Ot\/URkh6qeyU2YufKkJMDuBE2J2m\/e2OQEmn7no4QSvq1BI8ScjN9p0xnLQjIS0gVaF459e9hkJRnELXnF1\/E9kCV\/jkgId+q7x1ain7opTD2WS25lWvgBWQZHhMkCIychvZsp2\/T0AGz1IdHCznX1PxRgZ5PGY0nUTaWKb+Kl39uQ3j0ECbnkWlwL8pa\/WmcKE597ByoabOZ9+FnWbN6GOinLbrrHSgtYuchBPA9Z4PIz1M0uLy+DEAL8\/6cQ8mZfh1BBjsJ4wDAr1HQGgEquNSASTihsKgIgVXLrY78O5uIxyWgGFLeI086pNZOZmII1zT3zgkgdXSv+0oevBSKqkxJhe63dxOGs+fPaytnnL8DZ4VDqmsTBmR+cVtqCiakNlBuyzU95K\/2ZibFgiaVeHS1pw6xIlV9ft1Jj6avMuy7b0YS6KmWLyhfYtV2XK3wj6DDPcTfA88MHrJiIxj+jCDSyYPTwTkqWJo6cBaHX4Z7vE2jWAw41\/qLar4O6rO2U961AZnmjrW9kY97tAlzV+lJwVG+\/NDIYj9RyBHg+Gci451ytqHxLKVvU\/o0LudAef7KeeEX00u7eItYDHLvMnJY+ZTiyWlsLEJVmZd16aKBuT9YknS5ucyhf4GwcVKyjIvxV6IS+rRExefmZtXizyVq24laJr2IIyZPW0H0fG2jbvGOuinD+0MWtfcur2IluyKBEPkMSXuQ\/Z7zTU+yQtaaYL+dCaZS\/6LioYNnCiGHxzSF59\/yUO\/\/vsAvfZI0Jy50\/AkrfcC6pgFAxpVJMhDnr8IYAw==\",\"page_age\":\"1 month ago\"},{\"type\":\"web_search_result\",\"title\":\"NBC News - Breaking Headlines and Video Reports on World, U.S. and Local Angles | NBC News\",\"url\":\"https:\/\/www.nbcnews.com\/\",\"encrypted_content\":\"EoMECioICxgCIiQwOGZkYTRiYi1lMDY1LTRmMGQtOWEzMi02M2M5ZGRiZTg4YjcSDNGgxAogy1asDE\/HtRoMfVdA7EgBqFM0NNS7IjAsiGHp4i7Lrt\/t6YZBAxgqrFyeYiJvBNxxU+OpWF31k0tkp289ENH\/68YfFiylEE0qhgN+xCf\/Fr8wPUdMjuD+gESZIck2l6VR6f86SrLVoDsvbAA\/q\/8JOZoa0QxVUPIURqRk4940BohX+opul4PrJJuc4Szchu3j1AWpPmv\/aHx3kRbPqJxQ4QQT6OZ+aZmMI57D1Kh5LUM9u63NKwRu4f+Hi9mSLt3pQ79PdFj+wnRQxTnt6IdoZ1iym5LCIfgFpe\/cPFEbwOFpa4PdAPbbD\/wYoRB82fTr07hOKdFc\/CLSRzogPyp0Z0qppb9l\/HRkLtVSRrFvu4R9h6dCTvB7fD3QPMKfnzm71JG329kGqztIVimGYe0ehs14HwCNkEtoPincR2oTdcHaG2xIzxXcu0rj6gS5lMl\/qJJnT36F\/vhj7QnsWOjdXkYuRG1S4glgRTCUmt\/Q+Ee9W0YAzn5yjLzb9X8F9ktyvszaukwm6Az5ECXwmWOqefm44GkfbNE0r0gHEuI1iCkDoCC5xrcqSZneezA8sH8OFJUQu3PwjF3EWAdYaPk2VnCRUfcsvvj41e7y5B0Mvj8YAw==\",\"page_age\":\"4 hours ago\"}]} }\n\nevent: content_block_stop\ndata: {\"type\":\"content_block_stop\",\"index\":1 }\n\nevent: content_block_start\ndata: {\"type\":\"content_block_start\",\"index\":2,\"content_block\":{\"type\":\"server_tool_use\",\"id\":\"srvtoolu_012NFYbjoa8MRzXouQ2uxTRs\",\"name\":\"web_search\",\"input\":{}} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":2,\"delta\":{\"type\":\"input_json_delta\",\"partial_json\":\"\"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":2,\"delta\":{\"type\":\"input_json_delta\",\"partial_json\":\"{\\\"query\\\": \"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":2,\"delta\":{\"type\":\"input_json_delta\",\"partial_json\":\"\\\"good news D\"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":2,\"delta\":{\"type\":\"input_json_delta\",\"partial_json\":\"ecembe\"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":2,\"delta\":{\"type\":\"input_json_delta\",\"partial_json\":\"r 2024\\\"}\"} }\n\nevent: content_block_stop\ndata: {\"type\":\"content_block_stop\",\"index\":2 }\n\nevent: content_block_start\ndata: {\"type\":\"content_block_start\",\"index\":3,\"content_block\":{\"type\":\"web_search_tool_result\",\"tool_use_id\":\"srvtoolu_012NFYbjoa8MRzXouQ2uxTRs\",\"content\":[{\"type\":\"web_search_result\",\"title\":\"In a year of tough news, these are some of the stories that made us smile in 2024 : NPR\",\"url\":\"https:\/\/www.npr.org\/2024\/12\/24\/nx-s1-5235667\/good-news-stories-2024\",\"encrypted_content\":\"EpEeCioICxgCIiQwOGZkYTRiYi1lMDY1LTRmMGQtOWEzMi02M2M5ZGRiZTg4YjcSDB4hs6Y0C6ihV2l+khoMbKwOtdyl0XURYvnJIjBfTkRCw45cmfafQlLqvneVOyz76Vq0BqewfEDEzxLIdiIOWoAWD4vSLtV5MKpOs7wqlB0aX5ApUj+zfbbKLJ49mIGX\/kZU21BW7frFAE6qNkNWJ++BOTRne7UV8BzBveAsQ34jm+vxZTMHvCdCsedNYUBC412AJGeqdB\/GiO613QKLAU\/aaytqnExZv5Rrdsp4u5IPZPSfpF7eaBTzr+J7OleeV4fjhrvlu4gYUMOs17pynR2goK88eqXgesL1bXaLdxLw8z0UyQXoYSzXH9oqi5hMGfV0PyZGKjB44uqetUsydnLQMQ6\/Eu3zfY2nPLS\/y6\/Oxtm4twmZlKYQRt\/hLFm8V3kSeLXRILXHkg5MdJdmkAi2Rw83OCKlv+a1+2ydJlt\/OSouf9yQp3BCjndN4GxoxOzeA64Bv4SY8cr2IuNPw4BW8E2ItlPaTu27pnEvt7\/5PxJi\/gcG+4e1jm4nDLJ5tQIVrHEodNuCwwrqnAKFritAd+MKZxxjrCeF+mpklo2XD4BqdxyWJV0vsA+CpEWAbZnC3xvqMPH2SrURpDs6CfXheC4FelxtpCrdHGNzAZoiuua2uFPM8Qr8Kno\/\/o2iSKst0xiHd\/0\/UCIS\/6G5MCV3blzJv77f7xQgwCVC1YA\/pN87akkYgGc2Goybt4Kh6CPaGvvRXeOqM7zhez7TffsK4gsUOByEG8PNSkA+V3\/CPKHMlvdxMQ6Bd7lPDLRgeRy+YyEYtxUMyRo4NJ0sdEDcOWj8O6ECScmHYSJNRwL5pfKKrqoekMFrh4gjrW9KeupxTtEKZSXy\/gu8BUx2zfd3ruPU2k3gPiO68Ly+d0lzjjQVnmTwjTOgxf6K9GPGpuTgthpQHDqJ+A0XsXMAUU8qVPzziVLt8Y1G1Bh6IHFeCRvNVbWbyLoQZ9YR15NrcZReZVeoJhrfWjqNpLVoC+Na1xi5X3K+H6MuA6Q5JU\/\/iYwpep+e6TtoEpEB523vF0B6oQiNw7sBaoB\/bMagJMuLkmBcsPgNxfqEFZnn3+ysSjiDK7lmqIJDl5HLQB2V\/9cQEBQsiVWTOCgDHVTTxmlh\/kjFU1jEBMkI2kI50pSrGwLcMoG0DRsiyiLCnFxpXxlXqEgA39HjHswMf8ahq2rbxNm2CONOCBg32W86JIvO\/PHP8ePTt1hVTvaB9+z+To+xua7yLzb2XTTnX4hYIP1F2S3HIwkUh9Ka6UR+1kSjQOtCI6CXo3SouPNbbyzpnk4WkhW5tyt2Yc3tUJRr3HA9jMazNhZE\/plTTgDEk\/W8bJcyOPzY8hkMKhz3FitFGeAiDjlIX7s+SRZ4TXN5ZdQT82FrjtgzD\/ZAqnJUJLV5qy9mmqFs\/yp3ytKDF+kUa4vDX7DgX6wqdywGhTkVsPp+XK9fNMJW+OJHOcoMeSKMa7aduIjjpOdlshTCxlr8mbf+sS9S0DQpX9O7iEnvj4kfVvDyXENF0TN8i5jNZnojWcHfqbk3mjpbEx2LXoz5heJrWMmqN\/uYQy1Qq6qdi+DozEkLwH2FdEJUAbraTXGfpYOyHPQ\/6F\/COdh129rY8GTPA98sH7rdTpMtnpw+PoYW9TilCfa12a6opOD6a3He9KprxLNDz+TEaOESyX4W5pZ\/RAFJ93fSNeUYlWEFRg4tRtnfayuddvlZ4Glqipfcz6S4BDNGOosBwjldX53V4ytCwE7niri9fPHvGgb9Imkr+9IJvW\/\/XbvVE8irZLuJ8rUixPiyC4SEaupJAYpH86USgY+fojDZ9vp4h\/anCD1+YRQ5A\/RR30xsGaJn5g0qogIZqkwY2BKQcaQJuYUSCC1a4bUUGc+kFElu3jgjn3Tu975\/ieIVa8AzMia6dooVeIXV6S128BGWQgno0iSgjsRN4xxY6upahiWveLgt8X9KeBb8oFFBu78zpKKZQr1DV\/9A5LY0o7Nwhx2TflJ5QexmkKPywVyAMN\/Hgjcmu9oJCKkUJ1W1gWD1\/7BwKUs8w4SxslJpqXrEfb5ivjgNhk2WeyGhjiTZKiHm1PhFxGXiwHwVdjXJqndVkGzF1WqrubpKtSS0yQ4kKMAm6iubYR5OTNJwQkntzFFpTXNgbUucGi06Rpe7pk8lOkCSzmMv\/lVVihU5RkUia16HRNe1MD1cZ7fSvv8d33K+KfTXu1AYuSeD2jvMBOpxSUketX6\/WxeRymEAqNVd\/WnRMGAFupoOhCqY+Zi6EkUbxrD2CUXQwF\/bD0zKrWPsnjUWKmEB90nKRY1UCjnxVtlDOAhE8dePDuXAV1Qkj4I\/KtOyb\/L6hh+ny5GxkZIXsTbCwRFbm6kbUXb15dfLddDNPF57VoLDEBBSmlyMN8+euQED+jUdZgKdgfHtKpqSsPEe2m8v6aGi9HyxpSPQBI51y3GWoKbLcT4iZdUJb9VpixdqUQez5E5BgBunObX65JdEf3h7MLLgdoFlVkJTVciOirtDTreQLU2kE8EwpVAB0BIx926yUxoGFc00vT0UcsqsDlEHaLfbGM2aLaovy+\/RzsA\/dTaEU+reUJQD2twIlirfVfbXWsfG2CyiKvXogbV3m7o1366bIX1VAIjiE\/lQkY\/JEg4WSkqmxbV4bQgixAMrvkVT7aFFus6ymI+aQTzMBMv2S\/lrszox8eEo289losm+2ESYXdgqq\/YC3LKyO505jxR5FMe2UjQni6+8\/HSxnKK1lsRvfC7eB9n0DE7oYcSfs5R3aeKJA\/P5jJmM38zmB96u0dwx0LL2tFxCzJzF2X441qcmbkdHWEArOYoGCNwiKEJl3UqNpTafA+RtnFL0HQ9VY8oh7V55AAWr9uxTYGc2f7nv4cn\/Nts4ad4Aw1fY1QShsES9Gm\/fLItE3Ij+VhobmtxLGN+hUq+3cCmWXl\/soUwirM6h95GU4vr6IGIYiCsvlCFtH+tgSbYsUbbwGqCDxEvc79gY6fFeWFNMdf6VSUKMo5qQMpXNDvQtlWX3y6TmeoMzcismFRtC7FG+0mWyjMKKZ2vPnnj9WfgxlbkLF7c2sYNl7w5XVeTL0a4CHp7FG5lf51wq0REXU1Lz1n\/vUmpuep3okDvsoD1I97rITxbCy\/BZDTT8CH5M2MdkFMEoobNcfdiFvffReYzBTfTcqzis63TOwJxXG\/y8W5\/JXlFCUhu\/Ev\/BQKwdoIYBZO151+x1Y2h30NKC9WcOT\/Bvp\/pT\/7t5XLcWiZVq9XNr95N3yKcWHzZcilxE0LNCxcq4BH8b5ZSoRtAKURFQ9sGooyuf1yyjiMbAA2foyb+nwcHNK6SITpPPUUR9DYBSTwoyrCp0m85F9fjr1v3nah+CnKgCdEjXHjqf1o8Grwrgtl8lrNtpUt3SbS21pOsk6+cKx5kAAE++Uwkr+pRzlSxJk201pCAb7EdvIitXuyKUN\/1tokyg2nIUQcGlYIFEorTtQ\/AvkvFMv\/7Ro8nn0a555\/3sEsJngoGqz9Qatnw+KuufKK5lFcDD8Z9W74nPhC4rsfasbK9QcHd3KurNJIQJHeWxDS\/v1VM6EJs4KrZm6kQJiXU5eymqJ1dRni1imHjXKI2eoqsbbdau5C4cXRlJO27wpuKFA1tpgjYF6ju6hoff+dcWIqXk\/tLs+66+rIEijDfZNs8E3IAO1gdRt+d6zvpylLTpzAna2COTTp+8oAdSPGeZuoPEsc70yBJLJpgkww9TqtAq8J1AZIZ54j19Xvc9O69cg8I6mdE0iajkl+wvz6Q2JTGjdMvL6YQogUq9j4xIl9kJhuWzwfvQDgBEqjYPZnulNHoDLGq0\/dG8wOYRjJdOGaDa+1J7XL7UBv8yw31gG0WotyjC2rvh9k2xubaHGnZrBSDdNUv\/H4fvl5oi82QMRStFSB7WnZ+c7DYSTEo7ItCZK1Se\/qyRGBIqjxMvsEFxK+GVfmKT1DcRsx6O9zOAkFhc2HT7L7E0F5Ng9q3GaFpq9gwZipQNgguwQdv\/YBF0\/16p5nUjSl60HnQcjCjG0OrwIzKMKakCKe5y3V5xwEaRJmvh9bo+AjRXYoizBXZW\/WAJeGwzmWNrEwRbDbqS3tu9dBkkUZfj2Ks5ibC13SXyDJ2\/Paj+0pMMtDFNH3yP5YKj5LqFVfV+ussEFgWvRLwtrTTaiHmT4DdrM6XSDaIGaPV5nonykNX4Du0garif6d6hCj7pbV6GNvtSFIYuLmjmXfRzeAIM0dn4uYGND7iYz0BjAMTos5qOL35Nf\/OFNZunY1ksIAzexLAa9gyWzuDxkON5Pf369m24Y5ZYq7ufokoCNKglGO6GFvRE8EuwKByNcjRRKaXp\/Udgg\/dQgqqTmbFARe8dEevW9+f03JJjbHrx2NOYlY6LLv5fAeTsRnsNe1UKWOCvXTEonXx8FWQ2B7IAho8JxEtaoRJPHL37PrYNHJBPxr\/suqrMJNTerzaWvWe8Pu36FDRwDcqDTVa4A1EKbSXCMR64DVtbhbrqtYh65uHZLWSkcJHK+PffUlym3t6MpeL1QbjAI12lQkqBlyPQFzrbrBx86tkm0PfaXwAx6J7yQSLRg+jD+a8KwhUcsI486PmFltf1QhPKeepO1Q7AYSR2JzHUGNCAXUMQt5wnWjwckFRsW4U3XPeYQhaiErLvJKaPqu2x\/XlAUpGF5opbJ0xXZjwYKPgH2oyFTdfulGI1PIkggeCUwaz9rX8tZfmcFpT6NbX\/QjMDFEiDiB26B8qXktfSsV\/U2MdW3If45NHrvvmV6Qu2hZOQ2tInMVpZbSD7gkcVThanfo\/Sqs2\/B7jadXZ7dAZZtJnS6gozq5Or5ET\/pu424GOY+7lqEmvekmVv0h2fjk23Fz8dceWucbxsCjzpnEP7vuhTgvVpuaC1gxvHTxHehdpPUU3CNPgSUn1IrcxSmOMdh+a+HomtAfElK536M7lU7X08ESdRwksbSxNmEM9FNp8q9G5SjCjoSa\/kbsGHPl0pXhrQElFqKNoTcjdFbbR7GaldddiNx3SqNOYCJ\/QIhuKrWCFrz20FNBmWQtr862a\/\/UcxmJVQVjowaKpOD+qzwaVYkzxsZ6DAr1G7Jb4YAw==\",\"page_age\":\"December 24, 2024\"},{\"type\":\"web_search_result\",\"title\":\"What went right in 2024: the top 25 good news stories of the year - Positive News\",\"url\":\"https:\/\/www.positive.news\/society\/what-went-right-in-2024-the-good-news-that-mattered\/\",\"encrypted_content\":\"Eu8cCioICxgCIiQwOGZkYTRiYi1lMDY1LTRmMGQtOWEzMi02M2M5ZGRiZTg4YjcSDCiexNby6zy4ic9WcxoMmtDeJKAC03o6u1PkIjAWPxd1fXfDhfWCf4hGoKZzsCcvjZDkEmxxnD40k7Y\/R0UDVkgNWQ2vyvDSNNy3qLgq8htEDOYYFKgilSUdgGxxnZql1EjrCYOwKXdx89cgm1DnKkNCuCRUL7CEG5mdV1CQSEroznAqdJn8DhUqutTV8OsjI0CCDvNwiKePE75LJpE7EWsROFdBj1FbuZ3QMbzR0IRMOxxnCrvof\/d+Q6dWvm2Hi7KV4RUeLvAydBWnUFgxr0qXQFrSotnE8ka2oM+BKhpfLMYFj4J8Nc58idNfuWz7zuSX\/iCsb0dyJm4gH7kzlMtVZuMcjDuoIgsq8oUHnQL\/ar13My9lcroFUThtoDxmzRvCmxJCBnPSOuJXSIf7T58jkE0PQRjv8tpJo2cexLs3FY7UFstjRhtpfwRRM3cHnGPGDnTZD7YnSXJiubRrx+1bT8WIvRq0qJ\/Cp\/TS6eTMq+2kjPaqunRTV4rdZ668YK8JcotYUiIdeBhKO+RAuZytEscQv6zQvkteZeOy3fg8YoaHdnFpLYx2WZPM7AoLuJfEWdZqUn9WkFJwmAe0Bmqiz3NZA2PVmuqO1t5W9J+syDrHjuj91Ns1GjenKlUk7b6iLkjD2oqzWEVAhsbY4NNkMLuioRAuoi8BiP2J9h7Zu9ZUyn5\/2CA9UrpH\/Yd6bV4B0fcS5fih3\/pvjfsd4I3vrMa7Rlfd6mxnELdU96VsGGV60JhMUBff3nc36Cj\/cTfJ86MVcaUdRkwRwfOq3XQT4yQdPokhVSrfExekppMkfFWxBRrNC1UmQ87+xhbBJJwuLmpFVDK3hvcFhijWvz6vlN9pT8Q1tNx9SJxEtW3KnPOgW2hS\/Ulv0vzl1PVpEHRwDJk+eH9N+lbwtR6C1SRzlYkom7NZX67uPFiqjHlonM1d47RniDdLHl8l9CJwyJEL6gxGjP37BKLLUZp7ifFHEEveyvI6FG1J5ruW7Bbt1rFrJh1o+di6OECE1gyJz4nbjV3UwKkybR5mVd5+Suzy2dMp90s86WGiTQ4at8Xe74+8iZX3xr2LRnObtB8vI5NScwFm3ZOHRxDL09ehpmcPJC4w6YlEljnl34Xm4V5M0VCBp37L1Bsx63wRx\/DklVZMcLY38DTcMg5lTzQOHDWgxwzTbBXc8qcWlvEOh9ht8QBaUs17eRzQLriAGZSqvZCw+p5yrDR55au945YSM7zJXQkpgm3mMQodOmYzTvUf824B1su27IANbvmMOhmKrCMhT5WcUXo19YIOKuJpJ7oHRPshH8PebE1zjnHvmxrPqDA8pUKsd5mvbbtNdn7dmZIuGFnIKgXCJVDdmR2zCv9xYpm2wFxS\/HHr74RZHXPaok5bvITDls\/Jz6+o+k4qFLgb8xWTNCWuH3ojLWG+gH+5A2TLWmO6TsRIVd9uvtedSWMJJvJiSb8L5xwH\/HUd1zkaEEGzHa3gOucxXutsnmkfSnmmshTGcTvS\/dTytQ\/G5Q+5X5OGtLCwgYsuswmGeSuQa+VdhaEA7PI0qGtbISnIFt8WJhjjDYc7OdG6miqhR5Z9MIWnK2+fNSzhfPu5fXd78hR73CkdvVeD+KwM3xTbWnJhCkNUmG66UPkW+CNKrz3KOZJTGP0p5mTaQXzG2HNVC3A2BFFUmL9xiuLwehcah+maHqkhEKVDqwTbLnmoAlZQ4Dcc8TsGFhyGwAN+vvswTrh8uRQQsGA\/lJyrZ1o5KwrW+fjvFvB4zu8tLXTK5Eu2fxzP62QpbTcHcARzGn1d3mLG\/7xCsu71XrF+MMZFI\/yRF58xAclgX3e\/VYiQThEP9fJTELlJmCPd4utAdTFr2StLTt5c3q1nTacj\/8BTsk+wxLcfsn4RLvhXBunzf1U34eRB2DiQ1TNGtVv28BjjPYQSrwpyy8ctnwYuo+jO2GUX\/GgLfkjPG\/6HYA83RcoVg0HsdO6D3aYWgLcjgXYrl+WaMiQ3Bci+7O6DaULkeXVfkTBFqpGo46PL6WfelZVMeEMwPnl3KndVNWOXIscs2\/fRugrl4ggGENDG9EHsb1V3E6sNW7oNkBGRAE4WpaatVvDO5MBnQwVRB3vaYhm\/DUw5pLt3WllIBdwtDM\/4UorZoUO3BktsH4OP4J6X9p7ZOFa4H6I1lw5f66Pht1mNQsTl9Uj+q2u6e0AyzxHzrjF7p7fTL6Wl1bYEi3BBDUdBkYEFER+fr6FwNRuKUgjnmmIm5VwGpl5T+SBrk6sV9tZoyZeLAJ9HjlRxHoBYSQhRqw\/bA6nHQesM56NpjrNajJYHqFeewzoa9YHKZkaQ8kDEBSozpHnq106O4ER4WBzqsVJxaYMH4TLERVrqnYDNgU0IiPzxKGTJ0nCgeGaVHxbGxD2u3Dvs+5\/6yzWpSF7raaC8wVT\/pD6m7dY2qzcfeqprzdYXIs\/WI++aZc9UaYrFNpM61d\/Y4R9AjoQL50P16D09k9CS1SPfKIvsCTEDh47\/cnonqIUd\/8HBu\/kji8o8UbOXzaJR8WOLrc5CPDNXK0lp0rY50X6UmPfh5c2+TyOJ5DgdmrmjSiYAXhKmdcwqVykhL41rxAmt+vvvMXX3Xit1HMTBu8v7\/DxKpa1sVzgMyoRc2c\/R1bvOGJQtyrkjOes5JXpq0aNRpTpcBFmTmztZs7i+P6i4+pyIAqXbhytoc0mjKYTSi3fs7iOn1VE18S6X\/LFOWjdHTtWKk0HBErIZ4KGR0kacBBo1iN2DnMZImXDDkELoOn3EGbfSq7DobDCBA5TA0SltbAu+FrQfXmnohB1lqtGWHyvgLIMRiXuAWrh2yZUQ48K5Z2k2iPlvlwA7shGC9F4wSooo8VxdCZ5OKtQDfkVKvflVUMOu1G1E\/LPkbNRFLul6qjpAX97k5GwjCjwV1eeOKboDkCWRAopCpBfMXpruHAe97QBwx1VkurX7Fqr+BQrL4wSJ9gIu+QKD+JZ1OwKV\/VNKMf+RK0dLPYOJhi9u8t2dWK0xA+1vq1FlHu7hJucjspZh5HmZr6n7SfC+xAS0i6C0Zd3UR1RGUfwM3zX1HzqHAnI7YBtbqozDBvKgYJBH\/58ZReiuyT1dbjQjinvZ1ZrVqxwVqh5o6gm9KS3ILF1j7cVjFX1N+h8YDf8NF8heTsZyLbePKDobf7y98tCCCHM9rtrgVOhtwzvdmQ2Rlw2Fc2ieOarY4DiIoGDPm3RgVb2NuwDLFcqZ0fGj4i2LsXiptoCcTSn4J2tJeomNWwJXeLP+rpEwXZ\/XcFN8Fg6nEKOx8ITSu67bZ3G6gvp7pqF1HKzwQAzTimUd\/IgzPq4hoVeIikcLYp\/BVZfIR2N7DCMmac1KMikTDvbz\/li8QWm0yXjXxAgSnT0E8hnfGpBqdHpU+w4t+8J2S\/qqLvFi48hVTGQxjSMOLcqWn8eoOQacdd\/dbEwKfS7KJPzwHXwqADroR5tLunZR9ffjuIqVY16OCuZExXNf8EOOq2xdisqfzkPyZpTAWHkYCc10ggaBC8B21ItYM1eqiZWPjhnvdnUx63kbp3V6SvTNAuDzFN\/zrrgJe+afzxm5Bq2wYypL6ikNtYvrgH5fkRxYG7rWunbMtwxEoqZkEFsv4ZmdJ7Ew5W8K9gGA0ELLC25O+ucM3VaK+82578ymTrThUBnXSTmHA\/BhNBbgMrV4WdpSA9GHVIwI4LDv79Kz4L+ztcaXIVyWzvVUzwHZOnOpbHYPMn0GKneZbpbZOq7urI+Td0EjSHAv0yOcaR\/1FYVBYt441pqphwBaY1z5Pc\/17ZmYpWO1IYb\/Ts3o3JOfdHlnqagIEG+coUAgNhSNg+RsTtp\/\/1CKA0FfznGJmE1JLRohuzXdTgcCy+dYZFMIXyRe66vX3QhTtPWKEYZkNNNnNWJnaYRDp5RI6yTWRAB6kswdtXm7xqcm51GlX3bTO\/nUBk\/08R\/E4YiaIAIOZ\/YhLkLEskioHjaRMKPpVcdDcHh+HiD3ApYg9FmLbUorFpBOjOdHoGz6Hlre3v9lxdO9f2AsLku5YrxFCkLbAtSQRPpw4JTEVvSxPhby1WN\/BFokQav+0iWxg5XJIeHInSONznfHHna9MFz4EuxzS4N0UU61FpLtlAiDLUJzhEsUI2T1JmoVpj61pgiUTdB2kmreF2XUUvstUdmdHZQkrJ78DdYg0hvpP6pHX63WceFxUVaylvVlVwpDh+cQGxqxL2omLsb8CbGYdsELCPYRG89Z0++1nfYDtOfdKe4y0fRy5tS2Pgu6QdVFOgSBqaSZRaicSDURxpBcgOkj4VyF7lCWdDve6RzDpB7KOUsudsK2I7At+EVD2O2BamBWgTHkHTydZQA\/oUJDmj04uMQIeGtd00lqw\/+4BeVseD9th1KVOogI4Yc0ql331HEC4ZMoizS4j4fRKU38x313or15\/TLU5AYGfsCEg8bqgY9l+AcQGvbJhQ2s3H7wPUxmQ07fhUAv8xlS\/vCpEW9WKIp1hADLno5sXKhh55tGftpu8LrKnGYkQBqaKeXJjQ621WrsJEQV9xCHHQl1f1azwsSOxvpGfIazfY7ncq2YQHSOICzC81Lcb2jx3gQgs89TdR93gigU1bg5ltM+c6okShSItEFEu3Po69lHF3tMtuF4JtO6XI4Ol0oiSaX8Qd8WIZQZQbmBRVbprSGJuCsKWnQKZGQmjWZBHsBi2R\/G5pX37MKCjxKAC1\/eeZ9liZNcAQFflo4Ob5HjcxUsx1CWU+YAn9AsL4sdseiYhMclvD04N4OI3GUAAWx3aRfTDSFxandcv0GfFiYn8MiPTMkG1QgdOtduotxaGk9jzPAThX2zUjP5OMUp5UcAV4gczv3nseeDqkERozwDHu4YAw==\",\"page_age\":\"January 9, 2025\"},{\"type\":\"web_search_result\",\"title\":\"Did anything good happen in 2024? Actually, yes!\",\"url\":\"https:\/\/www.washingtonpost.com\/lifestyle\/2024\/12\/26\/2024-positive-good-news-stories\/\",\"encrypted_content\":\"EpwRCioICxgCIiQwOGZkYTRiYi1lMDY1LTRmMGQtOWEzMi02M2M5ZGRiZTg4YjcSDKU43EmAGKZww6L+YhoMas8d0obvtH6w6Gy2IjDMUNwyRexJExpXIUioLVIjD1+HRimPa8NhulqZ8dPJvqsk2JhorLqPndWbewDOnL0qnxBOm\/0erVFm4VrrrshdvdHzbPTwnvBIUAxMWH6yowoBXzHq5STYpKRjuq5HkwWo11Kvy7wYWgAWDDljwmrG3CtjLr1pDgGjyPRQw7E0aIdkxN6PGmv5iYT+hUUnwRVaM+P4Hq+96UI+Wkp2QfTUgY5aozT7bs5nbLrBFP5TWq6DEus2sum9h6L6FjTdMe3PPOaHh7C0wHpe0oyTZPna8cMKdoXuMGzcKCFfdiA8wp5NJBb5GhNaWZmNPAsqZ7OOQMfWnCR9DLEeQVZFF3PXm4\/gsdj575wfxS2nYy1gqe1Hq4T7vFUT9YX34gyztZLiEvAS5kJS7FvGsZssrpbXkqQ\/WLoo94t29Cw3nkw3oA9DUEfWE2rlFSfOgWSHpLZVTjWPHMez7zp1aWDXtG40GOBdfLGIdfY9hRBi3MW4bfGvLhlcpGT9dPSJRQ2Fur8P83wdA6zn2j7yRDMROZRj1Zmh8IcjG44vGsXA3u0gJKWEea+T0DquvIOz\/DtY1hXgwTl0g1\/jGfYndqTF0+1s+sRlN8k5DurBzGmK2iD1+6NoWy\/PAo+sx7o20lGNS47zEdrumjXzdkR89Hq9O1LNmriiWRemIkujZzxQi4vO7vC6rqyg6sm6otIGEHbnYiieod6uQNXGxbjSI+W1R3fTQMzT2YruyQ95545Igyi24glc4L4TX8j87XTGx7jaAh7+81DpFZZ6i\/ddoUEiyYHItFgrdB\/xPiAhjgFM53YYIcEvHjkaF1HAu50PN4l5Rcm8j2UriCktmDWd5rTfgX\/SXylGTCZpYYKuOZMSktc2FLUuQvp6PLRujGZBM+14XxxacvCSnWUJTEZDCFcuridWKharziTfdQJbpGNkdWhSb+aVArupPpdSq\/4nkTp6XKR2GsgQ\/jEraYHeBmu6GvZfe\/FdEObTV7p\/d6cwg3xuxj\/9W+5inR301Ncw9CuH1S3eh2fUaAaxDlWhmPjazWe+cR0H9z2uKdDjRr1AxeSiQFrwsmgHV1KL7rXoptEYOwT0jxJ33\/MAPrbrn\/wPyxtxZY5pYzNTEVLAzu6pnVTOG\/WZZ4KZ4aOlahGij9KqpkGDPUDRtAjdo96OP+YG9wRt9BmXdLa2Y5GP85jLhFncDCdJvcByyLtz8uMhEjPr\/Op44BdDsrv9USEWAdcun7DIh+naSaed9I4W5YQxmxyESkK\/DwNh9PbEYhFW38xLAYJLN\/9F1W\/XxWKQblUmu5dEQ7vWR937UH8qmFODDpLQtcUOF4Bsarww8C8VqC0nMoHtIprqzPssgAVJCDyiZnnpf99kBmr92D9rJc\/MbM9I3mueBbc+d0BMH1sQYh2LwyRnlvRbSM6Q9NZ2dhyqt689zOFptZ5jp5umapJ2eKpIW8hMYbEyiAapSWuwZt978VLgndgwCq5BSzKhEhVB3jsUBAQjK38eZvqVaNgx9lh8C\/POx0L6ZgGTMCQZDmje7K8rp6DG9WyhHV1krNNiGwkHYQbKqFYnaDIP+mIPrt2\/MFvuJ5lz2rlEcJmxHhkO7Rp2Nl6S5GSnvuf9qUI3osIskOQsZC\/f7n3eR6Lzn35frufq6mHITlKx5ranUdz8NPWKhjXdGBX4LkMKO5enXkd3KHhRgiLl+F4Fe0eTGvkCBmNYo2W4q41ohthb07CE+AElLLk+IH+nBdC3O6A3jDusHOZh7WYNCdYD2Mza8xlY0+R4HNuVO9EHBJVF1a4tDOR3j+jwZhnHgpL+d+3YxxPI\/0O0anjETfpvdkZPLRatdnc\/Bra7i8SsHahE38HpTSgmG194NKmFbuYyZZaJYoFwpnRcfX\/SRXHQ6ZTE9fCdAziP+6MneBv80Cr\/lPlyrDjA2RwxiaFmUJEo18OEPYcqi5rnLwVJItWWXV8QjSmtabNxvYRvjNOnEyY4yjoyHp7iOG+R3aV1PlRCm24Ji+Q7y4bi7G+y7yw+2Zl\/UlmXV0hbNLWXY1G55LBy9UkMyVLrcXFc8v\/3P83gz9Whe0mE4RbggT76MzYkjBZTJXc3gvLhtPNSq5hbx4R9nDsngy20ryXi5PWCLpcYNYEz37\/Rw41WXv3FIpAVsQkupiXvBmX\/nrB7\/IurxhMa+1jKm843Y9afwFcDLCglMJRAqT\/5op\/IHaG1U\/60OXBC+rvEm2AgCYObSgTCksIu2mnWmWXyDx5BfT2DESFQqYI3yKL6bpreYVAiICeAzN+SQhMoeW8ko4eoP4JC5MhKG5XO4r5pN9OOC3E5yZ65NHGYJRnSfi+QtdPLQsrm0dFyYqTrCufDjm+KvTllbN+NChpDPRLK0gHNoxTOSIjcbKE58a7j5siw3fgJqf9tJ1EvTrxIsZ+ki8TYDLtn\/cnkR8w8yRWJR9fqnfC6F+7Fx2T2vSBwUxK72oZlRm7cQIVoRdX1a3LQuiNwNVjoROdtpf9rED\/14q2OLL5KI+e8uMrmAW3sxDpWzWBUvh17CfLx7phGfQ25YiEHT0N3R\/HeSNA\/g08TwQTP1TLL3wgmXkmwd8oBwlznfjwDOnsSeLUQ60sprtVHNQof2FKqX5HEhmoC3+L6P+0wWWrmfgc1Sx3arunrMoA\/lN7PgmWtCyI9eljdwLRDSN6r\/184+knWYECBUWdDRymPiKRgQGWSujphVyHjysOLx6dg+5+\/y5BH4pw6+oVy7Q\/M3oNMhpNZXL0xbpOWqIdEocv9v2WD92reperuqMquHhjItUg\/reRawwxzBaFqGv7zIgM1ERAiG+c6Itn3o+jguoXhcB\/lsCQq0+faH3sYAw==\",\"page_age\":\"December 30, 2024\"},{\"type\":\"web_search_result\",\"title\":\"Good News - December 2024\",\"url\":\"https:\/\/digital.goodnewsfl.org\/2024\/december\/\",\"encrypted_content\":\"Er0gCioICxgCIiQwOGZkYTRiYi1lMDY1LTRmMGQtOWEzMi02M2M5ZGRiZTg4YjcSDKtUOBURfJyQs\/vxsBoM7T42fvyNocHeZTttIjD37aaqfCKPktxH5P7igI5g+v9agY2F9MOm4w5ufl+0SWgnlGStH0eErLUtoIAO9jMqwB8fyyYbAmeiRnExDlXoyu7MW2RLKjwGegVofWyZ7EeduH4uhWwsk5+trxSUo3zxnJDdq49ObQLjjeiihonsnYBHlIwmmVBHo9C17Rw4Gi\/VNL1nCHzSyuWMcfdcH9in3Nx+R+AeZL6FXQBYORsERhMFAG2r+eLZ6swRxIfb3y5YRmUb2WsuvvuhzBBiMGtysPcUwd9IJZlzPxHQgntq9xQmGuDCqzrt1CIsjmC9V\/m2FXovwHR+e\/bCKRjhWRRJe578cSgaWfl8WoqFNhZ+LGypE4KwK5T9BVAGr1Ybh\/DKLiLF\/tgqG4JWeTq201cdCU3HqkiOiVufeRJ1bkohGsPF75\/uRlvXqhEybddNMMdSMFvFIpepNy0v4sX6Sc9OP9GiWNYUvnE4Xp7d3laf6+7bqagxD1gEL3By6i+xc1\/xt8BgdCi0db2EMxk0IIIIVB4kQr8bLcaNJrAt37G9vSCpr9esV9HoSvfeuk0XJ+4CBCeeQA3aQG6XlRE0OT7nOaLwFVwnb5ys9pSyLM3XD0oGTjXhT1xH\/qL44sjXlS2bItffK4cGi\/eGT8sMOyEHcfnCoYAW1WIlG+PVWDi7uMtzu6ddznZq9fy8Ytay4X3o3UOSqbF3tYYF0Tq6DEnxwT\/unQ0nf7VHatLN0yF12PXlSGrTBElzU9qwj7T1JrkckkblwB+opwjgVvkp\/dL111428uEh6hfGu777E4hqy2rYyonJqTMZ0rjRYdWChDC7K75uKkmvM0tlBYqkhgir3bLefAzNVcgjT2Hcq3Vtf0HgCDF77vPVhtnW7YnHpPliJ+JfYBh5CPiR5+7VdgI7hCrqhzhmrqKIjedrfzJq7gG0IAM0JUKwG1eg9G5fXvO47f1qprYuKJqXqHlDC9t11KNWmcHe1caBfx8jzN5qtNcLgBIb1bD6TMTBp5jwpM34IRuEKhquBvbb6tscWjEkXQ4TLWDA46nI6yp+OyOrOoYS0kydlLQ2S+EhnFp4CfogvWYZHkezQWhGV7uWeNt8dE\/EYs53udOLS4Y1Minu2ace+d\/AO732ZeWaLBxusPkHiagSstLACOW63xg+sNU2s2fsK7NGQuvcKMTSAvDO7k2xutiD7fMRwxOMuIUlUxxyCUsjC3L6i72VkqWSr8y+2LIuciyjHN\/dLOZmh3bsGZlCcc77dS56oCIQpDmqqyzMMxN0PjYotL1lizVvNZALLIzLoynxQKKovHPIyaNvM1iY0dU1O+5ap6Wizqr4JQ3\/xu2wCfHAlLxSm6CX6AqplTOoO7xacShDNTzpJVhlpHOHNIaZLH8\/vDwjUHd4GEqqetvLPUPKSDc\/L5e9+bjpfTPKGmxBoYhpHN5M0yB1Vosk\/hMs\/fQOD+k9+od4LltY5cLuLpiG6hhpsS07RmS4jkJMHDXV83uXioToFyQSc2N04LTRRN7QYYgsPz+EKlJICbSfvQjVS8r4bQA1TJtGER0m1HHoL906ptXXIGp\/NYJC92tzoSqbDa54HCzCIBNZ7tBPzTSQ2fRON3e+QOhJQAK7rVlc9L8dolm1lK2Tg5p4padeEuAe+uXjsWllBELBPVz2XIyQLVFxPls5gMczas0iMAO9099K67xL\/SGFbgINJUWe8VzM1Hru4E7ktFn\/EsFBXJKd9WyIet0DBOP2fhPFQS1pISOf2Bu9klfhMWDDVARox97ryth\/gl6WascsfYHLtLyspUrt\/WwGs91Kn3vQiwrSTyH+ZtbShfkUdPXOQ38Z\/x21Kftb1p93991rN0EoZC5Yp\/YNOWt7jZ3H9gujI8s+D949Ox8vZv4tvTS1UbCHeaxN5WX+jJlhuv9GIUsECsvW6CRLyI5+JYaBY24K1VMjszhyY+X1a7Wn4ZBISmCj8nFXkJDXYQ9gviBBT2gRIiQAT1\/5veDGmLm3aSTPQHcrWW4FtmwH3KVp8Uw+Ih4g5b4LAtXtRC7fW\/xfnaA0DTeWLtWvdTMU1nWaEFXkXg8LVDiMjD+NLcMa\/nzzn5jkUwsqmhj5d7Hp53wKDXIcqWx8mWbjCE8DtQn8Ytr83nHxjorx4ta9gZA7+RnmQpoo0GwQQj1Wv7bJMM5ftg5FP+LDc9soj\/dASSXBEv8bNmcActs66yc5JHLxO\/fgB9cdwVxq4aNvYUGeJtqT9mfJscF1pkvEaho4wXEprZKZchImg7SG3HSTySV4MgzjgGOJXet8FgrAzHmV3Zyh6E4KGKpA7ibPCPMO5CSEOkEpEFNJE75grTxT8A0jrKAdhArvJdhWCRQMRiPyoiCpHg+5ItaH2yOjJqn2d5l1eWdzyhbf\/bB3WiDh3NwtYyDa6PN68NJRy3geKxW9QM7IZajUUQvUrANL52rIzRH7uIYzh6gAX1xzR0HmKMTVYQNOrexu\/l3hDhWFH7Z25Frg2WgDdGNJePXJ5hmXSqkHwp9mWJZne28Sbx7tp\/Qipf5I8aDx5ma4jBcrbh1Sl4yfmUWrO6tMh0QgWmg2vyyLoBM+br1KFUVrDCvJepX\/kgSfc9OI73h3ScDR9zW5bYaWuF8y4uY0fjvhoAsBdRNqpzGdCHrmF3D14oer\/QIPMCoKSjDXYdbyziAYyQgfkFF9N3eci9e4+jr+nJzkTAnqIKcuourSvBlQypLip4RqIuLNBAeIH45iY\/A8GxohG\/0E4r4UxLNyUuWxxCa\/Yudx8TVrZcqkWD2EUQhRC0G9NmHyzBmF+1q9Nss6pi2sER+hjgJS793cC1dKIrfp45Aj\/n8vbcJhchOabLNv0N0Yf9HiKtF7glbCiujMVgRd62yp\/PyuJWKyPsCPYZGWfGlahXO8fQI4ORo5lKDJ8Sk7Tx715dRboOwe4YmIUQBdKclmcCAWF4n5swGkL6yh8sLGk29x46AMLek8P4MOLZ+naHdnj5UYJwkd6U8l6bEVof+un7EKiUmyfNz1Rd4bphH1dGCXcdA+bWEI3ppK\/xiHYpdri9WLRvZG1jCL0YDKR705EJhLr7\/2wM1Ajhr\/T3y49qfb72htKG7QIob6FUQT+FqPZo+l651A8HZIeLP3RNQ+i+rYU7rNjEOVPROtDoxCasYT2VOLrO5dG\/ufQALMoqJEHzSRl7FHQlcAr92JENo9HSSkl7ihMlHrBihzDcXydGrIQcsD6PUNiBSdZMibn\/5pAQK\/btDgwBax4PLEiVuV151D17QyT3iUEo2x30lZBKSQkbIR4mXVoGe2ZGj0wai97nu2JNi6LMu9QCSRFL0NOMJMD07VKG6vcGoiJtikLPUN4uuJx28\/TWBDJ1IFQnwK59oji3A1UApmTM2c5Z7cK+gW49C0MkHIKkg1sztmxCglQzEYQgMa5eLVMC1tHgklRWY21Tf3jRxDypWlb7dAJAi1+UNl+sMdkkkH7KqMMEiY1qotIAhAIpf2tlSgRpxrcnhF4e6kuRD3M076LVim9glpCSqgvIfkdcz\/sakawpDjIso0UBuSEfzXDsObOKUVn6S+lywxr689u+qcZD2uVBt441e1G8IvxRSbIOiVDOjZYOjYVSoVu4wZ0YnvAqA0YDygHJOIl1DJe01HaFOe7HIvd0nqhZ4Y9PDRueWJib5OCjBpjMTW8WzmrC003RsRukJ4445KnA6qv9Yam3Wp+U4sOHLRyRNHtYzrQt4Jymto\/POjUC4naPtCvr1XhoZXIH7BK+6Aw2tZywEmLIy7A4bU7CVY\/05v1vw4L6gShkveZZnBgtmXBUhYeWIzxnQ6PpvCi\/GZtLEy\/RNCrYPaNwYNb40jQkdVogUA45GdISkuXvjyZc\/s\/9wOFD9q9vjd09AmnUAKKg1l2s1bxOjBLUSxHXsdNVB5PEwlPx\/625mTbqPUVuiNWdGLBpZcaeu5CL7X\/pJA7zNTx0OhTPHjDCFm3zeWz4153PXp1WqENAyLQ4wFTVWPXzn7dFtBP46HRGZ7MJrhJDp2anxCCs\/3QhcYrAEE7\/uK4vHSceFVufOBjio2ninKPOWkkG\/n9iVVGvApyo8MMZPMkJvtH+0Bj7ghUmDFu8\/L66WignoS5PBbupX0oYtkakdbnMydVPoS6Iu2VNwOhbSLv1mJ+VOPFzOmTGLMUpv9HJ33GT5vTUTVc70BpYl2m\/4MkzHZW0Ie8cllh4Ul5aOg1c0N6r4g+K4HTKGs3VU26LAglgrqe0ANvHdFtYgS6YVg4bJ5WL\/oVYM8aBzSxcI5Je6j5L3vEFm1FMZ\/YK15XHTlncWwqIEVyxqPC0vj06q9JSK1XgO24BhNC9YSHeS\/DeEO7D4z7Y7NBoqid4+5mPgqCvvwRt8XghZv+Nn2bTr1zRRXJM1eJJ\/j69wSfYxr9vFd8vayKBBYF9PPUST8jrCeh36pwBuyh4LFZllYM8cyKlzWp\/ZnGk94Sx8dvAUmZabZT6yEgHAc3pb+bMBvjrwpAoizLKcHIkywLGsTdlHWOhnSn7IglSQ6lm1BOwljTyScEHhiRG3rdEY0qPD9eixpQMJu52ABRhANeQY8XVoR9SjI8W9RwjhCR7rJGRDFkd5avbHalQq2R4HJUa0qaQGGpMGGeoDBNV6iq+2vxgFxmkusKy1daXy223pZvAZUSOPQzhDVxulQeYCZvWuBHvmtcDrqF2W1cipvr5tUmP9KqpM22ZmxL4xxvuZCjIvsBJZwGDEJwvEWqbPiHOtVbM65Hh22YAepcDoTejletNnx1xcLJpYwh3ips\/\/fh01do\/qiFfRQek50tlVkfbmyD4yB4s1t7XXbZfCkTtCYBRaWIRz27N+5I1v2GbsSiEeDL16wRsnCW7etTjd3kW6SjRBzcUa4NE+An+twb1QKjNcS9QdbxwuIlbLatr69d1iHS4D0P7y3Yj2u37dQ+UANvMmAePABZn+58I1FXNn+VOJOALih6rXvtWD1ySv3oq36UPUnS8W9yrWdjRFbomaHT7npRC3un8SpIf+TEXvEiXm55E3Dth6LAJ7r5j\/Om7TrPJ5B96rA4VchJJzgPSmtTeEku9UWwBlVjH51BlpjZm0Avb2HTFHaOCa3wfxR6Ja4c8dPfHDRLHZ6i9FvTcuTc2lEcM9ufalLbo9fg9dQY++lN45KLCPWfzXkDDrZ0kFGeLJYZiR4qZ2dRVmG4JR4CwIitGm6K+LiHdS\/Yu\/h1j0rymdJ9uDM56DBsNNZ6ET+iU\/CF8zaxqqPj1HDBaIDwIwdGrn7GFdJPjlKkOn9XmtHmQlwW2Fgbk32CQ00zpBFJpxZeJEOFwYTtlvR+AE+lRa7vvlY8E1FKvRJju8OROjqliER+MWFEcpsM9EMmDZV7gsgB4WnAq7f8ysHiJa9rLtt41LUNT1hRzBhcCwKKddzlmPCItlA\/7B7FsK\/RDy15yIBrw7+r5xppAf50Z7l3bVhtJX\/YakYAw==\",\"page_age\":null},{\"type\":\"web_search_result\",\"title\":\"Global Good News - Good news from around the world - 19 December 2025\",\"url\":\"https:\/\/globalgoodnews.com\/\",\"encrypted_content\":\"EvAECioICxgCIiQwOGZkYTRiYi1lMDY1LTRmMGQtOWEzMi02M2M5ZGRiZTg4YjcSDKDAvVuTKN6SNGZrtxoMbnp8FLAdKerjZs7hIjBOt1gr5s4Urnhj9hqrBIlAwRagjn22dOyk2AKRYNCy8c+gQRO2q85MWXrRtfOONV0q8wMCxOUzo7IN7i\/xm50gqFa5m\/PKWGvmJuyBXEVV3eo4LSErOeaaGVBeDWMzjBjORgFCrBwuIUMguepgRgzmkf+IZcV8J8JolAvm7Dcl1EIk63noMM894QtZH9wKLD1XxeR3ojRXSCTgyiwLrqTjMnDrVdlYjrJu1H94AiJt9cqkiT3xxeK52KmawQ5lAXtG40VDm9h0nVydvpkfrlHk9c\/GG7MPOxxDzghBDzKFNDIJvV589q9gUAojUzDadqcAO3IDNX+MDzGCQoRFg6AYXAAxZLZZ6lhgByhrF8oewKJ6kcqPAuAC4+KCVis2ejkqpt3i4IWZrvj4pH3kYU1N7zeLEpoPxfGRVsQURrfxpt6\/r1WHdmZ+\/QMWd\/B3YcJXBJSqHYYUyWRgyCZIrGDGT+Vm5Ux6tY5BPN7wQMDLE\/KLOsomlJJwmlydUcLkhhKme9zCNVxsR0z1rXGBXELPqVWbPMippKQfXxzWTdplcyEx2c+dAXvljGUZVKSd4VB8uatUstOfqbDMHfRWLcXOo6a2ICyhjRkSUdc45PiczxR9Me0HSZ3OSfj+3izltPMGxPY871GOkxhqyy03W4GXw+jUUyES3wU1Kd881TqrCm0SozF6Et6dRajhNgLIxksh8Vi1gL\/kDgU1zmRr5zsYRibteYtcGAM=\",\"page_age\":null},{\"type\":\"web_search_result\",\"title\":\"Yep, good things happened in 2024. Here are the most uplifting news stories from the past year. - CBS News\",\"url\":\"https:\/\/www.cbsnews.com\/news\/good-things-that-happened-in-2024\/\",\"encrypted_content\":\"ErUgCioICxgCIiQwOGZkYTRiYi1lMDY1LTRmMGQtOWEzMi02M2M5ZGRiZTg4YjcSDHZnYwjNV4arE92DDBoM238BSDfKUtbL40ebIjB00xUSlJvXARjrm9lmT2M5Vfjf+7IM3nu4bJVOjf8If22BcFR+vqooJFvi+844XkcquB8FULzy4W73gE+mxJlhhOixLz2nyEC103gjk7wBqpCTsq2Lzs3P2zzRWkGTmLJtmsa8VNNLQ\/Dl8ChNBOtl\/87Ud+L1ICV9c\/T\/EEdp1PCcW1aKNyRDRDfXg+vrbZTsrPkoUticSy+FR7vlzCoVztl2BM2f3Ulpk5qRrLMHZU\/ZHBOfa6NyCtp2G3apq+ojExlHd1NS+g\/v9FLeTzUFAgAoWNQ9+x\/onzWzsmOsHISyh+68O6s11Qz8Yj6FomkXt0OkoA6RWetmz06NyQMxxP7o2+pPkxhafRYp2SdOr+3fNq5yaGujAZ76HIgMAoTfjFuvmQZqnOYhg2b1e3x3h2Ton23iHxiJfq+RDxZqCP8j97o3nkJORUtTFm+btBc8t10N8eQBbqxYjQW8CZPTGxrYETa1cYPX9izHiEe+xYJushXkK8SV4i6nRprzOpCDL5f0ahSAJaEzcUQaYVlInjz3KaQXNDgSjWxnB98kO1exQL9wGPBfcw9fhmwJpjx5utlnyd0LLkys3MEN3WydOTKhReugOeBGb+Ak79k\/sdvY\/mi3m3h30lxHJXtr3elqnQJlpx+Ya4\/SqzOqWHFm3t8z0kB02MMfVtgyNT0z9M\/9OUh1eeEWXqZ+37L7XkaaHkcfw2ZvzAdTSNceBLW2fno0\/cP\/f8YwsP78VtdqyA19HKo5Gdva8fIuLpcxLD2MgmZZwe\/VkMtrouEMXas1z2QKl6rNmQXwqJ\/2q07iHux3hpilj+oi9LPtpdei\/tQeE\/PzabcJ5ijixxcYS7f1GX1vptrmGqwuqJHw3C977\/or2CtvscKQ3V9O6AGfU4YHn6HWiX\/6MO5HegbwVwxxLXI1qORZEaMc8\/wqwldW1YTUvJXY5ICQ14zRcp3558Nk++o\/xW5D+4R\/URAoGhYylpNCLshmZb+tVEP3ZbtaS0u8v9j571nIniLYPKh3QvBdmrYDXQHDtACtj6jmnEk66FCH8+LzjhXPhCg7Dt21Oa7cEzK5kJ84jPIQF1z9m+7CDchgZMLX5MjcCA01drf3JkaT56bRrhf0IeJUyvkKyzFCxsRRsqjXcuLjy9njAfXMT8l9OZfRdxEAZsVd253djFIkOc+4gyA5Ji4XhiY+LJaaqZKJkXioMutpJ8C05daOE7mjyINgzDIAxcwvTegOdlhxPxnZqf4qWJzJR908bkqd\/JHLYFcJe4e6IiXUUTSsIvEvfoRgVdJE8JcGOKZBP\/TyIrFzFs8WGstlCYKwidLS3MGp+xLHZDkziuEDxiEql2dC\/VJEygfKv3dEqfnIu0j8veLeXFdxTKgdENCVzW4o0HJY3DN2HA12H6B6vtD8rZJjOggDYY4WapEhgQlZUAbJJjyqrFJp+5EMv7BrDJkcj\/BXEmDvVEUs8g\/EjNDKIMGiTHU6ttAuXBZ9OIfgWQvveF4AWwDAB7YlFhqpTXXLN8W+SmSVYUwvKo2EzTHnWLOSy6UE+aW+nUPXGmo6PvTkoG+QEtriYR8\/Ntv7zzDvy1vVb43D4z8iJ1iPKDhoUr+tVz\/7jzRBSWTt\/EZj3LgKb03LJgE1lveuds4UAd0RNO6VeHDMpW6Tk7SaZQmVU\/6n8fmSqOtJI\/LsKRIfZwaT+HCaEqY6RGiiCnd5JVDEG9JkAnBVuQR7brnKDLAIXGKOYHLFBuupLi63Mb4JMYOpVkNV4hSf8v5xKCyTdhpQSywJTqLymNDSmmq1ReSea1d0HpzKYsWUbGZW5XRqD\/IKJzK+BZ63RSXOkOlfrKGLzb7gmJOdEPfOV+AnLl4D2yhFph9Y\/aHxWAnhB1Jb9pNKTrUi46bukA413rkmDMAxpBqkNITEms4xCGckMmPHNd4fHVpGZtdduLCyguyZNtvJJTG2zoSHiqpcmPtmwl01uUZrNkYUC7t2scOlEFtGO4B4Yo\/TaAqszWxQzirF89wkh4qgN008HJiqp+34+huFbB+naHh63KTgPdwf90tjbMyI1G+Cn0\/UWq59t6CzKX08r52+TLpJk8U2RRAPQmLFsP7KPyjqmociZFZE5TCDJgOq4fdkHObsKM1QIZSaTBEcxIDAucBKyKEym8SFX0L4Mtk1oog11PKX6Kvs\/595iesEUm7JIw\/4GdT19wcf9+Fost1MzqgRU5GtkYoVxxqYISWcDtFpXuw4WKT35nUtsy2pEhW2U6JYqfPbiR\/TUYkwkgRVmtfsBGea1cSLsXreELj0RNXov7ebKyQj7kjfhMWGriR6jvvtoIdsjOsqoyYJK6phc\/weer0iRhFzFxHPHJeZfqHegQOUPjosuYNh2UdjM\/09q25TjUpfYK7uA9zjlPup1Imej1uibaymWlVB\/CPZ3UAAQf+1u\/7jw5MbeFZfLcgFE9DBhb1JJUbYmKq5W7oODKfk+CVutUv\/S29LsRh6Gj+eWJ+oM8GhpOKom\/12qHGvnnJYHiDZixHjynjKT\/p3CG5LFRBjBBzcPDHRncKEylfjF+Dje+7cJ3QXOxXqNBh\/qNQleNr2uuHx7lhAzbVq7TaZJcJp0Db3raBi\/txkce9vXP9+zD8Mtg3XlDfB\/u29zauuJ9NxFPVnrYZ2W289dXZZeNzSKw8dtQwpOS3K64YpTx1rHYR\/Ht3Ig98HtRhgo+6dyC\/CKGHBMvRVc\/1cO\/vNwxr4HT\/SPK+T5qIVpNgQqK7Q6Yx+KNzeuLCxUVVxtznU6koRhAuTRyoF2ZTvbPF6lWc0qRiLF0lXJdsa\/KcwjLN45Smw1GOx7RfIUFq3N0jEr\/L1wpp2iIiN3lkAnBuMFQZJR2XLDFvuJZ\/0jVcmernJyNgvSJFACW6xu56R4ucLPa\/e6pUXbZY2cBsk8TBXjaiAoFIvP61NeU3v6e9geEFVYUMCWDKDtpuMQRHk3Ma6m2X1YChWj\/GAEinlnnBmI4c6+gCbwIbzsEyx\/zh6kMXSaPFGrluwANShbZ5lDpVaJgxr5HSoLD63exw739oAnRRYrEhV53WGe2VvbxZnQdpEfvZ2cCaa2tNbGo4I4NiuI74MgGCfrmq9spCs5jNeqzeIF\/g\/hLDlRSSY4M3FfqTad0LE6oYjjjtb87e5h8oLv1zuX2mNn02QSDwYYu48ajo29nVXw7WTzCbDBn37QAo0j0C8n1vaFxC4L+f3vCC4elU9ACQetx6YYyh\/FloQDC8uJqxpiL1\/U5ThQ\/MK4YYXe+HJgPzZVxUpm7dGqGUp3X\/WKdbP4gLL76iaI7AJJ65uydL0hz+aBcxOjWqTHTI8nLhEP6BxK3n6mdWSBoUwhlcYCI92g6pifUFPVc9fBCmE0mWS+hVA4TuhXP0NXnRFjgxYKzgEq43Y1j5SAdbC5I+05gJZmc2FUprqmQR1hP4JRw2ITn0lYF7OPefqi6HeYYtQArLgiyw4wfNKgw27ri7ScVa3yBoBo3SbWDQSSE33WrCbQ7++F8b4iSvAA6+i0XKoNQpVXCPiDl1fuN4pLKaRJ3V\/lQ6ZC4aNu3LHOOBtenANpS7x6y5GOYEL0DpxyqZSJ8WGjOOteDOAur8E7xvYn5Nt45vclu3aA2iOg7THbtC+5UdSTZ4tJdar7D3ERdl3Ux3blMXnnPqGIrsSgKGUovy6tO8z4WygHXsjyjxKEmjGR90RFCa6nYK2GETpxla843qMwDkESRGJpW2PqqU5XI2mMWasNgrmBwLkXWaJ070uKLQJIJ1IcCeoFqcR17DtK9YKZS0DKfhp0VMhvn3VcC2NoVXMm\/YsVIU2A7CEySwsDC\/DVwM9ltTi+9xQv0twjQ8ZLjaiMDqIn4qjdJhg0cBO9Z6hhsfRvOuM9kA4ttgUjM659raovGKn7oUY6\/zjzb7vpiRiqCC6cZGULYRDQYCPdblJycjzcZ5IXVogyvKUl\/h9gfNTsV08vemXcPUFC\/P4gUpkqlkUFZhS59MUFYz9FVtmlK9XHBVZ7PFymTRp8AAItmU7+COOl+vdMmYRHNT6LZDVkFSAprOonx3bj4tEa+2Cg9Lbl9olw1R1oCKMwL5oaANdl\/ze6wH26zXmBTnkRuEsY6g1EFHJVIBVMFlF4ls+zFYdgkw7SN5M8F4ACdj\/AGZ+Q2819BcJiDD0FR\/IlPdIS8zFKcDSfAwBL\/heVmgkmN6iMFE\/UzyF3iZujrwXW3038COy4h7ihXMrk3ACulGNbF3sJVN0yXqCKM2WcltwdRhyPH5vtIP+P8I0Db4T9zV0UbEw1tlxLAvjhTxJJqdPAInpF3NzLIZl\/QX5Uu+ya2OFm2w+A6o57NCRVuo4NJ0zyrpL6v\/vG1WeURkeJeSIeokoK7RauzxfgqS9PTGnxtX4+ZpEmO+\/FB7XGUJJVTx7B6O\/Wjt9IIk9pQcD\/3dtmCEyr4VozjBz+1j+E9xeGAWiRhFlaSLxTTzF7A4iIr46iBPec4PshLDE5dkcCKoP5XAx8CmRBetJkFY2ddHtSCWbAgNO1Af+gxzkil3gCbefxEH+zc0OeiW1z9xDV+QFbxrwW0YcTuzyRVLZnmsVZNW572ABE8REDfAJpephXCIVurvZM53IXIMo6hM49YPcoQPHHypewLACpPCnyQE+iqMWiz1zvoVAupmePuPCDxEk0YwO02\/mYqknmvWY4XoIbbCkrMzTrG3q1HSa4pgVkc1y4BLR6wqF3Cy0OvBy7hFXEjogoaJfutYcUgemBW9mGi3xwzBo8g43J7E2xcYM93m2pU5Euo8MrVXlH4BWXEBsCfZOKSYbP1z1mtKY1mrcwiXwVMyfrDsOnUVfTdCEXpCq3Eu7X2KiFwUEzOZAa5x2\/YAFLW143GIFzNs1vygrrW77bCMnBp0jhYX7S4Rn0VCP9N\/lJihz+40btCdapGGffvbrZTvUKxF49UifUJA\/\/AMQQNWfGg4kAG4ETBWlhhIzsRncuyZd1SFGIaysMtLp2FG\/GPn7IdjcrUQAziT6c+Dr5uPRpsOvPmaJeblEZwXR9nh+lPKDjo6GpWSMVyJvpfYTICq0VwII4f6laEnSpfezofUF6kRUmZ9RT0LosN1vCCmgmDE29n8xXfkJZYn89U6OgAi5PpV\/lvMk3LLXWISZjq53lf64AMNVmWc2ibQ2FoKRELPYuO3GPfonl1CqhCwQyEZ+OEIw5P80iW2wJbHkjFFaI2MZMdrKRGbdNNv18aL9ICzE1U2CddZvbZPNq3K9\/09faVd7lTgICtUanjl1mOyUNWpnh1D6DgTlA8\/kwH2p1Ql6k426btdIfE3SFG3fIoeGBHz2cOTIDgWzbOrUYGKpP\/Ti+y+pdIww3vJ6n5MsX8oJJFRj3\/FIHW\/KHow+8Wjla7L5fmoy6l2h5UcPstp6fE6iqQvXFZtsJxOsj3OLlmx16AM4eVJQnlLd5MaVnK6MGAM=\",\"page_age\":\"December 30, 2024\"},{\"type\":\"web_search_result\",\"title\":\"Uplifting News: 2024\u2019s Most Inspiring Positive Stories Worldwide\",\"url\":\"https:\/\/smileymovement.org\/news\/the-most-positive-news-stories-of-2024\",\"encrypted_content\":\"EvAPCioICxgCIiQwOGZkYTRiYi1lMDY1LTRmMGQtOWEzMi02M2M5ZGRiZTg4YjcSDAXnylj9zG7e23fKQBoMqRvB1+2UZdyOpvdQIjA4XnbTwNP9TUXbjoSwXAEzJKIGQyjrogF3jxAtFfqJfw\/w6NAcPurjg5ZCu3yVdN0q8w53M2Qdj0D3Y6LI\/Df69j8xBHAPUsf9g1hf8J6hapE8eNCcPibPUYw4tbV0AIcOCGU\/tvHdoTgYmg+cuw36thUd+U3DbqKGzWXVuFmLtJyUcnAryYrqrBlFGC9z8GMyDSvsum5NwyKq86+BWiLpZb3AlkVdk8qwUz0yioDX89zWabDtvDbBvpl9rrYohF10Uorf+toTfHGvjlErhr0ph9v\/OzdB\/nKIUUAjpz0JP\/dvT4Ze3hE5VetltIyGaIS7Gvty93orqponjwuoRaeIeu1ATWS5SiUNYcFgPe1kgmYiwmhvGF92J1bFeKlwGXQzSRJSPF7DLROuYsaLNA37IfWSbXC7zqGXKc3n3D\/NPZRo6fWOEEJXmu4XVfJ446avK7uXVZ7DosjSAb5VVg41jbzGXY6sLNsme9yjdjdEkop7ibHi37ZrWB65Ys1BoSurRqlof0eWcg\/ygfnbJIwubSplifx+EkJPsKRsby0gZJqHOBBEYs9iFEkoYSON3mFJPtbaVjDoHrjb8Qb\/beZ0G+zfyTVVB2Rgk85jLa75oA5+O5v0dZ9UemYzaE2IvBqkZhpIf050RWQ2WZgvGqBWThlKHgucFXl4gcX0\/GbniFG3iQLnTY16vTWaKpp76KaAuiZiTw6JQpoCUBEGfIszgj94xA26jIWKbaGMXUc+PG3hwABY+8Pn\/kebF98IJAa5Vpvmu\/BGX5r3WUVjuuKCQVqSE\/ukAiQSm90j\/LUjEGH8AYg7vkus5x6LcrFFL0dHrWwl3UU2CZhlEgevHChP2pnsB2rDAkEjZYrdenamSGyN\/ZR+aDEngWsXaIJ2T+7CqzNZPZB3cehta5FtIatuVz8NQtuPLtuS9wR9i2hnMBpiDHFi01Fc1TJsgnywO3ZVbjojQP5oHviLROhTqR+yfw2BKbT+BQ3qjLSbpk63TYkTRBZEbB7ADL5UUEng0jTprzFG8pIjNkX7ejpK6CoaqkLkPhuCPmMSoRWiusJE6NcUCS6ItYcHUO6oz3mtm8YZD\/oM753uBN97lkEB2Rzkv+SxggUl0YKqr1jh+bcJzvZI6csqGsQM4XH\/Xt2KUuI91uyyE5uD80Blm4V3AWURWh4QfN3nyvcOd\/Am0bL53j78aROLDx4vgwREGUtIUjVboSduhPkvADpDGzwjhNYkSggxRfkZdhGRD2qHCkO5XjtcYZ5\/B9cQuLoPYS\/gOlsGgriBwiCphKpOAngBYXaLfktsABbdtigOQfwVRjppAOOByQCbCn8X7U4eSbGYBWWs\/Op2EqShnRy1opJSYR845ETkzNCDhxQae+D9vVOjVL1C07WKwshHjjbvfpusZtGypH5tSdOiCIEshqZwwNMaqmucB8yTMzcZt+vFAQZMJ8fNFxm2UVzvoAlhluTVlBzvmJGe5uOFt\/+4sGztjVXHZt9OegyBBVyF+YdxtQjUUcQPJVm5pNvIm8c0cb3qt\/HlpFecQiCyNQxq0yBdFgPLVuCqF8e4p38QLRFwARLiTYdHCmABt8VD8gQJoxbnihEXw6GdPn4WgnVtqq913B0SkJcsaKIEa\/Ex8F2PyISZpP2vLoP2JGjc7EVFGoT7LTtbEe9QvcORj4YU1tEJmV7YPf00m907hUaE7ne\/VLYmo957kWsY3UpCSw6Hwlu7j2Ybkyi07iwrluiD2we3gUFCASCu6an4z9vk2PtSls7Gvfn8AacFM5VG0RX\/EmlJ3sw1uzmL2NnUIHp4fxty4REP4OSsqSIM5iGru7bkXVNRuwEW7BqjP0+lSUmzThAK3Tnqe1ZUUBToc5++lmcnte5JRdrFr+v286FpPBd+vKXVc29A1FQ\/VP581lmhD0bKJKpTefpQtWNUDkP2jneMVAdxMX1Fn6IVaxVeZnDZgUOmuYIWc5qJ3LQj3b9SZKvMyCJ7aSL\/85CIxEC9NcSbrDIYjq36W03KBGvxVCAZnKWB1uvvH3OLajxlYGRzLsF6n9h2NZJUjLuhwWb651dxO2O9wAG6v5y5t6n+ll5XUYLrDn9Iq5mSvuXXW7F2gxVazd+NJBvFmDfG25+jUUvcJXPN4FmDkJutt\/SWULPtvjbWC41sfZwhClgjv3bmzTAIs53Yh9MuarVIjVxi6fRoXBwqiz74HDFgjU3Osnype77BJVvCQ9hgMtqVwddgMO7fuaY0WJeqL4ey2a65VfsGSN4jMOotplJ\/nLRqpDw7w5\/pCFDXkDNIEH2xkdMQrD\/+r9GBFZhjyUj7y3R0b\/rtICoSm43QbI1bQ50EJk2mapTbxwGt5TrmUj6tXXb05UASeHBuhvY2kUT1kDvVxr8RdVPKNBhexa5ffHOTUu97g9y9uYi1GZNBWXJYj3K6t4Ixi1VJnxiC+F\/ekc2TdQRuQxuwFSNELaTLNE90FiW6GRtdPxLgZoN6YfADGw2s5AAe7VV0gi5YncfqiuqnZ5eSiW8TKD2b5Pa9W1NOgd1w796YmzI26B7Rh5T8Aka5FcIKG\/UqQMx0cmtG7qOTPepzIRLT1b0VBQewFeE7NM31fqiZq366dAuFcxgD\",\"page_age\":\"June 18, 2025\"},{\"type\":\"web_search_result\",\"title\":\"Good news you may have missed in 2024 | United Nations Development Programme\",\"url\":\"https:\/\/www.undp.org\/stories\/good-news-you-may-have-missed-2024\",\"encrypted_content\":\"Er4QCioICxgCIiQwOGZkYTRiYi1lMDY1LTRmMGQtOWEzMi02M2M5ZGRiZTg4YjcSDAMl8x1VHGCyE35wXBoM5Nyacdb8M4BNgoZGIjBt7X2PfRU1OqpX9yPeeXEq2NRB6tOMpsWgqhVvjFci2NodGr3+WTZbz+O9yykYfE0qwQ+l3qNdwzkQEH\/pAHBH3HVL7RTyM2lfOMyqs2iGwuQDgv+b9bunymTRXXdhFCJhiI8vX\/OTGoQVTX0SkN0ZejzQbMXxgThcnr6khE3P27S\/xd5VdIp1rfk5rwj8gc5UeiHfDl5QvGKqm7q22DVuA\/sx+AxUwz53lDlTRNvlwJ7TFGtZwl7dmp3GOmf\/wrSMzgh0m1aGqqow+9YX7IxzKJ\/JOcIIeG8PfaR4LSc8J2Y3li\/rxpnjeCNckoksBHZWqo5NAEdBaCJ02p2H7TfmCUwqcSOPLKT5vZEGj4TkwFBl0vel9Q7wdcyuoT+C10peXOyoNN025zmGq5MTT4UPSEvdrDam2yNnjaAgG7qtYBTaMWIcqckZ3VP9N8lj1Bql7ooC6JJrkvZGZL32QyLBtStLYKzwEMg\/SfxQCn+yzJYTYgi0rG4S2ST\/Kw2zyqS\/440EooaMFgaa\/zj\/fX+tSyfDJNUjTmLQk\/mIZsOp7cRSV2lgnJWsfE6kFbaSRSVQyrAf1AVVuJIA4tKmts26BLxZCEpoEqcMYFdjZwqogw3lniTrjEdfRQfMenQOn+nX\/2GQUdtDjv1qbte270FCeV9+qSNp2mWsd6VaSvv2Apy\/Vaf8TyqkKJ\/tilNou6+7qm+W257G3RtmJ5aS5IoIRJqB88csEdBfCy8srPTfYSMhZ+5WjU\/AWWOJODuM7Nfu4YiVVR49I6vMuhB\/9GjSGrgVBrU8GrlEaFPAe+GaFRIpU7DZU1QB8b\/Ams1NFy2wXH3W4IBUzWPU2K4WHEKgdKdtHcAqxISwpZ8peKkHIvJwx8BVJp8s4UivRz5TeD8Ey\/k+8O2qOBPCx\/UB774gW6XPlZaZYLKY2gkRMzQ1OAkYBqg4l72vkzOydhrP+v2ootzGD\/RCsVjNCceLLqxIqhCMmUFo6frT96+72cDzp6negR+fn5AhvZvpG8k\/2n4o4q+UlCCQMumjOvT9jXXqnifci8BT+xN+ofdnIacX9NFG2GdJsJOcWBS1dbbjsy9KnlNYmSrp7x5JvA079ph34Xq6z6CNn6kzsUGIyb3NhB8PBfPSIPdm+I5i\/YCNjoXdRZEOzHxzFXGcFAIkmXp8qoc5IWpspGlUZ7aHzYyTBsY4cKR2ZXunTwSMJl71dxg3u+KraixVc1YPt7QdjILSj3lRVCRFcYRYz8JqMbAWCsySYswf2HFBrHUwToo1exCXXR2\/+K2usxP3DYD3+A+jFjCJMgs2DCR5mHS5yACeV859wADOPfg+Nv22xkYlkaFJfvL5Itj\/Gt2EszrtUsyAH4WwH8SIG5darS\/LV4Va1XPBzoGiLjWH\/YDIR+XnQzteq2288P9Px4y5FkNUvntebKWLG92lkpjcijWd8PIl\/pssp\/Xvfx1IFSp5EmG5wxwnllnxqDyEuEbiecC5lJsoxP7ygdNKzbBKv12wxGkGDcHPtx+lnSRp7p7nkZF0OPSR4aC6AFWMiJr\/2qHS50aneBrKs+N4ZwuyUsHCGmnpLMozi8slQ+lxRQMRXUwq677rtFzcyqSfoLPpal+Tm1133GUKZoLWG+wJ6U3QyTzpq4Hjzkt3MB6MP2Mn\/e3LSjrgzFqw0S6WCc7h26iTpNRyASwTxwVg\/W+pZUdjkn7AoEto61Djgpot7ayIizG8LBFLV2rKp0icDq62MmVcbOjQ0vfKIBU7FpjVbNim6F5Zop+W6+7BMw64eFubEZtVMBAUOfQczGHF5hXq7fEHugBSx+pvyR\/Sl31nJtnmUKE7BpJzxgEKVDCD\/srUhMWjANaIwqp4vgOdzfh5LHsswoAtSQBQMOPbWftiseNL37yBIJ8HL3j130l\/XX2J8mWLg2lTC\/mO4LWjLIFXNahQx3SQULI2fXPwVPVzkh91W5QuEOCJmSNAJMx0T\/tX6Z3Y2agmBFN54H4guuWK4h32mZt+s6Tlte74Xd5s9v7kjO87UdpQoADmwo+zXYq1wPa7s49Vwg0qNd2iA9pFiBaYBbxiSMz\/K57MGN\/aXspOiZCLNS00y\/9k5zK6lK\/mi1Og2kM4vGuJikAGbY2JGQqTZJP8W36tpo9IcLGShYlfsboBpXG\/6FGtlvQPdkRFresJI4EKlOFi\/HL3+2BrwaGRosl1u5Z7TpKG1yj53g+yA2ObPyGf\/dlcWIJO\/QW36VE6WNhuJtdpD996gKi0fJhVCgHsBdhXC7KeZA4QeiqbLvRW2W+aC8tI4kQllACr7JicWTkWGsY2QZUkF7s6eG+12bjNkfzqAGjBXNHaRNCd3\/RtRt8DiMuD2uBi6CTGHVGa\/j6k9iYUZCIYo7NTwRmghFCFAuFZU5MPVmjFGwV8KeV\/PIVfVqGegVgw8b8eVMWxWjj3MnCVr5u1IavEqM\/HfT2XPCmF6xGQgliOty5WrfhlwTDcr8JUOdGWfEiIcvcoVswRy6HCuWyZz6D5TvjMFF6QI9\/55F4lyjl5Ua4XSF5yRj4pp2bhx2L5E+IH43D2xdDDgDG8NJLJH0QFe84gYUMslnm13Xy\/zm9XaGGyby0YaQRSo8e5WdJdDl88yr2r+GcmcC6v70vOLKASuO\/1uWdXhS8UhLkSBkqxEhKWMc5SINR2vUMvsRXGBPhiT1\/OAbJMR3+KlEjH8QLJbLBG\/d9+gFokMRgD\",\"page_age\":null},{\"type\":\"web_search_result\",\"title\":\"The Uplift - Good news and stories that lift you up from CBS News\",\"url\":\"https:\/\/www.cbsnews.com\/uplift\/\",\"encrypted_content\":\"EuIgCioICxgCIiQwOGZkYTRiYi1lMDY1LTRmMGQtOWEzMi02M2M5ZGRiZTg4YjcSDK1e1rYI6nyszjeKQRoMcpjlP33JzHdpbinrIjB8mjG8zQthWu9rE0AzSuQwGiYtXKacyxmWn0kiroYXcZvayvxHhvgpNkCaBc5N0Wwq5R9FJ8qy7cYNOL5A3PK9FVA6BHqSdIymhz267XP4+tURmJYgLsgUyDqMV\/hl3c8A\/pjNdHIGf5tP\/T+00ePuoBcyW5ojK9xpqLECRh3TN7FaIxggFNRWsWxkj6z7uSudkmTokaAlUbBgtwPhBol4hfzQqdh+WBhXRBKQvBoijYCiniWHQwR61MCGxUQJX8X1tz5\/OWAuJXVAjgpRIC+iHFKRzIsiL2PwnMW+sVqA1D9YPnv\/fEtlWZnbluSnP\/CZK0sJDi7n57ygj06BuqIqmy8FQPkn8NITol4Z3OsOSCvE3IhcOvooR5ECauOGtuNeNNdJ\/ax0bQ84KNJD7rbMEdCgVHQv8dENiyiQwVbwvL+YFFnPDiBJwKG531uGWaWBsav3WgMKuJrxNA\/RUvWKOx6NvDyRyrlNQTpprl8VNH8UWB6dJ\/K3C0zY5S19MHrker1FkqwmCpmf+g9wmE9YZWz6Us\/cVuCPTDHCB\/JZZEhzpZh6vsfAVVb3CD9xVMnAsFS+zdN9PECUSUqG1poGvDWWy3Zo6QIK27QHZ3C2hStbQ1e4ljAj9ENTZ3VTN3htnniT4BEkOPk7QdlWlKuGUdxiViyH863OLc9of5vCyKCHttlEOj\/wov14K+1O+Zu9apwuyMhGVmurSRY8UMYRNLe6i91qTkLsQ0GBipg9vtsRgsnWrE\/znqjXOpHasYeTeaTA\/eiSN1aE+4XgQxxoQndN06ufaexIZtHuXr9K\/xSJMuAmWEETYNXJfG\/xehFFe+qG5JMVh3TGrYQMdYKMGeXpaDWrLPrtNsmV7KVH61v9Dw1XfHRamWPPvX9C38lLpozzOVQhr4SB2mJtbWEnFwHD1b7WcntXvlWvDFvs\/cRBf1F\/lOARgJW3VQWX3WJ6Wgc0gZ1ZhAaYFcXCFL1eBRJOZHhqNUko67md\/L5oZruBv8A1W+nu6q5NbUyoAL6XrlFVgCW8NJhw1kcgAp9XZMj4e7yMB6c6KLX2jl9koZgsoXKihfTfOWcfkXjeDtrqUDMv68MtRPBr4W4m9XeAJ+z8nYTS5MoGP4qFt9lRQBP8I34k5BOkomam8Cw5yB4W3PF6XcoMARrW7tU00NFtH6o0UaFNuw42KFZtqhcYybaNPBh9uoNlzV\/Vavbhhm9CyZaf22jTyoOy8TkM9aiIWbLiXpjwiJ+vsKy7lyg71mCQ4EVVKapzOqCZ1TKtIDcx\/pxejusQyyZhVJs\/vfXfwLRbo9Gr96Zshl2O8nIViJUa2bU2wkHvd37sc4wVeY6lo+mMQqDUeCuWG8W6JNqMiuX5IxxthCIo7aQeiSlwYDWIFF2M\/vD\/MZlpJsddivYXFA9kY1o5JuShmnLkku2UY\/WPW2PInc9oy\/ExfTIJfVk7FTnF7eVMz9PvfsNphYhT8EjvGBsUAnyjfQ6tw3x2qnqpjbgp99DREp5Msquja4qjv1er\/f7c82UWtB0GV0WB+IgE9IqOF4h9v6BeVn0IRc6WteDXTw5CcqMA5EjMU3q1HFIM50vDbF4jKQ94PK40mPqalHH1df5cjXBi0RkA7Isnbp6DbWbPTNhpIG7oMehtI15UaCugHYsxybYkhYWUWBMNwg4UVqj6gV7YJSdmkqgr7+lJwcZn6H7qttSVT2W2fwHWd97jmLjMvvGwppZamLSnHKNClFc6vI3mvucPRCLCmY14\/r2SyQpZeRpnoXOfdbjSSIujupfPpn31Xb2adaLFhCJiZp+FNt4s+8+a2CB13XvyxWErMajO1rNurUB\/lHA9yzE5S6MmY4hq7vK+J\/4alczFsAw+Vhd8LUbaeWSMlcTiZye5ubfkKzRaBOBJq\/3DF90F\/7e91aD8C4ySJlpVCLLNAYB4nBpdSKE52FwO+r83lMwjUlhU59+bk8Zj8GO0yqT41wwqrHMOHcPjjpIj7TwLr5sgCvZE5Ln\/4Z9an5UDr566Ohn6qJHy6+j884\/YQGU+DcKFWkpNDp9F4FdP\/N3KcPjCZv1aFxW\/a3WFSSE0U741g\/EXag5S2YGDh8Fl9eFZfd8Lo3ghPm3o9eTs25FvSps62EKmvBkOl6gA+iYDgW1YOIXZx18bizfmFHBptdnm9tm7DbuLDmecitcj3msJqasIlrQFeHkqdIcZ2cq1mTSuXdPAL6OPSwUG3RSXbPgqixbRhtAAxTiqJmMoz0Lou4s\/7ZfV7VP8\/dcOPKCPNzBr4pOznDwk+bU\/ntBsjd0ZD3ZrahE0M1yORBnUhyMOBtdOS3ffSiszdzBMcDhSII+UVJwjVON99rsz8QXUYD4ReLrRzy9xsB71HJBDBs7pE150bwI6hOlSPyr\/HBfpvqcXTvu3j0eMVzaiNAYKAKkYrjcqZaU3YhHw\/8dYeE17Bo8dhNxKW1lOiazBQ2nwRIPG87uUJ7xQDUInHv1hFGX8VZQY2jpRpK5fd0WJaFvQZTsZWROuyI9JVR8BA3KLwDdauXpk\/FjuWYxR4Dw9K\/vJhRSi19mv7jO5STBNwl8feuS\/UoK5oO60vnFm40WVIdaWRHspjrWl7kN3glg8Tm7ub5OoVB\/FS+LAOYCfNy4BkMzqsal\/uhFDXpFHwq7N9KPCD2\/rOtCG9r6wIlmzNu3GLoz8PyWQ9Tn\/wxolXgwHXs0R60ctCh3QZoZMM+yP9xKVVldBq8bmLfw7tHvtQuwv2YSAtWIQ2A8d+EDc7ducECxiQSexSRbTVo+6I67TZA2FA6\/rMcbE1jXJaReqdz3bibiyhOvz4zbMxn1Q5\/X4hhu\/Snm9MM4p66qbsa0XnrVjiQFjGTIJayDqe3zZnbLCCZKfGY1WmyronCklRuUTsosCLtFcqISH72jFYUAaDjBC8Ag9e4ID9UXFo+fDt8I4EhIPdlo21u5BGZDfnGYmi9UjZ6wGgiQQjPKp2o2pwP4EC8pSg40y36ycwGMRakKyoCe3qurhFGJu2ODpbgvasa8SEGJA5QouHcqusCZ0n97bLpHnfYdEQl1GiyMBUPLHOCtaQVOnSbv533cZ0ot9zbEfzVrunZi0iIy2PeACa\/eW33+fKrsJVa5dPFrehAjMVtvnd+cF2eldxkDJHszMloEA3K0M+bQyU0FxOWc05k7QT0CB3nhHeylC1wp97YGTJV4uRlqAZ7qJ22k7E1HNm8I01roXwrR2uLohwFc9GguBmYK\/\/tHTGnbkv1nHlhjTbK+4X0Z9zJPDB\/\/EPcFQiCtiQXtMtoDsuEHD9occgEj2G4LLRXpm3lEyciilynQLmBuC2TxuEkuYWvM0WH2DQYEh3mHlYLTsgCmodKMGXWqZz4vm1Q7N5gW86biKc9aiUYY+s1IkXiaxjKEa6AVtUtOPxHaaE2YiCgoVjiM1AjuZGx3UWguHaxs23F3NuuBYmPlS19rucgmwdZIzxaJyLU0VscSdg+FsaV5AzLa7z8Nt14HEuxMpGY+Gx4mj\/jGzqCJeG\/SafrNniyfG5fIYwNnIspWVq0MXOLlIaQ5swClXFb+\/uCUakeRYppp\/Rd1psnmwAEnjS5VRw709H3ejB5yeki\/pWz1++FTWOzBKMJYTYrfcQ7h+YijPoMrDqIuF0H9gufZ7+9gqtRyY5UMq5BZJwGsK0JqVBpIk8qZzHQ4mBcy\/tjq1TQs7Ot0IIaVkgUwpGwqj4Vl\/u5HwKaGSLUWi0OnHGNuCNuBpFAS6eRX0pflTBPHctyTuaS\/dYe3KJNrbOGztUzkF+0mdnNCbyTBmLUA7MzRny7jO4Zkv6rifH+hLFqi7spDLTcglhF1Zx4NuLb9WuaevEnFeMF5Qv9f8lJR3oEAki7LAYJ97LouzXEU\/HzMmO72Zu+kz5PbU2U4AM6LeVOxMaJFb5oeudvjsuaadq3akOfMbQeQUibeMa7pbGjKsSneBQdvEczAerKHIiRMk5ECNfSOyeCrRPut5LJk9C1WD2Z\/DqMtRFoNPBA5FGZgo3T5NfIIPibuNz6oo+ttb8CSdLrIHrZcZMGz82E7GsYEKKQCO27NOGfeH6hYlrd6gsLe8Qk\/KVJWPr+pSQLDAkkmx8gX7o8N2zYFQsnyGYyV7+BKPF6dK5fc9TO\/oeFZhMuJfmhYkosyWRI+YghDA6V1mtR4V29dBovZVSk72ZZcmyIQxNeQZ6DyhmvlIwxCI5Oh25AruvhrCSmMI1P5e2Sr6fOC\/ewKqq6fBp4d+HNcXlJuAVJYFtq\/B9w+TnjXSAOsobkVedg7YPAfWR5nQM5fDK2+RAsctYu21W7Keso6tGQSDthjWOOsf32\/mV1NsYa8foJr\/G6ORVqTn3XrcB+CMe9v6ylz9BQ4OTSNKMA2pJecgh01+Blcbxjr2ILiUuoZz2fY6bAwSRARh3CKfFzb4Nli1q3\/7oZNRRGNRi2rnGFlXbZSa\/nCw7OVVRWakOodUvkQpo2kjTo1HSHkOxN5gmSrWf9xx49PJKue0c8fXMvt9jJIZRsSmjYvVi5FYqWZF\/Y6A\/FtyY2SU9WAZA7W0PNb+1p9wiCjU4r6s3k7C8L5d\/jRAJr8fXpVrcAhpGmnEF8+KTGEIu7CJIf9D+E+U6oipFT6KefIr+mbjSljzvkoZkjqHR+kzvzJ5B0nniQtrXkPQEdzGJx+Oe\/0vrdFLK9jr29ML+5UK21gbkmkSKO8kA7A06cjJVcCr6mI0THvyawPVe7L57CPpvx+Rare1dG7\/eUdSwxP0U+0AcBxiREwyXuhn\/ClP1Eir5tmhF\/NkENJcpr2FULZBPf\/QlExwY2aCa3yWhBlsaHsoY91wwdAfwD\/axsAZmc7+UpDRLcecEmQAHGhwXiONm1XJCuyGfKutt6Ci\/Sw1WkcG5gWCTe2H5U6ULMWEsz+MaciaNdXjiP\/w7mWhyNERmUHaGxGEyMjySxyR8BM3An760D77CrQjewSHwN8sfqNr4ELi2Qi5Pe\/clnlGJ1kJlnCZLXIOW47lyEU2rA0QyOhrPpygGPnUwRb7yD6R4cONpFw3+rxQTeKsyiLX8cP4clofE1ZtvXZqn\/pDKiTWR0yYI8\/rHao6IMopM8dLC7JSGJjSFdqk2tb9kNctcQ7euuC6HkkDpIFkAgV3LC7iTydnrajFlBORTNQry5StkRfA21cHtRZxnAwjpa5IWIwVW5E7iEtB9\/au0X3nVHO+BzA\/O8crTQLOnmkAb2TccivSwiRgpAd40T2AeqSWW3x4v+H8CiM97kHJCOMY8oXG16Fy2E4PpliZc4Po4CSjUPTT3HpJ6deqERXFelFLQvMp9aUBbmpoIW38X\/b6wqdmQsurFjUa0YMOLKMyXyqDEfdTDA+YHt9k\/EpxM+a5X0HAu\/2od3FG8x0LrisZFdl478+GGe9d99c1mVQPwJ\/dGWYn89ew1FbOKqcHJ1DXh1nErmDdDedXocb+SFhEz9ODD57VZahPJk5+fCbN98h4A6nHEU7zeIy1s3uP1D2VGAM=\",\"page_age\":null},{\"type\":\"web_search_result\",\"title\":\"Good News, Inspiring, Positive Stories - Good News Network\",\"url\":\"https:\/\/www.goodnewsnetwork.org\/\",\"encrypted_content\":\"EqkCCioICxgCIiQwOGZkYTRiYi1lMDY1LTRmMGQtOWEzMi02M2M5ZGRiZTg4YjcSDEP0g3aEVMwaD+jHoxoMblOl+dVQqleK+V3pIjCKvA1x\/hPI++fUoFwGitjZLZhstgUnl5WXrTY1eVU4kmMp04QcDPHsim\/UHjhxYzIqrAGc7iawTJ9hm2hXle5cOn7cX5c1\/mHzQ0n0hzxs7Q5nav72gNq\/EL8Ns1UcIbX2Md2ozVl9Gs2766SMFLETJLL+Yw\/ggkUTwJ3GQWXAYyVPrQtzUwwuJ+3tpquJNvz16ChIPH4aYpbL+FO+JrcBDIiNILB7m18Czig\/VVY2BaoQOgOozNphV0jLrMDSl\/4cbIJcvSoeQIG1sPCgkw4MyXKmSMX9o2jU8hjETh4GGAM=\",\"page_age\":\"October 16, 2025\"}]} }\n\nevent: content_block_stop\ndata: {\"type\":\"content_block_stop\",\"index\":3 }\n\nevent: content_block_start\ndata: {\"type\":\"content_block_start\",\"index\":4,\"content_block\":{\"type\":\"text\",\"text\":\"\"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":4,\"delta\":{\"type\":\"text_delta\",\"text\":\"Here\"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":4,\"delta\":{\"type\":\"text_delta\",\"text\":\"'s a\"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":4,\"delta\":{\"type\":\"text_delta\",\"text\":\" recent\"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":4,\"delta\":{\"type\":\"text_delta\",\"text\":\" positive\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":4,\"delta\":{\"type\":\"text_delta\",\"text\":\" news story for\"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":4,\"delta\":{\"type\":\"text_delta\",\"text\":\" you:\"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":4,\"delta\":{\"type\":\"text_delta\",\"text\":\"\\n\\n\"} }\n\nevent: content_block_stop\ndata: {\"type\":\"content_block_stop\",\"index\":4 }\n\nevent: content_block_start\ndata: {\"type\":\"content_block_start\",\"index\":5,\"content_block\":{\"citations\":[],\"type\":\"text\",\"text\":\"\"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":5,\"delta\":{\"type\":\"citations_delta\",\"citation\":{\"type\":\"web_search_result_location\",\"cited_text\":\"The list of imperilled species continued to grow in 2024, but some creatures came back from the brink. The Iberian lynx (pictured) was one of them. It...\",\"url\":\"https:\/\/www.positive.news\/society\/what-went-right-in-2024-the-good-news-that-mattered\/\",\"title\":\"What went right in 2024: the top 25 good news stories of the year - Positive News\",\"encrypted_index\":\"EpQBCioICxgCIiQwOGZkYTRiYi1lMDY1LTRmMGQtOWEzMi02M2M5ZGRiZTg4YjcSDAJiAv0DS8YpVcJZkhoMiWSuM1TWAokEriYxIjA+\/NOu2+nUGsux7eS8m2HTHTU6os2\/hoX6AXyXkvqQhngZWyFbFrohC+sCnTlhaeEqGEI7CbxR1WpLmIJralxzbqcmogUZ\/fTBbxgE\"}} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":5,\"delta\":{\"type\":\"text_delta\",\"text\":\"The\"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":5,\"delta\":{\"type\":\"text_delta\",\"text\":\" Ib\"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":5,\"delta\":{\"type\":\"text_delta\",\"text\":\"erian lyn\"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":5,\"delta\":{\"type\":\"text_delta\",\"text\":\"x cl\"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":5,\"delta\":{\"type\":\"text_delta\",\"text\":\"awed its way off\"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":5,\"delta\":{\"type\":\"text_delta\",\"text\":\" the endangere\"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":5,\"delta\":{\"type\":\"text_delta\",\"text\":\"d list following\"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":5,\"delta\":{\"type\":\"text_delta\",\"text\":\" a decades\"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":5,\"delta\":{\"type\":\"text_delta\",\"text\":\"-long conservation\"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":5,\"delta\":{\"type\":\"text_delta\",\"text\":\" effort\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":5,\"delta\":{\"type\":\"text_delta\",\"text\":\" in Spain an\"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":5,\"delta\":{\"type\":\"text_delta\",\"text\":\"d Portugal.\"} }\n\nevent: content_block_stop\ndata: {\"type\":\"content_block_stop\",\"index\":5 }\n\nevent: content_block_start\ndata: {\"type\":\"content_block_start\",\"index\":6,\"content_block\":{\"type\":\"text\",\"text\":\"\"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":6,\"delta\":{\"type\":\"text_delta\",\"text\":\" This\"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":6,\"delta\":{\"type\":\"text_delta\",\"text\":\" is a remarkable conservation\"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":6,\"delta\":{\"type\":\"text_delta\",\"text\":\" success story from\"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":6,\"delta\":{\"type\":\"text_delta\",\"text\":\" 2024.\\n\\nAdditionally\"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":6,\"delta\":{\"type\":\"text_delta\",\"text\":\", there\"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":6,\"delta\":{\"type\":\"text_delta\",\"text\":\" were\"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":6,\"delta\":{\"type\":\"text_delta\",\"text\":\" several\"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":6,\"delta\":{\"type\":\"text_delta\",\"text\":\" other up\"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":6,\"delta\":{\"type\":\"text_delta\",\"text\":\"lifting developments\"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":6,\"delta\":{\"type\":\"text_delta\",\"text\":\" in\"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":6,\"delta\":{\"type\":\"text_delta\",\"text\":\" 2024:\\n\\n-\"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":6,\"delta\":{\"type\":\"text_delta\",\"text\":\" \"} }\n\nevent: content_block_stop\ndata: {\"type\":\"content_block_stop\",\"index\":6 }\n\nevent: content_block_start\ndata: {\"type\":\"content_block_start\",\"index\":7,\"content_block\":{\"citations\":[],\"type\":\"text\",\"text\":\"\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":7,\"delta\":{\"type\":\"citations_delta\",\"citation\":{\"type\":\"web_search_result_location\",\"cited_text\":\"Data published in October revealed that emissions in the European Union (EU) plummeted by 8% in 2023, meaning greenhouse gas pollution in the bloc is ...\",\"url\":\"https:\/\/www.positive.news\/society\/what-went-right-in-2024-the-good-news-that-mattered\/\",\"title\":\"What went right in 2024: the top 25 good news stories of the year - Positive News\",\"encrypted_index\":\"EpIBCioICxgCIiQwOGZkYTRiYi1lMDY1LTRmMGQtOWEzMi02M2M5ZGRiZTg4YjcSDET5iSMIwD8PZJx\/lRoMr9V+\/XBx8RlbHs3WIjA9FvLbAI3CzEiJz1JqezLgOpSuU7OIyYWVnaBTkzBT1dHzMkn2AHAMnzajMmcOil8qFu+LnGj7MmQxJvuV7zK7oHLyl43zZckYBA==\"}} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":7,\"delta\":{\"type\":\"text_delta\",\"text\":\"Emissions\"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":7,\"delta\":{\"type\":\"text_delta\",\"text\":\" in\"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":7,\"delta\":{\"type\":\"text_delta\",\"text\":\" the European\"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":7,\"delta\":{\"type\":\"text_delta\",\"text\":\" Union pl\"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":7,\"delta\":{\"type\":\"text_delta\",\"text\":\"ummeted by 8\"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":7,\"delta\":{\"type\":\"text_delta\",\"text\":\"%\"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":7,\"delta\":{\"type\":\"text_delta\",\"text\":\" in 2023\"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":7,\"delta\":{\"type\":\"text_delta\",\"text\":\", with greenhouse\"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":7,\"delta\":{\"type\":\"text_delta\",\"text\":\" gas pollution\"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":7,\"delta\":{\"type\":\"text_delta\",\"text\":\" now\"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":7,\"delta\":{\"type\":\"text_delta\",\"text\":\" \"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":7,\"delta\":{\"type\":\"text_delta\",\"text\":\"37\"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":7,\"delta\":{\"type\":\"text_delta\",\"text\":\"% below 1990 levels,\"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":7,\"delta\":{\"type\":\"text_delta\",\"text\":\" driven\"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":7,\"delta\":{\"type\":\"text_delta\",\"text\":\" by the\"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":7,\"delta\":{\"type\":\"text_delta\",\"text\":\" closure\"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":7,\"delta\":{\"type\":\"text_delta\",\"text\":\" of coal\"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":7,\"delta\":{\"type\":\"text_delta\",\"text\":\"-\"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":7,\"delta\":{\"type\":\"text_delta\",\"text\":\"fired power stations\"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":7,\"delta\":{\"type\":\"text_delta\",\"text\":\" and rapi\"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":7,\"delta\":{\"type\":\"text_delta\",\"text\":\"d roll\"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":7,\"delta\":{\"type\":\"text_delta\",\"text\":\"out of renew\"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":7,\"delta\":{\"type\":\"text_delta\",\"text\":\"ables.\"} }\n\nevent: content_block_stop\ndata: {\"type\":\"content_block_stop\",\"index\":7 }\n\nevent: content_block_start\ndata: {\"type\":\"content_block_start\",\"index\":8,\"content_block\":{\"type\":\"text\",\"text\":\"\"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":8,\"delta\":{\"type\":\"text_delta\",\"text\":\"\\n\\n- \"} }\n\nevent: content_block_stop\ndata: {\"type\":\"content_block_stop\",\"index\":8 }\n\nevent: content_block_start\ndata: {\"type\":\"content_block_start\",\"index\":9,\"content_block\":{\"citations\":[],\"type\":\"text\",\"text\":\"\"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":9,\"delta\":{\"type\":\"citations_delta\",\"citation\":{\"type\":\"web_search_result_location\",\"cited_text\":\"In Ecuador, Indigenous leaders have gone in a different direction, teaming up with Lavazza and us to produce the world\u2019s first \u2018deforestation-free\u2019 co...\",\"url\":\"https:\/\/www.undp.org\/stories\/good-news-you-may-have-missed-2024\",\"title\":\"Good news you may have missed in 2024 | United Nations Development Programme\",\"encrypted_index\":\"EpIBCioICxgCIiQwOGZkYTRiYi1lMDY1LTRmMGQtOWEzMi02M2M5ZGRiZTg4YjcSDPKxdynTAy2IBJVE6hoM7gdeoadYSYCnt8gsIjDy2zV7Rd5a\/asNKCAi78p50UrIq8s07SLozwp6ciahyKoMlGDXLtk0QnoW4UW5hJAqFjLzLOJ2u\/DlVlEB2xNKCTyCDnYIqr0YBA==\"}} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":9,\"delta\":{\"type\":\"text_delta\",\"text\":\"In Ecuador,\"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":9,\"delta\":{\"type\":\"text_delta\",\"text\":\" Indigenous leaders te\"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":9,\"delta\":{\"type\":\"text_delta\",\"text\":\"amed up with Lavazza to\"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":9,\"delta\":{\"type\":\"text_delta\",\"text\":\" produce the world's first \"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":9,\"delta\":{\"type\":\"text_delta\",\"text\":\"'deforestation-free' coffee\"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":9,\"delta\":{\"type\":\"text_delta\",\"text\":\", which\"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":9,\"delta\":{\"type\":\"text_delta\",\"text\":\" hit the European market in September.\"} }\n\nevent: content_block_stop\ndata: {\"type\":\"content_block_stop\",\"index\":9 }\n\nevent: content_block_start\ndata: {\"type\":\"content_block_start\",\"index\":10,\"content_block\":{\"type\":\"text\",\"text\":\"\"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":10,\"delta\":{\"type\":\"text_delta\",\"text\":\"\\n\\n- \"} }\n\nevent: content_block_stop\ndata: {\"type\":\"content_block_stop\",\"index\":10 }\n\nevent: content_block_start\ndata: {\"type\":\"content_block_start\",\"index\":11,\"content_block\":{\"citations\":[],\"type\":\"text\",\"text\":\"\"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":11,\"delta\":{\"type\":\"citations_delta\",\"citation\":{\"type\":\"web_search_result_location\",\"cited_text\":\"In Australia, three iconic species \u2013 western quolls, bettongs and bilbies \u2013 returned to Sturt national park, New South Wales, while the UK\u2019s crane pop...\",\"url\":\"https:\/\/www.positive.news\/society\/what-went-right-in-2024-the-good-news-that-mattered\/\",\"title\":\"What went right in 2024: the top 25 good news stories of the year - Positive News\",\"encrypted_index\":\"EpEBCioICxgCIiQwOGZkYTRiYi1lMDY1LTRmMGQtOWEzMi02M2M5ZGRiZTg4YjcSDPvsCDsvP8nPLolP3hoM1hIY7dZvZTQc67eaIjDffKwB4cxNbq\/x3E\/zVKeFfwavHfusVnt6xASnSj+yFqAoOVjwV1u0JN+FqyRNjfsqFalUEc0GKLqY4MbOlk8FAzUcm\/cEehgE\"}} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":11,\"delta\":{\"type\":\"text_delta\",\"text\":\"In\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":11,\"delta\":{\"type\":\"text_delta\",\"text\":\" Australia\"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":11,\"delta\":{\"type\":\"text_delta\",\"text\":\", three\"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":11,\"delta\":{\"type\":\"text_delta\",\"text\":\" iconic\"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":11,\"delta\":{\"type\":\"text_delta\",\"text\":\" species\"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":11,\"delta\":{\"type\":\"text_delta\",\"text\":\" \u2013\"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":11,\"delta\":{\"type\":\"text_delta\",\"text\":\" western\"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":11,\"delta\":{\"type\":\"text_delta\",\"text\":\" qu\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":11,\"delta\":{\"type\":\"text_delta\",\"text\":\"ol\"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":11,\"delta\":{\"type\":\"text_delta\",\"text\":\"ls, b\"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":11,\"delta\":{\"type\":\"text_delta\",\"text\":\"ettongs\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":11,\"delta\":{\"type\":\"text_delta\",\"text\":\" an\"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":11,\"delta\":{\"type\":\"text_delta\",\"text\":\"d bil\"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":11,\"delta\":{\"type\":\"text_delta\",\"text\":\"bies\"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":11,\"delta\":{\"type\":\"text_delta\",\"text\":\" \u2013 returne\"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":11,\"delta\":{\"type\":\"text_delta\",\"text\":\"d to\"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":11,\"delta\":{\"type\":\"text_delta\",\"text\":\" St\"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":11,\"delta\":{\"type\":\"text_delta\",\"text\":\"urt national\"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":11,\"delta\":{\"type\":\"text_delta\",\"text\":\" park,\"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":11,\"delta\":{\"type\":\"text_delta\",\"text\":\" New\"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":11,\"delta\":{\"type\":\"text_delta\",\"text\":\" South Wales, while\"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":11,\"delta\":{\"type\":\"text_delta\",\"text\":\" the UK\"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":11,\"delta\":{\"type\":\"text_delta\",\"text\":\"'s crane\"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":11,\"delta\":{\"type\":\"text_delta\",\"text\":\" population hit\"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":11,\"delta\":{\"type\":\"text_delta\",\"text\":\" new\"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":11,\"delta\":{\"type\":\"text_delta\",\"text\":\" highs.\"} }\n\nevent: content_block_stop\ndata: {\"type\":\"content_block_stop\",\"index\":11 }\n\nevent: content_block_start\ndata: {\"type\":\"content_block_start\",\"index\":12,\"content_block\":{\"type\":\"text\",\"text\":\"\"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":12,\"delta\":{\"type\":\"text_delta\",\"text\":\"\\n\\nThese\"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":12,\"delta\":{\"type\":\"text_delta\",\"text\":\" stories remin\"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":12,\"delta\":{\"type\":\"text_delta\",\"text\":\"d us that despite\"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":12,\"delta\":{\"type\":\"text_delta\",\"text\":\" challenges, \"} }\n\nevent: content_block_stop\ndata: {\"type\":\"content_block_stop\",\"index\":12 }\n\nevent: content_block_start\ndata: {\"type\":\"content_block_start\",\"index\":13,\"content_block\":{\"citations\":[],\"type\":\"text\",\"text\":\"\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":13,\"delta\":{\"type\":\"citations_delta\",\"citation\":{\"type\":\"web_search_result_location\",\"cited_text\":\"In a year marked by crises, 2024 also brought moments of triumph and reasons for hope. From groundbreaking business innovations to examples of remarka...\",\"url\":\"https:\/\/www.undp.org\/stories\/good-news-you-may-have-missed-2024\",\"title\":\"Good news you may have missed in 2024 | United Nations Development Programme\",\"encrypted_index\":\"EpIBCioICxgCIiQwOGZkYTRiYi1lMDY1LTRmMGQtOWEzMi02M2M5ZGRiZTg4YjcSDAh63hyj+dGHtgR54RoM7hlT1YiatNO2cOXgIjCCRm+vHOvFQy1NRePP+SadoSVx7dSOBHcDwSRY8kHuQihmNtZh7T4GmCBUZ84DLBEqFliOKcBlBNQEu99484aWQvQaBa8jFXgYBA==\"}} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":13,\"delta\":{\"type\":\"text_delta\",\"text\":\"2024 \"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":13,\"delta\":{\"type\":\"text_delta\",\"text\":\"brought moments of triumph and reasons for hope\"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":13,\"delta\":{\"type\":\"text_delta\",\"text\":\", with groundbreaking business innovations an\"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":13,\"delta\":{\"type\":\"text_delta\",\"text\":\"d examples of remarkable resilience.\"} }\n\nevent: content_block_stop\ndata: {\"type\":\"content_block_stop\",\"index\":13 }\n\nevent: message_delta\ndata: {\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"end_turn\",\"stop_sequence\":null},\"usage\":{\"input_tokens\":24172,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":0,\"output_tokens\":451,\"server_tool_use\":{\"web_search_requests\":2}} }\n\nevent: message_stop\ndata: {\"type\":\"message_stop\" }\n\n", + "context": [] +} \ No newline at end of file diff --git a/tests/fixtures/sdk/anthropic/messages/tool-use-web-search.json b/tests/fixtures/sdk/anthropic/messages/tool-use-web-search.json new file mode 100644 index 0000000..39828d7 --- /dev/null +++ b/tests/fixtures/sdk/anthropic/messages/tool-use-web-search.json @@ -0,0 +1,31 @@ +{ + "statusCode": 200, + "headers": { + "Date": "Mon, 29 Dec 2025 00:44:10 GMT", + "Content-Type": "application\/json", + "Content-Length": "48506", + "Connection": "keep-alive", + "anthropic-ratelimit-input-tokens-limit": "30000", + "anthropic-ratelimit-input-tokens-remaining": "16000", + "anthropic-ratelimit-input-tokens-reset": "2025-12-29T00:44:29Z", + "anthropic-ratelimit-output-tokens-limit": "8000", + "anthropic-ratelimit-output-tokens-remaining": "8000", + "anthropic-ratelimit-output-tokens-reset": "2025-12-29T00:44:13Z", + "anthropic-ratelimit-requests-limit": "50", + "anthropic-ratelimit-requests-remaining": "49", + "anthropic-ratelimit-requests-reset": "2025-12-29T00:43:56Z", + "anthropic-ratelimit-tokens-limit": "38000", + "anthropic-ratelimit-tokens-remaining": "24000", + "anthropic-ratelimit-tokens-reset": "2025-12-29T00:44:13Z", + "request-id": "req_011CWa3ygsBLhAHKai9Anoas", + "strict-transport-security": "max-age=31536000; includeSubDomains; preload", + "anthropic-organization-id": "08fda4bb-e065-4f0d-9a32-63c9ddbe88b7", + "Server": "cloudflare", + "x-envoy-upstream-service-time": "14596", + "cf-cache-status": "DYNAMIC", + "X-Robots-Tag": "none", + "CF-RAY": "9b554418dd1376fc-LHR" + }, + "data": "{\"model\":\"claude-sonnet-4-5-20250929\",\"id\":\"msg_01E1EhXQH7WWEs5GDv3J1tvP\",\"type\":\"message\",\"role\":\"assistant\",\"content\":[{\"type\":\"server_tool_use\",\"id\":\"srvtoolu_01UCca6C8QR7VCM8YV8efrub\",\"name\":\"web_search\",\"input\":{\"query\":\"positive news December 2025\"}},{\"type\":\"web_search_tool_result\",\"tool_use_id\":\"srvtoolu_01UCca6C8QR7VCM8YV8efrub\",\"content\":[{\"type\":\"web_search_result\",\"title\":\"What went right in 2025: the top 25 good news stories of the year - Positive News\",\"url\":\"https:\/\/www.positive.news\/society\/what-went-right-in-2025-the-good-news-that-mattered\/\",\"encrypted_content\":\"EpkRCioICxgCIiQwOGZkYTRiYi1lMDY1LTRmMGQtOWEzMi02M2M5ZGRiZTg4YjcSDOCd7OYZCZJGJ5J53hoMl2H1b1+WJEyljCTsIjAIZJyvRQj7Gz5\/l5Wj8U+uJFASJUJ6Wx8XMs\/1v4kpnN1iEfqACSXAbFNKKHZFHfgqnBAbIZGCkg7SzadRWWxA0hU5NBuhkMBxtjzu4Svr3bSyaOShc\/lVqyFIUlFjUBSMZFt1tnD4Whx+58cy0AuSIWGnOqfPhaZVyQ9ZmjUpP1yuo9D6P0BBXzDi5s2SGCBfFuFHEhQ12\/SZeY0J\/PvpIkC4Hyy38+lhPPixy9DNk8Tiwe18PVyRVtIRr9fc0A+pMP0Qu6DUmoA0aKcSk6NxSYJSGVBEe\/qmvKJJzpzx1QHdbmDBaHuG315BDBSWP7yuqSDX90BLH\/XUDFBfDHeugqnAtWAW8Ca0yjuVpzk2XIC8+Ob3UJvCzbj75ndyj1P6Ih4VhArvzp2JcgrKgtLAe8HG2Yb8i1O2kFkaHK3ebOwNnbdMnCtEDk94WljsZHvTJ+7nrvAWJ5qLZvNZzWChHpZtyci0cs+2mMZ7AIW30R+Oa33pHF8xWU6Fvkp53hqqJOqGP07VhI8Diq9j2efUK0KePaO9C6Y1G+\/JS0GG0JLOgV2IGIVTi+SRqK0v6WEOkEpGLngKUDalfWUP5UCZ2M4MG68uhNMvxwPHSK3DungxXx9jeveRLje+2de9aKJHZJKnvc2Z\/3+0Ev1hDJs9tAX\/saxpq8g4WLLA7xfrxIxrQ5wTpOBuEPzdx9a83m3R1nhgfqefKnSF\/K0zV13BUdDgy95ZQHqHjT8CsPxgMZ3BA60MzbzBPtZv6Yqbo4bTSUyrj4QDeayavTha1pEfXbJ5hhcoROjAsIo0WN9hqVXqHY5I3F8QJQEjJ\/JNrLbYzR3yPpDvnMO2W4hrhWABSMXzymA3F7y2w1KO2D\/M9Yg5+AoWONJWfAViSCUt3fszRqU8s6qAcDak4cMKyxp5ZONq0J4CcUwz5QiefmdVBcmn1UT4sdjxK9AdSp1qDulXAv8Sx60mkR5SvGQeH4niAX3Q7tJegy96J+pJGJe9pxJMFDMxNRYI8IlMNZS1pnP4AFGGiITj\/T1c752uVOmvIsbm2Iw+djFu8hYrDfB7dR692BZiRWeeKPTxLRzLVOG\/O2+pK8A0bqOqfMg1q1UNaJ2SCf\/TdG\/AybH3ngRrcrUNrezHkUZ1FKVLHQ3GJp4G0KrNauycVC\/DgyZG6kOvve1d3HQzM3pyE469XgAkoGJmUnsdpYZEt2fUxaPb+s+NhSUs51vGqdw1b9NQVl9KOtIWfLK3fQh+0yj8dBVlukKgvvl8NLNmBufxExCa80ihDSnDog6nQ9L83HYJ5uvyox9\/oyCJpGEAnzJx+sLBQ\/6b8PQo7E8H8Lr0UYBC2wj3bngZCeWRsPJL0LYR7LwnrWYKxVwFhkzohx3j3a6j5mIePyoA8rskblaNpjNTaq8qQslfSo3Qduv1nQauhSphyIuVevdBO1iEJSe0Dd3lLvPdnTMm3K665uI516JvY4TUkeXokxK\/1sClNIUKGZFn8X442tJOQ896zfTAyPbHOcA4kQbrh00SbuZilpMqP+SOQL1IVgIURh50AmERKHp57v58f6GA8ZW6AkB93j5uDSD52M5KMgquiTJDSKlbnZX+bq\/2Y7Jls4bbt5ImC5G7s5ojI9msDFbsoTkCG\/w7zagAtmhBez+PEqzCKQ6NMucsr\/NmWuPmAybNmljhmvbrgA4CBVPjQJM4TZ2J9ctILhrF9rQ9Kh5OzgeJhYqmOUdq70riuw9i5oCL7yR\/pAS3VYryfBI\/32\/gG\/eGf80UhSfsdqEXRvhvGriosgwwJ1PkgBbnZqDKqq1iJvLMnFVFkKZ6FQIY2RP1dpU9cfkCX6cu1If+fFtUJY86eI\/yu7xx7Z3WD\/4xpUMNId0ao0GN18uOY5KbLy1+UslOjdPcKDlRKYo3i8j2YVqf1jC8ozhGPGfBgeLpnovpZjE4Yhc5ZVuGfDLrczY6wWIh7DugmTYxmJfD3773Ez4LaCeV73dHLgn8ym4NV\/pbaBkcXo59Nc1hLYIpSUM3aFbBs6ruCZBMrfBMJ+yA3f0KTM\/45xdRoG7g0YUTCZgeDk5wXHgH0UgJNj4T2Dg\/nFgjSIW1371\/EAcFYBWMUzVTMidHqL\/owxEq9CkpCA3Ajeyt71LXJpdQV0XhmG9yROjJXfNuRcdDTeo8GLE5KcZltazYaLPz0aDFUXfm6eE98EuWrtbMZnc0ixGFsR6ecczdnEq8dBHsSxGT0o5vnqd9dDvfNtNsRxdPuRVxBO0u4J4\/tWHkdwntQ0U2QQI\/DYUAEHLRp4v2INP63aOpUAF5Gxee5UO+Mc9fa3rXB4fAKMve1COVvY0+PqJaHHki5rhRnMisomKd\/CkFQfqNGSP8foc2mplAVMCwDVU5gRIP6ioUFx\/WiDMj\/oFXAuSxRTptjgLRWXS7MEn+WTdU3nbyVy0L+mnSJXC0MaYJoK+tHIeWVqRH7gruUmLR5xFu8ue6wG3ImijI\/WzdmGyoH4\/wway9j0eE6sisrFsVZnV03KEftExqgDB4w1kdLyz6bZeioQYpmbS0U3EHmbgNtCvRxpHvclSNId\/f4Xg56PP6aodiyOoU1oluGRAxQfUqFsuCyohpXIbfyJBFVw2QJJN+1ZQe1d4rG5XwPo6erEqNb+7DC+icVMFs\/DP5+bsDqH8xIBQzwemGiscUTVHNR1ju2IG\/RuVieUD2DLHttHvJIKVVU8FKNDT+JoCSpv\/FPG6igOm1xWaOSfNpUHokYoBJAtpH\/8eExp0NJdqX50zSnf1AdHBQMSxu\/rOj9+hkpHvUWIGdSsDKdRLwCrYS19yil6uxpzwn91VjlDMWgHqY5dsJ+TQYAw==\",\"page_age\":\"1 week ago\"},{\"type\":\"web_search_result\",\"title\":\"Global Good News - Good news from around the world - 19 December 2025\",\"url\":\"https:\/\/globalgoodnews.com\/\",\"encrypted_content\":\"EtALCioICxgCIiQwOGZkYTRiYi1lMDY1LTRmMGQtOWEzMi02M2M5ZGRiZTg4YjcSDA06XQMfWOu3SxK0xBoMQgkPB7\/957m5PXIcIjCN1nTT1BnaH8mLozQBFW3+6FJAkxDVdKrKPXDTWcmQ9IhfQvW+JjrKDwb1dRt5RQUq0wq1EDIvpU14W2pVemdmdh\/X0C1PVPXyMIKcZzTgA2L7SB3eD8ZiE6abA\/VFQFDhxQ0y3JXW4Y7Vaof3JkxxlTqwMa0etKM6UYjMyoZOQ0B3V3hjQPXyY7P7NWa3e8xIDWI5IHieuPTh5ZRQJ3SfxL0et5shFUksJ7HdwuBOT+a8lMvNcsAYIjhOHKwg39ea8eWc7+ES1NRYlXNQorx8VpCvpXyBbNrdgY+tOjpAk5V+\/tLp7uml4G1ZRpuBMVdxRRe7JeGOIVb6vXgV3mpFL2he7XiVjK0kYwlmdPZMmAkuTg4IjFrSMYY+6umdOxSFbz+JKl8Ap\/v1TMbCZT0LYWZo8fKF2bENqxlRmAzS2+f0cd7WSAw641xvLuNx6ueLeLY8gk1bKuZgMQ+1JRDwuDoJpzVl6Tl\/jA8ogqGGW8HU2aBaP7EutuNGcdup\/YxaQJvzaFKzeJxD+taMox\/hPIYjnp70\/1PeRFbDqAieZlzTEHEvG2oZtGokELz8GuSvlsaMyhrIUsS9aB19k5rfF\/p9m4PJHtwXGVbCoVxnHmg8vM517b3GGhuYtgUlPZlRUCAHuNC5eOW\/a7xbiBF0BZU6fxHdF\/0Pnbw4SnUBUGmnqQFFTSjC0\/TxIGtHtlSt2I\/Zv\/BCTb0HU\/\/kEDPj0\/ynkxXkQNyiTAaqLnX4oWsQmii3ca2kzc38QDQDOpDl6WaOweH6fT4WWDF\/VViaJdiZK74cBNfrGRDWhviY8wxQ4ZKb3TxMQ8twanF7qoqktTb+C7uybI87HYnY9pRY5K8LShKQ4rHBAQLt5VEWOCgha8i+7OvVGwc+yBbFLIYmkZqwZDfxhNT2v9a4JeFCOG0H65Bwq8T7jF89WB9VlQOaoRjVgNy1s1rxzxvvVKe3qnGziWN1BHjfiwN0eb3WkhbV4Rir6AjAi+AVEcypbEHzZj9KTmPbvwX4wSig8JXwU\/mobELgJSBIMNj1cBs9Xl2dO67SD0LP2pPgZR+c81LdHtdkLqs2oWm2iefp3rt1NC29NWvJUmocNEhzMtkRCq0SxuH5jrCFmDzlovVupQIxGXzV7gDWthrNczBY08FfO0FOv5cHbyP8SvWHHpgpOKkA89DDYFK+zxUhRhLAwKbc73MjIZP6YkUMH12fccSYbpsfb6JPKFPGueLeYo8CSSkQhro7rEAkeS8N5HmJw\/IaKQ9U8M37iiYNMhNNSvZnL+5s8bzlj6YtQSGSfzrNNRPmR0nCYcvPZxuvsWA7t9swCogSpbPxleeJZiUMA8gcuLQp2HftKBbio4K1uXRcFhQH+Tpg5\/1bRgvKTKqUlZgzok86iodSDYmtk\/xTpkK2l6IYHVhyEF7Kp2yNFVQYYNn33Hy8cbw7Wdkmq5fM10r7LWgjUkd3U6J8qpavqD6y2TVVF770mGT90XKyJFVApUI1AmrPzA8PtQw2uhaXMSqFSDRwmXRd4eb\/IPbmz1i8lvJGvmQ5Q5us\/hOhuppCOqP\/rvS976feo4LO2h\/kz8\/txrs1lHwPjB6JsLJmSH8O2upuqd3RBmDVyOsqHy3p37YIQ\/h3KM0yKRMCDUOgjvqD730l58y574JhV3QtvNOALGob9sylHrnWKdIAdk9dNRoeQjV5aKvyju+UEcvkflSZaZiQPEmbv5kWGkNJYh6HjHlxsbGPCSyLgt8ymirL6umlQt0OHqGD4zrOVCLOIYLpkhPtlsSvWj5Tm3Z0h5Nk9t\/zqyb5ZBJJygItvKNAK+KReR4TeDH3GfEj8h\/KRVAFRfuaM4EUZOEfCx\/dX0pwXA2CIyjF+OI3M6RFfropsqTDRevRGAM=\",\"page_age\":null},{\"type\":\"web_search_result\",\"title\":\"Good News, Inspiring, Positive Stories - Good News Network\",\"url\":\"https:\/\/www.goodnewsnetwork.org\/\",\"encrypted_content\":\"EuYDCioICxgCIiQwOGZkYTRiYi1lMDY1LTRmMGQtOWEzMi02M2M5ZGRiZTg4YjcSDPwxhD7d2MbVRKkwKhoMdidy6QPDM3n8GafzIjDSPbkbm\/VnFAGpa8u4oLmQa5EVrZsacvRfoRgnvccMSHPViLKppsK461IZQw0RRDcq6QLksmnf6I7gNg4y+LWXGSMPRh+Z8lAQW3yRX+c5RsE+FRkxW68+f7mUcO0+usq+ix+kMu+WlhWlk1OViLEu8aSoJPAStFn8FBcIOFXMOQHEDMV29Mu60qV2TYgg0HJ8lHm4wVRxmwe0pibAkLHzz2flOPdchjrzuu9tPIrUiYqefG+KEHKNFx+V8yAaMbb2oM+5T6Vskp9BaFJGRkC6pizrgHN\/19ZS4rVTfzsta7vjUXM+X\/TjdFK72xA\/4U5WTIgPaixTfpfWQa1mRaB4hhEfz5mLgxaiwhfY7FW54pnXekO3UHDLcMD\/\/ImoCElCZaAzCE0D3yssaG\/4cbd5Hq32\/bc3TcIfnnqUMkwQIUEaUo+zKH3JRsiABBBrKF\/cgHY5H7jjD4TNr5XMaseclbGJHBY0Bws9oQjY99yt9Jnh8iFiVauJbDratTu0NQDQ31oMERx0VE1XTtq1pm23uDhAkaqWG9isLt9yGAM=\",\"page_age\":\"October 16, 2025\"},{\"type\":\"web_search_result\",\"title\":\"A rare cancer-fighting plant compound has finally been decoded | ScienceDaily\",\"url\":\"https:\/\/www.sciencedaily.com\/releases\/2025\/12\/251227082728.htm\",\"encrypted_content\":\"EuUXCioICxgCIiQwOGZkYTRiYi1lMDY1LTRmMGQtOWEzMi02M2M5ZGRiZTg4YjcSDAbIeuJsjI9wE4CW7xoMugchUKMRM1M4f8KbIjC2wZ6uBZkpdK6lW7Q9QHPGOcQ88WwezUtZKezkafePzFogOZruS67thBAXQz81wzcq6BbvaRHmc7vd4b16GovsDudHOXXgIXj4qMrcRBOsUSkbJqkei6h0seuvo7uMdHiNSBYhqodId2uT1u08rpitP5uKkp+4Mw0ajUtM\/nnAuHJQtzGvOUzHKtCQ8vcrxRfxvPFAvai2WKXpPT0sH+i3KHEYqbVp5nKQlqAV9nvzkgkACs7LNy5wjZErsyTeoP+y0KRpOniorOrMuiAv8fdMkNW8au7rl+Hat7uC6LO0EnGfJoiyHyp2Q2BSisjKQYgU\/eWi1ZK4yOom1DFaR7kd6e5T8zsMDkxuOj4+CSJ0ZzmqEajTqT7B29H2Krl1l2jxanuCiZM5KMhJOGoXs2sZFl0iZKaEM8+EdX\/JfofQZApdgSiElhxdSZ\/UNGKDpmmSOaHwW59UxRWV2JJbd4XVQBow18YFnpW3dtD52iwLqr3zSb4etR+RH7ZpkrVnoxpa1N+AYMnWE75BLVDODtK6AgNgXtYQ8QD2U2fxC4bgf48lLdH1z8EAUBozzzVOVsYz2FAB5Os6Nb5lkAD4gesQz3DALOgIZS\/7sjNp9WRxvMYUXBHCAdhSL31mQG7QoIl0+Y0mMAyJPg8PuLKzgkfj7GlVuJq9WWA5JquKRSX3HzLLIkPT0EuqAuXoGQYqXfSsDWaYKZDTvu+2sI4LQY834Jzs\/wdYg5txxYQh0wBV6qUuFuRSEVLgg+sOJ6gz2ber7qRie+omH2Zu\/oTZLMSoO75\/JGY4WOEuscjGwmMrlBXBp+F+F2QkHOmb+GzWeS0B8WfXy\/YrYMZVQ9H0Hvxfhls7DFLa5TEoWz0w6jsRrN7uFsbC720qIQ7WaRAI2xojoTvTWoeg8EXWocaGP5oxStnR+VZwImjeh3m\/sjqWb1wmvUP9fyTHsMNGHwT7OUKmSpes4PgPmuQ2rcZcwcEahMvFqHG+uLM8M0T2B76rgox0XYYTRMiokNkX2bPn8fVZyoR4iqftCKv6+qEvpajFvai6Lemam9GF5SK7kofYuaW4vmwWfF+a41nUVww4KoXEhH0NurW1KtUHHIITW8mRgFa1Pr5ycDy9ObiMOTwUozDb88QoU24yybKxTcTo+KsBNW+Gd2X5PBDFhPQPNjj2l48cS6RwDm7rpQlac4X\/TLbYE0xaWDRv5dVhUIV+kejoU2T3HykbG5W5IF4B9w3n\/5FqhFIaPs+LMpGDqHB4H+\/7xcb98i+R6jD5L9YxYTsnHrlMCer5RhA1+ol35nNPBnxdp\/+wy+spewzfLvyOIlLAeNhSmpj7EG5MSmF2bEPfAHOUONjubLtQ9vN4jdhY1RXKUqdH4lVSV1CRajwptfWuo2HktE8UYFfb\/ZHe1dCA+jsu5CemTNwyiZTnHDIalYpIE9jeTuMpJ96uZwMIUvWTj9xHN8JORsDxTy\/hBo70kXUkxW824NlYPLwjQk5E1yqTByYh5PWOr6y7WpueIIa1XPsNGtwuo5GWBMiXnehoe6JvoL+daccYuQ+ExkEkNIjPrDOUCEK2NxRZ6ZwjndP8VQ5yElWn70IzB2rZrWnioSgN4+Wim6\/HYHCHywmkCvBxeXVt\/ad97CpvetTI8CUWBduDLwasK6VS1STz1hRDnPLPnhVKsKewwK5eMkm8DFFgDhbPvRkkzHN7qiF\/VfplG7I9CPl6lQWV90W2OS7vLku6Zc6ld51CqDeqSA4S2dnpTaMze2gg\/vl3N2hYmq\/ja6QOpHhWcJkMQsKU1JG+XdG\/7Pc1ECHNeGrdmT+coGOdtBUGMji1dIyhFAfjFhqAZE6OAHoAEsboQgk2BWzJn84N0oSF74DuGhkiCkroY17Xl8A38\/3Eeoiy+WTVz+WeiNIkePPkqN6a68LQU8YbnkZIE9lINBkVKzqQfhD3Xha2Xogu\/9nHVrcl7p2vPsjVmXXeCBQUg83tHA3nMi6uEd2ZF5ORdw59hIw6EoxppZa3RmK0beS\/gf3DSzXqK53+blQdP39wAovYi1\/0OYYh+2fcXbolyvFUZM2pWhwG4W9eoVDYEbJwVe0Yt6VVLGItDyJ18Kimq8LO6je1B9QsSAUr4QcE0yefODr1wedU49ck\/8wRO4Lzw\/+3mlwtb5jhuv45ptNDCaiDmTXZ9DoBnbVvF2S1W1TKdzzVX0g59uzZ62fa+u8eUEnY9k\/2tCXlVo2Uc7pbA\/E1L9fEaqRKJwddSG+LYyBjMIwJHxmy9kRp0Rbgj32C0BgHA74mYn2A1F\/djsDja\/+MLUSyY0oFqCSDCkB9cl7ALUV7OhKxS+F6tmId7KoX4sjguGe42FQIbeAwYAjlTx8sq46HHDBnLgNwgcNbrbzVehAqRi+Zl4djzJ2hoWK01zdP4iG9Pj7i1LGxRFpQSkWYtdrJR4x3Vn1NuD\/cWecJ4b3vE7QUCtRl2XOdxoXyHmYenw+YtrHNqdgzPGws3EYe+Mi9sJ5cIpB1rPyTlrC49clF+2YSMt+yn7QqoZ6w54l5L1AW6SGqUpmwd\/BueW03T9qBcRvYolwbaygtZEDZbSohLl6KRx1lhqXQVL03w9AqWQQmAyVq5fhPaWasmnTQUun0eQoHl8\/0qTAJDWph3RWBtpGBdsJUpTcMJWKfUzqCU2FfKMqX7t1ZbB3hhDAJ8jJwaoy5WDhDLGJxs0LtO5h88la3ESUom22fp\/IDpXVmJpHGmGJG3ruAlTbkpoVsxK9h0WzlqQaJkFfNHsmsZnuB+dFfqqrlHzXmXhcXv2XjZVafPI1MqFYY480ps\/8ep8n0I2H0yiRtGP7S8EDMgDm+3a+mkaj6TZhQUj1cjo6PNEebRAeuJ1QJkeuf7dnm1GzDqWWLzNmo3fXRoxukCqj942I3Xw\/biuOBQIVhSspHPTsBX5oq2z0297oRyei2S9DAfzcWX3kpU2ZLcZYHrQ6\/dcjgCjwteubqNaMwoGZ0cZm978Y80cickHd4mu\/KW8mqfqfsVLwzxr85B9YumMllOFyihVsSxWXJBlKAjdR7oEYaFAT8eh03LZk+MhdUbGzLLBlhC1HCujuqcwUed4d05WGyzrYV9UJKPnFH9521vwDBiKcBj8IebdHfLc+v0mchG07wiEJC5aOLnzDHLbQRL7yupV\/AisSEvdW7XujxGMDyaVxenoZ2HoEHYnsR1gKma09mLxohjPfCz55xqLKajAzQv4BmU8kmUv4vbcU6Pel8qWAvpkgNtlpULW3+n0UQeR+eG15ocHqUWH4O0EGOR3N4rYRqoMvbwvmW+p7+mS0tJztWh8B8b4yZdKf9PikSrG9E6000cH\/BljCtC7Bni1nBL5RKnazB+A\/SUCT0IWnjBGtkU3VRd9\/ivkYmXsjbdIh8pzsjCQD6H9kN79hES7GqsFAmm0CRlp1cnkTWZuTCIRWfVmnQI2LnXBL+j48\/B1u97rpSBj+cO9SjjA7o8PtWro18NZDVsi3qgX8QlVQPCW82obnKI8RGoJaA5crdddN31e\/r\/xLTGUvNAqpIvIP8EQWeG2wHtv45ZJDGzHMbMiiHA6yicceJJlRmh3VnxpMOOB1aSip2zIgdGMu9iHdT0AxtDs6KLBt8uCZ\/oxWfWqOkApnmuefyhz77IwNM+M1AgT6sWjXP7enERHxgJI\/Ye3N1vz7Kw1o2mbmIl9R+g3TCp9RFajcQf37qwRe4nxNzuRpZ5OSPHPK3irxTjx0cdj8pdXLRjgUq99b1bP6dzuavOWMuHruAWAyTxvQwTAv1Db0COGqgoaJUpfEVZGZSWQLmG3g0QiEGsHYdm+b4weegvXtmY8gN4hCr\/FT08q6t141Fu4+GC63jmZWHzmv1vtKyfEjeKVNtGcDyA00FsPF9p7G5yQyB6ppeV0uuu8G80GuV\/03jKRR0gMth10vOeoIp3FRDkyhX75NBUAvi9j2ISrHnUuRA6o3pGAM=\",\"page_age\":\"9 hours ago\"},{\"type\":\"web_search_result\",\"title\":\"Good News This Week: December 20, 2025 - Puppets, Raccoons, & Grandmas\",\"url\":\"https:\/\/www.goodgoodgood.co\/articles\/good-news-this-week-december-20-2025\",\"encrypted_content\":\"Es0bCioICxgCIiQwOGZkYTRiYi1lMDY1LTRmMGQtOWEzMi02M2M5ZGRiZTg4YjcSDGpZooGyu\/sZmueiVRoMku++G8XgwkDr596gIjCv+DGReR+FDUrihztK89Ek0d5q1IvHjuC7hziyd3KYWEF+cxPRV129fTu30\/L0UBAq0Br3BED6QxMHe0H8ZuCgtpf53z5Pza+J2x0nGOTTqMAKjX2i1jZJ7GsjPZT9CzbOPnY2DgMaFFPIj2uAfZech2rrxzCAumfaBql94NQX+jEZ66Xyzid3D5jp5Sy\/gt+CLU7UEGzIZMGozyEKVKXRWh\/+UujHANLK4xO0GqYgvh6ZK2JVCx5\/MGsat0UhYHXcaO7dFNjQvJpokL9N5551pQ5Il4Gq3P\/PiAyV9hIiQxCpnPPudx5hBUwN\/baQxVqSaTD1iVatnhIEDE6\/0VTsdwgNnsaluKYwQlq33DQcSPDE4SUZVlVpBo99MCVdV7eo10PuhS9dxtqY39sIwDicOLNu4NIId9QViYj4ClnMqVgGkjvu9MRDVpaLIKIvFsG\/gGLIQrmegaFYeO3Nw1dqKZ9yzygWxwXQouGQ31SG6\/ykO930i7tJhZebhkx9bSdN5shESi2HVe6uDwBwPwVdFefs1VdNOhQ0hUBlFfJma0htnlzEztcrKJCJAedZ03eCKwAP5+YYEvisWn76zdJoV5fzsIBs5QiTcQcDuYxPUg4sSCrqJRfCBr0VYuOLWQ7OCRCBCWjRChdrZyZMq1SdcPVWS+LACj2MVZsWSANbWUnkOmZsburMgE8uvt0BT1CQBEMX6xNlQo6h+aLxKMOfos4on8AyIwj99OLMMeV+gwwDdRbYkAx2ZC2mjPCXlHXQvQJB+dEWD1NRlHGHgssi6jBJG+PY2K94F\/O7NPlx49az1J+A6rbF5F0z7cvaoTbN1DOwrK6NJ6AGt5c5a0PppsIqZQXFfKsBmKB8rsrY63tipi6GY0CRJMbdO+aGQunSYAVpHEZHr6jXP6DVse08ktnBUE3VjWX1AawXtPHyhumEt2nrWGrkO8J1eudFmV8ezUJD6dFZbw+J23aVa6as8LAb5RYpjrZrMmKXpJNMqGp\/xO7eHM3G2bYXeAJieEZMX+GCxTudhvS8UQCyPvKF7Zf6\/J\/SzOBANj9l5r\/BjOWmA7t1jB8nEzNFkbNWi\/gWi2T+s\/3nVq7Yy6XWSqbsRogyfKw1a8FgkrdqT3RGEG0SOyk3q93pDl2i+B\/A\/BAlvbDq1qFnfpsmP+hCtS5edW7ezzH\/zleDV6IqAUD1f36JpEgrpYoX8tYb1LM3dm3MIuk5nl46bg6JT\/i1fZk1vlBsn9c1RkT77TMGXuWKEGdt8j8+GO+92hMheC9BonSRMOU9OV3V\/LhDP\/IF3opYdPyS\/8fdwZUBizxYJ6I90v4bC\/hyt\/jaikKX1gQ5itY20QfIXO9OQKrelRBHq8uTYvJ2TLm5pcLjt2C1RkaCG\/RazIiAB9j2isUTLoULD3GYKKx4c1exEjFXzBEBSVMCUqXb1pNi1HUrru6ryArRd7kE4mdc7g63rXrT3uoepjHIczf6hPZBc+\/SARgKWpJJAOtrX7tzsdCEgU8OKbvxhM0RwZRnulMS32wShMn8n7xu1P\/KDpGmGgIlvNAshnJv6QHtuL6\/uvjzwJxycUcrwAiRSOotwCMABZKDjJ93rs6NVQ8immGsb6VigwXBj0x0Ck9zY8h1wXp2\/F8r5v80XIWhyneonvQIKxTxfQxKhaMY5QiFIZXkcPGS2eWLxBAIFcHvqE5xIuQaaU3bhHoRlE2KcfOusUnnc6OY8Q+ByOa\/p5+nCczA+I0wz6yplsHF1gGGcSw+U9MdYfKmIi4m3GajwUtiT5\/Rz+EO8xi\/isrdSPkLLJyHCGwozhskMHPFjaaaDeARhoDKehHKvYw6hRkcG3RWLDzLNHP7SD0JMbo8yisibu9wNCpZcwHNyz056qs1tVkVLwlG4K7y5UjlB3tajgzt8\/77teCpguHPnTPg3CkCwWa3NaDmMYsJUW1YGZSE6j0tTZrRU18BGk7Q\/iKoCOaWs99SAVCKqAxTXjWSgVbJVeWJtDM7m40+wc8xMSlxj+kvULypy3\/rEo6iLaoUL+CBH\/G04PEngbwJrOGfVecfgUGkBrE5UNUlLtR6p0fK7nm4g+k1pOyBQbCh+1dHygrtOPH5znTwrcRmfkixRXOcck0ovZd\/uY3FgzH2w\/jlpZ7l++HmLoJIe6P1AaIak2Xq8a8Rfap0JIFMVXIs3+lIQGicYxdzpgvl9imLZqTYDI\/R+Q8j\/kBiClVOJBM0+Ow7jvN4YS+YwFOwHrDcpG37CVuGooY6A2FEXlZUmXtt3D\/Gcgfizf6GLqi\/\/ogDJeym2RKaMpVsYoQUh+98yy52zT6iTe\/o\/QXfHYV7snLIDrvhHL1d9RpaGT+n6J3mhL816qHNrdm3OzDtH\/Tj9adrLe15x7UtGUNBuQfvYKx5+NEj2HK1c\/dS\/qHaoi+M7tczUadce+pSqYeVeEEVY8XUFC8IWW9zVwiEb265YWi25F+L21tKbJBRclns+Fc5ARfXgpaKwTHpu1pimJ70OgdQ3S4lQ8lrL6BhotI0tkY1vCmQ\/djkpaPyIjWDZuSt1AQ1qLKMeuUhV1x\/a7+4B\/e0yTpOHxTp4x97qv\/kOR8JpAnbtUmvCj7U6EkHBZ7nKRxSPQTXi+rAxqF1YYBZDhTDRQhHud9viDWlmEkaFiN2w2Oj4n7sVn3\/zqaHp+CLOQSC2shSEgxZ5SYkl4RiREYzAGC2LbpvN29LouF+5xfkUNe0BkvamL2o\/HJ8VixXCn41eQ5ughzAtTjzGpHzmQWF3PdSQ07ATnvYEscS77zyZnOUVEDMxwk1miGpl3Wc6WFirsj8edWA7v8B9A8ZcMZoa2OEph\/Q6rzgjdgJUT8QhfjWpyEEwK+V9I0bjYWL3eElZrDCbiN5z\/9JZmZSKP4CJWR\/ajZesqy9bPuhCsYTrsSgfKZ5JZakv0FW1oeIOlbjnjEZr\/t0YygH\/eEkcL35OqYuCcssC5JesVUhfZ8\/XYnayOKlVo6RBV\/4LEnV8kfb8imZv2VuSMRc24GvZpeoDPa9kbaU+vFpyPlCHcRRsbs1N8CG2RVyTb0N5dwwCLnohkSm36jM+C+OZ31bXgykByayVwNBTj88o5CW7qJwIH+rkjZ6vwwCHdKKrdBq+hs29Tj0YeNPzCkeERI+uKKMoTeQFD3Dh305JxUrmbzntYWzQ174ZdYgQPyPE6Jxqqjo96n7oscNpVpuVvjzVH7FN0rv5OcZKsCXyuzTOOUPwdBibuJKeVQC2D90l7P5b3Vha6LNylc6IODq6Vuk5Oe\/LHMNZJETc0NNnzhuENLVEb6PHicVitFkMeyymLjPg9azqfkcpR7kP7NgOmOvM\/QWb7N73TdXxOqbgS6VsUXsB2TG9YVJrm076br+hol4l53YtxxmKIq5joIUJ9STmA8UsA6NGxUVyjSzw85hZJuVR32g0xpkcS2q6Ae59ur7ghrJIb7\/ood536fJ0VIiGwnUzNgOO2IC0\/2aNGKpJHff+dabEm1Hx2\/0k3+2C+dVi09QH22KpKLG\/Tv3Sr58aRjteVl5WDMPOik7S0Ps3Sl71KQGM2T0EGfzPphA9ZfcJVrraneDacoTI\/3bSngrW9DmItEU\/OC2FakAuvEt+mnowPdh5J1iQGH1\/1LFPTyoaBGfUFsUYIOntMfTKxIticGXTeOgCCUmlKYf+W7xeiY0ywALMgiuaYFIJpgGWTGUzDmjpJKZ9mhVu1iRfa4xzFVWtS\/lfiO8FwHteyhR78LCJTgnzkkgee8n5BpSeW2Lzegkv8s0mNG3ghv\/AKuv8gONFBbAGAQkl\/CZrrBGKQkyq1MNgepwkf5\/41SiabdBh2NzfUc5qFUFRDW41AuMcVyhYvNtT6MgwzbRu3FKXU1f8b0ZieoOQFXaib0YTadckbVXNBSBYa179Pfse8I6d\/dp\/nrUXmXuHStIVfKJ0ulpvbe5Df2Y+Suk9qE\/0ytkrZVpWa2+lSeyzmQVgSC2bTEEQy\/p2+VhxJdfojnlmet92PXZXzK9hOf0LfEBDhcyOItW33\/yp2ixNSXiYTXAQxAhV4f2fDQoauz+iLaKNxcc+edqPlQpdRErVhJVQ3Y\/GTqVGUbUcmaVTQOouFdajmRjD03YFaY9NBZxo9ZYxSxL+ysP6XZpIGJi5FWRGX7HSNdYQmeszSsaVqMPCjrpuowT\/Gpr2zjj08J3V3F+7S8gR66k4ZaURF2D3wOUDXGYhWu\/woslv\/jjJPSWxow9VJDFVW5YPFqzCA0jjAru7uq5vf1LGITT30hdZzVlc9Lp8tzaOZRVEVunEnuTigbZ89q5PouYCWCd8Het0YRgTNVYfz4UaWDx3fDQdvQCrafxdAuf9okzULl6jd73BeBmK1Fa\/Y1Tn1ut3Ew\/gObCs0ZIaJxjaLA1MVBtfEbMrQGHnauNC3+8VyZXo0eVAJ62zlptH3cpUC3nc4d0u+bDNqajer9jAf4FryydJmNil2FkAsa36+O6CIDKxq8IK9eaY70F67kvVkZ7dhw6HTb02r2LZfKqlNfMVcBw9NrFztTBTwsp3e\/pp\/wX1wFjp85ER8IA9S98V6n8gx6OFL2CPAyv+YgQ7gw1mfTn+p1xRsFRn4g\/G+EKrmKIZGD816tIjEEYAw==\",\"page_age\":\"1 week ago\"},{\"type\":\"web_search_result\",\"title\":\"These 5 Good News Stories Are the Antidote to 2025\u2019s Rage Bait\",\"url\":\"https:\/\/www.rd.com\/article\/good-news-stories-2025\/\",\"encrypted_content\":\"EoEfCioICxgCIiQwOGZkYTRiYi1lMDY1LTRmMGQtOWEzMi02M2M5ZGRiZTg4YjcSDFLGvIIYhDAhI4nmrRoMqF3vp8+ia8D6qMyNIjCcXSskPsFSJFP3nJ00Zognd3xYG9KcTMLyuoqwsUpDpljBiWi8taMEyvucas6seWMqhB4lJ0+SOgNUpMBFFNgKYnW9dByXuHMe2XRqVu4\/ZfGGFQASCd1PwNrxKhkZo5IaRb264Jt65ww12PKANDC9tDDvoWGMqLZUI7a5KvPfw+m43tbGbK7jizEbdjigceR9DwtVqsiL0tJ7KbHZQgQKOGSnQRZfEXTvdADovo12qf926E8fjoDbm4y3TpUUZUqRwZk\/eaG7EcgyRU3zO5hN2k6itTlefMYTs5kk1+viwktNAgdfxIG6zvHGgudWLmYzNIARTioSnK40wV+9cGjS32d+rUVtSrh1DwL8p2+MEIZDqH\/aaGTzwx7UYBpGJ9rwTS4TfX1QE1MHk21rYdKXZsNHSZoDyJAYrDqSSOuGueMcd9zlW3xEK4F9K6so3w2c5BApPgqCK6QbqdoWotUkLwGrBsw6gwZts8t9MV1UPk9hjpiFTfUyQOtjpWpJnOxEvEjd9KIhwOuH0fAS3ITB8h4Cl1FvM+QL8\/kTbYx+uefdH\/Vv0wEXxqfJiucLkjQzqtkyVPp2Q6aq7vz55RZlDJRnORsNiVUij6h4xFcXLSwR6EdwhqVJ6hqRYJsVAPJL7s2hOj1k8s2IEaiMWNqj3WQbgrxPo8EOcF\/cttEIKT3uSPRpvj\/JJNsr7eR1T\/xf4H6g3eGTNGpPJa906kQtbEwK7liarlDt+tF8NFAovdM3nXiT+ZUdhCKFmVkJAEzOrJ+czMf0IJn06vXxyLhgplkWWN3e8fMhgVElAL5kw2sOoqSVaOs2MY7+0VCXK9\/ev9zjAxWW6Zwr1lwihRLT02eDSP1UER1det9SFzSyTYClToQdUBAi72ZzTi7wR5AEaXIgJNybkM9dq4qEF4EYaYjdctR2rWh9wpZN6QEH+2B\/KS9zQAowUs2nJEeCkar0e8\/ONlQUFXXVoYEVtnmur1SCrQduFIrVf5wcZaG9HClNr183HxohoznPTi1hEj2l3Wv1caUA1Tet6aeC20D\/9eJom6xIy9rd3lgbUKuaK64\/FZVmx4HPq6tR8UHkl9uk63T9znflBmp3XUokGfV1FBDihesYqifTWc33qtVh9iaXhpFt4P2Zci5ibwwUj0a7qeJKwafDqi+vCopcFZ+tq1G+\/OmibEN7yoWe8SNwOGCEfw7jW6cgPqTD4ZIRP+7xeRX75y6Xc8wWINFXeDwQc9aTvrT\/nFdtUQ4vy1GyKzCWEL6GIo15wHwROG8gGNJasggii7ww+S3XArkmsq\/fVV\/d\/IBu9Bwdq8IAzXsiVOFJ8LK7aNS8O5nWC2BPFimPN\/MP5VEUUazmcXzVJ0YJkFcwidSubKp9mroENAd1S5EcAIjO92+YMpj6NSVwSwK3C3MdPfoORTQoL2uK81lVjD0de9M\/YTUblBIwIqM1Tvbt91AapdiaisYdbC7vz7FZ54M9ztV84vGA6daP1ZdMksGGPWyj0P6GzVmcwVCKgSncm\/nkAGOToyYL+gqpcoKQzN0smEesaZgvEYge15X5LuSG8AI\/Xpm8HnBY2xHBm88WrVtTjla+GI6L0hEyEhLB61kF6Ab4K3FOmICgNqUqSZqAY2zAdSmUGg018QY3jGWX37Bzh3YvJzXXY2YIk86XLbvCG0oTf+qDyDHLRN1FqoZGY7n6NqOoPyf1nNJFTI+LCDqQqsfdlmxz16inNGZIQprsPjEFwsdZFwZs4VuK10if8irH66pB4YD+iec9Fh\/TgNpMUgnb2DklnljQvH7TVzIs7uKhXbC4kluYwxcnRB9Yzzy6nVuCiaRtHWMP+5Q8OE0uPEr8WUb1n1U1ycEPj1K4UAlItXIP\/wuJgdzYAawyziQ2zmP8kYRqY1rWiT4gPbDU+SpAWIfTT2HzirSFSqbFEW+3O2BMLdfeN9cKPKEiE8Wbk2vYJHnMITMARlPYXvhZCImK+Gv7i8TdTsKfHZGEgl1FIGLeohxL7xyne8Mcdp28aXOVFHXAhAQ2iLD6lRCzhsgfMu48rDV5MXFdxSwbA3QWxqoVOtGGtw0NRmEwEWNrCQYAfF4wifpTpj+CBxHYc68517D+ImXLB39uAs2XsaQ5XoCdYeso8VYpIlhO7JsfRw5NklY0M0KlhHpEyiPKIlF+o1xzWu9Qwi2bKvDoEl\/ZQ0\/AC6tVsBE9xPhG0sWZaILp2f5sYG8hLehmCq\/adErO\/K3h+1sfJctxrjSxapHbaf0V0GDx87FADOW19spF+PtyVW7LJ6BWuYG5uul3pYzE2jykpfw9i6WD+p2JcAPYdyyujKzEgiFbmY3L0V\/YfF696CwXKlLLSeo\/qtDs5\/EywsyxaPTlQj5kjOmq3XmzjR5hZaKC9kE0rN9rEqiZDrI26G1tZLhyyyGRw8qO9bBNaKA9LVzc8kYk2aLpiSIHPB+EjeHfcDcUig5vmSzB7bShI4XaRjck4mUJTh6M+qpGOmA+4P0ABqAEZMzigwgi0VvvX3W0bk68vQgkyTeLieZFew6qtvcSCOmhp2wva\/rm0Kh\/6asw1D8GkF4JkubQuONOvjEVdmM4GYXVmCtmzuokhc\/OoWhCcyN1Dt+QzZjn7EoAXAjsVKGPE+pD5YoX6CanvmoxWeLnDB7151bqflBdbtBRbhk5J4Lh1LMBDT4oHKJyxT\/3Q0HZq+B0B1RxoCrpjtBc3eDl6rVcMm+NRRuPiDTkBeqEXrevTk0uWjox+\/TbyBRcfpe8lcwi5oPshhbXHjsqS3ZP0knCybUOBjpj94A\/9QsZTTNnaNLFw9AGEL14WKO68hxvjpV0IM6jrI0EuqkrUITdP5204gii8lNyhRuLUogs+TuV8EJ189dfIdoIT9r79js9mjymmx5dckomvGZM1f9a5xdHbz9AlfBiOZWpm4gpK7AV+nGiSYL+Zu\/XmBRJ3I1OtT5bd8rwXwQanj22fyvAIPDXiRbb7IYw0wq4yqzZ+t+670JT09YsIhRvZ3RGRztUHJDv6+VB+XAhQskjq93SbpisXe\/Pnh\/YzD7zQi3buMns3kY632dY97U1s7gThDhLpu8z8MdRkQRYI+kZJmwS\/mH3Lt+GvqTKGusB6SniVci2nkwkLmOwvDcK2\/kwOimATQp1Y9QZydfGwDygSrffpCVZ4TPOCB\/+CgRXWyFkHNnyD5oQ\/Agvjn+gMQUZWGChhF51LE9qYC5GRVAeeC14iUQAEmF91G5STynBFrxl0I\/gKo2s9uHu3cRSPqCgBKX\/xGtftwRv4MZb44m8avQOhR+FM5IWTGoqruSm62sjAKe1Xg8o5NnDOkLDC+FsI0r9nW++yVSKqFrZugJ+fAGu5iWNGxYfnwdpPbiN2ymH+MCsQuL34HOJ+48IPz3Ge3BvQL6hA7tHBspJgSARG9NB8JRxxE4sBCO+rF4lOgwRo5xaQrLnQ4ktgb13s7zM\/ZrTwgO7zurRs6eFwMsltBKrWsciF8BMRiEgQ9Z8luZ6YrYVQ8FqLiW\/rO02Bpaysm80AVpOQU1GBiqv0F4R49k52F75M7fmtkR4zJ1z7hr7tHlU6mSFPtv+GKkvAQ+6LWwSH+S0168Nokp3qYTlZXxpXuhmxIw+QDRu9hM0bn3eO5bnUDs125et0IQpPGw5CUfACsUoXGaIEfx\/j+4T68Rj6UNkDcO4VMcyHUv7gtVMvThK6I2i5+REcG56d18af0LXNDVDtRoaEavtqx\/k95Fhd0JmWAMwRrXfj9MMQnuMqXnLaU5YRTYMNJAaKO9eWa3+aAb8HmVS7W+9TEHcTxRCimE2E93AWt6euKDgUPePsxW4v6Ya2dSy9QSw54FmC8ZfYZBHCsgeJZZwwho\/y\/uS6bv6HgIb1Y2Jf0fYs54smtG+2mxEVfNA4WZWo9CSkIh6gS9zVmgykJ6acWtsbSkJQbxsqBToq2ibq\/LibPHNtbbQ7tA+Fy2EXwRMCYNDREyDZHMul02+C4FAS3n2TqDWYAAQkqFYOy\/UeS8WD+zgrQs8t9bnK5KAQrfIdxCFEprgUZ3XrRM3yNj1qOBpiUe2wIrbJKRYjxQ\/zXQZwPJ7C+vQtP5BK5+nYeQDq4iq1nVyRAqcykYSDPvDY5M6qgdHlEjlIj3S4SpN\/cT8M9o8OHO+dm6\/rJM0OsR3wVa6InVQuoMEoQYsfApwoDEZfmpmIe6ni0VuPLYHQKklrSxG01uOHjMR3u1kfUO+pJEpAJfhFl+jQLZDmT1lx7KS0vkYCRs3hv+s6LlJ7rjDQamF3cJUDdoBQeRIoyEmD2t0ahYGfD8GPEQ6JEmrEZziIpRM04EZuUPMJ5W9aE8VUB+wt9zuOJl9th\/RHqAeTJA7x3rgyqkxlY4+4ZC32zSAFUgRxLTF1FpxPlWLUfVnLcDLGOB8Z7R2Ntg1sSXph\/ClAYc2PhAsoep0ViCRzWOrzzIZ\/MgHlTV++dO04bOX\/Eecerdo2tKYEXzgUK9D\/iV9Y1romS4iovpzPfMrlBPErwawDhWLuI+tt01i9K3BqG26EK3uBIw5AOgJBzG3MtSH+2Rw5FJpZYP2lDHHMDzyP103ion6BKLArZpX5h0+lC2BMwgRydzaZG4xr1JGU\/Awgz3H5uRHsisJp1Usv0Jk7C7mUGEgXpIXdJXZJasU5iSAZ6PRuiVliplWTvrWz4Y5p4R543N+QcdYD8onCJNhIYO2Q\/VyCsTaIKEgmYKjjHvAo5bJIyvdN5ZpGVgh0500Ux3f4bXgaTh3RCbvCIZYrLVz+p\/8aTXV8wVFQtL0bMBDomV52jVBkiq0Z9u5iZA4rUKwtgs0VBYTiV\/w426w0CrbTRmtQvt6PDim\/5kpFaRFO0u0yc5BVHeZBL8Tdhu98e9nfte9Z1+nQAv6Jzq8vEilsByCZOclkBxPEMe+KuM7IxRvKrGxk\/QghzIyl+M4GEnKeKeWGCYzuFWe189n\/ChJGNLmS+kvnC5JojHsFu03Ahs6RzY5\/UqBrnZ5pBFAvHm+f1U9Vd5L8G7AE7rx5vC\/uYOKY8mphyWb2gIWI6OET\/+f+khOVgBi44mtONVtOxzuFevlDVQMOprEer0lC+2zHJkF3g90eaYwklDYPW6eLrB\/UOFZOGMT+mcnw6zEQKVW4izALdSgbEje+yBKb0vipI4sAAP9lFizS72kEsSlVBbWJvqqpXA9+7OIxwAgYyADx6VPoiJNGAM=\",\"page_age\":\"2 weeks ago\"},{\"type\":\"web_search_result\",\"title\":\"Seven feel-good science stories to restore your faith in 2025\",\"url\":\"https:\/\/www.nature.com\/articles\/d41586-025-03505-7\",\"encrypted_content\":\"ErEUCioICxgCIiQwOGZkYTRiYi1lMDY1LTRmMGQtOWEzMi02M2M5ZGRiZTg4YjcSDDAWWWw3LNiQqCq9fRoMsUejAi0nzqMPRPyxIjBdia01+kKTFNhWfZnT\/05TEzivdKVHMnC3OcEVnvbMF0RivspAtlrWp\/FZ7xEsk74qtBP4NsAvyAzrIe\/zUiMUu05sXZuO9nlk0ouhkJ6TDDQyqJPztpI5VG+\/NQS10GcWYdOUAJPWaRjhzsybf9Bkq7M9+UnT3MskzVHZGDE\/bZb0Uc1mVjnh16p2wzEi86+cghPBcspgpPP3oXRpaImL3zIqqRM4i8iHAWI212TZujI2IvkRkuskj9YlQ0AKHVjkQSbu4nDiqYPCO+AQeZRU3lvFFaOUOEW6WjhtutsnxMh2m9PnlRQipUEWR24ZqeojLFKIaN9u6Xd4ucIC3mVIXtO3kvD5bRQKgrTYfboS7ul\/aCugW++AKjjasW\/83Mg9Oa0EzhMYfkeHfDgrP3qQmOIySFfLFEdLpfr010cWuFcq2\/Et2EKvQR15WzJyxdv0ANDDfF15+MkBIsIAPqDKTDrHFkDLWVApRnMGPeGcbSDOR7JkB3DqibAcB4nrqqBEGDCTRrROoNbNuKJx6JuZ1Fvd3SpRsph8rZInuDod2bV+6QQ9AC2mnyukDh3TsTKfhoRko+N7t\/rXtoRjgmk1xAxok\/MDS+Sj22YGz5s\/Ef8jty3fNmmqY4\/0i3Yonz0oh+fspLL8eD9osdMtMTxOz+I\/+oNHocWFgwy+ttI8snnKixhIUFaS+B2yoS4SPmPJw0gKbpClR3j3updD9PHahYiAR1hWRtbYMikQHw\/ilVuLR5r06s09zMOKGEuljTlxkKuHPuBGK\/ufDYCDQbqlBFCqZca6jf1WHnT5tXkVmbws5Ur+ykVGb2A64VaoDniIEFb2zRqmA2eXSoINn\/pGGEv1xBwszSdDInxtLfwYKBxwbXA4tNEZrEaFbrrjQ32NlROIohAUvo7URzbx63aXUf+8mceuenbILxHx2llsMXlOlKh9lS+T37YAklDHy\/gFwcIUoiCW4UCxVba3ZkHOJikFh9+s5HZezufmVZUPaEsZTB\/3J5G6W6f+uekrYGaX89lvWyBH849rBC6ip8m\/ORe6dQXfKrpDOLd04ZZN3KQQWkGo3SlNm4FnscO4bmJljZ71I9Yg+eVLy3rlTi8suHlBxjURPtEee245UPfeT\/yNiys3oC\/8hPBS5IMNQH3hxILDUSrQ9Gy1kRcIcAUx1PB\/2K3cCG5NEgmHceXWXLlaM5OHYX85vhL1ymoGehkk2zfAgH0CS+tQ5gi6hoPbSkNpwWsTlXZuwA7VgyDsYMwlsg4zlZNm9L41l6efxkiEjW+GiKVN4cEINC82dKHgeMr7Zj0dRtO\/JtJN2lau\/s5W3AgsrIasWb4uV3HVO+jQ0DigOxAy4nqNl+rLPhP1oO5rsn0rumfSC9FJatrWr4b6rUV1YLxXcPuJ\/MEXJtQPhvXsTLvEMvJXATKYkzYB2z7UvvVVKREmWKTs27aE9JFwp0LCaisGUDdKoHCfHZuqVuWjhCPbJ0aMGlYXR43gb4l2fKfDIELHaz6eUXUhCZdU\/J74HlHiUZh5naH+Ee8u+VWo7WpWud\/Jr+KJZn3RZtZFSl71w4YIavCy885poWC55er40uB6kKKq7PQK2W162AwBzbXyj4cuEw0MYx3bJvGhvqbtrG6qjEdVjdu+Ro0dGgzqUPgeGBgVN7vm4+ChDvgH8sfsij5HeQjM5IF9TSG1JGLJICR6SC+NhJPvVPSPGnDtNnr2+FS8HDo4Bd\/61QHbffF9BYuG6CAz+WtXfJW+I0zL57j71b5N5krgZT40Irhhq\/6UPskP17y08R2TDZPL53zCEyVk9GEm4nEnPKx5OUipJ2lFsAKhh6FD8BDfmR8gXsvpP8JTwZFBbee+CkZfhUe9IokJX9qrrxGPPWmI1hhQx67mkjP6NlVLVu0RaDk+NKBY+ggr5uT+QSYPdDbBRRjOAUjMRrZ0ZTqqkUsIeVx814dcDpblf\/EGPRDxyezQDYXrYCUx+HqL0E0ULAJbjbyz\/R7qBoT5utHt50frmTA8ZYfN+se4e2T1s0VztnQlG12zKNrL2FnV70fp0WhEFjJiRyu5wbNH61EQV6K0jnlfyT+RkkMqDYJFj6igMTZbOIJCrrafyLGYWHWnhoGO0L6YRennLQDla03HZ2VrWyPJETFuY3QH0BWMWYk4f9sVW56Tf+DlThl0Z9I3oL8APp21+D\/qKtrAWjth0jJSyP0olvY9eIACjhOZ8pNtSyWu4ATKDsiT83FFGZhZySIa6QVbpnltVdTV\/k7aSgDC1dMfX9\/vBdvCCvlqZVN4ZJqKkZPTSv9wAW1Q3WbVzuKCoycs1AcYeIeUw94EQz9ApL7jVILW0vKeZyvrSTAxjqzk9dTBLSLw4OBnK6X7M\/pN6yG5qE9QVnlGyJ\/KK1c+X7\/bB2+\/l5sudYoJVPiJfpzIbwlzjTts8HLpZkKQmNsi57KafuTjpfpfBpC4xL9Ua\/4N4\/LUim5AzGcfHGYKZ1EIT+Mtvx1Pm4JIzLX8kGWpHNiBp9MWLIh76vhCpfA7LP7z47NT7dE7wAzF9fT1ZB29QphoHTuX2Y3u2Hz42f7ziVqLVdezPgQa7KjiWNwqJJApqSHx2UF4+rphobayUYWdlyJ6vGP4z5y5So691azCATP27RHGk1GhcDCwDlLScz75IENmL10+nzM+zMN5ZMA6yhSLWPrZvQz7ZArb3w8\/RjgyjJlS8JENJsNOsn6E3Ip+K6Rhd7GiIp22ugYv9h3tjo7YoytP3N5U6F2xzQI6VmcnRKclY48LhroJXQsqvpCgJRuRdKHeOEIdv5rPB6YXd8Dm4HK5TKDMMRoG+TX\/5wv7exy6wfRhBsg8EnLRWdIIfmdrrfZoUEt9NkskfpjwSWcX2Eq+9WvHqt\/zu8adJDiUPEI3M3yXBYpTCopftlXKQ+2jdTsii14x\/mSFtX\/Ik12NMouyb4\/5vaj9EwzpHtckhI0jFZmha1PI\/cE4+QW3n6qKDJdV92c9bPHnsUQIV9kQfvryoaUqTnthbVex\/OXBv1xPpmbyT5\/fwxJKSho1YWZo4qOF\/uiwYF8G9T9zHSbfSHqqCWYqZEPH8QfTz\/pPyL0U9jf4qdDfmrCIhF+FEmKhsptvUrk0\/zYcFyK2RgqCe+ZEY8inh8IJByoqhxTEXYHcZkCRJ2OsY0PVqQ2LSektiZU53sx1rcUjkbcqbWyfEU44QmFzRd5oOxJdQQEYigm6rP32ozWFHtmhkyaxaszhevUbk8SWaB5YH5eQ+BPu4gnpqBP4cQ8v\/0EVq4g2T1asdzSW1MHXP1TuhFdLaTxR5\/fDr00ZN6EpqE9Abg5b5EQQO7fniGXAgU5rqigC+MgztE6aY7mMRS9KGQ+iUJA\/8w1zY+KE0hovK7KrdwcYAw==\",\"page_age\":\"2 weeks ago\"},{\"type\":\"web_search_result\",\"title\":\"Portal:Current events\/December 2025 - Wikipedia\",\"url\":\"https:\/\/en.wikipedia.org\/wiki\/Portal:Current_events\/December_2025\",\"encrypted_content\":\"EsYoCioICxgCIiQwOGZkYTRiYi1lMDY1LTRmMGQtOWEzMi02M2M5ZGRiZTg4YjcSDJma6+\/qKU5pd9xqixoMwoLXJashWZOp\/uuCIjAtTJBQg8BhvtvrgNWBapl2XEEuSHoGcLStOxMqpLBTgDEpp\/oTwwVGMeRBQ5J0YfQqyScnrfPThAg1MVyi9QPTTU1V8BLHSmI2mjiFmgKXB+WfPxZYORxprERh1Nr4pDDn3lCuU7GieVPebY2TxeGMXmBNu+7c8wWejij3vyuZTDJlmcPgww3Qu59BHmBk0SL\/\/EbAxQ+bvyVIod2I7EKraUir09V2DfNn7+hGkEuJQIO04tkkgPpi53fneQMwGiVuCcVITaG7KmlyAyJL4cG1R+NVm+SUuUDzgKRAo7AtnG42kj6FR5lR2PknTJ7Ut1Z4ij2JFf7bKRSDuboALxl+0uV6AFgacJLPH0rVYiJiOb2QaabgFGhHUudccE9xC0AaAofHPDXIE63bNY0ANU3HKA2Vddvdz9AKwNIG7FkssrrEagREkwKtgSClmFZooOqBj\/RZncGW8H+C0jM6cVZlUutzg4p+G+pPalelbfHLhJh1nVGp5jNcsU02xLxzAt\/aSZ9TvTEniFBeQk\/ax+2kPty8zKMQCJ8nICMuq23Uc0ol1mVvd0EdyAF7TrBa610Kczetg+Ona\/q5J8z+gLRbwHiHQSjp17J67Ar4p90H2J80790rtljT5wWv8djSemM7DmMqjIX6Qr\/YD3RgGlBTDChYrO+rWN6Ku35F6ZeRdz7oNC3JHl6AXCIC+L1H2I0FhMrRT2Rw7F1vXtTI4LVjp3XoNW6o3VFkdfjEVDBDyzBp\/c9en1stPf83\/pezatdpYNdrthRXTxb6NZsPLW0nqS1bBigDg1\/A7dw3tZPmF8Gc8UkFzmW7PN5ykTof34UYvyGpW42WSyQlmiq3llydt+EDBhhlTyI49Uwx\/HdEQL7WLETP7CrVhBTz7viZNuNSWi8VTjPZEvAIrYs7v6zauxqCww4nI2xVNnfTM1gSQC0fvWAQ3fO5Sk4cgFW5UxEY2RH7Bej4wZVVRypHZN\/QbSUFY8YSlb0b57RM1fnndY1Gfmnwr+ToF7EZZuEfZaOjjEYwhzsKF5c9IEVkl0rZGD\/fpJrG6Xwspwb0ioYFZZCBOtQpq6CbL4aAJxKoojzkN\/MlqyaOFCgDizICJqqx8Bi3uZ9tjqZm8fkRzSNFfH7OITWi+fSLptcdgmnc7uQbMyhUckdvi4c1S0+puff8\/UHTkJ118vsHo0GnlICg3vdEDj1kqCbZRz12hF\/WZUuuiBz\/icGA0noIUYurg4KUBLWO9Te5q0efwg6vQ88dsCWvhX\/xmbXicd80ue5OnRxQ\/6xhgvtIfG0MHxzTzvciGMoep20rbc3wwnaTbwCZ5VnCt0bbhp+1JmXs3dSyNOwOIN1yjxS5qwJGI7yxZojl0tL7Yb5dMMhfNXlxeLhXNbV\/DCPpMhj\/UM0l7HyCO4P977VoNN\/J0M8UX6yXkQFx7vnd9ISnPhiKoS805eQsh2wJiFlQ1ATbWOreTTcKoP0xvnVUT2lF+unFnlN8kWirXLaDiteVOnRYJdEsZEh7a7xCxuiR8d0Ntz3zYTHHWKtzFWp\/Eulnmchq+bYrbH3bSO+LjgiPqWAGPY1FqmHXbcixzvgZUIvlxbTJA0SAGRwUYgLEhjtvcTIPw2Lw2UIHsg\/Wr2IY7zUei2AKyZOPm9yCs6Xb0+Yiaf6aK1ADKHWmlj5eOLA4VNFb9o6vnmLiu1tdxGRsFpuUeEJkkUL0mSgYvuiULF\/pfJTvBAZwf3Lkm4bdy4B\/kg\/VUqhqGfTaiLl+T1X99I0CjaciTs7zs0t\/jhKvKryUNPG86P81xcRxtyT8aEIAcDIAp1f5WadBoN0GX0nBLmrLdBCjdBuk28QOdZCMr92dToJY72NQatdnbZVYt4AtuOOwksncdjLs48mOTm7eN0G7jinByQwtSwu+\/q6A1VOhLfSFjF\/2RKi8v7EZr3AIezAddZ1d1U8qv1HaVqjMyKLQ+w4961DaTPBs\/rnIs3hkvefSc+7VD4QSqAD9\/KOs3NWW8BzA8BaMQVz0qOHfBSTCZJj\/cDH1BfKsGuCKU5zEOYmc2VqBXPLEhIBkqhM\/ZOPUyfIuhqWs+srIPhtvtth56aQDaivLVNvfeLs\/nqKdxZ\/EZ1trqsA505F4ddzXDCSIxeJljViBNW7hF7LUv0g5pysEOpJYgpKjbw8h9qAUNrRngO\/ZmvH84TwEIKcTKGvYwa8VrjSfNGMiZZGlnABpF\/GFHhAE0Z\/fsjXyx7GsnsBax3XXk6eTVakMUuZt+BwXeqVB7gTUAf9lpd2UVpOMV1xr8+8+NL5R7jKKFvtLp5F9stZpWo0CA1cpmyi5uawtSFeRBXkTr9w2IFLC5exV4+7bKABOUZ8F3y0n01fdDS0mpfodqtmx+WyGVRJPULnnLQ25+W3J8Dzd82MFXcoL093mgQQExYilszVmXMgpPaA6QBgNdIK010nJKAVxJPxeAHcunr8aQBAbrWNn+\/bOvdGkShQdwvg6p\/3ch1QlszB+Ht9kbdL7msjRfikZuTzyfxrUI9Um0Wp4T5eFNYQskY+XhUyex4NcQkt5wkpztZAEo1qOX5IcxLMSZLDPtj9plaPKzE+OCb8QBTtxDm8qrGjE4tjgxUmPadx1PuniW+sMlyYptVHZsPyyplUeRgCTfKJKQPzuClbrgeRJ10pTfczsPFutDCkurj9hlV\/6u952bcW2aT4hwfnBg9f68bT+hawUFKZlTN34yqdUImtaPkIUxi8jdl6AZx8gJHXPXst0Fxlx756PF5SX5320qz4H85zNFzZbZRp1nScSbYgYR2SCktFik0DVazyiZ+S+O+ThBl7C6NY206Lv0T9kRUKePj2n65HCuqUP+W\/A2qDEafEj6UjTp9Is9AXpR\/tfovlbFSMcz0wvD9DJCE8czTuDlcyeKL8tPTzQs81l5qXfqEt36\/+nvkzaTo9VOeqfSstN6l2pyUCC70kU5e0sxbBGxoZilY2us+BRH6m+3WW2IsZDFB0mq0aW5\/FfQQ990LZFovvk\/N2UjaZkRMF51HQcwFTE2maHsvzCyQKAS5uO75dlfhV4+wH+qjwvRtMpAXNJ4hHeOxKPH73CCKJRrLKmlQHEvfyI4Ct9G5p0D1WEdVR6nUpgbHYq1beMq8b0Tbv4mdLCKb1Gbv0PGmASI9MxjyyZ5aI35\/Uae2RHrGbNmFZctpeVBUOj+2ygIU7LMMN57Y2GH9g5vyyM6qxdaQC30B4Xf7ImoJU+cyXTdDjYLxSMeLCZr09eWwRMBd7C8xAVo9MvV\/VDeOAib2h5\/dXvhuqc7GGPm9IZMagtXvthd8Br4Ppltjxi\/rcDCBWd5QsOQzAGXeRrhKQbRFmRfKAkeLm\/m1glRsqOiu12XIZp+xE2WpNX25qvYLufdS9GBfr2fSEsRj15Xqj9SeAnXM+nD05g2zWim2Bk8y4XZ1Y9BwZBPYVJSnsg7IiTiBIP6bG4+vgv9IrGPC7pL39tl\/mzh+ToaWFKZuroScQtd48InjPcaaqYideuj2t5pFFuqPoSPGhpJyqBJ7dLCHftJEAW3B97+AQgTjtrKnxBVu0nJKiEVd0+aZWpwDfES7+b+Bszm9krBAEjCG\/DycBdQJVK+yF1AtDATgSEJpdWW7Zr9S+0cCu8K2POU9Rw0Y54DFv99+hCbjfz8JXqDiQfLZJTOngZ9xwzLGkoKJ8lx052lGWowNHkiFFNevrrkdnwplacAWirLKhh+MuO6TdHC3wTEnT7Ti2zW2G4iPb4LQZLII6go3Lp4Lad9BLZ955THyWoQENYd4h3iIglte9U\/vk0DQb0n\/Q\/9uW+L6w6WXJ9lJLQ553GVPxio\/hNidYGZ2YwNsfTW1yKORW7XHpkuu7Y\/nBRwdZKUOFp40lD5v\/2zh8kaoMvNlk80+\/XsMXnzO6HoxW591Gbid1iG61gsgbTD20K5O2RrxOe6ZqMRoKN9BrVUR9xh5LCKleT5Gutia9TpOtqOgCLmjadyJwo8rVEEEU0t+OBro\/6LtiFIPiSC91+mMsWd+ijhurjnBWP7rqZtI1MJEzIkGPfHPywSxjvoUREmrdqoPGQETmLo9rXCV3DtyPeFSkom2CXhs7IM9vuR4mT14K9rnR8PvFxXnHcsayy32GPPQjDDKdIhCNSyRrXZmqV1LDZwKCLZNz52iolniu58lUfM3PyEjxLttc\/mgVXGSa2LRyTpeJPZODKk9mWhYzNI+ZtZeF4dm8qXFxmXkpTMN+s0kJd3iMoJO5SPuxC8iao4HJyC3wH\/E2zpHNbExKqzulqVQpfbd9a\/jObncNAiG0qJweTMw093WFQl1K1KUewuyFJjicldRs34wHbDbD9b8LndqA2zabnsSwMsBl0QyqYrLcLmnV75wwz8kLIZHxjtA0sA875L8kzMXuEZL27lLsii2RwqsSVRMt+uvzqM8DWCBG5xWr+RN6XeAvuM0VXwjY2YkGIeckNAHWuAl2h2NR74yAT5PJCCBpCY8tBAB2HcFgRfVizqW3PPkUtQ6ggjHzTCHebjpTlNtoPye3AfMSKh6mGCOFyXDJ1iyPkthEajpUN1il75Ev6Q\/54Rl77Ems9nX17KVEMtyOF9xSImQuxHxclt1scsxnpZKVhYejRVbAlUYi7GcgIPUuthQjF9FEX7VUuzaallACcEZvdQpGFDhE2LBWofMjyINH1Iwm7fe0rY1j9gy+MfPcENiU7qvtd\/HNKy+ydFFPRI3wAt+F2G8zxjJnFobLzieqc5ANzrKPJ\/cF0K618ECqe4Ivu+f6tSphLblodSFPvBwt7hVlDL3Sao1CmJqJTq67QyVwOs0pLiReBBpQLaj5Sw\/4GvdRysuOEyb4ZtYbCxNpL0wj9uW\/yyLYTsFRBKyRC0Dua\/pgCDXaDonhUIyIYOBrmsiB\/g6HZkiOqAwAFSj18ppxrtXBIUOkd0bTJweZecXlBylCJByH1gH+19pZEgU2P9PohHm+nZ2UhPGKh5CnnGpLoBCczevIrqbWg8\/JgPJN1940nFkvsMX0\/9u88pMFRBLFvohb7dDdwPn7jrVp5eWDE2porv09miR9LlvQ1e5GVys+wEQ38ku9qaCov66h8+hr1+aG5j87cRfgVrEUAqJae51B+Go8Ywoj981b2uFQpmQGMhPYHIl39Dm9OKd2\/rlnX+3DrbKTEMzqKyORW2n+mANSh\/BJU2VPq3bb3JCpQRNY7vpxfqKpKhDttHfqVvCJ6HtLpuQt+yXAhFiDsCg7zsupfMDuLznMr\/AUlGpaClQz5C\/y\/OLczVY0SlC2nlAeSyvN4V+JzdEsT9n8wDVf7gXfkPSr4IyO9FNMICf61qO3HC3M+BQJLW87EFMcmAk9wWLdkaa3KUVHZ4J8CV9fmsOHlU\/3s4o+fvz+riuRNLw4iV94uxixF8qHbYUqvRhCt4oQGkMnVr21jehj79j\/bK8b9Wt8DgtSL6eK4uxjmt5NSLhAYSuP95iihHFi0xKtIr+oGq6kUI9IxbnTRer7fjW5o5jlg7y1Z2JzmaTPjcxXBqgoUhbWWmFD8NZmUlkTw5xyyqoFurMlxJFemQP9xk1xg0XrB4kPDV4v6Ohu9EBx35JDEw9x23byJcphgL\/0RVFAGIxXwneIrSzGp7yXMj\/YkMktI+qtBAUYwik6r8KzP7sEpiP5hAw94SKstG+agUL\/FFHF6PF8b7KEHuF2FnbtrsLAytWpnYg4C4thQgjZb\/B7wuc3giXqLRMwQFt0AhwCkjNJxapO1tFhIvZoeDEr0846njqQlKWjvEApZuniHIujiYNO6FXwEV6ss9Enldnn4nouovqf5XOOrCAoFErepkN0wFdaFiCO971mtNc6cxgUum\/B36aMBQMRhwqNYlu1NsD\/67bP4F0qgtedRhpq0yzgYu1N33EbqGWfk9NgCtxyGr0w9aku1Y94TMG8tuuSzI4Zc21yUvbIRtEW7VeJ2qRb+YGFrYQ0yvzkMLi3no1xGvilKaJvTITeTIBxLtsRdVqsplg0t6hQGRSC+a91TlgWknELDhRa+EUUPq6T7r\/lKX46jYglie7bp\/VRisd6lHGplBMS5FUiDZWCkT\/svjR++fmRvMt8gUSxnA6zOjIl+FO+1Tv2tWOYZ7voB49R+nle33CQrkb1JizdKLucyttHkSQP6yasLhiMPmg1Yx5Rt1heMUzW0f8J6jEmOJXp5JGvJVLhuuNTpjipHM0hTqt1rk\/pGFsXcxZJ5xfYNu18kXMl29MCsIRbpu7O20klOw\/SzeeP\/f7L9TQlzE2giRpt6U6KJq4BVZ1mD99PJfgSFU\/dIDvj5C1iSELdXlzEyog6FwDrmEIP658xUv6HSX6CPa0TtG6PZ103Cfwo5mcSpZIWaIWXEuyWI4V8oL1+o3+uPXidOox2jSH6Ku74PBulyniIUCIdf\/58nnXpygSZdDv0JJVHBid0R\/hosOD8zcAkQGAAQBguEGWsm34qUT4rEoN6oS5lvTGXbSj2IWe2yTZ7QG1sUyWws9u+Xx5gSrdokj1YT0TO3R+blMdnwHe6gMdQXjJ2rjdCHRMCkAoO07442eKXMT7Z2cjYyXFgdtox0gtBPasnFWvIyPrebFLwQNvvDbktfEIf0RQ15eYD7WOyPU1L84sdu04BU9nypJCV201IVjl0iDG\/aI2H5tgNkIyhvOhQP\/gqp174UFO0cl81Vp3pAHbnz9F0+aNkTEP59ZKjb26r3bD8WNvSW35uRw6R6fR9Mx6FHFu\/xtiMyX075Jt9LiF+Yzjg8BDnLL9YhWZh\/tqKEN5AOD\/zeiOd221P3Ie\/lpYz7lMUIHpC+3a2os8Z1VnhvIZ6GQDnE59ATlOWUGAM=\",\"page_age\":null},{\"type\":\"web_search_result\",\"title\":\"Hope comes alive when we take action - 8 Good News stories 2025 - Greenpeace International\",\"url\":\"https:\/\/www.greenpeace.org\/international\/story\/80359\/hope-comes-alive-when-we-take-action-8-good-news-stories-2025\/\",\"encrypted_content\":\"EuEbCioICxgCIiQwOGZkYTRiYi1lMDY1LTRmMGQtOWEzMi02M2M5ZGRiZTg4YjcSDBv3DHnUxPSMQuJukhoMEB8EXXiZHzmOzcsIIjAF1n9dTwXBrcGWQPSh8u2vTIFIUhyA24NVFcqC7DPh\/GRpaZ+WNCpo6nKrL+UTxHYq5Br1ih0MbNm6jwVxAN7Ol5rL33ND+zzXIdOzlYa5eJHY9S2d0fZfv7DPY83TAWyKMu5vb6YnK63Tdt4LD2NapM4NDCBh9cAcAOO9Xit3iXE8PQUxXM7\/SU932ID8LxtDoyGu35Fok+SLQtUAyx3EWXYRWOIgIVCS924gm+shbryP3uU2kJjF5bIT\/sWVQwy1nOVGAM1CxXRWk7CBKRXUJScYYfkGKOxoPLjFzyGixvYougDgUBjTJvneJYO89O8T2Xfa4eb7mSW9hsv5JPc3QvZlGDggoV9l7t5YLK3BahWTVMcIiTqIECVyN5LpAnkQzrcMyCid3NJpsr89KDSOMMdQcZb120qJApAt7UcUyLTywsd0Z+xdxQS5ZPdB7cwRtUCpr1liT\/\/QJLJ2jh6m0Ug998QmsS45eG53M5112XkuHjqfBX6SfLi4wNhe8uTMMp9KsYddu\/AxOYlVeDK74\/R\/yK19xQ1bN5W8MsB09TsvFmEQn1w3JO+AjWyydnzQUkwuLw1en1Hx3WCYt+z5DlnJqDgI53Tiuprzyr3pDSjuIBWRHvlnSASuxTyE8iF7hlkB36xJJQ6+KOt8Qr08Kr2FRtoeZ\/yay2YXymmq7PsX6DGtz0YLmaqaJXUCi+XoBviTRqsXbJaV1hBAZsB5UpPr+y6gr97azdd03CzlAsOOxrXZ1OukJfOAA8\/vMJHbcic2O2thXl2pAUYduvZKvIzLojTAO1Q0+Np1iKVsg8p2h+nbIMp5v7X95t9Z8KicJ+y4fZr25l+AfJz1cZJHYihlglxBwNuUsUrxlZQDZzmwP16wPtNA8Tcu8x8u58YlsXklb2RA\/ODrpdNwZVLy8UPeY7bfvstcYO3hsUVQsc72ZUCGY3ASS5UsOO994fcIz3NJucV50Q53w+BtK2Mtfj3uPTblMpPbIye6cVByzSU6f8nM6kAxKeiLyDLtc0talLWZb7GwWZi8fIbAhe6OcQ+BwEW7ExDG\/IDs9ZsLzv7tAe+cYSrUOVw6z3mnzKqstTDFL8ri2\/i\/qYVN2lteApiFU3SSBu74IiMlukiThFHZG74N7pU0l4Hm57qw1TUcE05XQHYFHh3nGSQ3rLWWEgUCODZVWa7VjjrEyFMNFNV0bOfp\/VxHdRQKTaBFZCCwCCqQs3tY+z0SNUn6nkXP+lCxKEkHFIyq8h2FmGp9T3tJ74UE\/7qTrgL4uXmgL4o7PlPcTX2Cfc\/NQy57smyAoJoue\/OCr+cOVXlEUwavG4LtfRFPt+zM0nztbHWeuUXKydc+NFQj3nDwqgSLjpbA+wJX10GV7\/\/qIPR084v8uY7E5iBvjWmL0FY+jyjbZZutCe6eGYT8WjG20WEFI8We4oyZ79dHzBFZndeKN4uKQIUqRYO6lmq3+NF\/1SpSHPzoQbtpbkgBWhtsm4HMXPwLKkbuCh2KpIitgtc+vH26KoBRjSfosPOtTgynxEeLVX+PwpuFvbUTJ9mh6I3dK8Dx8fxvpb5I2mY\/IhtHflMmJfxLpNML4orokkKNArYYjDJL56JNwf79+7LPq846DOPOpADxrKLOuR0hbTZxArO2YM5oiP1mKT\/+1vbN5e5sS3QRWCsCfVcQqCExOotHYThUG8wP8L9DEzqFqLeLarfrYRHgmSNaoYHintwWoTkvX8GjbxTvYFh7XuvkTSq8yttJAyFyQIsOpv9mNm2Pv9fI+B1nFFRjhhgiJ\/lShiY3Ycy+9eHtXzJVYa8PLhGC8pBuRQ7K64TXzkcOKTKWknwziG8KUxiX+bA\/W0X02zYjwcq0Q1KDpex5SL6q\/NbFRoCRd1hTyuaieScJwIhZpOheKx5BC20RI\/X5A+T8b\/ZzMj8rlZ390I4Jlr+1ADbQV3mo1sq8GtK6TxAOu3kLFslb+vX5mV6PVfdbyPlwCs55iswpwEXA+gXUL2k\/hFOh9G02hJVKVUS1C9H3WkXcK4KrC6H5fjCR4DFQJuhLuP5xYLyUl5lXNphoRnqYNWS44Wh9j7244dTxR7pBrfkpt77Y0ZWayJ0uxliHQMgRmTqkHO4OJKScURygxPdaUtd528Y9UkOMPm6rnFDkh9XlvVx1OJENRfvPKZUFP97FK+pOavkpAOCThl\/+KWh6Ea0sM40ubTgm7tZZdQJqzJOPQUFOFcgpYv0eroglRdUNoP90UiRaMH9BqOO1\/KXeaXO+5ayq3XzoYf+Kzhx5LyD8Oo1X9wUP3+MqJJ17lksaZ3QIiOWmm30+T9TGWK5bamAx2I1AcZvtsSAIVTpZZrbpuZJmu3va+W6kf5t9PrlzLwq5433yvANZrF1MeEaMBZEy64mW08qTChiV01M2obi4TZ1ftDxdq8XMDgL+rczDXpVA0\/u0HDKtmWYR26Y8Y4ovGPCAphB1jR8lltW1wwvbhmG8SbbgOyL1jU4qBAjxHh5tNjb417QXz2cCiv+OBaYJAqEkExbwDFRXbdJP8mdMHDD3QB\/PUB3stWdjF57s0Lu72CzeG4DY54T6nAu5nvYhoIUlUpC8bWe7GvvPxtgNlQqCRwHWxEQSMY2emtuTkqiNlcPl7FpJ0i26FG5D4rgntuCvGUvJQaAQniI\/IyAlEc3I8H47QsuV24ybwlRI4yUcRDzVWWYQUFfBjnSdZwy4geieiQ6UIgIUM+IuasXscEPmy7QM8+TufXssmMDVM5YRVpNbntN\/vf9oluHt8qe8GSHyzmMSbGxqC9s6NsNI5XXf0xjcPSHxvIcaNRK6VM6WN6uKLibtJFg20mPELg4myxjqyQUPxxMyikS\/REWBEa3GcsVrzyaXdvrnRHAkQorkBiSGx+1BSpQZU\/uRtret\/qdKEOjn9EM77JG1RuYbm+RBC0dzBQiqsvwG0+2+68IdpLIyypy1B6QQ+mjhXeDGuv5WlJP+FpCZJQ5oS6\/VypqVC0tdA6IzSIHetfSBzZhtBpXnmWQaMGeJFHjE2XhdZTqNUwq5++WPLRGdehCiZGFwHy68RhFeKclU2Pfaa5UXj9DfbKlqG+KGpDKrdciF7UTC0ZRqyPFLocZwqtq8QH89Cs3B+lDiVXto3aTUO02Xwen50O0y34R\/aPqpw3XPHTSDViwPSNz4XFzeuoQJNTVjyrYnmdg25SAco+jfdyldL3GRTwwYYg0UmgP7FBc3Nm2WbvDhWRfW5YBuDP0AMiRDbgCSWAkDE1ydmqGGDQJYXQLodH8vjusUhmVIo6EG17MY5wJc1umibuZEKe3neVWtohnNfZoB48xH6hMhnTILk3j5aYF9GuszE\/Qfs82qsL\/i0cvYUWhcRugv0J9SQJZCY4q8ClTCVvrBPiICZQXbJK1ADTEbJLJ1mde1trnxRNhFJBkX86EkpU9ga2kiLjm7ufgSfsx\/w61L8V2sB\/5M4Ol+nJi3FHjyKUKD5EvaDm6mtCo\/xzEXZ5\/BIb+FTXqth+SCR+ISKLagO12C7ZferDl8OX9d\/KLMQZmpmjtxa7ZNPxDN8iNF8CxwgkfbAj\/YdN0rZqTe1jw+\/tEKnvRWsw1VWGQouLcZnEZcCaYXrr4LPVWq0hN7tRGoiQNmjgvJ2X1YRtoCbTkm6T5sRHGBx2+ClWrRn1xlBpiAR+p\/lxevYAm7dHp+3e9fQrfjCh9egSa2T7VXP\/XQczREABSNYwf8wCmTOoRTGC1UlW4k92xVSvbXBfpKQP3ZQc11HGgo1EEZDu9Lc1dV1m77KqupRoYoi6g6PK6vHahfgOJoqm\/ehhr6bQmo20OHMc1dPT3z0hTySzKfzv3PiAz7jJq+oWrDo0ul6gGA5VJ0qCOEXIfC\/SZyjQgV7g1oxQhEZFbst5MnmzG8knv9eO2jKW9gKPFhNXQRkKngdEiiqQTH4OekAWZjwy0hXRW2NxEkgZ22BvF4c8X8RdSVIuz14HY0h5KN+vbBlPuQVALtEpXlBRptb\/Ppx4Rt8hqlqqC5UXzJrNQ2IM0Ks+TMpI0ja1nt5GDsOWdlAg\/6nrJUSHfF4Th73y+ya5ipWjzNxzaZZ7eleYf2syj92AtlfkRXLibTpd3lOWaSjNnP21wuQecKukpus3+n5FPr8e4hDJvj3f1vbVRoX9g2IJK3251Ve9Id1tf+P8NdYNIrLuz3KFU4g2Li2GbpJUet8nqDd26bYPQXoKRwh22\/NzrFtoz6SKugyrUcNEdNv1yd\/iG06ynrPeAvi1MvZTYj7YnJlmow6jZr2QvPOB0PeFL5qoIs7h0OzMjnL+8I5Gc8vPzbpWh2Zk1yS9ZtCvbrEo7Q66jX78v2GVRRhjgCf1bS1M1ZKMH0Exa+E\/85pENtqPMZ9HjYAduli1xzMfvXao7xKGrSQG2gWoRJv29PAfo\/ZEZlydAIWREBhniJvrR3iJlyO9sVuk1hcHsYzx6uKVWXwIIg7yKG1aJa+JkHdlC8vGeujt4oR6sLm40vh3PmSo5di\/VKAF7lZYHN5KKtI\/HI9R4ozTBvo6lPMUcIKGNjEG0rl30nl+w7AdFaL7Y0cJMFJBz\/UgThay7fFrG23NwAWRpfFI41Rh8P+AqTOU4C3xJJRU5XgYHfGq7aKWxK\/Kw6aHpH3PomQ24m1jmIAhgD\",\"page_age\":null},{\"type\":\"web_search_result\",\"title\":\"The Uplift - Good news and stories that lift you up from CBS News\",\"url\":\"https:\/\/www.cbsnews.com\/uplift\/\",\"encrypted_content\":\"ErAlCioICxgCIiQwOGZkYTRiYi1lMDY1LTRmMGQtOWEzMi02M2M5ZGRiZTg4YjcSDIRs0gKBceLJBplWhxoMatQCt6Ef54\/P7+lxIjDp7YCzX7RLYsadHVSCXHZ7NkBzilRU6vKDt9VaoqYo+UPm+AYPdLf3hLlnJWH2LWwqsyS0\/TwAY9Eacl\/BB0EVHhviRd5mGAF3TSFonQF9C4SHDh43JciC+868m0P28hLjRh+ZyZCNsG2aj9hsp19AS6TzSRx9Y4FPC7rrCu7ajNF\/bs+ozx73dzTjfU0swk9gqK4QyFebFkAeyYIA6KzkmLbWn\/a1xLucoRGGmQ8oILg61lX17lcra7Ddeotv\/+k4XCAkMPxWAv9TFmpWfo3gUijV0+sM0kR\/B2\/\/luUx8TS+YlqmSrMMAHKzbzkbM6uMjfMh6bV2B4xlerTFpg1rclX49eea3bQPf3x6Wlty0TI2+jyMaMjdxAAI3HSjMG+cCJ+Cnv1FOcTy6Y6liwmFg6dPR7b4in620Dxaa+LfVmsiKZApNQt69gOIBipLuyF2J3\/SQ1Zrj1ieoFUR\/II774vQ1xSVNWDh9penAFqjTXeBNYHxge8zp0yjGjC3pUSYVAtwBKnk4VJLfl5tT1G0\/s5AbiWUKcMJzk+YstP8BO6oHSRobk7DWNgBwbJy14MR8WTSkJGDy5hvKq9bKsqTUMpsENpgrZw2Gn3aPbTQD44nohlhJRT4kb5pSP6qSAOwWTAj2Kz5Js1dRCeN4Ljkf5QHIt9cPyMkEky1pmYMsd1z\/qvpaN7G\/fljYcfSccN0uHUp7mGa4n8FJYUQoy\/4gMGbNCxaF0qMt9YU88kBuwet9xjc7YgBZK69LkyfPyUPdbI2jbxT\/Fz\/Sn8A1yjX8fhKz70wxGsfVswp\/BgRhRTC5gitWfgV+jK4O3d0pZ3wSq1JOJ7pVHF9JL3WkPXE\/QKTaNvgOBlBzkdnOdQTbdtpbQqu49IyM0sCr\/pawiXE1cnhbHxHi1zTbzo4teZLI9OHl2SscQtYccmxZFEIAHOsGhewZPge+iTo20CB0Ty2uJ0dqV2nVhjB3VJWWh8XViBN\/pSDfUyfDshUF6sDHpx1e4BUDC+0y+Sg1YxaXoXyZudr\/kXUge0giJGlgGCwUyH7lWI1jKehLw+sZaVNoNeWRpTH3R0dvume+ISXItT+snxxnlYet9pjm3aKPsu9M6os6e9zjlvtSzcBM7kqZ4eVT5aCUfW0r7tnhjyy1u9O3jaurG1XjeITpE1opzkgVyan8VEI7uBOZc0d2utwQczCoEJXPRzlSFiUPp4RZ3awmGqtNDl\/dtRdjonu\/i6L0CCbGITJ6MGN8b6I6QZ2HQAS1rdAEe0Eg7CvTKXQSMT3i2l+BbPkM1RyXrV6sywuEcYrPUFZ8cZhas06GQZOIU7rAz69td1S4HSTHWWJn\/LiJ8O972tekLmHqCUWbF5Vvcsmk8TSs5VIDB970HGa4kn\/RQGVpiuBsBef1VdbGhTyyUFvm9SjXH\/nVeNJ7ZYqZIUTn286eEkFBa+\/psT\/TFIr3lIzQkkb970WTKdNzZ84xreW2CAl1HHm7sfMYvIe1\/ZVZGGm94OcJw8DnA7dj5kwv2jaZtu5un+cXikAruCA3Fo9\/d+LngUoExOOTYNNofrjIn8\/zsNYf945gpK5FSXiXfLoc4hr0uf6Fr3tBwjUZWt2BcYxVE+S79tdjBkr\/PCpFJ+jqZf32JmvW73jMMfx2iInisOrdzuidLA5+8Cc4Y1zDOjY3H+q3AXXeTI0Een7nhPvW4LuxTODf1Ukvk8DHfUNuFiXM\/EpFxYTp4ZJaQdLXYUHWfU\/FGIqsePl+dJxtYTbjMaJmGyq6hydLHmaldmpY+PNhhuGRlQtXciLeQbELSKWmrK\/M\/sRFEnih3a3+kWzj9f9qyUaKWh99YyY5o\/HTFMtsw9HMCAb7eTsfRPZa6\/QHNZTnHHQDUsHb+At4bPE8Jf8GpLyI7yFHdGbT7ZnB8w9UWeWAjTKCoj2Pn2rGtik5S13zmXWYJH5BvXYGXKrl1rhJbejD+daAR7Jl0Sj4Pzs0L3zRPo6Te7E+XbHMN5rX\/IWYT55RhSn56xfIPYFY+F31adIIeMAL0brZYI975YO6\/LM9j5o4NRlkwT4\/F3F9h0AlDmPZ6caoAd5Yjpq4sTANVwK69nnuMIMSZssqBANKbu1Uig83DIODbwMCtWaDXVgclBsZBiVwsRSKlLPHysJWMnz3C70nPUS9s58zW0Z0wpKqnb2RykNz\/HcAYuPFVyQczfjtm2memngsTgFjqFZ\/Wn3YaLiZyHSwBImZE9uTx5dfcJYqj7Z86SyQ4\/52fX8woMH2tFZjDrHqzjfiKsgdGzL+wByPKTHbbuiGqhEQshaAXZr00Hsa342XJEHlDkAqO1Otk+P79xmn7j0Tfzs8\/Z4E4sSbjqGKWbZrz8S8qAMqyXEbQi\/QFri9c\/lT\/\/DoovJM2iGbLe6wy62QSwKQD8BRInlyzVL8L8gbJUdPdIvKkJorpYttOnO9xXVGhI+4gCGWfK9G1rkiekxLZM6mt+WdZVPA7iPAyTMhEN724y8+FX1ItEarjkQONGFg2oJH1rvpJMb+XMeeczk04oi72cmjS7FXJq+4fQg97NuHFSDWd37EC5Tq4e6C\/raIwzXnCu0ebV\/7Ipu3el37qd2pcMNZOOclND1pOKzEDKXL1CVGy7tbAVSrnNKy+bNd66dalBWqaEdOe1kQfrrGkZxCFlB3Up7Svtt+I6wHa2hghUHSbvN\/W1y+C3e\/8yLuZ\/PR2D91IQzU\/ymWpiCPJUs1o1mKTOlnhL+EzuuSVIl+dWhkyAF\/C\/eloWDLmu9ek5S8PJL3iDR9u9BlXhQl0rYlpHfc+98P1qd35Cke5PSdIOoZpXY4S34QWQSJHJZ7mrBODNBPpWxo8zzpPK\/iF7i7ZrHgiehO7TQDP0PFvVK4zTEa+o9RE9HrTtJJxI83PLyTf+OinoZvCWaj3m2UNWQ8mcYmKVlciUnWSrwFZGIfnx++4ZTrLcItd74Tk4R36iZvGQtLkJTr2TRUOXBeHqo\/YwxLn5WaRzBaRayET20iMVOWugincMIAdXXI6rqyWt+meiKs0mZgVZgfaj0jndMC3ifqQ9kmXwp9HQyQI1+wurp1TvScMtJy7pr1iRUGerxt5OVpj568qilCAROTfVjbDv8BO8p+MmqwVgi3BoYOzvCPhUBbajIhE+qoWG4nHRWDFARVZ\/BnhwlCz9QyripMsQBAYodYVxTmyB7h+OYk3\/e1BYkGLXXa+wPodGhEUso8U8IT8r0essI5cnn9McczyAcn0oBc5M3Az40wRQfeSJmKf1AVJ8h\/Gnv+uP5PIGee4bxBkCCnlR5ObkgCb\/mSsP+C47kcriVkmMf1kX53FfXePJ53L045ZhbfkahRynDxd11bs0pQPY5WZfmDulTx04xGnmExYCmiyk9Z2QZb67G1w7m1Bd7AeFa5yII+gio+XQoByX5hxAZ4PQ8U\/DKnpFkfqXRJsj3gnJpHTyXRSHBDaudczy+sXhp1ufT4VPPB9u1q7TqUjkbgkVmX2uKdipCFtX5xDtdhtJDaN2niwlYRwshmAXcTLaTIiiQZ5savGs2i5QVxLZuL2T7bZ+u6117IU8ko9J6CbtKyuRyO+LHVYus\/ysX6fFHYvjTts60jsOkQSxdKOvInhdBS9EzFskSernyxh+rTQqxCMX8czWo0ElpCsLdFgQclknrP9S2T2L71ia1mHwqTUDeoQj\/4P28YMbIpGHjDhQ3yfmr6g7TPQqMYtTa+rNGRxVfeuNdBT1mEGeemXsNtdhdLEUcFQZoWQseW9cGq\/qgWjh0MDefQLDtQ3kkf7gtq4miVXtdGr0H8RVoIn4p+uWLFqoO8hXyNV0hbLKy9PjyqZBBtqdj\/WyZbQJlrtjGEyBpP4t6gm76hDaCfzVXabS05JkfVIgKlR5VNxpfxkoXNSqvjMv2R3Cou1AlpjI7HxDGc8B\/iEEWilTZ3zZtrIZo\/Ujcrl+DMXSJSoOzhRsUx9FtGS3DNkooSq1UrAtXHgJZH8bum7gKJa4EIlC2pJvvqszXDXVxN4wPFHOzw3f2afP\/BUfFEiL\/YzJL+\/N9dOj3BO63diIwlX0bCGrBASN7+2UctgvwVDCZRYyBRnOEZRsZwVH7ncYc3+Pj8UNmQ4XIOs1K0amIcTeCfIYPzHgFcjfjHh9gkTb78Yn6yRgjSNEhlP\/9t88VUwWo236NTyO54WB90hhTwSuvWLK7a6uq9cZpAJbxQ0TDdwUXElDWKmr04v1fkjD69jfAI2SPCWGGHs\/hS\/90lWCdhktTXgHxj3bg1uRuVsqmmMam3qAKsElCY65c9k35NlgNkCTTgTuGhCwt9dH5JZFzCf+SiIVL3q23JNxBQA0DG7cToY1p\/vjl+EpPmMmlFDMfzSOzaOAQ2QCCmylyYBABA0CUMnKQeAarjWvn5XJunwTeh\/2yUf6P5YSqcV9r1YIqMbaEFdF3mQWDHA0zX3udbOKoThqxUckf6bJr8GZgdl7t5R\/F5sgldApNO5INX\/fO6UcX3iKWEqZj\/pqaYHHEH0YYDU0hrD3hToCUjxDaS\/mggeLZ6ZI9xe5fmgn\/IwoRFZMSUd3gq4jgcunYKjs7+mkfEsPdWBa85gZPbbclc+d8QiDPDtlYMp4jxwDeu1Cb+hrDCNSBBQ4zTE7O3ds9fmBduw8tMha1t44BRkJbcwTu\/\/DASCKAyAiqa+\/ZcE66YFqR04uvEDnlaZKWKd\/FbDUuK4j3G\/fNKH2nAxg7hJuqgmwSXWWB9CtjsB2famNZOWypFltWYQLahMxzBuPbXTYSOKTZGEMZ8wQe1iw5HMc+Jzcz5X63Q9m6fkfb8wq7phCEOxc3RMehLadCzqbMOCrlx0KYC7ylskinChp66fLZ0ouiO0BgUG6Nmqes72gJ67mUFJ7txCcL76FukGffDipxyQINIoX77IDjITVK\/wRdZ37oUB2gLzf7Ubk60wRbtV98E+k0W9qygvyqwAaW26lUdcWViUIRREyLD74FlHp12zSiTzFHliyMXYeQTe3r1pzzLsnzA8GePnsahVfXOVjbl9nOOU3cf\/K5x804esU0NlJdcXEq7wzKaDNQcjVorUDTjJtvicBq0fL9imq\/VdbQJLzTFaR5BnZcxK7qWuRpE5sVOh4820la+UerIyMg5JbkIx\/m\/qKtPQJBpb69WUKdnc\/rRvGhhaY7qo1aHullLS\/p7eqJ15AfdR\/L1tK+3PUgxHiwIMYNRnajRfcIuXdYXXgd\/44XopZhWqrVspYLYyzyDnNVV0gUM7GL8co4\/9if9PPlwZIg2BElPQ2kCD24tr9rc5eBfXRpJVUOjHevkurMezH8LJXYtPY0jEl4x506bXCPZPkr+EI0lC+yssEwqYplRm9i3+N0OELHm0GpHD8ykq+Y\/ZeVg5\/q8iC6UwrRZP04XCZVQNi6jlX1Aoo0oXsFYoieftrEZgCUhMEIsR6o2HtfDbmiJ\/jF1EW6ccI7zwofoT5FozqSqCM96TxGRBVAWMqIyj7F4NZmG6r7u46Nb1ORauR9pNEZgS6UKZ74TYCNETuAz+B2QBBqDS+eOT7G2EBV2NErG4inOwcdrPo5x9t+ATTz6ZZSJV8Xo3+Bksd4Ccya\/03VvADWsIxQTZG2mPgyVhQ5b0d6AyLXNuUiOuX\/5\/uCAZn0u8O1GVeZeFhhiISOPn\/1xi9010V3TALHR1ohrX9CWjiUrmO9jCioJz1b3B4uSDPCLeWdWr3N+v4PKjZ0r56uBLzgCSqgxHYWXP\/DOf5yh2k3bxSoyJuw3dbl55sBIfoUcuT6BgSrMjfcRCtrNxnJzGUogFyw0kmGF3W6EW1jxqJ5UqwWftLVP6C+KXjnhsEGnqpxwOHEnlnsqOG04pTolNZN+s36C1JKzMozZMP6pDkNce4qUFDiX726KoGXoQYBiy9pxeHyZXp7BPuwSZDrCQftSP5N3EppX4o0\/E204H5JaQAvCAa5qFwWiZD\/ELPEtgwCUdsQC+fnLn0s3dENxZeFptiAdWgS04ShmNWvPuA2Kh4RWjmI5Iops36y\/3wnrPGU6b1mfqycv5HRIKTiQEngQmoRKeZzSjepKQoEEEXN2gG+WFozH2OEZIrsbdJ\/yExAtikMPkbd8X8AOuHLUbFI1s6y8Hlk+XiXw79pbtI\/dfh+flWst30wS4ajzFqE58CPy\/OZFCTsNnyavsk65q\/3POh96lo38GucBC\/c474kOhBCSW693YZSxdfBZKukDY0tlOe3vWzOWQqq4c9OOS5MAi7q5g0bNAPEgjbGOEeNd7xOqxPi+jMQoltAjnqI4GEzJIbd78YYAw==\",\"page_age\":null}]},{\"type\":\"text\",\"text\":\"Here's a recent positive news story from just two days ago:\\n\\n\"},{\"citations\":[{\"type\":\"web_search_result_location\",\"cited_text\":\"Science News \u00b7 from research organizations \u00b7 Date: December 27, 2025 \u00b7 Source: University of British Columbia Okanagan campus \u00b7 Summary: UBC Okanagan ...\",\"url\":\"https:\/\/www.sciencedaily.com\/releases\/2025\/12\/251227082728.htm\",\"title\":\"A rare cancer-fighting plant compound has finally been decoded | ScienceDaily\",\"encrypted_index\":\"EpEBCioICxgCIiQwOGZkYTRiYi1lMDY1LTRmMGQtOWEzMi02M2M5ZGRiZTg4YjcSDM4zpg0QAALVtUTuOhoM6VKw1L7\/Pe6E\/3\/1IjAD\/tlTeVnJPlWCvtPNEwdsZms8ijj9a53EFjGajhPyvGIEK8qszIM38OsFeUzN9OYqFRyykquzJjr8p\/p7diO\/\/iR2KwHbUBgE\"}],\"type\":\"text\",\"text\":\"UBC Okanagan researchers have uncovered how plants create mitraphylline, a rare natural compound linked to anti-cancer effects. By identifying two key enzymes that shape and twist molecules into their final form, the team solved a puzzle that had stumped scientists for years.\"},{\"type\":\"text\",\"text\":\" \"},{\"citations\":[{\"type\":\"web_search_result_location\",\"cited_text\":\"The discovery could make it far easier to produce mitraphylline and related compounds sustainably. \",\"url\":\"https:\/\/www.sciencedaily.com\/releases\/2025\/12\/251227082728.htm\",\"title\":\"A rare cancer-fighting plant compound has finally been decoded | ScienceDaily\",\"encrypted_index\":\"Eo8BCioICxgCIiQwOGZkYTRiYi1lMDY1LTRmMGQtOWEzMi02M2M5ZGRiZTg4YjcSDIytH83O\/HuNNOkqkBoMdlzxXa7O03mr2G0xIjCucr3nuWNyViOYKgz1l6XjWNLwxhBPOf07OCPWvkKA5SzSrRNdVwm70aBt9OG5PK0qE+1Hw+tGZnNL41fLc\/Lfwn3AsRUYBA==\"}],\"type\":\"text\",\"text\":\"The discovery could make it far easier to produce mitraphylline and related compounds sustainably.\"},{\"type\":\"text\",\"text\":\"\\n\\nSome other uplifting recent stories from December 2025 include:\\n\\n- \"},{\"citations\":[{\"type\":\"web_search_result_location\",\"cited_text\":\"The entire city of Los Angeles is now completely coal-free, as the city officially stopped receiving coal-powered electricity from its last remaining ...\",\"url\":\"https:\/\/www.goodgoodgood.co\/articles\/good-news-this-week-december-20-2025\",\"title\":\"Good News This Week: December 20, 2025 - Puppets, Raccoons, & Grandmas\",\"encrypted_index\":\"EpMBCioICxgCIiQwOGZkYTRiYi1lMDY1LTRmMGQtOWEzMi02M2M5ZGRiZTg4YjcSDPe5rOuFU+GFGLAwbRoM9JuRlXw6zb9kZ2n3IjBe0fLIrsNWXEs1YLVTIMMf6UbR4y5giXJq6yHWDH4rwBvcQPg4UmqxXKzMgBLVpyUqFydOjM\/wZdOgsxcpuwrD37YkIiYx6t6bGAQ=\"}],\"type\":\"text\",\"text\":\"The entire city of Los Angeles is now completely coal-free, as the city officially stopped receiving coal-powered electricity from its last remaining coal source. It's a major milestone as the city aims to reach 100% clean energy by 2035.\"},{\"type\":\"text\",\"text\":\"\\n\\n- \"},{\"citations\":[{\"type\":\"web_search_result_location\",\"cited_text\":\"British autistic artist Nnena Kalu wins the 2025 Turner Prize for her wrapped fabric sculptures, becoming the first artist with a learning disability ...\",\"url\":\"https:\/\/en.wikipedia.org\/wiki\/Portal:Current_events\/December_2025\",\"title\":\"Portal:Current events\/December 2025 - Wikipedia\",\"encrypted_index\":\"Eo8BCioICxgCIiQwOGZkYTRiYi1lMDY1LTRmMGQtOWEzMi02M2M5ZGRiZTg4YjcSDEJ0PGnIOUuJ\/RRYkRoM2dNiXK+TzEKFNu8rIjCc8aECNl9VPZUpcsJRhW38VVGBmV1D5FFSN8aIR2Dmo8ew9+CtD73IV4JH0EOAnv4qEyYdyonwxOhvjp2jSABSbMBb5MUYBA==\"}],\"type\":\"text\",\"text\":\"British autistic artist Nnena Kalu wins the 2025 Turner Prize for her wrapped fabric sculptures, becoming the first artist with a learning disability to win the award.\"},{\"type\":\"text\",\"text\":\"\\n\\n- \"},{\"citations\":[{\"type\":\"web_search_result_location\",\"cited_text\":\"Nature\u2019s 10: Ten people who shaped science in 2025 \u00b7 This year saw populations of some endangered and near-extinct species bounce back owing to strong...\",\"url\":\"https:\/\/www.nature.com\/articles\/d41586-025-03505-7\",\"title\":\"Seven feel-good science stories to restore your faith in 2025\",\"encrypted_index\":\"EpMBCioICxgCIiQwOGZkYTRiYi1lMDY1LTRmMGQtOWEzMi02M2M5ZGRiZTg4YjcSDAKV+2IDfaVMNzxzqRoM\/rB7mIFEVQMGQOXVIjA0sxOYX72tBLv8lI2cUwjfT759ZOawZ+6Yryvl\/2YFOQAxzG+vSwrgOptE1O99pxoqF8b6BOsfak4\/uKladUCG48\/KR8039li6GAQ=\"}],\"type\":\"text\",\"text\":\"This year saw populations of some endangered and near-extinct species bounce back owing to strong conservation efforts. The green sea turtle (Chelonia mydas), which has been endangered since the 1980s, has now moved to 'least concern' on the International Union for Conservation of Nature (IUCN) red list.\"}],\"stop_reason\":\"end_turn\",\"stop_sequence\":null,\"usage\":{\"input_tokens\":16315,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":0,\"cache_creation\":{\"ephemeral_5m_input_tokens\":0,\"ephemeral_1h_input_tokens\":0},\"output_tokens\":423,\"service_tier\":\"standard\",\"server_tool_use\":{\"web_search_requests\":1}}}", + "context": [] +} \ No newline at end of file diff --git a/tests/fixtures/sdk/anthropic/messages/tool-use.json b/tests/fixtures/sdk/anthropic/messages/tool-use.json new file mode 100644 index 0000000..a2aab9a --- /dev/null +++ b/tests/fixtures/sdk/anthropic/messages/tool-use.json @@ -0,0 +1,31 @@ +{ + "statusCode": 200, + "headers": { + "Date": "Tue, 23 Dec 2025 01:10:02 GMT", + "Content-Type": "application\/json", + "Content-Length": "498", + "Connection": "keep-alive", + "anthropic-ratelimit-input-tokens-limit": "30000", + "anthropic-ratelimit-input-tokens-remaining": "30000", + "anthropic-ratelimit-input-tokens-reset": "2025-12-23T01:10:02Z", + "anthropic-ratelimit-output-tokens-limit": "8000", + "anthropic-ratelimit-output-tokens-remaining": "8000", + "anthropic-ratelimit-output-tokens-reset": "2025-12-23T01:10:02Z", + "anthropic-ratelimit-requests-limit": "50", + "anthropic-ratelimit-requests-remaining": "49", + "anthropic-ratelimit-requests-reset": "2025-12-23T01:10:00Z", + "anthropic-ratelimit-tokens-limit": "38000", + "anthropic-ratelimit-tokens-remaining": "38000", + "anthropic-ratelimit-tokens-reset": "2025-12-23T01:10:02Z", + "request-id": "req_011CWNj7TxFyVvwaNaSGdiFL", + "strict-transport-security": "max-age=31536000; includeSubDomains; preload", + "anthropic-organization-id": "08fda4bb-e065-4f0d-9a32-63c9ddbe88b7", + "Server": "cloudflare", + "x-envoy-upstream-service-time": "3060", + "cf-cache-status": "DYNAMIC", + "X-Robots-Tag": "none", + "CF-RAY": "9b23fa06587a582e-LHR" + }, + "data": "{\"model\":\"claude-sonnet-4-5-20250929\",\"id\":\"msg_01KFJgeiL78UwUXSNVyMnb7S\",\"type\":\"message\",\"role\":\"assistant\",\"content\":[{\"type\":\"tool_use\",\"id\":\"toolu_013eyeBacKytgaGXQCdz8KMX\",\"name\":\"get_weather\",\"input\":{\"location\":\"Manchester, UK\"}}],\"stop_reason\":\"tool_use\",\"stop_sequence\":null,\"usage\":{\"input_tokens\":622,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":0,\"cache_creation\":{\"ephemeral_5m_input_tokens\":0,\"ephemeral_1h_input_tokens\":0},\"output_tokens\":55,\"service_tier\":\"standard\"}}", + "context": [] +} \ No newline at end of file diff --git a/tests/fixtures/sdk/openai/responses/function-call-streamed.json b/tests/fixtures/sdk/openai/responses/function-call-streamed.json new file mode 100644 index 0000000..3d224c6 --- /dev/null +++ b/tests/fixtures/sdk/openai/responses/function-call-streamed.json @@ -0,0 +1,27 @@ +{ + "statusCode": 200, + "headers": { + "Date": "Tue, 20 Jan 2026 08:46:38 GMT", + "Content-Type": "text\/event-stream; charset=utf-8", + "Transfer-Encoding": "chunked", + "Connection": "keep-alive", + "openai-version": "2020-10-01", + "openai-organization": "user-kh6mcena3vodflcmdl35kovz", + "openai-project": "proj_lMW3RmfgZCZ7JPoXeXik9Etv", + "x-request-id": "req_0feb7782e4a3421c9b499fcda060efd3", + "openai-processing-ms": "73", + "x-envoy-upstream-service-time": "76", + "cf-cache-status": "DYNAMIC", + "Set-Cookie": [ + "__cf_bm=EW4qktJVNdoGt4BUOJu1qVrZ9QOBlomsNNpWqTRzyfw-1768898798-1.0.1.1-1bQlrp2Lysot.j4FG_5IzRh4yZZM1zYF4G800BwPee3PbClVTstoP75Sgl878luzB7pz9Zt26CTNZDLBLxBDJy2nu0kYlGkST955dYoT8cI; path=\/; expires=Tue, 20-Jan-26 09:16:38 GMT; domain=.api.openai.com; HttpOnly; Secure; SameSite=None", + "_cfuvid=mR65GOlVcIJVsnX7lCR9ylUThvH3QG6xnLgcEa5TtpQ-1768898798349-0.0.1.1-604800000; path=\/; domain=.api.openai.com; HttpOnly; Secure; SameSite=None" + ], + "Strict-Transport-Security": "max-age=31536000; includeSubDomains; preload", + "X-Content-Type-Options": "nosniff", + "Server": "cloudflare", + "CF-RAY": "9c0d4d6bfa8fad4f-LHR", + "alt-svc": "h3=\":443\"; ma=86400" + }, + "data": "event: response.created\ndata: {\"type\":\"response.created\",\"response\":{\"id\":\"resp_0f51f16555c4726b00696f40ee3434819abb8aa785fafbd8ab\",\"object\":\"response\",\"created_at\":1768898798,\"status\":\"in_progress\",\"background\":false,\"completed_at\":null,\"error\":null,\"frequency_penalty\":0.0,\"incomplete_details\":null,\"instructions\":null,\"max_output_tokens\":1024,\"max_tool_calls\":null,\"model\":\"gpt-4o-mini-2024-07-18\",\"output\":[],\"parallel_tool_calls\":true,\"presence_penalty\":0.0,\"previous_response_id\":null,\"prompt_cache_key\":null,\"prompt_cache_retention\":null,\"reasoning\":{\"effort\":null,\"summary\":null},\"safety_identifier\":null,\"service_tier\":\"auto\",\"store\":true,\"temperature\":1.0,\"text\":{\"format\":{\"type\":\"text\"},\"verbosity\":\"medium\"},\"tool_choice\":\"auto\",\"tools\":[{\"type\":\"function\",\"description\":\"Get the current weather in a given location\",\"name\":\"get_weather\",\"parameters\":{\"type\":\"object\",\"properties\":{\"location\":{\"type\":\"string\",\"description\":\"The city and country, e.g. Manchester, UK\"},\"unit\":{\"type\":\"string\",\"enum\":[\"celsius\",\"fahrenheit\"],\"description\":\"The unit of temperature, either \\\"celsius\\\" or \\\"fahrenheit\\\"\"}},\"required\":[\"location\",\"unit\"],\"additionalProperties\":false},\"strict\":true}],\"top_logprobs\":0,\"top_p\":1.0,\"truncation\":\"disabled\",\"usage\":null,\"user\":null,\"metadata\":{}},\"sequence_number\":0}\n\nevent: response.in_progress\ndata: {\"type\":\"response.in_progress\",\"response\":{\"id\":\"resp_0f51f16555c4726b00696f40ee3434819abb8aa785fafbd8ab\",\"object\":\"response\",\"created_at\":1768898798,\"status\":\"in_progress\",\"background\":false,\"completed_at\":null,\"error\":null,\"frequency_penalty\":0.0,\"incomplete_details\":null,\"instructions\":null,\"max_output_tokens\":1024,\"max_tool_calls\":null,\"model\":\"gpt-4o-mini-2024-07-18\",\"output\":[],\"parallel_tool_calls\":true,\"presence_penalty\":0.0,\"previous_response_id\":null,\"prompt_cache_key\":null,\"prompt_cache_retention\":null,\"reasoning\":{\"effort\":null,\"summary\":null},\"safety_identifier\":null,\"service_tier\":\"auto\",\"store\":true,\"temperature\":1.0,\"text\":{\"format\":{\"type\":\"text\"},\"verbosity\":\"medium\"},\"tool_choice\":\"auto\",\"tools\":[{\"type\":\"function\",\"description\":\"Get the current weather in a given location\",\"name\":\"get_weather\",\"parameters\":{\"type\":\"object\",\"properties\":{\"location\":{\"type\":\"string\",\"description\":\"The city and country, e.g. Manchester, UK\"},\"unit\":{\"type\":\"string\",\"enum\":[\"celsius\",\"fahrenheit\"],\"description\":\"The unit of temperature, either \\\"celsius\\\" or \\\"fahrenheit\\\"\"}},\"required\":[\"location\",\"unit\"],\"additionalProperties\":false},\"strict\":true}],\"top_logprobs\":0,\"top_p\":1.0,\"truncation\":\"disabled\",\"usage\":null,\"user\":null,\"metadata\":{}},\"sequence_number\":1}\n\nevent: response.output_item.added\ndata: {\"type\":\"response.output_item.added\",\"item\":{\"id\":\"fc_0f51f16555c4726b00696f40ef8d6c819a9fd0c583afa621fa\",\"type\":\"function_call\",\"status\":\"in_progress\",\"arguments\":\"\",\"call_id\":\"call_mpisVwMpENDmDdyLFejlSBkS\",\"name\":\"get_weather\"},\"output_index\":0,\"sequence_number\":2}\n\nevent: response.function_call_arguments.delta\ndata: {\"type\":\"response.function_call_arguments.delta\",\"delta\":\"{\\\"\",\"item_id\":\"fc_0f51f16555c4726b00696f40ef8d6c819a9fd0c583afa621fa\",\"obfuscation\":\"avZYKQrlB1eKPx\",\"output_index\":0,\"sequence_number\":3}\n\nevent: response.function_call_arguments.delta\ndata: {\"type\":\"response.function_call_arguments.delta\",\"delta\":\"location\",\"item_id\":\"fc_0f51f16555c4726b00696f40ef8d6c819a9fd0c583afa621fa\",\"obfuscation\":\"nagAmiEH\",\"output_index\":0,\"sequence_number\":4}\n\nevent: response.function_call_arguments.delta\ndata: {\"type\":\"response.function_call_arguments.delta\",\"delta\":\"\\\":\\\"\",\"item_id\":\"fc_0f51f16555c4726b00696f40ef8d6c819a9fd0c583afa621fa\",\"obfuscation\":\"hDch4y833XPUm\",\"output_index\":0,\"sequence_number\":5}\n\nevent: response.function_call_arguments.delta\ndata: {\"type\":\"response.function_call_arguments.delta\",\"delta\":\"Manchester\",\"item_id\":\"fc_0f51f16555c4726b00696f40ef8d6c819a9fd0c583afa621fa\",\"obfuscation\":\"AkoQN0\",\"output_index\":0,\"sequence_number\":6}\n\nevent: response.function_call_arguments.delta\ndata: {\"type\":\"response.function_call_arguments.delta\",\"delta\":\",\",\"item_id\":\"fc_0f51f16555c4726b00696f40ef8d6c819a9fd0c583afa621fa\",\"obfuscation\":\"sjokXqgzLLsBySf\",\"output_index\":0,\"sequence_number\":7}\n\nevent: response.function_call_arguments.delta\ndata: {\"type\":\"response.function_call_arguments.delta\",\"delta\":\" UK\",\"item_id\":\"fc_0f51f16555c4726b00696f40ef8d6c819a9fd0c583afa621fa\",\"obfuscation\":\"lvwsWWrYaLOyP\",\"output_index\":0,\"sequence_number\":8}\n\nevent: response.function_call_arguments.delta\ndata: {\"type\":\"response.function_call_arguments.delta\",\"delta\":\"\\\",\\\"\",\"item_id\":\"fc_0f51f16555c4726b00696f40ef8d6c819a9fd0c583afa621fa\",\"obfuscation\":\"sxanikOwp2Lyq\",\"output_index\":0,\"sequence_number\":9}\n\nevent: response.function_call_arguments.delta\ndata: {\"type\":\"response.function_call_arguments.delta\",\"delta\":\"unit\",\"item_id\":\"fc_0f51f16555c4726b00696f40ef8d6c819a9fd0c583afa621fa\",\"obfuscation\":\"fm7yWSnBoOb2\",\"output_index\":0,\"sequence_number\":10}\n\nevent: response.function_call_arguments.delta\ndata: {\"type\":\"response.function_call_arguments.delta\",\"delta\":\"\\\":\\\"\",\"item_id\":\"fc_0f51f16555c4726b00696f40ef8d6c819a9fd0c583afa621fa\",\"obfuscation\":\"ipiZDMwzXFRbr\",\"output_index\":0,\"sequence_number\":11}\n\nevent: response.function_call_arguments.delta\ndata: {\"type\":\"response.function_call_arguments.delta\",\"delta\":\"c\",\"item_id\":\"fc_0f51f16555c4726b00696f40ef8d6c819a9fd0c583afa621fa\",\"obfuscation\":\"46cnBIKuqM371AJ\",\"output_index\":0,\"sequence_number\":12}\n\nevent: response.function_call_arguments.delta\ndata: {\"type\":\"response.function_call_arguments.delta\",\"delta\":\"elsius\",\"item_id\":\"fc_0f51f16555c4726b00696f40ef8d6c819a9fd0c583afa621fa\",\"obfuscation\":\"5bB1BBlnim\",\"output_index\":0,\"sequence_number\":13}\n\nevent: response.function_call_arguments.delta\ndata: {\"type\":\"response.function_call_arguments.delta\",\"delta\":\"\\\"}\",\"item_id\":\"fc_0f51f16555c4726b00696f40ef8d6c819a9fd0c583afa621fa\",\"obfuscation\":\"HxGBCZaEP8xdtk\",\"output_index\":0,\"sequence_number\":14}\n\nevent: response.function_call_arguments.done\ndata: {\"type\":\"response.function_call_arguments.done\",\"arguments\":\"{\\\"location\\\":\\\"Manchester, UK\\\",\\\"unit\\\":\\\"celsius\\\"}\",\"item_id\":\"fc_0f51f16555c4726b00696f40ef8d6c819a9fd0c583afa621fa\",\"output_index\":0,\"sequence_number\":15}\n\nevent: response.output_item.done\ndata: {\"type\":\"response.output_item.done\",\"item\":{\"id\":\"fc_0f51f16555c4726b00696f40ef8d6c819a9fd0c583afa621fa\",\"type\":\"function_call\",\"status\":\"completed\",\"arguments\":\"{\\\"location\\\":\\\"Manchester, UK\\\",\\\"unit\\\":\\\"celsius\\\"}\",\"call_id\":\"call_mpisVwMpENDmDdyLFejlSBkS\",\"name\":\"get_weather\"},\"output_index\":0,\"sequence_number\":16}\n\nevent: response.completed\ndata: {\"type\":\"response.completed\",\"response\":{\"id\":\"resp_0f51f16555c4726b00696f40ee3434819abb8aa785fafbd8ab\",\"object\":\"response\",\"created_at\":1768898798,\"status\":\"completed\",\"background\":false,\"completed_at\":1768898799,\"error\":null,\"frequency_penalty\":0.0,\"incomplete_details\":null,\"instructions\":null,\"max_output_tokens\":1024,\"max_tool_calls\":null,\"model\":\"gpt-4o-mini-2024-07-18\",\"output\":[{\"id\":\"fc_0f51f16555c4726b00696f40ef8d6c819a9fd0c583afa621fa\",\"type\":\"function_call\",\"status\":\"completed\",\"arguments\":\"{\\\"location\\\":\\\"Manchester, UK\\\",\\\"unit\\\":\\\"celsius\\\"}\",\"call_id\":\"call_mpisVwMpENDmDdyLFejlSBkS\",\"name\":\"get_weather\"}],\"parallel_tool_calls\":true,\"presence_penalty\":0.0,\"previous_response_id\":null,\"prompt_cache_key\":null,\"prompt_cache_retention\":null,\"reasoning\":{\"effort\":null,\"summary\":null},\"safety_identifier\":null,\"service_tier\":\"default\",\"store\":true,\"temperature\":1.0,\"text\":{\"format\":{\"type\":\"text\"},\"verbosity\":\"medium\"},\"tool_choice\":\"auto\",\"tools\":[{\"type\":\"function\",\"description\":\"Get the current weather in a given location\",\"name\":\"get_weather\",\"parameters\":{\"type\":\"object\",\"properties\":{\"location\":{\"type\":\"string\",\"description\":\"The city and country, e.g. Manchester, UK\"},\"unit\":{\"type\":\"string\",\"enum\":[\"celsius\",\"fahrenheit\"],\"description\":\"The unit of temperature, either \\\"celsius\\\" or \\\"fahrenheit\\\"\"}},\"required\":[\"location\",\"unit\"],\"additionalProperties\":false},\"strict\":true}],\"top_logprobs\":0,\"top_p\":1.0,\"truncation\":\"disabled\",\"usage\":{\"input_tokens\":86,\"input_tokens_details\":{\"cached_tokens\":0},\"output_tokens\":22,\"output_tokens_details\":{\"reasoning_tokens\":0},\"total_tokens\":108},\"user\":null,\"metadata\":{}},\"sequence_number\":17}\n\n", + "context": [] +} \ No newline at end of file diff --git a/tests/fixtures/sdk/openai/responses/function-call.json b/tests/fixtures/sdk/openai/responses/function-call.json new file mode 100644 index 0000000..565d3f7 --- /dev/null +++ b/tests/fixtures/sdk/openai/responses/function-call.json @@ -0,0 +1,33 @@ +{ + "statusCode": 200, + "headers": { + "Date": "Mon, 19 Jan 2026 23:46:01 GMT", + "Content-Type": "application\/json", + "Content-Length": "2247", + "Connection": "keep-alive", + "x-ratelimit-limit-requests": "5000", + "x-ratelimit-limit-tokens": "4000000", + "x-ratelimit-remaining-requests": "4999", + "x-ratelimit-remaining-tokens": "3999697", + "x-ratelimit-reset-requests": "12ms", + "x-ratelimit-reset-tokens": "4ms", + "openai-version": "2020-10-01", + "openai-organization": "user-kh6mcena3vodflcmdl35kovz", + "openai-project": "proj_lMW3RmfgZCZ7JPoXeXik9Etv", + "x-request-id": "req_5108479460924017a01853dfa8b6f629", + "openai-processing-ms": "1528", + "x-envoy-upstream-service-time": "1532", + "cf-cache-status": "DYNAMIC", + "Set-Cookie": [ + "__cf_bm=UQ3fb5Qgq1dHTjmoYrAQanXPRSIqbprc8XmbKS1Gbs4-1768866361-1.0.1.1-e5aosIFI5vXFuIcs8w43baZsk_Pajn5_su81Jlb_57Q61FRATq73PKkHph8VivwO7qUDcDjFDgbPTDShFNTdNISk8d0e._Nld6FVfhTQy9Y; path=\/; expires=Tue, 20-Jan-26 00:16:01 GMT; domain=.api.openai.com; HttpOnly; Secure; SameSite=None", + "_cfuvid=rohQ0Gs4Z2IF_DgisNfX6YIEDQAaqhvdEyxQMy2J.w8-1768866361162-0.0.1.1-604800000; path=\/; domain=.api.openai.com; HttpOnly; Secure; SameSite=None" + ], + "Strict-Transport-Security": "max-age=31536000; includeSubDomains; preload", + "X-Content-Type-Options": "nosniff", + "Server": "cloudflare", + "CF-RAY": "9c0a35772cc37685-LHR", + "alt-svc": "h3=\":443\"; ma=86400" + }, + "data": "{\n \"id\": \"resp_00826c2ea443aee900696ec23791308198b702c21f36c7cd86\",\n \"object\": \"response\",\n \"created_at\": 1768866359,\n \"status\": \"completed\",\n \"background\": false,\n \"billing\": {\n \"payer\": \"developer\"\n },\n \"completed_at\": 1768866360,\n \"error\": null,\n \"frequency_penalty\": 0.0,\n \"incomplete_details\": null,\n \"instructions\": null,\n \"max_output_tokens\": 1024,\n \"max_tool_calls\": null,\n \"model\": \"gpt-4o-mini-2024-07-18\",\n \"output\": [\n {\n \"id\": \"fc_00826c2ea443aee900696ec238c1348198b92b47a1894ce3b2\",\n \"type\": \"function_call\",\n \"status\": \"completed\",\n \"arguments\": \"{\\\"location\\\":\\\"Manchester, UK\\\",\\\"unit\\\":\\\"celsius\\\"}\",\n \"call_id\": \"call_sXyT5eGTVisSbFWEBSjquORK\",\n \"name\": \"get_weather\"\n }\n ],\n \"parallel_tool_calls\": true,\n \"presence_penalty\": 0.0,\n \"previous_response_id\": null,\n \"prompt_cache_key\": null,\n \"prompt_cache_retention\": null,\n \"reasoning\": {\n \"effort\": null,\n \"summary\": null\n },\n \"safety_identifier\": null,\n \"service_tier\": \"default\",\n \"store\": true,\n \"temperature\": 1.0,\n \"text\": {\n \"format\": {\n \"type\": \"text\"\n },\n \"verbosity\": \"medium\"\n },\n \"tool_choice\": \"auto\",\n \"tools\": [\n {\n \"type\": \"function\",\n \"description\": \"Get the current weather in a given location\",\n \"name\": \"get_weather\",\n \"parameters\": {\n \"type\": \"object\",\n \"properties\": {\n \"location\": {\n \"type\": \"string\",\n \"description\": \"The city and country, e.g. Manchester, UK\"\n },\n \"unit\": {\n \"type\": \"string\",\n \"enum\": [\n \"celsius\",\n \"fahrenheit\"\n ],\n \"description\": \"The unit of temperature, either \\\"celsius\\\" or \\\"fahrenheit\\\"\"\n }\n },\n \"required\": [\n \"location\",\n \"unit\"\n ],\n \"additionalProperties\": false\n },\n \"strict\": true\n }\n ],\n \"top_logprobs\": 0,\n \"top_p\": 1.0,\n \"truncation\": \"disabled\",\n \"usage\": {\n \"input_tokens\": 86,\n \"input_tokens_details\": {\n \"cached_tokens\": 0\n },\n \"output_tokens\": 22,\n \"output_tokens_details\": {\n \"reasoning_tokens\": 0\n },\n \"total_tokens\": 108\n },\n \"user\": null,\n \"metadata\": {}\n}", + "context": [] +} \ No newline at end of file diff --git a/tests/fixtures/sdk/openai/responses/reasoning-streamed.json b/tests/fixtures/sdk/openai/responses/reasoning-streamed.json new file mode 100644 index 0000000..1f1b4bf --- /dev/null +++ b/tests/fixtures/sdk/openai/responses/reasoning-streamed.json @@ -0,0 +1,27 @@ +{ + "statusCode": 200, + "headers": { + "Date": "Mon, 19 Jan 2026 23:57:51 GMT", + "Content-Type": "text\/event-stream; charset=utf-8", + "Transfer-Encoding": "chunked", + "Connection": "keep-alive", + "openai-version": "2020-10-01", + "openai-organization": "user-kh6mcena3vodflcmdl35kovz", + "openai-project": "proj_lMW3RmfgZCZ7JPoXeXik9Etv", + "x-request-id": "req_71e7b7f8c6cd46ba997aa9d2684edd33", + "openai-processing-ms": "47", + "x-envoy-upstream-service-time": "50", + "cf-cache-status": "DYNAMIC", + "Set-Cookie": [ + "__cf_bm=oUD8AMGBP2Izjp1uK1FyScZnjmKs0zMstCNWm4UvZYo-1768867071-1.0.1.1-zbvYF_fvwkPde7kL48RRw44UAibsHRFXmBA5yZOG.v2IEPjuSuq9A5tsOBh0AyjuMWnQ45Kl96K3jk2r3ZRAEtaQeuCTO7IqvX38zV_GG5A; path=\/; expires=Tue, 20-Jan-26 00:27:51 GMT; domain=.api.openai.com; HttpOnly; Secure; SameSite=None", + "_cfuvid=mC9SScKNNP_v8TELunOmzaZC8BiOPK6KxsBabJd0LpE-1768867071660-0.0.1.1-604800000; path=\/; domain=.api.openai.com; HttpOnly; Secure; SameSite=None" + ], + "Strict-Transport-Security": "max-age=31536000; includeSubDomains; preload", + "X-Content-Type-Options": "nosniff", + "Server": "cloudflare", + "CF-RAY": "9c0a46dc8e92f746-LHR", + "alt-svc": "h3=\":443\"; ma=86400" + }, + "data": "event: response.created\ndata: {\"type\":\"response.created\",\"response\":{\"id\":\"resp_03487c3cf35c7c9500696ec4ff8b508199aedd9b65c92c7545\",\"object\":\"response\",\"created_at\":1768867071,\"status\":\"in_progress\",\"background\":false,\"completed_at\":null,\"error\":null,\"frequency_penalty\":0.0,\"incomplete_details\":null,\"instructions\":null,\"max_output_tokens\":2048,\"max_tool_calls\":null,\"model\":\"gpt-5-mini-2025-08-07\",\"output\":[],\"parallel_tool_calls\":true,\"presence_penalty\":0.0,\"previous_response_id\":null,\"prompt_cache_key\":null,\"prompt_cache_retention\":null,\"reasoning\":{\"effort\":\"low\",\"summary\":null},\"safety_identifier\":null,\"service_tier\":\"auto\",\"store\":true,\"temperature\":1.0,\"text\":{\"format\":{\"type\":\"text\"},\"verbosity\":\"medium\"},\"tool_choice\":\"auto\",\"tools\":[],\"top_logprobs\":0,\"top_p\":1.0,\"truncation\":\"disabled\",\"usage\":null,\"user\":null,\"metadata\":{}},\"sequence_number\":0}\n\nevent: response.in_progress\ndata: {\"type\":\"response.in_progress\",\"response\":{\"id\":\"resp_03487c3cf35c7c9500696ec4ff8b508199aedd9b65c92c7545\",\"object\":\"response\",\"created_at\":1768867071,\"status\":\"in_progress\",\"background\":false,\"completed_at\":null,\"error\":null,\"frequency_penalty\":0.0,\"incomplete_details\":null,\"instructions\":null,\"max_output_tokens\":2048,\"max_tool_calls\":null,\"model\":\"gpt-5-mini-2025-08-07\",\"output\":[],\"parallel_tool_calls\":true,\"presence_penalty\":0.0,\"previous_response_id\":null,\"prompt_cache_key\":null,\"prompt_cache_retention\":null,\"reasoning\":{\"effort\":\"low\",\"summary\":null},\"safety_identifier\":null,\"service_tier\":\"auto\",\"store\":true,\"temperature\":1.0,\"text\":{\"format\":{\"type\":\"text\"},\"verbosity\":\"medium\"},\"tool_choice\":\"auto\",\"tools\":[],\"top_logprobs\":0,\"top_p\":1.0,\"truncation\":\"disabled\",\"usage\":null,\"user\":null,\"metadata\":{}},\"sequence_number\":1}\n\nevent: response.output_item.added\ndata: {\"type\":\"response.output_item.added\",\"item\":{\"id\":\"rs_03487c3cf35c7c9500696ec5000dbc8199b883ee3291f29f93\",\"type\":\"reasoning\",\"summary\":[]},\"output_index\":0,\"sequence_number\":2}\n\nevent: response.output_item.done\ndata: {\"type\":\"response.output_item.done\",\"item\":{\"id\":\"rs_03487c3cf35c7c9500696ec5000dbc8199b883ee3291f29f93\",\"type\":\"reasoning\",\"summary\":[]},\"output_index\":0,\"sequence_number\":3}\n\nevent: response.output_item.added\ndata: {\"type\":\"response.output_item.added\",\"item\":{\"id\":\"msg_03487c3cf35c7c9500696ec5077da08199aebd640fcd9704c1\",\"type\":\"message\",\"status\":\"in_progress\",\"content\":[],\"role\":\"assistant\"},\"output_index\":1,\"sequence_number\":4}\n\nevent: response.content_part.added\ndata: {\"type\":\"response.content_part.added\",\"content_index\":0,\"item_id\":\"msg_03487c3cf35c7c9500696ec5077da08199aebd640fcd9704c1\",\"output_index\":1,\"part\":{\"type\":\"output_text\",\"annotations\":[],\"logprobs\":[],\"text\":\"\"},\"sequence_number\":5}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\"Strict\",\"item_id\":\"msg_03487c3cf35c7c9500696ec5077da08199aebd640fcd9704c1\",\"logprobs\":[],\"obfuscation\":\"9mLC7Qxe3E\",\"output_index\":1,\"sequence_number\":6}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\"ly\",\"item_id\":\"msg_03487c3cf35c7c9500696ec5077da08199aebd640fcd9704c1\",\"logprobs\":[],\"obfuscation\":\"GOBw56nmrlUvqs\",\"output_index\":1,\"sequence_number\":7}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\" speaking\",\"item_id\":\"msg_03487c3cf35c7c9500696ec5077da08199aebd640fcd9704c1\",\"logprobs\":[],\"obfuscation\":\"Y1o2l1g\",\"output_index\":1,\"sequence_number\":8}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\",\",\"item_id\":\"msg_03487c3cf35c7c9500696ec5077da08199aebd640fcd9704c1\",\"logprobs\":[],\"obfuscation\":\"NEn7Ao8RCETjvkk\",\"output_index\":1,\"sequence_number\":9}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\" \u201c\",\"item_id\":\"msg_03487c3cf35c7c9500696ec5077da08199aebd640fcd9704c1\",\"logprobs\":[],\"obfuscation\":\"s57iYlNcEDNfib\",\"output_index\":1,\"sequence_number\":10}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\"weight\",\"item_id\":\"msg_03487c3cf35c7c9500696ec5077da08199aebd640fcd9704c1\",\"logprobs\":[],\"obfuscation\":\"2Bm4XIS5ey\",\"output_index\":1,\"sequence_number\":11}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\"\u201d\",\"item_id\":\"msg_03487c3cf35c7c9500696ec5077da08199aebd640fcd9704c1\",\"logprobs\":[],\"obfuscation\":\"sqnp55wxxZBhNGa\",\"output_index\":1,\"sequence_number\":12}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\" is\",\"item_id\":\"msg_03487c3cf35c7c9500696ec5077da08199aebd640fcd9704c1\",\"logprobs\":[],\"obfuscation\":\"8PaU7MiNR9DwI\",\"output_index\":1,\"sequence_number\":13}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\" the\",\"item_id\":\"msg_03487c3cf35c7c9500696ec5077da08199aebd640fcd9704c1\",\"logprobs\":[],\"obfuscation\":\"ThnNhAyI5G2D\",\"output_index\":1,\"sequence_number\":14}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\" force\",\"item_id\":\"msg_03487c3cf35c7c9500696ec5077da08199aebd640fcd9704c1\",\"logprobs\":[],\"obfuscation\":\"cSCWx9oj3L\",\"output_index\":1,\"sequence_number\":15}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\" of\",\"item_id\":\"msg_03487c3cf35c7c9500696ec5077da08199aebd640fcd9704c1\",\"logprobs\":[],\"obfuscation\":\"nmUbHW1c1LOcU\",\"output_index\":1,\"sequence_number\":16}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\" gravity\",\"item_id\":\"msg_03487c3cf35c7c9500696ec5077da08199aebd640fcd9704c1\",\"logprobs\":[],\"obfuscation\":\"4vNx1NVN\",\"output_index\":1,\"sequence_number\":17}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\" on\",\"item_id\":\"msg_03487c3cf35c7c9500696ec5077da08199aebd640fcd9704c1\",\"logprobs\":[],\"obfuscation\":\"Idnd3obGpLEIT\",\"output_index\":1,\"sequence_number\":18}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\" an\",\"item_id\":\"msg_03487c3cf35c7c9500696ec5077da08199aebd640fcd9704c1\",\"logprobs\":[],\"obfuscation\":\"Rd1GMQsk5cd7W\",\"output_index\":1,\"sequence_number\":19}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\" object\",\"item_id\":\"msg_03487c3cf35c7c9500696ec5077da08199aebd640fcd9704c1\",\"logprobs\":[],\"obfuscation\":\"HcG7Wx5Mj\",\"output_index\":1,\"sequence_number\":20}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\" and\",\"item_id\":\"msg_03487c3cf35c7c9500696ec5077da08199aebd640fcd9704c1\",\"logprobs\":[],\"obfuscation\":\"DDIKTHiBrxna\",\"output_index\":1,\"sequence_number\":21}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\" depends\",\"item_id\":\"msg_03487c3cf35c7c9500696ec5077da08199aebd640fcd9704c1\",\"logprobs\":[],\"obfuscation\":\"T4RDmlJl\",\"output_index\":1,\"sequence_number\":22}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\" on\",\"item_id\":\"msg_03487c3cf35c7c9500696ec5077da08199aebd640fcd9704c1\",\"logprobs\":[],\"obfuscation\":\"iCPo0uPa0Tcab\",\"output_index\":1,\"sequence_number\":23}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\" where\",\"item_id\":\"msg_03487c3cf35c7c9500696ec5077da08199aebd640fcd9704c1\",\"logprobs\":[],\"obfuscation\":\"lfiV4jfbLj\",\"output_index\":1,\"sequence_number\":24}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\" it\",\"item_id\":\"msg_03487c3cf35c7c9500696ec5077da08199aebd640fcd9704c1\",\"logprobs\":[],\"obfuscation\":\"DSsmAjDnapGHI\",\"output_index\":1,\"sequence_number\":25}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\" is\",\"item_id\":\"msg_03487c3cf35c7c9500696ec5077da08199aebd640fcd9704c1\",\"logprobs\":[],\"obfuscation\":\"iYzPUrSfJkVlL\",\"output_index\":1,\"sequence_number\":26}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\".\",\"item_id\":\"msg_03487c3cf35c7c9500696ec5077da08199aebd640fcd9704c1\",\"logprobs\":[],\"obfuscation\":\"HUdlspzhwFx9nhy\",\"output_index\":1,\"sequence_number\":27}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\" Usually\",\"item_id\":\"msg_03487c3cf35c7c9500696ec5077da08199aebd640fcd9704c1\",\"logprobs\":[],\"obfuscation\":\"YfrovejQ\",\"output_index\":1,\"sequence_number\":28}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\" people\",\"item_id\":\"msg_03487c3cf35c7c9500696ec5077da08199aebd640fcd9704c1\",\"logprobs\":[],\"obfuscation\":\"IroOem3nf\",\"output_index\":1,\"sequence_number\":29}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\" really\",\"item_id\":\"msg_03487c3cf35c7c9500696ec5077da08199aebd640fcd9704c1\",\"logprobs\":[],\"obfuscation\":\"kgnVSex9u\",\"output_index\":1,\"sequence_number\":30}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\" mean\",\"item_id\":\"msg_03487c3cf35c7c9500696ec5077da08199aebd640fcd9704c1\",\"logprobs\":[],\"obfuscation\":\"HB3mHlakjsW\",\"output_index\":1,\"sequence_number\":31}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\" the\",\"item_id\":\"msg_03487c3cf35c7c9500696ec5077da08199aebd640fcd9704c1\",\"logprobs\":[],\"obfuscation\":\"N7hx3Ygscrok\",\"output_index\":1,\"sequence_number\":32}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\" Moon\",\"item_id\":\"msg_03487c3cf35c7c9500696ec5077da08199aebd640fcd9704c1\",\"logprobs\":[],\"obfuscation\":\"sZ35pKs46VO\",\"output_index\":1,\"sequence_number\":33}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\"\u2019s\",\"item_id\":\"msg_03487c3cf35c7c9500696ec5077da08199aebd640fcd9704c1\",\"logprobs\":[],\"obfuscation\":\"qcbPKpJAvLwsTZ\",\"output_index\":1,\"sequence_number\":34}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\" mass\",\"item_id\":\"msg_03487c3cf35c7c9500696ec5077da08199aebd640fcd9704c1\",\"logprobs\":[],\"obfuscation\":\"WIOo48g7SbK\",\"output_index\":1,\"sequence_number\":35}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\".\",\"item_id\":\"msg_03487c3cf35c7c9500696ec5077da08199aebd640fcd9704c1\",\"logprobs\":[],\"obfuscation\":\"kUHoIB5jLJEpCTg\",\"output_index\":1,\"sequence_number\":36}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\" Key\",\"item_id\":\"msg_03487c3cf35c7c9500696ec5077da08199aebd640fcd9704c1\",\"logprobs\":[],\"obfuscation\":\"jZW7sRNoSsef\",\"output_index\":1,\"sequence_number\":37}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\" numbers\",\"item_id\":\"msg_03487c3cf35c7c9500696ec5077da08199aebd640fcd9704c1\",\"logprobs\":[],\"obfuscation\":\"fLttSnin\",\"output_index\":1,\"sequence_number\":38}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\":\\n\\n\",\"item_id\":\"msg_03487c3cf35c7c9500696ec5077da08199aebd640fcd9704c1\",\"logprobs\":[],\"obfuscation\":\"18InBlUK3JX7D\",\"output_index\":1,\"sequence_number\":39}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\"-\",\"item_id\":\"msg_03487c3cf35c7c9500696ec5077da08199aebd640fcd9704c1\",\"logprobs\":[],\"obfuscation\":\"SDyH99fpvF7npGi\",\"output_index\":1,\"sequence_number\":40}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\" Mass\",\"item_id\":\"msg_03487c3cf35c7c9500696ec5077da08199aebd640fcd9704c1\",\"logprobs\":[],\"obfuscation\":\"SzzHzyXDq2o\",\"output_index\":1,\"sequence_number\":41}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\" of\",\"item_id\":\"msg_03487c3cf35c7c9500696ec5077da08199aebd640fcd9704c1\",\"logprobs\":[],\"obfuscation\":\"TQo93jXZR4ShN\",\"output_index\":1,\"sequence_number\":42}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\" the\",\"item_id\":\"msg_03487c3cf35c7c9500696ec5077da08199aebd640fcd9704c1\",\"logprobs\":[],\"obfuscation\":\"GKwtSkhxvpLN\",\"output_index\":1,\"sequence_number\":43}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\" Moon\",\"item_id\":\"msg_03487c3cf35c7c9500696ec5077da08199aebd640fcd9704c1\",\"logprobs\":[],\"obfuscation\":\"FD6wdvQpS1Q\",\"output_index\":1,\"sequence_number\":44}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\":\",\"item_id\":\"msg_03487c3cf35c7c9500696ec5077da08199aebd640fcd9704c1\",\"logprobs\":[],\"obfuscation\":\"wo4fbhniQ3L4RZN\",\"output_index\":1,\"sequence_number\":45}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\" \u2248\",\"item_id\":\"msg_03487c3cf35c7c9500696ec5077da08199aebd640fcd9704c1\",\"logprobs\":[],\"obfuscation\":\"zh7S59tFppqgpF\",\"output_index\":1,\"sequence_number\":46}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\" \",\"item_id\":\"msg_03487c3cf35c7c9500696ec5077da08199aebd640fcd9704c1\",\"logprobs\":[],\"obfuscation\":\"UY6dL9oOVrJ0ddg\",\"output_index\":1,\"sequence_number\":47}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\"7\",\"item_id\":\"msg_03487c3cf35c7c9500696ec5077da08199aebd640fcd9704c1\",\"logprobs\":[],\"obfuscation\":\"UjIKeUSGQkSZn89\",\"output_index\":1,\"sequence_number\":48}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\".\",\"item_id\":\"msg_03487c3cf35c7c9500696ec5077da08199aebd640fcd9704c1\",\"logprobs\":[],\"obfuscation\":\"9qqeeKhlzbjJtEF\",\"output_index\":1,\"sequence_number\":49}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\"35\",\"item_id\":\"msg_03487c3cf35c7c9500696ec5077da08199aebd640fcd9704c1\",\"logprobs\":[],\"obfuscation\":\"jh0JlfZcfmypPf\",\"output_index\":1,\"sequence_number\":50}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\" \u00d7\",\"item_id\":\"msg_03487c3cf35c7c9500696ec5077da08199aebd640fcd9704c1\",\"logprobs\":[],\"obfuscation\":\"dWRZTddbioJ7IG\",\"output_index\":1,\"sequence_number\":51}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\" \",\"item_id\":\"msg_03487c3cf35c7c9500696ec5077da08199aebd640fcd9704c1\",\"logprobs\":[],\"obfuscation\":\"3gYdAdsO7SPnoTE\",\"output_index\":1,\"sequence_number\":52}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\"10\",\"item_id\":\"msg_03487c3cf35c7c9500696ec5077da08199aebd640fcd9704c1\",\"logprobs\":[],\"obfuscation\":\"kOLPcGu8N3ILhh\",\"output_index\":1,\"sequence_number\":53}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\"^\",\"item_id\":\"msg_03487c3cf35c7c9500696ec5077da08199aebd640fcd9704c1\",\"logprobs\":[],\"obfuscation\":\"qj77YyvBBt97Vi4\",\"output_index\":1,\"sequence_number\":54}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\"22\",\"item_id\":\"msg_03487c3cf35c7c9500696ec5077da08199aebd640fcd9704c1\",\"logprobs\":[],\"obfuscation\":\"d7UE8CQilA6e42\",\"output_index\":1,\"sequence_number\":55}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\" kg\",\"item_id\":\"msg_03487c3cf35c7c9500696ec5077da08199aebd640fcd9704c1\",\"logprobs\":[],\"obfuscation\":\"6SL7OTUbd3Gz2\",\"output_index\":1,\"sequence_number\":56}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\" (\",\"item_id\":\"msg_03487c3cf35c7c9500696ec5077da08199aebd640fcd9704c1\",\"logprobs\":[],\"obfuscation\":\"UVsUXE0ts9wCDa\",\"output_index\":1,\"sequence_number\":57}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\"about\",\"item_id\":\"msg_03487c3cf35c7c9500696ec5077da08199aebd640fcd9704c1\",\"logprobs\":[],\"obfuscation\":\"DbjDOyznRzE\",\"output_index\":1,\"sequence_number\":58}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\" \",\"item_id\":\"msg_03487c3cf35c7c9500696ec5077da08199aebd640fcd9704c1\",\"logprobs\":[],\"obfuscation\":\"ePxrFNGqkuqn9Ux\",\"output_index\":1,\"sequence_number\":59}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\"7\",\"item_id\":\"msg_03487c3cf35c7c9500696ec5077da08199aebd640fcd9704c1\",\"logprobs\":[],\"obfuscation\":\"nVsU8M0Ya6SFmIV\",\"output_index\":1,\"sequence_number\":60}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\".\",\"item_id\":\"msg_03487c3cf35c7c9500696ec5077da08199aebd640fcd9704c1\",\"logprobs\":[],\"obfuscation\":\"AYY9apeTaG9365N\",\"output_index\":1,\"sequence_number\":61}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\"3\",\"item_id\":\"msg_03487c3cf35c7c9500696ec5077da08199aebd640fcd9704c1\",\"logprobs\":[],\"obfuscation\":\"SMXWZV3ChIzT9qv\",\"output_index\":1,\"sequence_number\":62}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\" ten\",\"item_id\":\"msg_03487c3cf35c7c9500696ec5077da08199aebd640fcd9704c1\",\"logprobs\":[],\"obfuscation\":\"4NmYOuIJ8qDZ\",\"output_index\":1,\"sequence_number\":63}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\"\u2011\",\"item_id\":\"msg_03487c3cf35c7c9500696ec5077da08199aebd640fcd9704c1\",\"logprobs\":[],\"obfuscation\":\"BodjzakwbNBskZY\",\"output_index\":1,\"sequence_number\":64}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\"22\",\"item_id\":\"msg_03487c3cf35c7c9500696ec5077da08199aebd640fcd9704c1\",\"logprobs\":[],\"obfuscation\":\"JcivjXQYb4CQo1\",\"output_index\":1,\"sequence_number\":65}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\" kilograms\",\"item_id\":\"msg_03487c3cf35c7c9500696ec5077da08199aebd640fcd9704c1\",\"logprobs\":[],\"obfuscation\":\"mAoIV4\",\"output_index\":1,\"sequence_number\":66}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\").\\n\",\"item_id\":\"msg_03487c3cf35c7c9500696ec5077da08199aebd640fcd9704c1\",\"logprobs\":[],\"obfuscation\":\"JNLMnvIbVeJGE\",\"output_index\":1,\"sequence_number\":67}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\"-\",\"item_id\":\"msg_03487c3cf35c7c9500696ec5077da08199aebd640fcd9704c1\",\"logprobs\":[],\"obfuscation\":\"qV5nsUWR0GOmVkK\",\"output_index\":1,\"sequence_number\":68}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\" If\",\"item_id\":\"msg_03487c3cf35c7c9500696ec5077da08199aebd640fcd9704c1\",\"logprobs\":[],\"obfuscation\":\"SgA6tge42BKEG\",\"output_index\":1,\"sequence_number\":69}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\" you\",\"item_id\":\"msg_03487c3cf35c7c9500696ec5077da08199aebd640fcd9704c1\",\"logprobs\":[],\"obfuscation\":\"nGwj50rJVe6L\",\"output_index\":1,\"sequence_number\":70}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\" asked\",\"item_id\":\"msg_03487c3cf35c7c9500696ec5077da08199aebd640fcd9704c1\",\"logprobs\":[],\"obfuscation\":\"pTr7hcUTWU\",\"output_index\":1,\"sequence_number\":71}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\" for\",\"item_id\":\"msg_03487c3cf35c7c9500696ec5077da08199aebd640fcd9704c1\",\"logprobs\":[],\"obfuscation\":\"42KsWdSnV3Hz\",\"output_index\":1,\"sequence_number\":72}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\" its\",\"item_id\":\"msg_03487c3cf35c7c9500696ec5077da08199aebd640fcd9704c1\",\"logprobs\":[],\"obfuscation\":\"N5LCJXz40qwj\",\"output_index\":1,\"sequence_number\":73}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\" weight\",\"item_id\":\"msg_03487c3cf35c7c9500696ec5077da08199aebd640fcd9704c1\",\"logprobs\":[],\"obfuscation\":\"WbGq7K7n5\",\"output_index\":1,\"sequence_number\":74}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\" at\",\"item_id\":\"msg_03487c3cf35c7c9500696ec5077da08199aebd640fcd9704c1\",\"logprobs\":[],\"obfuscation\":\"hR3UdhmmzMs0n\",\"output_index\":1,\"sequence_number\":75}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\" Earth\",\"item_id\":\"msg_03487c3cf35c7c9500696ec5077da08199aebd640fcd9704c1\",\"logprobs\":[],\"obfuscation\":\"5qdDatjxyj\",\"output_index\":1,\"sequence_number\":76}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\"\u2019s\",\"item_id\":\"msg_03487c3cf35c7c9500696ec5077da08199aebd640fcd9704c1\",\"logprobs\":[],\"obfuscation\":\"cPjIW6qiXDSj02\",\"output_index\":1,\"sequence_number\":77}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\" surface\",\"item_id\":\"msg_03487c3cf35c7c9500696ec5077da08199aebd640fcd9704c1\",\"logprobs\":[],\"obfuscation\":\"3UWdHh9b\",\"output_index\":1,\"sequence_number\":78}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\" (\",\"item_id\":\"msg_03487c3cf35c7c9500696ec5077da08199aebd640fcd9704c1\",\"logprobs\":[],\"obfuscation\":\"dDg4uLEFkHSSD1\",\"output_index\":1,\"sequence_number\":79}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\"i\",\"item_id\":\"msg_03487c3cf35c7c9500696ec5077da08199aebd640fcd9704c1\",\"logprobs\":[],\"obfuscation\":\"1LozSDVXpNcCZic\",\"output_index\":1,\"sequence_number\":80}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\".e\",\"item_id\":\"msg_03487c3cf35c7c9500696ec5077da08199aebd640fcd9704c1\",\"logprobs\":[],\"obfuscation\":\"vooXGSiga7NOAV\",\"output_index\":1,\"sequence_number\":81}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\".,\",\"item_id\":\"msg_03487c3cf35c7c9500696ec5077da08199aebd640fcd9704c1\",\"logprobs\":[],\"obfuscation\":\"JYZQhFxeWWnOGh\",\"output_index\":1,\"sequence_number\":82}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\" mass\",\"item_id\":\"msg_03487c3cf35c7c9500696ec5077da08199aebd640fcd9704c1\",\"logprobs\":[],\"obfuscation\":\"qWWE5uD4PFJ\",\"output_index\":1,\"sequence_number\":83}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\" \u00d7\",\"item_id\":\"msg_03487c3cf35c7c9500696ec5077da08199aebd640fcd9704c1\",\"logprobs\":[],\"obfuscation\":\"yRGSk8qJSPpFU1\",\"output_index\":1,\"sequence_number\":84}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\" \",\"item_id\":\"msg_03487c3cf35c7c9500696ec5077da08199aebd640fcd9704c1\",\"logprobs\":[],\"obfuscation\":\"ysj7Swz20sOSdNI\",\"output_index\":1,\"sequence_number\":85}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\"9\",\"item_id\":\"msg_03487c3cf35c7c9500696ec5077da08199aebd640fcd9704c1\",\"logprobs\":[],\"obfuscation\":\"jth2xB2FRJVTLHz\",\"output_index\":1,\"sequence_number\":86}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\".\",\"item_id\":\"msg_03487c3cf35c7c9500696ec5077da08199aebd640fcd9704c1\",\"logprobs\":[],\"obfuscation\":\"WFFLIf33xlHgq3L\",\"output_index\":1,\"sequence_number\":87}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\"81\",\"item_id\":\"msg_03487c3cf35c7c9500696ec5077da08199aebd640fcd9704c1\",\"logprobs\":[],\"obfuscation\":\"gAlRokckIcXHL1\",\"output_index\":1,\"sequence_number\":88}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\" m\",\"item_id\":\"msg_03487c3cf35c7c9500696ec5077da08199aebd640fcd9704c1\",\"logprobs\":[],\"obfuscation\":\"72HWqmtCjB5G3S\",\"output_index\":1,\"sequence_number\":89}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\"\/s\",\"item_id\":\"msg_03487c3cf35c7c9500696ec5077da08199aebd640fcd9704c1\",\"logprobs\":[],\"obfuscation\":\"w7QtZFNcedHmFq\",\"output_index\":1,\"sequence_number\":90}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\"\u00b2\",\"item_id\":\"msg_03487c3cf35c7c9500696ec5077da08199aebd640fcd9704c1\",\"logprobs\":[],\"obfuscation\":\"UPhE01SAEB4gM32\",\"output_index\":1,\"sequence_number\":91}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\"):\",\"item_id\":\"msg_03487c3cf35c7c9500696ec5077da08199aebd640fcd9704c1\",\"logprobs\":[],\"obfuscation\":\"5nIYlwsLpwIos9\",\"output_index\":1,\"sequence_number\":92}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\" W\",\"item_id\":\"msg_03487c3cf35c7c9500696ec5077da08199aebd640fcd9704c1\",\"logprobs\":[],\"obfuscation\":\"mZkWjCGvQ2jJrw\",\"output_index\":1,\"sequence_number\":93}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\" \u2248\",\"item_id\":\"msg_03487c3cf35c7c9500696ec5077da08199aebd640fcd9704c1\",\"logprobs\":[],\"obfuscation\":\"085Yn3p80l2mvi\",\"output_index\":1,\"sequence_number\":94}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\" \",\"item_id\":\"msg_03487c3cf35c7c9500696ec5077da08199aebd640fcd9704c1\",\"logprobs\":[],\"obfuscation\":\"THfUqkSGYofiZjO\",\"output_index\":1,\"sequence_number\":95}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\"7\",\"item_id\":\"msg_03487c3cf35c7c9500696ec5077da08199aebd640fcd9704c1\",\"logprobs\":[],\"obfuscation\":\"S74Ccbc2BoRTy8X\",\"output_index\":1,\"sequence_number\":96}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\".\",\"item_id\":\"msg_03487c3cf35c7c9500696ec5077da08199aebd640fcd9704c1\",\"logprobs\":[],\"obfuscation\":\"IrM0VhabL2EYZDA\",\"output_index\":1,\"sequence_number\":97}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\"35\",\"item_id\":\"msg_03487c3cf35c7c9500696ec5077da08199aebd640fcd9704c1\",\"logprobs\":[],\"obfuscation\":\"YUsGMIdrvlfTx9\",\"output_index\":1,\"sequence_number\":98}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\"\u00d7\",\"item_id\":\"msg_03487c3cf35c7c9500696ec5077da08199aebd640fcd9704c1\",\"logprobs\":[],\"obfuscation\":\"aqFhRIl51pgfmZ6\",\"output_index\":1,\"sequence_number\":99}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\"10\",\"item_id\":\"msg_03487c3cf35c7c9500696ec5077da08199aebd640fcd9704c1\",\"logprobs\":[],\"obfuscation\":\"wvh39BYTNGuKf8\",\"output_index\":1,\"sequence_number\":100}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\"^\",\"item_id\":\"msg_03487c3cf35c7c9500696ec5077da08199aebd640fcd9704c1\",\"logprobs\":[],\"obfuscation\":\"pCM2hDi7ZWUVvjh\",\"output_index\":1,\"sequence_number\":101}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\"22\",\"item_id\":\"msg_03487c3cf35c7c9500696ec5077da08199aebd640fcd9704c1\",\"logprobs\":[],\"obfuscation\":\"rDWRBttMGxUjZO\",\"output_index\":1,\"sequence_number\":102}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\" kg\",\"item_id\":\"msg_03487c3cf35c7c9500696ec5077da08199aebd640fcd9704c1\",\"logprobs\":[],\"obfuscation\":\"H45ZgHaSTU2Ya\",\"output_index\":1,\"sequence_number\":103}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\" \u00d7\",\"item_id\":\"msg_03487c3cf35c7c9500696ec5077da08199aebd640fcd9704c1\",\"logprobs\":[],\"obfuscation\":\"PjPalg78JUXj1k\",\"output_index\":1,\"sequence_number\":104}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\" \",\"item_id\":\"msg_03487c3cf35c7c9500696ec5077da08199aebd640fcd9704c1\",\"logprobs\":[],\"obfuscation\":\"KUMfq0t08p29KCi\",\"output_index\":1,\"sequence_number\":105}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\"9\",\"item_id\":\"msg_03487c3cf35c7c9500696ec5077da08199aebd640fcd9704c1\",\"logprobs\":[],\"obfuscation\":\"UX8AhXA9BTB62vG\",\"output_index\":1,\"sequence_number\":106}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\".\",\"item_id\":\"msg_03487c3cf35c7c9500696ec5077da08199aebd640fcd9704c1\",\"logprobs\":[],\"obfuscation\":\"vojOpTOjaYs91ln\",\"output_index\":1,\"sequence_number\":107}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\"81\",\"item_id\":\"msg_03487c3cf35c7c9500696ec5077da08199aebd640fcd9704c1\",\"logprobs\":[],\"obfuscation\":\"D4w4B6jYLa5nVL\",\"output_index\":1,\"sequence_number\":108}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\" m\",\"item_id\":\"msg_03487c3cf35c7c9500696ec5077da08199aebd640fcd9704c1\",\"logprobs\":[],\"obfuscation\":\"ssXL1aLz84b3bG\",\"output_index\":1,\"sequence_number\":109}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\"\/s\",\"item_id\":\"msg_03487c3cf35c7c9500696ec5077da08199aebd640fcd9704c1\",\"logprobs\":[],\"obfuscation\":\"LPgbjUMdf1iCN1\",\"output_index\":1,\"sequence_number\":110}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\"\u00b2\",\"item_id\":\"msg_03487c3cf35c7c9500696ec5077da08199aebd640fcd9704c1\",\"logprobs\":[],\"obfuscation\":\"4F0qF5C5d7QfKUF\",\"output_index\":1,\"sequence_number\":111}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\" \u2248\",\"item_id\":\"msg_03487c3cf35c7c9500696ec5077da08199aebd640fcd9704c1\",\"logprobs\":[],\"obfuscation\":\"iD9AyJwUCar98c\",\"output_index\":1,\"sequence_number\":112}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\" \",\"item_id\":\"msg_03487c3cf35c7c9500696ec5077da08199aebd640fcd9704c1\",\"logprobs\":[],\"obfuscation\":\"7Gb0tj5Gtg8NSM2\",\"output_index\":1,\"sequence_number\":113}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\"7\",\"item_id\":\"msg_03487c3cf35c7c9500696ec5077da08199aebd640fcd9704c1\",\"logprobs\":[],\"obfuscation\":\"7AmdFr8lFU6W1qt\",\"output_index\":1,\"sequence_number\":114}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\".\",\"item_id\":\"msg_03487c3cf35c7c9500696ec5077da08199aebd640fcd9704c1\",\"logprobs\":[],\"obfuscation\":\"9LKPp85Wea6XFAE\",\"output_index\":1,\"sequence_number\":115}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\"2\",\"item_id\":\"msg_03487c3cf35c7c9500696ec5077da08199aebd640fcd9704c1\",\"logprobs\":[],\"obfuscation\":\"ksGdDQQfyMyWQQq\",\"output_index\":1,\"sequence_number\":116}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\" \u00d7\",\"item_id\":\"msg_03487c3cf35c7c9500696ec5077da08199aebd640fcd9704c1\",\"logprobs\":[],\"obfuscation\":\"1jOS2YQSp8oDcV\",\"output_index\":1,\"sequence_number\":117}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\" \",\"item_id\":\"msg_03487c3cf35c7c9500696ec5077da08199aebd640fcd9704c1\",\"logprobs\":[],\"obfuscation\":\"4cF3NMFjcp9d9C1\",\"output_index\":1,\"sequence_number\":118}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\"10\",\"item_id\":\"msg_03487c3cf35c7c9500696ec5077da08199aebd640fcd9704c1\",\"logprobs\":[],\"obfuscation\":\"p5IZbTHhOhBqq8\",\"output_index\":1,\"sequence_number\":119}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\"^\",\"item_id\":\"msg_03487c3cf35c7c9500696ec5077da08199aebd640fcd9704c1\",\"logprobs\":[],\"obfuscation\":\"OMd8QneUX05jSb6\",\"output_index\":1,\"sequence_number\":120}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\"23\",\"item_id\":\"msg_03487c3cf35c7c9500696ec5077da08199aebd640fcd9704c1\",\"logprobs\":[],\"obfuscation\":\"MWbqo3e049t43k\",\"output_index\":1,\"sequence_number\":121}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\" N\",\"item_id\":\"msg_03487c3cf35c7c9500696ec5077da08199aebd640fcd9704c1\",\"logprobs\":[],\"obfuscation\":\"HKAjgzyBb1EaTQ\",\"output_index\":1,\"sequence_number\":122}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\" (\",\"item_id\":\"msg_03487c3cf35c7c9500696ec5077da08199aebd640fcd9704c1\",\"logprobs\":[],\"obfuscation\":\"oPcHlYH1VASgiu\",\"output_index\":1,\"sequence_number\":123}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\"new\",\"item_id\":\"msg_03487c3cf35c7c9500696ec5077da08199aebd640fcd9704c1\",\"logprobs\":[],\"obfuscation\":\"cnVGjBQ1DyGDa\",\"output_index\":1,\"sequence_number\":124}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\"tons\",\"item_id\":\"msg_03487c3cf35c7c9500696ec5077da08199aebd640fcd9704c1\",\"logprobs\":[],\"obfuscation\":\"LCQZE12PrsG9\",\"output_index\":1,\"sequence_number\":125}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\").\\n\",\"item_id\":\"msg_03487c3cf35c7c9500696ec5077da08199aebd640fcd9704c1\",\"logprobs\":[],\"obfuscation\":\"nndwskwjBdSds\",\"output_index\":1,\"sequence_number\":126}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\"-\",\"item_id\":\"msg_03487c3cf35c7c9500696ec5077da08199aebd640fcd9704c1\",\"logprobs\":[],\"obfuscation\":\"gV6JDEn2xsmYx9u\",\"output_index\":1,\"sequence_number\":127}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\" The\",\"item_id\":\"msg_03487c3cf35c7c9500696ec5077da08199aebd640fcd9704c1\",\"logprobs\":[],\"obfuscation\":\"JIFp47HJ7qLa\",\"output_index\":1,\"sequence_number\":128}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\" gravitational\",\"item_id\":\"msg_03487c3cf35c7c9500696ec5077da08199aebd640fcd9704c1\",\"logprobs\":[],\"obfuscation\":\"bc\",\"output_index\":1,\"sequence_number\":129}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\" force\",\"item_id\":\"msg_03487c3cf35c7c9500696ec5077da08199aebd640fcd9704c1\",\"logprobs\":[],\"obfuscation\":\"sczP1qYfNM\",\"output_index\":1,\"sequence_number\":130}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\" Earth\",\"item_id\":\"msg_03487c3cf35c7c9500696ec5077da08199aebd640fcd9704c1\",\"logprobs\":[],\"obfuscation\":\"1tyb0Zl3aC\",\"output_index\":1,\"sequence_number\":131}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\" ex\",\"item_id\":\"msg_03487c3cf35c7c9500696ec5077da08199aebd640fcd9704c1\",\"logprobs\":[],\"obfuscation\":\"aS4DN526cQQWr\",\"output_index\":1,\"sequence_number\":132}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\"erts\",\"item_id\":\"msg_03487c3cf35c7c9500696ec5077da08199aebd640fcd9704c1\",\"logprobs\":[],\"obfuscation\":\"geFl0jDqOwFf\",\"output_index\":1,\"sequence_number\":133}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\" on\",\"item_id\":\"msg_03487c3cf35c7c9500696ec5077da08199aebd640fcd9704c1\",\"logprobs\":[],\"obfuscation\":\"pFuMrc9xWP7kp\",\"output_index\":1,\"sequence_number\":134}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\" the\",\"item_id\":\"msg_03487c3cf35c7c9500696ec5077da08199aebd640fcd9704c1\",\"logprobs\":[],\"obfuscation\":\"1hmbwvmlMuSV\",\"output_index\":1,\"sequence_number\":135}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\" Moon\",\"item_id\":\"msg_03487c3cf35c7c9500696ec5077da08199aebd640fcd9704c1\",\"logprobs\":[],\"obfuscation\":\"5jJ1uYGetAk\",\"output_index\":1,\"sequence_number\":136}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\" at\",\"item_id\":\"msg_03487c3cf35c7c9500696ec5077da08199aebd640fcd9704c1\",\"logprobs\":[],\"obfuscation\":\"MK2CRtW0rkyvU\",\"output_index\":1,\"sequence_number\":137}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\" the\",\"item_id\":\"msg_03487c3cf35c7c9500696ec5077da08199aebd640fcd9704c1\",\"logprobs\":[],\"obfuscation\":\"Lszrh8OYuypM\",\"output_index\":1,\"sequence_number\":138}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\" average\",\"item_id\":\"msg_03487c3cf35c7c9500696ec5077da08199aebd640fcd9704c1\",\"logprobs\":[],\"obfuscation\":\"k8R4dzE0\",\"output_index\":1,\"sequence_number\":139}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\" Earth\",\"item_id\":\"msg_03487c3cf35c7c9500696ec5077da08199aebd640fcd9704c1\",\"logprobs\":[],\"obfuscation\":\"AhML1nJX2M\",\"output_index\":1,\"sequence_number\":140}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\"\u2013\",\"item_id\":\"msg_03487c3cf35c7c9500696ec5077da08199aebd640fcd9704c1\",\"logprobs\":[],\"obfuscation\":\"oqtmnuEIOtxawTL\",\"output_index\":1,\"sequence_number\":141}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\"Moon\",\"item_id\":\"msg_03487c3cf35c7c9500696ec5077da08199aebd640fcd9704c1\",\"logprobs\":[],\"obfuscation\":\"ItKSnuzrvtVM\",\"output_index\":1,\"sequence_number\":142}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\" distance\",\"item_id\":\"msg_03487c3cf35c7c9500696ec5077da08199aebd640fcd9704c1\",\"logprobs\":[],\"obfuscation\":\"4j0GBg5\",\"output_index\":1,\"sequence_number\":143}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\" (~\",\"item_id\":\"msg_03487c3cf35c7c9500696ec5077da08199aebd640fcd9704c1\",\"logprobs\":[],\"obfuscation\":\"NNVgOX1AqRU40\",\"output_index\":1,\"sequence_number\":144}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\"384\",\"item_id\":\"msg_03487c3cf35c7c9500696ec5077da08199aebd640fcd9704c1\",\"logprobs\":[],\"obfuscation\":\"3GEvufuCUAMbT\",\"output_index\":1,\"sequence_number\":145}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\",\",\"item_id\":\"msg_03487c3cf35c7c9500696ec5077da08199aebd640fcd9704c1\",\"logprobs\":[],\"obfuscation\":\"KfFb81w9JkbW9ly\",\"output_index\":1,\"sequence_number\":146}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\"400\",\"item_id\":\"msg_03487c3cf35c7c9500696ec5077da08199aebd640fcd9704c1\",\"logprobs\":[],\"obfuscation\":\"9BuFtzenHOCud\",\"output_index\":1,\"sequence_number\":147}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\" km\",\"item_id\":\"msg_03487c3cf35c7c9500696ec5077da08199aebd640fcd9704c1\",\"logprobs\":[],\"obfuscation\":\"WSqavSCebNJCh\",\"output_index\":1,\"sequence_number\":148}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\")\",\"item_id\":\"msg_03487c3cf35c7c9500696ec5077da08199aebd640fcd9704c1\",\"logprobs\":[],\"obfuscation\":\"3DkJuorBvCAHC1C\",\"output_index\":1,\"sequence_number\":149}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\" \u2014\",\"item_id\":\"msg_03487c3cf35c7c9500696ec5077da08199aebd640fcd9704c1\",\"logprobs\":[],\"obfuscation\":\"Ijde9wCNut8kJz\",\"output_index\":1,\"sequence_number\":150}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\" the\",\"item_id\":\"msg_03487c3cf35c7c9500696ec5077da08199aebd640fcd9704c1\",\"logprobs\":[],\"obfuscation\":\"3zMMFbCk1Lsq\",\"output_index\":1,\"sequence_number\":151}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\" Moon\",\"item_id\":\"msg_03487c3cf35c7c9500696ec5077da08199aebd640fcd9704c1\",\"logprobs\":[],\"obfuscation\":\"CmoYVc4PYuW\",\"output_index\":1,\"sequence_number\":152}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\"\u2019s\",\"item_id\":\"msg_03487c3cf35c7c9500696ec5077da08199aebd640fcd9704c1\",\"logprobs\":[],\"obfuscation\":\"pysmAi02DGrTo9\",\"output_index\":1,\"sequence_number\":153}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\" \u201c\",\"item_id\":\"msg_03487c3cf35c7c9500696ec5077da08199aebd640fcd9704c1\",\"logprobs\":[],\"obfuscation\":\"Zi1iT5wGXbGJo5\",\"output_index\":1,\"sequence_number\":154}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\"weight\",\"item_id\":\"msg_03487c3cf35c7c9500696ec5077da08199aebd640fcd9704c1\",\"logprobs\":[],\"obfuscation\":\"LJE7MshiyJ\",\"output_index\":1,\"sequence_number\":155}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\"\u201d\",\"item_id\":\"msg_03487c3cf35c7c9500696ec5077da08199aebd640fcd9704c1\",\"logprobs\":[],\"obfuscation\":\"srcyN8FHZScL33N\",\"output_index\":1,\"sequence_number\":156}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\" in\",\"item_id\":\"msg_03487c3cf35c7c9500696ec5077da08199aebd640fcd9704c1\",\"logprobs\":[],\"obfuscation\":\"3Spuh6MQ9JtHA\",\"output_index\":1,\"sequence_number\":157}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\" its\",\"item_id\":\"msg_03487c3cf35c7c9500696ec5077da08199aebd640fcd9704c1\",\"logprobs\":[],\"obfuscation\":\"BU5VZMLId1Um\",\"output_index\":1,\"sequence_number\":158}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\" actual\",\"item_id\":\"msg_03487c3cf35c7c9500696ec5077da08199aebd640fcd9704c1\",\"logprobs\":[],\"obfuscation\":\"99dTtyPJB\",\"output_index\":1,\"sequence_number\":159}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\" orbit\",\"item_id\":\"msg_03487c3cf35c7c9500696ec5077da08199aebd640fcd9704c1\",\"logprobs\":[],\"obfuscation\":\"3Ugtugfttt\",\"output_index\":1,\"sequence_number\":160}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\" \u2014\",\"item_id\":\"msg_03487c3cf35c7c9500696ec5077da08199aebd640fcd9704c1\",\"logprobs\":[],\"obfuscation\":\"FV8i7IvXpiPHqW\",\"output_index\":1,\"sequence_number\":161}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\" is\",\"item_id\":\"msg_03487c3cf35c7c9500696ec5077da08199aebd640fcd9704c1\",\"logprobs\":[],\"obfuscation\":\"fKAWrKZdK4dNp\",\"output_index\":1,\"sequence_number\":162}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\" about\",\"item_id\":\"msg_03487c3cf35c7c9500696ec5077da08199aebd640fcd9704c1\",\"logprobs\":[],\"obfuscation\":\"awALWsaSUr\",\"output_index\":1,\"sequence_number\":163}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\" \",\"item_id\":\"msg_03487c3cf35c7c9500696ec5077da08199aebd640fcd9704c1\",\"logprobs\":[],\"obfuscation\":\"wc36YG0X42F4XYU\",\"output_index\":1,\"sequence_number\":164}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\"2\",\"item_id\":\"msg_03487c3cf35c7c9500696ec5077da08199aebd640fcd9704c1\",\"logprobs\":[],\"obfuscation\":\"7iZ5LWSYOY5QyUW\",\"output_index\":1,\"sequence_number\":165}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\".\",\"item_id\":\"msg_03487c3cf35c7c9500696ec5077da08199aebd640fcd9704c1\",\"logprobs\":[],\"obfuscation\":\"NF9tg12kn87HS00\",\"output_index\":1,\"sequence_number\":166}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\"0\",\"item_id\":\"msg_03487c3cf35c7c9500696ec5077da08199aebd640fcd9704c1\",\"logprobs\":[],\"obfuscation\":\"RN4TOZuBnpgmv3k\",\"output_index\":1,\"sequence_number\":167}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\" \u00d7\",\"item_id\":\"msg_03487c3cf35c7c9500696ec5077da08199aebd640fcd9704c1\",\"logprobs\":[],\"obfuscation\":\"0NQMKfM95JBQOG\",\"output_index\":1,\"sequence_number\":168}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\" \",\"item_id\":\"msg_03487c3cf35c7c9500696ec5077da08199aebd640fcd9704c1\",\"logprobs\":[],\"obfuscation\":\"Ou9XyuscKZwOOzk\",\"output_index\":1,\"sequence_number\":169}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\"10\",\"item_id\":\"msg_03487c3cf35c7c9500696ec5077da08199aebd640fcd9704c1\",\"logprobs\":[],\"obfuscation\":\"kmDYvBMs278okJ\",\"output_index\":1,\"sequence_number\":170}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\"^\",\"item_id\":\"msg_03487c3cf35c7c9500696ec5077da08199aebd640fcd9704c1\",\"logprobs\":[],\"obfuscation\":\"7EJOgdOSBzAfZWU\",\"output_index\":1,\"sequence_number\":171}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\"20\",\"item_id\":\"msg_03487c3cf35c7c9500696ec5077da08199aebd640fcd9704c1\",\"logprobs\":[],\"obfuscation\":\"PXS6hnUWUlCPmd\",\"output_index\":1,\"sequence_number\":172}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\" N\",\"item_id\":\"msg_03487c3cf35c7c9500696ec5077da08199aebd640fcd9704c1\",\"logprobs\":[],\"obfuscation\":\"b03PvJdgOxdGbg\",\"output_index\":1,\"sequence_number\":173}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\".\\n\\n\",\"item_id\":\"msg_03487c3cf35c7c9500696ec5077da08199aebd640fcd9704c1\",\"logprobs\":[],\"obfuscation\":\"FcKEi3zie8YGK\",\"output_index\":1,\"sequence_number\":174}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\"If\",\"item_id\":\"msg_03487c3cf35c7c9500696ec5077da08199aebd640fcd9704c1\",\"logprobs\":[],\"obfuscation\":\"kLEr4SwK1NyhE8\",\"output_index\":1,\"sequence_number\":175}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\" you\",\"item_id\":\"msg_03487c3cf35c7c9500696ec5077da08199aebd640fcd9704c1\",\"logprobs\":[],\"obfuscation\":\"jmeAT5uCGWzp\",\"output_index\":1,\"sequence_number\":176}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\" meant\",\"item_id\":\"msg_03487c3cf35c7c9500696ec5077da08199aebd640fcd9704c1\",\"logprobs\":[],\"obfuscation\":\"yRYZ8FhNHm\",\"output_index\":1,\"sequence_number\":177}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\" something\",\"item_id\":\"msg_03487c3cf35c7c9500696ec5077da08199aebd640fcd9704c1\",\"logprobs\":[],\"obfuscation\":\"mHDj73\",\"output_index\":1,\"sequence_number\":178}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\" else\",\"item_id\":\"msg_03487c3cf35c7c9500696ec5077da08199aebd640fcd9704c1\",\"logprobs\":[],\"obfuscation\":\"Weo2gwEtx36\",\"output_index\":1,\"sequence_number\":179}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\" by\",\"item_id\":\"msg_03487c3cf35c7c9500696ec5077da08199aebd640fcd9704c1\",\"logprobs\":[],\"obfuscation\":\"U6QVuEnfutvIj\",\"output_index\":1,\"sequence_number\":180}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\" \u201c\",\"item_id\":\"msg_03487c3cf35c7c9500696ec5077da08199aebd640fcd9704c1\",\"logprobs\":[],\"obfuscation\":\"F9TyTn8v8c2XJr\",\"output_index\":1,\"sequence_number\":181}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\"weight\",\"item_id\":\"msg_03487c3cf35c7c9500696ec5077da08199aebd640fcd9704c1\",\"logprobs\":[],\"obfuscation\":\"QDLQZ7Z4T8\",\"output_index\":1,\"sequence_number\":182}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\",\u201d\",\"item_id\":\"msg_03487c3cf35c7c9500696ec5077da08199aebd640fcd9704c1\",\"logprobs\":[],\"obfuscation\":\"k2f6MZq7qZP3yI\",\"output_index\":1,\"sequence_number\":183}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\" tell\",\"item_id\":\"msg_03487c3cf35c7c9500696ec5077da08199aebd640fcd9704c1\",\"logprobs\":[],\"obfuscation\":\"LjhqhzLSmYt\",\"output_index\":1,\"sequence_number\":184}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\" me\",\"item_id\":\"msg_03487c3cf35c7c9500696ec5077da08199aebd640fcd9704c1\",\"logprobs\":[],\"obfuscation\":\"vV9dp2Jgli2wN\",\"output_index\":1,\"sequence_number\":185}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\" (\",\"item_id\":\"msg_03487c3cf35c7c9500696ec5077da08199aebd640fcd9704c1\",\"logprobs\":[],\"obfuscation\":\"u0dC0mA5GgvIAg\",\"output_index\":1,\"sequence_number\":186}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\"for\",\"item_id\":\"msg_03487c3cf35c7c9500696ec5077da08199aebd640fcd9704c1\",\"logprobs\":[],\"obfuscation\":\"7N4oZcAHFt44W\",\"output_index\":1,\"sequence_number\":187}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\" example\",\"item_id\":\"msg_03487c3cf35c7c9500696ec5077da08199aebd640fcd9704c1\",\"logprobs\":[],\"obfuscation\":\"vjBN601d\",\"output_index\":1,\"sequence_number\":188}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\",\",\"item_id\":\"msg_03487c3cf35c7c9500696ec5077da08199aebd640fcd9704c1\",\"logprobs\":[],\"obfuscation\":\"1pR9L6uNFMPl1E0\",\"output_index\":1,\"sequence_number\":189}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\" Moon\",\"item_id\":\"msg_03487c3cf35c7c9500696ec5077da08199aebd640fcd9704c1\",\"logprobs\":[],\"obfuscation\":\"HfGsPYoVRjn\",\"output_index\":1,\"sequence_number\":190}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\"\u2019s\",\"item_id\":\"msg_03487c3cf35c7c9500696ec5077da08199aebd640fcd9704c1\",\"logprobs\":[],\"obfuscation\":\"XiYFf5tzuBghxz\",\"output_index\":1,\"sequence_number\":191}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\" weight\",\"item_id\":\"msg_03487c3cf35c7c9500696ec5077da08199aebd640fcd9704c1\",\"logprobs\":[],\"obfuscation\":\"qm916hVqb\",\"output_index\":1,\"sequence_number\":192}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\" on\",\"item_id\":\"msg_03487c3cf35c7c9500696ec5077da08199aebd640fcd9704c1\",\"logprobs\":[],\"obfuscation\":\"cnPPBO9wyicAh\",\"output_index\":1,\"sequence_number\":193}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\" the\",\"item_id\":\"msg_03487c3cf35c7c9500696ec5077da08199aebd640fcd9704c1\",\"logprobs\":[],\"obfuscation\":\"bBk74R73M7v8\",\"output_index\":1,\"sequence_number\":194}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\" Sun\",\"item_id\":\"msg_03487c3cf35c7c9500696ec5077da08199aebd640fcd9704c1\",\"logprobs\":[],\"obfuscation\":\"od5rTzCPXfr8\",\"output_index\":1,\"sequence_number\":195}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\",\",\"item_id\":\"msg_03487c3cf35c7c9500696ec5077da08199aebd640fcd9704c1\",\"logprobs\":[],\"obfuscation\":\"AKW4ydGMLKccCen\",\"output_index\":1,\"sequence_number\":196}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\" on\",\"item_id\":\"msg_03487c3cf35c7c9500696ec5077da08199aebd640fcd9704c1\",\"logprobs\":[],\"obfuscation\":\"BmEWKMvzxG4Nq\",\"output_index\":1,\"sequence_number\":197}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\" Jupiter\",\"item_id\":\"msg_03487c3cf35c7c9500696ec5077da08199aebd640fcd9704c1\",\"logprobs\":[],\"obfuscation\":\"WHozibYy\",\"output_index\":1,\"sequence_number\":198}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\",\",\"item_id\":\"msg_03487c3cf35c7c9500696ec5077da08199aebd640fcd9704c1\",\"logprobs\":[],\"obfuscation\":\"pzDUOzVfuM33DTP\",\"output_index\":1,\"sequence_number\":199}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\" or\",\"item_id\":\"msg_03487c3cf35c7c9500696ec5077da08199aebd640fcd9704c1\",\"logprobs\":[],\"obfuscation\":\"VUsTPfo0hb5xl\",\"output_index\":1,\"sequence_number\":200}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\" on\",\"item_id\":\"msg_03487c3cf35c7c9500696ec5077da08199aebd640fcd9704c1\",\"logprobs\":[],\"obfuscation\":\"yiss9c0aCAS3S\",\"output_index\":1,\"sequence_number\":201}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\" its\",\"item_id\":\"msg_03487c3cf35c7c9500696ec5077da08199aebd640fcd9704c1\",\"logprobs\":[],\"obfuscation\":\"kFg84yqnaV7H\",\"output_index\":1,\"sequence_number\":202}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\" own\",\"item_id\":\"msg_03487c3cf35c7c9500696ec5077da08199aebd640fcd9704c1\",\"logprobs\":[],\"obfuscation\":\"fxVPA8mmFoGk\",\"output_index\":1,\"sequence_number\":203}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\" surface\",\"item_id\":\"msg_03487c3cf35c7c9500696ec5077da08199aebd640fcd9704c1\",\"logprobs\":[],\"obfuscation\":\"E0h3nYai\",\"output_index\":1,\"sequence_number\":204}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\")\",\"item_id\":\"msg_03487c3cf35c7c9500696ec5077da08199aebd640fcd9704c1\",\"logprobs\":[],\"obfuscation\":\"Y3fC9gKInEeNCU0\",\"output_index\":1,\"sequence_number\":205}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\" and\",\"item_id\":\"msg_03487c3cf35c7c9500696ec5077da08199aebd640fcd9704c1\",\"logprobs\":[],\"obfuscation\":\"AzNtwbxpj7NS\",\"output_index\":1,\"sequence_number\":206}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\" I\",\"item_id\":\"msg_03487c3cf35c7c9500696ec5077da08199aebd640fcd9704c1\",\"logprobs\":[],\"obfuscation\":\"Eu9mIVF76FspfI\",\"output_index\":1,\"sequence_number\":207}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\"\u2019ll\",\"item_id\":\"msg_03487c3cf35c7c9500696ec5077da08199aebd640fcd9704c1\",\"logprobs\":[],\"obfuscation\":\"KsWxgX7ZKMMz2\",\"output_index\":1,\"sequence_number\":208}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\" compute\",\"item_id\":\"msg_03487c3cf35c7c9500696ec5077da08199aebd640fcd9704c1\",\"logprobs\":[],\"obfuscation\":\"ZDtx52Yt\",\"output_index\":1,\"sequence_number\":209}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\" that\",\"item_id\":\"msg_03487c3cf35c7c9500696ec5077da08199aebd640fcd9704c1\",\"logprobs\":[],\"obfuscation\":\"VbEUcz5eH6t\",\"output_index\":1,\"sequence_number\":210}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\".\",\"item_id\":\"msg_03487c3cf35c7c9500696ec5077da08199aebd640fcd9704c1\",\"logprobs\":[],\"obfuscation\":\"4pAjFEpiGiaeyM8\",\"output_index\":1,\"sequence_number\":211}\n\nevent: response.output_text.done\ndata: {\"type\":\"response.output_text.done\",\"content_index\":0,\"item_id\":\"msg_03487c3cf35c7c9500696ec5077da08199aebd640fcd9704c1\",\"logprobs\":[],\"output_index\":1,\"sequence_number\":212,\"text\":\"Strictly speaking, \u201cweight\u201d is the force of gravity on an object and depends on where it is. Usually people really mean the Moon\u2019s mass. Key numbers:\\n\\n- Mass of the Moon: \u2248 7.35 \u00d7 10^22 kg (about 7.3 ten\u201122 kilograms).\\n- If you asked for its weight at Earth\u2019s surface (i.e., mass \u00d7 9.81 m\/s\u00b2): W \u2248 7.35\u00d710^22 kg \u00d7 9.81 m\/s\u00b2 \u2248 7.2 \u00d7 10^23 N (newtons).\\n- The gravitational force Earth exerts on the Moon at the average Earth\u2013Moon distance (~384,400 km) \u2014 the Moon\u2019s \u201cweight\u201d in its actual orbit \u2014 is about 2.0 \u00d7 10^20 N.\\n\\nIf you meant something else by \u201cweight,\u201d tell me (for example, Moon\u2019s weight on the Sun, on Jupiter, or on its own surface) and I\u2019ll compute that.\"}\n\nevent: response.content_part.done\ndata: {\"type\":\"response.content_part.done\",\"content_index\":0,\"item_id\":\"msg_03487c3cf35c7c9500696ec5077da08199aebd640fcd9704c1\",\"output_index\":1,\"part\":{\"type\":\"output_text\",\"annotations\":[],\"logprobs\":[],\"text\":\"Strictly speaking, \u201cweight\u201d is the force of gravity on an object and depends on where it is. Usually people really mean the Moon\u2019s mass. Key numbers:\\n\\n- Mass of the Moon: \u2248 7.35 \u00d7 10^22 kg (about 7.3 ten\u201122 kilograms).\\n- If you asked for its weight at Earth\u2019s surface (i.e., mass \u00d7 9.81 m\/s\u00b2): W \u2248 7.35\u00d710^22 kg \u00d7 9.81 m\/s\u00b2 \u2248 7.2 \u00d7 10^23 N (newtons).\\n- The gravitational force Earth exerts on the Moon at the average Earth\u2013Moon distance (~384,400 km) \u2014 the Moon\u2019s \u201cweight\u201d in its actual orbit \u2014 is about 2.0 \u00d7 10^20 N.\\n\\nIf you meant something else by \u201cweight,\u201d tell me (for example, Moon\u2019s weight on the Sun, on Jupiter, or on its own surface) and I\u2019ll compute that.\"},\"sequence_number\":213}\n\nevent: response.output_item.done\ndata: {\"type\":\"response.output_item.done\",\"item\":{\"id\":\"msg_03487c3cf35c7c9500696ec5077da08199aebd640fcd9704c1\",\"type\":\"message\",\"status\":\"completed\",\"content\":[{\"type\":\"output_text\",\"annotations\":[],\"logprobs\":[],\"text\":\"Strictly speaking, \u201cweight\u201d is the force of gravity on an object and depends on where it is. Usually people really mean the Moon\u2019s mass. Key numbers:\\n\\n- Mass of the Moon: \u2248 7.35 \u00d7 10^22 kg (about 7.3 ten\u201122 kilograms).\\n- If you asked for its weight at Earth\u2019s surface (i.e., mass \u00d7 9.81 m\/s\u00b2): W \u2248 7.35\u00d710^22 kg \u00d7 9.81 m\/s\u00b2 \u2248 7.2 \u00d7 10^23 N (newtons).\\n- The gravitational force Earth exerts on the Moon at the average Earth\u2013Moon distance (~384,400 km) \u2014 the Moon\u2019s \u201cweight\u201d in its actual orbit \u2014 is about 2.0 \u00d7 10^20 N.\\n\\nIf you meant something else by \u201cweight,\u201d tell me (for example, Moon\u2019s weight on the Sun, on Jupiter, or on its own surface) and I\u2019ll compute that.\"}],\"role\":\"assistant\"},\"output_index\":1,\"sequence_number\":214}\n\nevent: response.completed\ndata: {\"type\":\"response.completed\",\"response\":{\"id\":\"resp_03487c3cf35c7c9500696ec4ff8b508199aedd9b65c92c7545\",\"object\":\"response\",\"created_at\":1768867071,\"status\":\"completed\",\"background\":false,\"completed_at\":1768867082,\"error\":null,\"frequency_penalty\":0.0,\"incomplete_details\":null,\"instructions\":null,\"max_output_tokens\":2048,\"max_tool_calls\":null,\"model\":\"gpt-5-mini-2025-08-07\",\"output\":[{\"id\":\"rs_03487c3cf35c7c9500696ec5000dbc8199b883ee3291f29f93\",\"type\":\"reasoning\",\"summary\":[]},{\"id\":\"msg_03487c3cf35c7c9500696ec5077da08199aebd640fcd9704c1\",\"type\":\"message\",\"status\":\"completed\",\"content\":[{\"type\":\"output_text\",\"annotations\":[],\"logprobs\":[],\"text\":\"Strictly speaking, \u201cweight\u201d is the force of gravity on an object and depends on where it is. Usually people really mean the Moon\u2019s mass. Key numbers:\\n\\n- Mass of the Moon: \u2248 7.35 \u00d7 10^22 kg (about 7.3 ten\u201122 kilograms).\\n- If you asked for its weight at Earth\u2019s surface (i.e., mass \u00d7 9.81 m\/s\u00b2): W \u2248 7.35\u00d710^22 kg \u00d7 9.81 m\/s\u00b2 \u2248 7.2 \u00d7 10^23 N (newtons).\\n- The gravitational force Earth exerts on the Moon at the average Earth\u2013Moon distance (~384,400 km) \u2014 the Moon\u2019s \u201cweight\u201d in its actual orbit \u2014 is about 2.0 \u00d7 10^20 N.\\n\\nIf you meant something else by \u201cweight,\u201d tell me (for example, Moon\u2019s weight on the Sun, on Jupiter, or on its own surface) and I\u2019ll compute that.\"}],\"role\":\"assistant\"}],\"parallel_tool_calls\":true,\"presence_penalty\":0.0,\"previous_response_id\":null,\"prompt_cache_key\":null,\"prompt_cache_retention\":null,\"reasoning\":{\"effort\":\"low\",\"summary\":null},\"safety_identifier\":null,\"service_tier\":\"default\",\"store\":true,\"temperature\":1.0,\"text\":{\"format\":{\"type\":\"text\"},\"verbosity\":\"medium\"},\"tool_choice\":\"auto\",\"tools\":[],\"top_logprobs\":0,\"top_p\":1.0,\"truncation\":\"disabled\",\"usage\":{\"input_tokens\":14,\"input_tokens_details\":{\"cached_tokens\":0},\"output_tokens\":727,\"output_tokens_details\":{\"reasoning_tokens\":512},\"total_tokens\":741},\"user\":null,\"metadata\":{}},\"sequence_number\":215}\n\n", + "context": [] +} \ No newline at end of file diff --git a/tests/fixtures/sdk/openai/responses/reasoning.json b/tests/fixtures/sdk/openai/responses/reasoning.json new file mode 100644 index 0000000..f542d41 --- /dev/null +++ b/tests/fixtures/sdk/openai/responses/reasoning.json @@ -0,0 +1,33 @@ +{ + "statusCode": 200, + "headers": { + "Date": "Mon, 19 Jan 2026 23:41:58 GMT", + "Content-Type": "application\/json", + "Content-Length": "2537", + "Connection": "keep-alive", + "x-ratelimit-limit-requests": "5000", + "x-ratelimit-limit-tokens": "4000000", + "x-ratelimit-remaining-requests": "4999", + "x-ratelimit-remaining-tokens": "3999488", + "x-ratelimit-reset-requests": "12ms", + "x-ratelimit-reset-tokens": "7ms", + "openai-version": "2020-10-01", + "openai-organization": "user-kh6mcena3vodflcmdl35kovz", + "openai-project": "proj_lMW3RmfgZCZ7JPoXeXik9Etv", + "x-request-id": "req_1bcc90ceef1d45ffa1ce280f9cc98b37", + "openai-processing-ms": "11521", + "x-envoy-upstream-service-time": "11525", + "cf-cache-status": "DYNAMIC", + "Set-Cookie": [ + "__cf_bm=Bp2OHEaUG8DaBzXfDPfPrNyiBJp3TsITqahsfda94K0-1768866118-1.0.1.1-qgBQOBJEj7xYHim.Ke9PpQ1FUm3WHUSrrtlG3dAgmvpCS4Ku1Gzy72Lz4aMobYumqFPqJDWSyIGehPQpRid_qmgapfNDbnVfJtqQS9dZxs0; path=\/; expires=Tue, 20-Jan-26 00:11:58 GMT; domain=.api.openai.com; HttpOnly; Secure; SameSite=None", + "_cfuvid=xNF03JNNrHdGEyxLtiZqQXYob4C1kJgP_WdoDsPACk8-1768866118255-0.0.1.1-604800000; path=\/; domain=.api.openai.com; HttpOnly; Secure; SameSite=None" + ], + "Strict-Transport-Security": "max-age=31536000; includeSubDomains; preload", + "X-Content-Type-Options": "nosniff", + "Server": "cloudflare", + "CF-RAY": "9c0a2f4a2f6ebefd-LHR", + "alt-svc": "h3=\":443\"; ma=86400" + }, + "data": "{\n \"id\": \"resp_0aac962cf73a606600696ec13ab1c4819595f24465a25714c4\",\n \"object\": \"response\",\n \"created_at\": 1768866106,\n \"status\": \"completed\",\n \"background\": false,\n \"billing\": {\n \"payer\": \"developer\"\n },\n \"completed_at\": 1768866117,\n \"error\": null,\n \"frequency_penalty\": 0.0,\n \"incomplete_details\": null,\n \"instructions\": null,\n \"max_output_tokens\": 2048,\n \"max_tool_calls\": null,\n \"model\": \"gpt-5-mini-2025-08-07\",\n \"output\": [\n {\n \"id\": \"rs_0aac962cf73a606600696ec13b527c819589b98e8125e4a263\",\n \"type\": \"reasoning\",\n \"summary\": []\n },\n {\n \"id\": \"msg_0aac962cf73a606600696ec142bf6c8195bcc3b061c651d79a\",\n \"type\": \"message\",\n \"status\": \"completed\",\n \"content\": [\n {\n \"type\": \"output_text\",\n \"annotations\": [],\n \"logprobs\": [],\n \"text\": \"Strictly speaking, the Moon doesn\\u2019t have a single \\u201cweight\\u201d unless you specify the gravitational field it\\u2019s in. People often mean its mass instead.\\n\\n- Mass of the Moon: about 7.3477 \\u00d7 10^22 kg (commonly quoted as \\u22487.35\\u00d710^22 kg).\\n\\nIf you want its weight (force = mass \\u00d7 gravitational acceleration):\\n\\n- Weight in Earth\\u2019s surface gravity (if the Moon were sitting at Earth\\u2019s surface where g \\u2248 9.81 m\/s^2):\\n \\u2248 7.35\\u00d710^22 kg \\u00d7 9.81 m\/s^2 \\u2248 7.2\\u00d710^23 newtons.\\n\\n- Gravitational force between Earth and Moon at their average separation (this is effectively the Moon\\u2019s \\u201cweight\\u201d due to Earth\\u2019s gravity while in orbit):\\n \\u2248 1.98\\u00d710^20 newtons.\\n\\nIf you meant something else by \\u201cweight,\\u201d tell me the reference body or location and I can compute that value.\"\n }\n ],\n \"role\": \"assistant\"\n }\n ],\n \"parallel_tool_calls\": true,\n \"presence_penalty\": 0.0,\n \"previous_response_id\": null,\n \"prompt_cache_key\": null,\n \"prompt_cache_retention\": null,\n \"reasoning\": {\n \"effort\": \"low\",\n \"summary\": null\n },\n \"safety_identifier\": null,\n \"service_tier\": \"default\",\n \"store\": true,\n \"temperature\": 1.0,\n \"text\": {\n \"format\": {\n \"type\": \"text\"\n },\n \"verbosity\": \"medium\"\n },\n \"tool_choice\": \"auto\",\n \"tools\": [],\n \"top_logprobs\": 0,\n \"top_p\": 1.0,\n \"truncation\": \"disabled\",\n \"usage\": {\n \"input_tokens\": 14,\n \"input_tokens_details\": {\n \"cached_tokens\": 0\n },\n \"output_tokens\": 729,\n \"output_tokens_details\": {\n \"reasoning_tokens\": 512\n },\n \"total_tokens\": 743\n },\n \"user\": null,\n \"metadata\": {}\n}", + "context": [] +} \ No newline at end of file diff --git a/tests/fixtures/sdk/openai/responses/simple-streamed.json b/tests/fixtures/sdk/openai/responses/simple-streamed.json new file mode 100644 index 0000000..5304cbc --- /dev/null +++ b/tests/fixtures/sdk/openai/responses/simple-streamed.json @@ -0,0 +1,27 @@ +{ + "statusCode": 200, + "headers": { + "Date": "Fri, 16 Jan 2026 21:43:45 GMT", + "Content-Type": "text\/event-stream; charset=utf-8", + "Transfer-Encoding": "chunked", + "Connection": "keep-alive", + "openai-version": "2020-10-01", + "openai-organization": "user-kh6mcena3vodflcmdl35kovz", + "openai-project": "proj_lMW3RmfgZCZ7JPoXeXik9Etv", + "x-request-id": "req_147d3f4d3cb2434faf39319e8c91db2c", + "openai-processing-ms": "47", + "x-envoy-upstream-service-time": "50", + "cf-cache-status": "DYNAMIC", + "Set-Cookie": [ + "__cf_bm=RMKPnwDEefJrc4kQ03NlpnM.vmmScMcSYEQqd8hZDEQ-1768599825-1.0.1.1-hiJ7JzQVf3TCavB_882LAP40JHnGK6NGaUYUsxRIWdl2f_7HM3S4bV7BQ7YpVTVK.EMAk3lyYXRHxXaGrihQu1aOGQk3ysn6KGaHWxGoi0g; path=\/; expires=Fri, 16-Jan-26 22:13:45 GMT; domain=.api.openai.com; HttpOnly; Secure; SameSite=None", + "_cfuvid=.DF453z.N_u7GRbC7Ev.nHgIwevEbThJD0LKMCd_Jk0-1768599825207-0.0.1.1-604800000; path=\/; domain=.api.openai.com; HttpOnly; Secure; SameSite=None" + ], + "Strict-Transport-Security": "max-age=31536000; includeSubDomains; preload", + "X-Content-Type-Options": "nosniff", + "Server": "cloudflare", + "CF-RAY": "9bf0ca4a38709503-LHR", + "alt-svc": "h3=\":443\"; ma=86400" + }, + "data": "event: response.created\ndata: {\"type\":\"response.created\",\"response\":{\"id\":\"resp_0030087ec9ede7eb00696ab111168c8198bba51e4989698462\",\"object\":\"response\",\"created_at\":1768599825,\"status\":\"in_progress\",\"background\":false,\"completed_at\":null,\"error\":null,\"frequency_penalty\":0.0,\"incomplete_details\":null,\"instructions\":null,\"max_output_tokens\":1024,\"max_tool_calls\":null,\"model\":\"gpt-5-nano-2025-08-07\",\"output\":[],\"parallel_tool_calls\":true,\"presence_penalty\":0.0,\"previous_response_id\":null,\"prompt_cache_key\":null,\"prompt_cache_retention\":null,\"reasoning\":{\"effort\":\"medium\",\"summary\":null},\"safety_identifier\":null,\"service_tier\":\"auto\",\"store\":true,\"temperature\":1.0,\"text\":{\"format\":{\"type\":\"text\"},\"verbosity\":\"medium\"},\"tool_choice\":\"auto\",\"tools\":[],\"top_logprobs\":0,\"top_p\":1.0,\"truncation\":\"disabled\",\"usage\":null,\"user\":null,\"metadata\":{}},\"sequence_number\":0}\n\nevent: response.in_progress\ndata: {\"type\":\"response.in_progress\",\"response\":{\"id\":\"resp_0030087ec9ede7eb00696ab111168c8198bba51e4989698462\",\"object\":\"response\",\"created_at\":1768599825,\"status\":\"in_progress\",\"background\":false,\"completed_at\":null,\"error\":null,\"frequency_penalty\":0.0,\"incomplete_details\":null,\"instructions\":null,\"max_output_tokens\":1024,\"max_tool_calls\":null,\"model\":\"gpt-5-nano-2025-08-07\",\"output\":[],\"parallel_tool_calls\":true,\"presence_penalty\":0.0,\"previous_response_id\":null,\"prompt_cache_key\":null,\"prompt_cache_retention\":null,\"reasoning\":{\"effort\":\"medium\",\"summary\":null},\"safety_identifier\":null,\"service_tier\":\"auto\",\"store\":true,\"temperature\":1.0,\"text\":{\"format\":{\"type\":\"text\"},\"verbosity\":\"medium\"},\"tool_choice\":\"auto\",\"tools\":[],\"top_logprobs\":0,\"top_p\":1.0,\"truncation\":\"disabled\",\"usage\":null,\"user\":null,\"metadata\":{}},\"sequence_number\":1}\n\nevent: response.output_item.added\ndata: {\"type\":\"response.output_item.added\",\"item\":{\"id\":\"rs_0030087ec9ede7eb00696ab1118b2c81988bb7c035b5f2003b\",\"type\":\"reasoning\",\"summary\":[]},\"output_index\":0,\"sequence_number\":2}\n\nevent: response.output_item.done\ndata: {\"type\":\"response.output_item.done\",\"item\":{\"id\":\"rs_0030087ec9ede7eb00696ab1118b2c81988bb7c035b5f2003b\",\"type\":\"reasoning\",\"summary\":[]},\"output_index\":0,\"sequence_number\":3}\n\nevent: response.output_item.added\ndata: {\"type\":\"response.output_item.added\",\"item\":{\"id\":\"msg_0030087ec9ede7eb00696ab113bd988198b64ce5a33fc2078f\",\"type\":\"message\",\"status\":\"in_progress\",\"content\":[],\"role\":\"assistant\"},\"output_index\":1,\"sequence_number\":4}\n\nevent: response.content_part.added\ndata: {\"type\":\"response.content_part.added\",\"content_index\":0,\"item_id\":\"msg_0030087ec9ede7eb00696ab113bd988198b64ce5a33fc2078f\",\"output_index\":1,\"part\":{\"type\":\"output_text\",\"annotations\":[],\"logprobs\":[],\"text\":\"\"},\"sequence_number\":5}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\"Hello\",\"item_id\":\"msg_0030087ec9ede7eb00696ab113bd988198b64ce5a33fc2078f\",\"logprobs\":[],\"obfuscation\":\"mTMON3u2j9C\",\"output_index\":1,\"sequence_number\":6}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\"!\",\"item_id\":\"msg_0030087ec9ede7eb00696ab113bd988198b64ce5a33fc2078f\",\"logprobs\":[],\"obfuscation\":\"CKTaUvsal6eqope\",\"output_index\":1,\"sequence_number\":7}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\" I\",\"item_id\":\"msg_0030087ec9ede7eb00696ab113bd988198b64ce5a33fc2078f\",\"logprobs\":[],\"obfuscation\":\"hKrnNtmAybWTE7\",\"output_index\":1,\"sequence_number\":8}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\"\u2019m\",\"item_id\":\"msg_0030087ec9ede7eb00696ab113bd988198b64ce5a33fc2078f\",\"logprobs\":[],\"obfuscation\":\"lR5WwAtECD7lFu\",\"output_index\":1,\"sequence_number\":9}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\" here\",\"item_id\":\"msg_0030087ec9ede7eb00696ab113bd988198b64ce5a33fc2078f\",\"logprobs\":[],\"obfuscation\":\"q2gi4uTJGfJ\",\"output_index\":1,\"sequence_number\":10}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\" and\",\"item_id\":\"msg_0030087ec9ede7eb00696ab113bd988198b64ce5a33fc2078f\",\"logprobs\":[],\"obfuscation\":\"SnmfY4UF0ApP\",\"output_index\":1,\"sequence_number\":11}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\" ready\",\"item_id\":\"msg_0030087ec9ede7eb00696ab113bd988198b64ce5a33fc2078f\",\"logprobs\":[],\"obfuscation\":\"YdRNRYpdCO\",\"output_index\":1,\"sequence_number\":12}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\" to\",\"item_id\":\"msg_0030087ec9ede7eb00696ab113bd988198b64ce5a33fc2078f\",\"logprobs\":[],\"obfuscation\":\"nemQFaG3nVIPz\",\"output_index\":1,\"sequence_number\":13}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\" help\",\"item_id\":\"msg_0030087ec9ede7eb00696ab113bd988198b64ce5a33fc2078f\",\"logprobs\":[],\"obfuscation\":\"8x2t3WdUt4L\",\"output_index\":1,\"sequence_number\":14}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\".\",\"item_id\":\"msg_0030087ec9ede7eb00696ab113bd988198b64ce5a33fc2078f\",\"logprobs\":[],\"obfuscation\":\"nRr49t3saxDuN4x\",\"output_index\":1,\"sequence_number\":15}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\" How\",\"item_id\":\"msg_0030087ec9ede7eb00696ab113bd988198b64ce5a33fc2078f\",\"logprobs\":[],\"obfuscation\":\"G11qOgAEtDwW\",\"output_index\":1,\"sequence_number\":16}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\" can\",\"item_id\":\"msg_0030087ec9ede7eb00696ab113bd988198b64ce5a33fc2078f\",\"logprobs\":[],\"obfuscation\":\"3D9F0wvsbYcn\",\"output_index\":1,\"sequence_number\":17}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\" I\",\"item_id\":\"msg_0030087ec9ede7eb00696ab113bd988198b64ce5a33fc2078f\",\"logprobs\":[],\"obfuscation\":\"QwpSebdWWkwsMF\",\"output_index\":1,\"sequence_number\":18}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\" assist\",\"item_id\":\"msg_0030087ec9ede7eb00696ab113bd988198b64ce5a33fc2078f\",\"logprobs\":[],\"obfuscation\":\"fQRPxHR9m\",\"output_index\":1,\"sequence_number\":19}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\" you\",\"item_id\":\"msg_0030087ec9ede7eb00696ab113bd988198b64ce5a33fc2078f\",\"logprobs\":[],\"obfuscation\":\"yBehfudQ7y2R\",\"output_index\":1,\"sequence_number\":20}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\" today\",\"item_id\":\"msg_0030087ec9ede7eb00696ab113bd988198b64ce5a33fc2078f\",\"logprobs\":[],\"obfuscation\":\"LnXXVogxCD\",\"output_index\":1,\"sequence_number\":21}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\"?\",\"item_id\":\"msg_0030087ec9ede7eb00696ab113bd988198b64ce5a33fc2078f\",\"logprobs\":[],\"obfuscation\":\"zGUGKF4cDBud12n\",\"output_index\":1,\"sequence_number\":22}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\" If\",\"item_id\":\"msg_0030087ec9ede7eb00696ab113bd988198b64ce5a33fc2078f\",\"logprobs\":[],\"obfuscation\":\"agpVeGa8Oc3S8\",\"output_index\":1,\"sequence_number\":23}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\" you\",\"item_id\":\"msg_0030087ec9ede7eb00696ab113bd988198b64ce5a33fc2078f\",\"logprobs\":[],\"obfuscation\":\"QZbn7vpENQ9G\",\"output_index\":1,\"sequence_number\":24}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\"\u2019re\",\"item_id\":\"msg_0030087ec9ede7eb00696ab113bd988198b64ce5a33fc2078f\",\"logprobs\":[],\"obfuscation\":\"WnLMWveWujbH7\",\"output_index\":1,\"sequence_number\":25}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\" not\",\"item_id\":\"msg_0030087ec9ede7eb00696ab113bd988198b64ce5a33fc2078f\",\"logprobs\":[],\"obfuscation\":\"mseHzh2qR06Z\",\"output_index\":1,\"sequence_number\":26}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\" sure\",\"item_id\":\"msg_0030087ec9ede7eb00696ab113bd988198b64ce5a33fc2078f\",\"logprobs\":[],\"obfuscation\":\"qmaXGfGchdy\",\"output_index\":1,\"sequence_number\":27}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\",\",\"item_id\":\"msg_0030087ec9ede7eb00696ab113bd988198b64ce5a33fc2078f\",\"logprobs\":[],\"obfuscation\":\"JxXQzkEHP2U2FFD\",\"output_index\":1,\"sequence_number\":28}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\" tell\",\"item_id\":\"msg_0030087ec9ede7eb00696ab113bd988198b64ce5a33fc2078f\",\"logprobs\":[],\"obfuscation\":\"tYqYaRDHcYn\",\"output_index\":1,\"sequence_number\":29}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\" me\",\"item_id\":\"msg_0030087ec9ede7eb00696ab113bd988198b64ce5a33fc2078f\",\"logprobs\":[],\"obfuscation\":\"HznFgpzEOxPc3\",\"output_index\":1,\"sequence_number\":30}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\" what\",\"item_id\":\"msg_0030087ec9ede7eb00696ab113bd988198b64ce5a33fc2078f\",\"logprobs\":[],\"obfuscation\":\"2G9h3ocVaIe\",\"output_index\":1,\"sequence_number\":31}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\" you\",\"item_id\":\"msg_0030087ec9ede7eb00696ab113bd988198b64ce5a33fc2078f\",\"logprobs\":[],\"obfuscation\":\"UvJAdXYF96cq\",\"output_index\":1,\"sequence_number\":32}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\"\u2019re\",\"item_id\":\"msg_0030087ec9ede7eb00696ab113bd988198b64ce5a33fc2078f\",\"logprobs\":[],\"obfuscation\":\"MPkdBO4aQ3LZq\",\"output_index\":1,\"sequence_number\":33}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\" working\",\"item_id\":\"msg_0030087ec9ede7eb00696ab113bd988198b64ce5a33fc2078f\",\"logprobs\":[],\"obfuscation\":\"x1W13LxH\",\"output_index\":1,\"sequence_number\":34}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\" on\",\"item_id\":\"msg_0030087ec9ede7eb00696ab113bd988198b64ce5a33fc2078f\",\"logprobs\":[],\"obfuscation\":\"4Znq6UqjZlLjC\",\"output_index\":1,\"sequence_number\":35}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\" or\",\"item_id\":\"msg_0030087ec9ede7eb00696ab113bd988198b64ce5a33fc2078f\",\"logprobs\":[],\"obfuscation\":\"2zxJUy5ZWT4NI\",\"output_index\":1,\"sequence_number\":36}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\" what\",\"item_id\":\"msg_0030087ec9ede7eb00696ab113bd988198b64ce5a33fc2078f\",\"logprobs\":[],\"obfuscation\":\"oEfuqPbtkwv\",\"output_index\":1,\"sequence_number\":37}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\" you\",\"item_id\":\"msg_0030087ec9ede7eb00696ab113bd988198b64ce5a33fc2078f\",\"logprobs\":[],\"obfuscation\":\"tLJaOUjHySXQ\",\"output_index\":1,\"sequence_number\":38}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\"\u2019d\",\"item_id\":\"msg_0030087ec9ede7eb00696ab113bd988198b64ce5a33fc2078f\",\"logprobs\":[],\"obfuscation\":\"xTpMUnWbE3hXdE\",\"output_index\":1,\"sequence_number\":39}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\" like\",\"item_id\":\"msg_0030087ec9ede7eb00696ab113bd988198b64ce5a33fc2078f\",\"logprobs\":[],\"obfuscation\":\"yo7d9UDhVZe\",\"output_index\":1,\"sequence_number\":40}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\" to\",\"item_id\":\"msg_0030087ec9ede7eb00696ab113bd988198b64ce5a33fc2078f\",\"logprobs\":[],\"obfuscation\":\"df9rXJBunK7yz\",\"output_index\":1,\"sequence_number\":41}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\" do\",\"item_id\":\"msg_0030087ec9ede7eb00696ab113bd988198b64ce5a33fc2078f\",\"logprobs\":[],\"obfuscation\":\"SSfgSw3g16sqN\",\"output_index\":1,\"sequence_number\":42}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\".\",\"item_id\":\"msg_0030087ec9ede7eb00696ab113bd988198b64ce5a33fc2078f\",\"logprobs\":[],\"obfuscation\":\"8VZaEo7vk86Tzle\",\"output_index\":1,\"sequence_number\":43}\n\nevent: response.output_text.done\ndata: {\"type\":\"response.output_text.done\",\"content_index\":0,\"item_id\":\"msg_0030087ec9ede7eb00696ab113bd988198b64ce5a33fc2078f\",\"logprobs\":[],\"output_index\":1,\"sequence_number\":44,\"text\":\"Hello! I\u2019m here and ready to help. How can I assist you today? If you\u2019re not sure, tell me what you\u2019re working on or what you\u2019d like to do.\"}\n\nevent: response.content_part.done\ndata: {\"type\":\"response.content_part.done\",\"content_index\":0,\"item_id\":\"msg_0030087ec9ede7eb00696ab113bd988198b64ce5a33fc2078f\",\"output_index\":1,\"part\":{\"type\":\"output_text\",\"annotations\":[],\"logprobs\":[],\"text\":\"Hello! I\u2019m here and ready to help. How can I assist you today? If you\u2019re not sure, tell me what you\u2019re working on or what you\u2019d like to do.\"},\"sequence_number\":45}\n\nevent: response.output_item.done\ndata: {\"type\":\"response.output_item.done\",\"item\":{\"id\":\"msg_0030087ec9ede7eb00696ab113bd988198b64ce5a33fc2078f\",\"type\":\"message\",\"status\":\"completed\",\"content\":[{\"type\":\"output_text\",\"annotations\":[],\"logprobs\":[],\"text\":\"Hello! I\u2019m here and ready to help. How can I assist you today? If you\u2019re not sure, tell me what you\u2019re working on or what you\u2019d like to do.\"}],\"role\":\"assistant\"},\"output_index\":1,\"sequence_number\":46}\n\nevent: response.completed\ndata: {\"type\":\"response.completed\",\"response\":{\"id\":\"resp_0030087ec9ede7eb00696ab111168c8198bba51e4989698462\",\"object\":\"response\",\"created_at\":1768599825,\"status\":\"completed\",\"background\":false,\"completed_at\":1768599828,\"error\":null,\"frequency_penalty\":0.0,\"incomplete_details\":null,\"instructions\":null,\"max_output_tokens\":1024,\"max_tool_calls\":null,\"model\":\"gpt-5-nano-2025-08-07\",\"output\":[{\"id\":\"rs_0030087ec9ede7eb00696ab1118b2c81988bb7c035b5f2003b\",\"type\":\"reasoning\",\"summary\":[]},{\"id\":\"msg_0030087ec9ede7eb00696ab113bd988198b64ce5a33fc2078f\",\"type\":\"message\",\"status\":\"completed\",\"content\":[{\"type\":\"output_text\",\"annotations\":[],\"logprobs\":[],\"text\":\"Hello! I\u2019m here and ready to help. How can I assist you today? If you\u2019re not sure, tell me what you\u2019re working on or what you\u2019d like to do.\"}],\"role\":\"assistant\"}],\"parallel_tool_calls\":true,\"presence_penalty\":0.0,\"previous_response_id\":null,\"prompt_cache_key\":null,\"prompt_cache_retention\":null,\"reasoning\":{\"effort\":\"medium\",\"summary\":null},\"safety_identifier\":null,\"service_tier\":\"default\",\"store\":true,\"temperature\":1.0,\"text\":{\"format\":{\"type\":\"text\"},\"verbosity\":\"medium\"},\"tool_choice\":\"auto\",\"tools\":[],\"top_logprobs\":0,\"top_p\":1.0,\"truncation\":\"disabled\",\"usage\":{\"input_tokens\":12,\"input_tokens_details\":{\"cached_tokens\":0},\"output_tokens\":300,\"output_tokens_details\":{\"reasoning_tokens\":256},\"total_tokens\":312},\"user\":null,\"metadata\":{}},\"sequence_number\":47}\n\n", + "context": [] +} \ No newline at end of file diff --git a/tests/fixtures/sdk/openai/responses/simple.json b/tests/fixtures/sdk/openai/responses/simple.json new file mode 100644 index 0000000..92ef240 --- /dev/null +++ b/tests/fixtures/sdk/openai/responses/simple.json @@ -0,0 +1,33 @@ +{ + "statusCode": 200, + "headers": { + "Date": "Thu, 15 Jan 2026 16:50:23 GMT", + "Content-Type": "application\/json", + "Content-Length": "1576", + "Connection": "keep-alive", + "x-ratelimit-limit-requests": "5000", + "x-ratelimit-limit-tokens": "4000000", + "x-ratelimit-remaining-requests": "4999", + "x-ratelimit-remaining-tokens": "3999968", + "x-ratelimit-reset-requests": "12ms", + "x-ratelimit-reset-tokens": "0s", + "openai-version": "2020-10-01", + "openai-organization": "user-kh6mcena3vodflcmdl35kovz", + "openai-project": "proj_lMW3RmfgZCZ7JPoXeXik9Etv", + "x-request-id": "req_4944d4a45fff4d89974f65dbf1b86dd5", + "openai-processing-ms": "1678", + "x-envoy-upstream-service-time": "1682", + "cf-cache-status": "DYNAMIC", + "Set-Cookie": [ + "__cf_bm=PNOAk.39kkVqAiS8LZbFb_qjusYBUqKHUnPbhixgmZc-1768495823-1.0.1.1-HyRua8_HVE1auH4x9SZfqwRsW_fwIHbVBeYuSiYcIBCyhk1TYsRHbUsgldToWv.qFusEZBRDg73Nu0qgzXji6ZcMi5AoU9KC.j2TX9YBfIE; path=\/; expires=Thu, 15-Jan-26 17:20:23 GMT; domain=.api.openai.com; HttpOnly; Secure; SameSite=None", + "_cfuvid=G1wQwFkE6QWrBkMgjUS3WWYmn_G.v7LyBrskj7_mGT4-1768495823921-0.0.1.1-604800000; path=\/; domain=.api.openai.com; HttpOnly; Secure; SameSite=None" + ], + "Strict-Transport-Security": "max-age=31536000; includeSubDomains; preload", + "X-Content-Type-Options": "nosniff", + "Server": "cloudflare", + "CF-RAY": "9be6df280b2ac0d8-LHR", + "alt-svc": "h3=\":443\"; ma=86400" + }, + "data": "{\n \"id\": \"resp_0a7a80a18fa483400069691ace2f108198823546480110e66b\",\n \"object\": \"response\",\n \"created_at\": 1768495822,\n \"status\": \"completed\",\n \"background\": false,\n \"billing\": {\n \"payer\": \"developer\"\n },\n \"completed_at\": 1768495823,\n \"error\": null,\n \"frequency_penalty\": 0.0,\n \"incomplete_details\": null,\n \"instructions\": null,\n \"max_output_tokens\": 1024,\n \"max_tool_calls\": null,\n \"model\": \"gpt-4o-mini-2024-07-18\",\n \"output\": [\n {\n \"id\": \"msg_0a7a80a18fa483400069691acf6ec081988bc87384ba1e0322\",\n \"type\": \"message\",\n \"status\": \"completed\",\n \"content\": [\n {\n \"type\": \"output_text\",\n \"annotations\": [],\n \"logprobs\": [],\n \"text\": \"Hello! I'm doing well, thank you. How about you?\"\n }\n ],\n \"role\": \"assistant\"\n }\n ],\n \"parallel_tool_calls\": true,\n \"presence_penalty\": 0.0,\n \"previous_response_id\": null,\n \"prompt_cache_key\": null,\n \"prompt_cache_retention\": null,\n \"reasoning\": {\n \"effort\": null,\n \"summary\": null\n },\n \"safety_identifier\": null,\n \"service_tier\": \"default\",\n \"store\": true,\n \"temperature\": 1.0,\n \"text\": {\n \"format\": {\n \"type\": \"text\"\n },\n \"verbosity\": \"medium\"\n },\n \"tool_choice\": \"auto\",\n \"tools\": [],\n \"top_logprobs\": 0,\n \"top_p\": 1.0,\n \"truncation\": \"disabled\",\n \"usage\": {\n \"input_tokens\": 13,\n \"input_tokens_details\": {\n \"cached_tokens\": 0\n },\n \"output_tokens\": 14,\n \"output_tokens_details\": {\n \"reasoning_tokens\": 0\n },\n \"total_tokens\": 27\n },\n \"user\": null,\n \"metadata\": {}\n}", + "context": [] +} \ No newline at end of file diff --git a/tests/fixtures/sdk/openai/responses/structured-output-streamed.json b/tests/fixtures/sdk/openai/responses/structured-output-streamed.json new file mode 100644 index 0000000..af1feec --- /dev/null +++ b/tests/fixtures/sdk/openai/responses/structured-output-streamed.json @@ -0,0 +1,27 @@ +{ + "statusCode": 200, + "headers": { + "Date": "Tue, 20 Jan 2026 08:54:38 GMT", + "Content-Type": "text\/event-stream; charset=utf-8", + "Transfer-Encoding": "chunked", + "Connection": "keep-alive", + "openai-version": "2020-10-01", + "openai-organization": "user-kh6mcena3vodflcmdl35kovz", + "openai-project": "proj_lMW3RmfgZCZ7JPoXeXik9Etv", + "x-request-id": "req_b22e68202d23453fb4f1a478fa6220aa", + "openai-processing-ms": "41", + "x-envoy-upstream-service-time": "44", + "cf-cache-status": "DYNAMIC", + "Set-Cookie": [ + "__cf_bm=H5TqX9siTKdHwWCkl.ZewwOq1iBbQPw25OlZ4tkLmDs-1768899278-1.0.1.1-7xM3nTQlQq_5pp36RWKPQPYwJVuyT6p.NyK_jaamENCSmqUDjm0WC8rtZr7oVfHsrlde.BW0hGbFSOvmveEH14mFNY7Usq.P5UIbEGso73I; path=\/; expires=Tue, 20-Jan-26 09:24:38 GMT; domain=.api.openai.com; HttpOnly; Secure; SameSite=None", + "_cfuvid=onBq3Aca0l4tEkQM3Iw_C0mAdk8m8lgWAL4KReKx8w8-1768899278283-0.0.1.1-604800000; path=\/; domain=.api.openai.com; HttpOnly; Secure; SameSite=None" + ], + "Strict-Transport-Security": "max-age=31536000; includeSubDomains; preload", + "X-Content-Type-Options": "nosniff", + "Server": "cloudflare", + "CF-RAY": "9c0d5924cd13ad4f-LHR", + "alt-svc": "h3=\":443\"; ma=86400" + }, + "data": "event: response.created\ndata: {\"type\":\"response.created\",\"response\":{\"id\":\"resp_0f76877a6d87a6af00696f42ce2e8c819b9748772b0ec50557\",\"object\":\"response\",\"created_at\":1768899278,\"status\":\"in_progress\",\"background\":false,\"completed_at\":null,\"error\":null,\"frequency_penalty\":0.0,\"incomplete_details\":null,\"instructions\":null,\"max_output_tokens\":1024,\"max_tool_calls\":null,\"model\":\"gpt-4o-mini-2024-07-18\",\"output\":[],\"parallel_tool_calls\":true,\"presence_penalty\":0.0,\"previous_response_id\":null,\"prompt_cache_key\":null,\"prompt_cache_retention\":null,\"reasoning\":{\"effort\":null,\"summary\":null},\"safety_identifier\":null,\"service_tier\":\"auto\",\"store\":true,\"temperature\":1.0,\"text\":{\"format\":{\"type\":\"json_schema\",\"description\":null,\"name\":\"contact_info\",\"schema\":{\"type\":\"object\",\"properties\":{\"name\":{\"type\":\"string\"},\"email\":{\"type\":\"string\"},\"plan_interest\":{\"type\":\"string\"},\"demo_requested\":{\"type\":\"boolean\"}},\"required\":[\"name\",\"email\",\"plan_interest\",\"demo_requested\"],\"additionalProperties\":false},\"strict\":true},\"verbosity\":\"medium\"},\"tool_choice\":\"auto\",\"tools\":[],\"top_logprobs\":0,\"top_p\":1.0,\"truncation\":\"disabled\",\"usage\":null,\"user\":null,\"metadata\":{}},\"sequence_number\":0}\n\nevent: response.in_progress\ndata: {\"type\":\"response.in_progress\",\"response\":{\"id\":\"resp_0f76877a6d87a6af00696f42ce2e8c819b9748772b0ec50557\",\"object\":\"response\",\"created_at\":1768899278,\"status\":\"in_progress\",\"background\":false,\"completed_at\":null,\"error\":null,\"frequency_penalty\":0.0,\"incomplete_details\":null,\"instructions\":null,\"max_output_tokens\":1024,\"max_tool_calls\":null,\"model\":\"gpt-4o-mini-2024-07-18\",\"output\":[],\"parallel_tool_calls\":true,\"presence_penalty\":0.0,\"previous_response_id\":null,\"prompt_cache_key\":null,\"prompt_cache_retention\":null,\"reasoning\":{\"effort\":null,\"summary\":null},\"safety_identifier\":null,\"service_tier\":\"auto\",\"store\":true,\"temperature\":1.0,\"text\":{\"format\":{\"type\":\"json_schema\",\"description\":null,\"name\":\"contact_info\",\"schema\":{\"type\":\"object\",\"properties\":{\"name\":{\"type\":\"string\"},\"email\":{\"type\":\"string\"},\"plan_interest\":{\"type\":\"string\"},\"demo_requested\":{\"type\":\"boolean\"}},\"required\":[\"name\",\"email\",\"plan_interest\",\"demo_requested\"],\"additionalProperties\":false},\"strict\":true},\"verbosity\":\"medium\"},\"tool_choice\":\"auto\",\"tools\":[],\"top_logprobs\":0,\"top_p\":1.0,\"truncation\":\"disabled\",\"usage\":null,\"user\":null,\"metadata\":{}},\"sequence_number\":1}\n\nevent: response.output_item.added\ndata: {\"type\":\"response.output_item.added\",\"item\":{\"id\":\"msg_0f76877a6d87a6af00696f42cf11e4819b914a39fff034fd50\",\"type\":\"message\",\"status\":\"in_progress\",\"content\":[],\"role\":\"assistant\"},\"output_index\":0,\"sequence_number\":2}\n\nevent: response.content_part.added\ndata: {\"type\":\"response.content_part.added\",\"content_index\":0,\"item_id\":\"msg_0f76877a6d87a6af00696f42cf11e4819b914a39fff034fd50\",\"output_index\":0,\"part\":{\"type\":\"output_text\",\"annotations\":[],\"logprobs\":[],\"text\":\"\"},\"sequence_number\":3}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\"{\\\"\",\"item_id\":\"msg_0f76877a6d87a6af00696f42cf11e4819b914a39fff034fd50\",\"logprobs\":[],\"obfuscation\":\"FcTwLVB9xmJVEw\",\"output_index\":0,\"sequence_number\":4}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\"name\",\"item_id\":\"msg_0f76877a6d87a6af00696f42cf11e4819b914a39fff034fd50\",\"logprobs\":[],\"obfuscation\":\"Ar9fBD25LZw3\",\"output_index\":0,\"sequence_number\":5}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\"\\\":\\\"\",\"item_id\":\"msg_0f76877a6d87a6af00696f42cf11e4819b914a39fff034fd50\",\"logprobs\":[],\"obfuscation\":\"gw8fFDHYglVyD\",\"output_index\":0,\"sequence_number\":6}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\"John\",\"item_id\":\"msg_0f76877a6d87a6af00696f42cf11e4819b914a39fff034fd50\",\"logprobs\":[],\"obfuscation\":\"OfgaEW4Kvl2S\",\"output_index\":0,\"sequence_number\":7}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\" Smith\",\"item_id\":\"msg_0f76877a6d87a6af00696f42cf11e4819b914a39fff034fd50\",\"logprobs\":[],\"obfuscation\":\"YVpB9mfGGP\",\"output_index\":0,\"sequence_number\":8}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\"\\\",\\\"\",\"item_id\":\"msg_0f76877a6d87a6af00696f42cf11e4819b914a39fff034fd50\",\"logprobs\":[],\"obfuscation\":\"WSSOf4o43TQei\",\"output_index\":0,\"sequence_number\":9}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\"email\",\"item_id\":\"msg_0f76877a6d87a6af00696f42cf11e4819b914a39fff034fd50\",\"logprobs\":[],\"obfuscation\":\"TrwmFN2491E\",\"output_index\":0,\"sequence_number\":10}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\"\\\":\\\"\",\"item_id\":\"msg_0f76877a6d87a6af00696f42cf11e4819b914a39fff034fd50\",\"logprobs\":[],\"obfuscation\":\"EqsJuoCnYLErh\",\"output_index\":0,\"sequence_number\":11}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\"john\",\"item_id\":\"msg_0f76877a6d87a6af00696f42cf11e4819b914a39fff034fd50\",\"logprobs\":[],\"obfuscation\":\"UES6Gca4BNfA\",\"output_index\":0,\"sequence_number\":12}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\"@example\",\"item_id\":\"msg_0f76877a6d87a6af00696f42cf11e4819b914a39fff034fd50\",\"logprobs\":[],\"obfuscation\":\"3VbFncW5\",\"output_index\":0,\"sequence_number\":13}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\".com\",\"item_id\":\"msg_0f76877a6d87a6af00696f42cf11e4819b914a39fff034fd50\",\"logprobs\":[],\"obfuscation\":\"mNsyxhIoa0ws\",\"output_index\":0,\"sequence_number\":14}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\"\\\",\\\"\",\"item_id\":\"msg_0f76877a6d87a6af00696f42cf11e4819b914a39fff034fd50\",\"logprobs\":[],\"obfuscation\":\"TWxx3trG6Jyve\",\"output_index\":0,\"sequence_number\":15}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\"plan\",\"item_id\":\"msg_0f76877a6d87a6af00696f42cf11e4819b914a39fff034fd50\",\"logprobs\":[],\"obfuscation\":\"2xJQxmPp7pmZ\",\"output_index\":0,\"sequence_number\":16}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\"_interest\",\"item_id\":\"msg_0f76877a6d87a6af00696f42cf11e4819b914a39fff034fd50\",\"logprobs\":[],\"obfuscation\":\"WxlRjc7\",\"output_index\":0,\"sequence_number\":17}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\"\\\":\\\"\",\"item_id\":\"msg_0f76877a6d87a6af00696f42cf11e4819b914a39fff034fd50\",\"logprobs\":[],\"obfuscation\":\"gmvNlZYEBEIJl\",\"output_index\":0,\"sequence_number\":18}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\"Enterprise\",\"item_id\":\"msg_0f76877a6d87a6af00696f42cf11e4819b914a39fff034fd50\",\"logprobs\":[],\"obfuscation\":\"XCPO8L\",\"output_index\":0,\"sequence_number\":19}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\"\\\",\\\"\",\"item_id\":\"msg_0f76877a6d87a6af00696f42cf11e4819b914a39fff034fd50\",\"logprobs\":[],\"obfuscation\":\"5cVzB6yo6pWXZ\",\"output_index\":0,\"sequence_number\":20}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\"demo\",\"item_id\":\"msg_0f76877a6d87a6af00696f42cf11e4819b914a39fff034fd50\",\"logprobs\":[],\"obfuscation\":\"wyOEeuAv7A0Q\",\"output_index\":0,\"sequence_number\":21}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\"_requested\",\"item_id\":\"msg_0f76877a6d87a6af00696f42cf11e4819b914a39fff034fd50\",\"logprobs\":[],\"obfuscation\":\"SJcimN\",\"output_index\":0,\"sequence_number\":22}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\"\\\":\",\"item_id\":\"msg_0f76877a6d87a6af00696f42cf11e4819b914a39fff034fd50\",\"logprobs\":[],\"obfuscation\":\"EwlLD6GmLg7m44\",\"output_index\":0,\"sequence_number\":23}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\"true\",\"item_id\":\"msg_0f76877a6d87a6af00696f42cf11e4819b914a39fff034fd50\",\"logprobs\":[],\"obfuscation\":\"BdxORvDBE2M6\",\"output_index\":0,\"sequence_number\":24}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\"}\",\"item_id\":\"msg_0f76877a6d87a6af00696f42cf11e4819b914a39fff034fd50\",\"logprobs\":[],\"obfuscation\":\"TqsqGWXpgMpCCNP\",\"output_index\":0,\"sequence_number\":25}\n\nevent: response.output_text.done\ndata: {\"type\":\"response.output_text.done\",\"content_index\":0,\"item_id\":\"msg_0f76877a6d87a6af00696f42cf11e4819b914a39fff034fd50\",\"logprobs\":[],\"output_index\":0,\"sequence_number\":26,\"text\":\"{\\\"name\\\":\\\"John Smith\\\",\\\"email\\\":\\\"john@example.com\\\",\\\"plan_interest\\\":\\\"Enterprise\\\",\\\"demo_requested\\\":true}\"}\n\nevent: response.content_part.done\ndata: {\"type\":\"response.content_part.done\",\"content_index\":0,\"item_id\":\"msg_0f76877a6d87a6af00696f42cf11e4819b914a39fff034fd50\",\"output_index\":0,\"part\":{\"type\":\"output_text\",\"annotations\":[],\"logprobs\":[],\"text\":\"{\\\"name\\\":\\\"John Smith\\\",\\\"email\\\":\\\"john@example.com\\\",\\\"plan_interest\\\":\\\"Enterprise\\\",\\\"demo_requested\\\":true}\"},\"sequence_number\":27}\n\nevent: response.output_item.done\ndata: {\"type\":\"response.output_item.done\",\"item\":{\"id\":\"msg_0f76877a6d87a6af00696f42cf11e4819b914a39fff034fd50\",\"type\":\"message\",\"status\":\"completed\",\"content\":[{\"type\":\"output_text\",\"annotations\":[],\"logprobs\":[],\"text\":\"{\\\"name\\\":\\\"John Smith\\\",\\\"email\\\":\\\"john@example.com\\\",\\\"plan_interest\\\":\\\"Enterprise\\\",\\\"demo_requested\\\":true}\"}],\"role\":\"assistant\"},\"output_index\":0,\"sequence_number\":28}\n\nevent: response.completed\ndata: {\"type\":\"response.completed\",\"response\":{\"id\":\"resp_0f76877a6d87a6af00696f42ce2e8c819b9748772b0ec50557\",\"object\":\"response\",\"created_at\":1768899278,\"status\":\"completed\",\"background\":false,\"completed_at\":1768899279,\"error\":null,\"frequency_penalty\":0.0,\"incomplete_details\":null,\"instructions\":null,\"max_output_tokens\":1024,\"max_tool_calls\":null,\"model\":\"gpt-4o-mini-2024-07-18\",\"output\":[{\"id\":\"msg_0f76877a6d87a6af00696f42cf11e4819b914a39fff034fd50\",\"type\":\"message\",\"status\":\"completed\",\"content\":[{\"type\":\"output_text\",\"annotations\":[],\"logprobs\":[],\"text\":\"{\\\"name\\\":\\\"John Smith\\\",\\\"email\\\":\\\"john@example.com\\\",\\\"plan_interest\\\":\\\"Enterprise\\\",\\\"demo_requested\\\":true}\"}],\"role\":\"assistant\"}],\"parallel_tool_calls\":true,\"presence_penalty\":0.0,\"previous_response_id\":null,\"prompt_cache_key\":null,\"prompt_cache_retention\":null,\"reasoning\":{\"effort\":null,\"summary\":null},\"safety_identifier\":null,\"service_tier\":\"default\",\"store\":true,\"temperature\":1.0,\"text\":{\"format\":{\"type\":\"json_schema\",\"description\":null,\"name\":\"contact_info\",\"schema\":{\"type\":\"object\",\"properties\":{\"name\":{\"type\":\"string\"},\"email\":{\"type\":\"string\"},\"plan_interest\":{\"type\":\"string\"},\"demo_requested\":{\"type\":\"boolean\"}},\"required\":[\"name\",\"email\",\"plan_interest\",\"demo_requested\"],\"additionalProperties\":false},\"strict\":true},\"verbosity\":\"medium\"},\"tool_choice\":\"auto\",\"tools\":[],\"top_logprobs\":0,\"top_p\":1.0,\"truncation\":\"disabled\",\"usage\":{\"input_tokens\":88,\"input_tokens_details\":{\"cached_tokens\":0},\"output_tokens\":23,\"output_tokens_details\":{\"reasoning_tokens\":0},\"total_tokens\":111},\"user\":null,\"metadata\":{}},\"sequence_number\":29}\n\n", + "context": [] +} \ No newline at end of file diff --git a/tests/fixtures/sdk/openai/responses/structured-output.json b/tests/fixtures/sdk/openai/responses/structured-output.json new file mode 100644 index 0000000..831c958 --- /dev/null +++ b/tests/fixtures/sdk/openai/responses/structured-output.json @@ -0,0 +1,33 @@ +{ + "statusCode": 200, + "headers": { + "Date": "Mon, 19 Jan 2026 23:52:30 GMT", + "Content-Type": "application\/json", + "Content-Length": "2246", + "Connection": "keep-alive", + "x-ratelimit-limit-requests": "5000", + "x-ratelimit-limit-tokens": "4000000", + "x-ratelimit-remaining-requests": "4999", + "x-ratelimit-remaining-tokens": "3999893", + "x-ratelimit-reset-requests": "12ms", + "x-ratelimit-reset-tokens": "1ms", + "openai-version": "2020-10-01", + "openai-organization": "user-kh6mcena3vodflcmdl35kovz", + "openai-project": "proj_lMW3RmfgZCZ7JPoXeXik9Etv", + "x-request-id": "req_4fa540cdb63195df95fb5b7ad41ff380", + "openai-processing-ms": "982", + "x-envoy-upstream-service-time": "985", + "cf-cache-status": "DYNAMIC", + "Set-Cookie": [ + "__cf_bm=uil_9Jw9slD869otH03gNe6dzr_6QWQqMBBAXRB0QFA-1768866750-1.0.1.1-CVIyYNKEZM5t7PWzNfWJUinoKacYwTlK1q.qqyKDLD20MGDe.Q2.vXV.cGWiTnIyl6X1iboPoKRh_xIJUq5WP_UWkj2r1cjlvaygu_.4.tw; path=\/; expires=Tue, 20-Jan-26 00:22:30 GMT; domain=.api.openai.com; HttpOnly; Secure; SameSite=None", + "_cfuvid=vip5qCgIxgNyEF6xzOGWB6KtZ3NVZSDUdp.8WLoRzGY-1768866750499-0.0.1.1-604800000; path=\/; domain=.api.openai.com; HttpOnly; Secure; SameSite=None" + ], + "Strict-Transport-Security": "max-age=31536000; includeSubDomains; preload", + "X-Content-Type-Options": "nosniff", + "Server": "cloudflare", + "CF-RAY": "9c0a3eff7d6d9627-LHR", + "alt-svc": "h3=\":443\"; ma=86400" + }, + "data": "{\n \"id\": \"resp_07a7b0581caf103c00696ec3bd7398819897bdbafa9b5f9172\",\n \"object\": \"response\",\n \"created_at\": 1768866749,\n \"status\": \"completed\",\n \"background\": false,\n \"billing\": {\n \"payer\": \"developer\"\n },\n \"completed_at\": 1768866750,\n \"error\": null,\n \"frequency_penalty\": 0.0,\n \"incomplete_details\": null,\n \"instructions\": null,\n \"max_output_tokens\": 1024,\n \"max_tool_calls\": null,\n \"model\": \"gpt-4o-mini-2024-07-18\",\n \"output\": [\n {\n \"id\": \"msg_07a7b0581caf103c00696ec3bde9d48198a397d1c7de974e6e\",\n \"type\": \"message\",\n \"status\": \"completed\",\n \"content\": [\n {\n \"type\": \"output_text\",\n \"annotations\": [],\n \"logprobs\": [],\n \"text\": \"{\\\"name\\\":\\\"John Smith\\\",\\\"email\\\":\\\"john@example.com\\\",\\\"plan_interest\\\":\\\"Enterprise\\\",\\\"demo_requested\\\":true}\"\n }\n ],\n \"role\": \"assistant\"\n }\n ],\n \"parallel_tool_calls\": true,\n \"presence_penalty\": 0.0,\n \"previous_response_id\": null,\n \"prompt_cache_key\": null,\n \"prompt_cache_retention\": null,\n \"reasoning\": {\n \"effort\": null,\n \"summary\": null\n },\n \"safety_identifier\": null,\n \"service_tier\": \"default\",\n \"store\": true,\n \"temperature\": 1.0,\n \"text\": {\n \"format\": {\n \"type\": \"json_schema\",\n \"description\": null,\n \"name\": \"contact_info\",\n \"schema\": {\n \"type\": \"object\",\n \"properties\": {\n \"name\": {\n \"type\": \"string\"\n },\n \"email\": {\n \"type\": \"string\"\n },\n \"plan_interest\": {\n \"type\": \"string\"\n },\n \"demo_requested\": {\n \"type\": \"boolean\"\n }\n },\n \"required\": [\n \"name\",\n \"email\",\n \"plan_interest\",\n \"demo_requested\"\n ],\n \"additionalProperties\": false\n },\n \"strict\": true\n },\n \"verbosity\": \"medium\"\n },\n \"tool_choice\": \"auto\",\n \"tools\": [],\n \"top_logprobs\": 0,\n \"top_p\": 1.0,\n \"truncation\": \"disabled\",\n \"usage\": {\n \"input_tokens\": 88,\n \"input_tokens_details\": {\n \"cached_tokens\": 0\n },\n \"output_tokens\": 23,\n \"output_tokens_details\": {\n \"reasoning_tokens\": 0\n },\n \"total_tokens\": 111\n },\n \"user\": null,\n \"metadata\": {}\n}", + "context": [] +} \ No newline at end of file diff --git a/tests/fixtures/skills/code-review/SKILL.md b/tests/fixtures/skills/code-review/SKILL.md new file mode 100644 index 0000000..c1eb438 --- /dev/null +++ b/tests/fixtures/skills/code-review/SKILL.md @@ -0,0 +1,24 @@ +--- +name: code-review +description: Perform thorough code reviews with best practices +--- + +# Code Review Skill + +When reviewing code, follow these guidelines. + +## Checklist + +- Check for security vulnerabilities +- Verify error handling +- Review naming conventions +- Assess code complexity +- Look for potential performance issues + +## Output Format + +Provide feedback in the following format: + +1. **Summary**: Brief overview of the code quality +2. **Issues**: List of problems found +3. **Suggestions**: Recommendations for improvement diff --git a/tests/fixtures/skills/cortex-docs/SKILL.md b/tests/fixtures/skills/cortex-docs/SKILL.md new file mode 100644 index 0000000..165f732 --- /dev/null +++ b/tests/fixtures/skills/cortex-docs/SKILL.md @@ -0,0 +1,187 @@ +--- +name: Cortex +description: Documentation and capabilities reference for Cortex +--- + +## Capabilities + +Cortex JSON Schema enables agents to build, validate, and manage JSON schemas programmatically in PHP. Agents can create complex validation rules using a fluent API, generate schemas from existing PHP code, validate data against schemas with detailed error messages, and export schemas to standard JSON Schema format. The library supports multiple JSON Schema versions with automatic feature validation and provides comprehensive tools for API validation, configuration management, and data integrity. + +## Skills + +### Schema Building with Fluent API +- Build schemas using intuitive fluent interface: `Schema::object('name')->properties(...)` +- Create string schemas with validation: `Schema::string('email')->format(SchemaFormat::Email)->required()` +- Build integer/number schemas with constraints: `Schema::integer('age')->minimum(18)->maximum(150)` +- Create boolean schemas: `Schema::boolean('active')->default(true)` +- Build array schemas with item validation: `Schema::array('tags')->items(Schema::string())->minItems(1)->maxItems(5)->uniqueItems(true)` +- Create union types for multiple allowed types: `Schema::union([SchemaType::String, SchemaType::Integer], 'id')` +- Define null schemas: `Schema::null('field')` + +### Data Type Validation +- String validation with patterns, lengths, and formats +- Number/integer validation with min/max, multipleOf constraints +- Array validation with item schemas, tuple validation, contains validation +- Object validation with properties, pattern properties, additional properties control +- Boolean validation with default values +- Union types allowing multiple data types + +### String Format Validation +- Email format: `SchemaFormat::Email` +- URI/URL formats: `SchemaFormat::Uri`, `SchemaFormat::UriReference`, `SchemaFormat::UriTemplate` +- Hostname formats: `SchemaFormat::Hostname`, `SchemaFormat::IdnHostname` +- IP address formats: `SchemaFormat::Ipv4`, `SchemaFormat::Ipv6` +- Date/time formats: `SchemaFormat::Date`, `SchemaFormat::Time`, `SchemaFormat::DateTime` +- UUID format: `SchemaFormat::Uuid` (Draft 2019-09+) +- Duration format: `SchemaFormat::Duration` (ISO 8601, Draft 2019-09+) +- JSON Pointer formats: `SchemaFormat::JsonPointer`, `SchemaFormat::RelativeJsonPointer` +- Internationalized formats: `SchemaFormat::IdnEmail`, `SchemaFormat::Iri`, `SchemaFormat::IriReference` + +### Conditional Validation +- If/then/else logic: `->if(condition)->then(schema)->else(schema)` +- AllOf composition: All schemas must match +- AnyOf composition: At least one schema must match +- OneOf composition: Exactly one schema must match +- Not condition: Schema must not match +- Dependent schemas: Property-dependent validation rules +- Nested conditional validation for complex business logic + +### Code Generation +- Generate schemas from PHP classes: `Schema::fromClass(UserClass::class)` +- Generate from closures/functions: `Schema::fromClosure($function)` +- Generate from backed enums: `Schema::fromEnum(StatusEnum::class)` +- Import from JSON Schema: `Schema::fromJson($jsonString)` +- Extract property types, descriptions, and deprecation status from docblocks +- Automatic enum constraint generation from backed enums + +### Schema Composition & Reuse +- Define reusable components: `->addDefinition('address', Schema::object(...))` +- Reference definitions: `->ref('#/$defs/address')` +- Create modular schema structures +- Support for complex nested schemas +- Automatic $defs generation in JSON output + +### Pattern-Based Properties +- Validate properties by name pattern: `->patternProperties(['^env_' => Schema::string()])` +- Multiple pattern properties in single schema +- Combine with regular properties and additional properties +- Use regex patterns for flexible property validation + +### Advanced Property Control +- Required properties: `->required()` +- Default values: `->default(value)` +- Read-only properties: `->readOnly()` +- Write-only properties: `->writeOnly()` +- Deprecated properties: `->deprecated()` +- Additional properties control: `->additionalProperties(false)` +- Unevaluated properties validation (Draft 2019-09+) + +### Data Validation +- Quick boolean validation: `$schema->isValid($data)` returns true/false +- Detailed validation with exceptions: `$schema->validate($data)` throws SchemaException +- Detailed error messages on validation failure +- Version-aware feature validation with helpful error messages + +### Schema Import & Export +- Import from JSON Schema strings: `Schema::fromJson($jsonString)` +- Export to JSON: `$schema->toJson(JSON_PRETTY_PRINT)` +- Export to array: `$schema->toArray()` +- Automatic version detection from JSON Schema +- Support for Draft-07, Draft 2019-09, and Draft 2020-12 + +### Version Management +- Multi-version support: Draft-07, Draft 2019-09, Draft 2020-12 +- Set default version: `Schema::setDefaultVersion(SchemaVersion::Draft_2019_09)` +- Specify version per schema: `Schema::object('name', SchemaVersion::Draft_2019_09)` +- Automatic feature validation for version compatibility +- Version-specific features with error handling + +## Workflows + +### Building a Complete User Registration Schema +1. Create object schema: `$schema = Schema::object('user')` +2. Add string properties with validation: `->properties(Schema::string('email')->format(SchemaFormat::Email)->required())` +3. Add numeric properties with constraints: `Schema::integer('age')->minimum(18)->maximum(150)` +4. Add conditional logic for business users: `->if(type='business')->then(require company_name)` +5. Validate user data: `$schema->isValid($userData)` +6. Export to JSON: `$schema->toJson(JSON_PRETTY_PRINT)` + +### Creating Reusable Schema Components +1. Define base types: `->addDefinition('address', Schema::object()->properties(...))` +2. Define domain objects: `->addDefinition('customer', Schema::object()->properties(...))` +3. Reference definitions throughout: `Schema::object('billing_address')->ref('#/$defs/address')` +4. Build complex schemas from components +5. Export complete schema with all definitions + +### Validating API Requests +1. Generate schema from closure: `Schema::fromClosure($apiFunction)` +2. Extract parameter types and descriptions automatically +3. Add validation rules programmatically: `->minLength(2)->pattern('^[a-z]+$')` +4. Validate incoming request data: `$schema->isValid($requestData)` +5. Return detailed errors on validation failure + +### Generating Schemas from Existing Code +1. Create PHP class with docblocks: `class User { /** @var string */ public string $name; }` +2. Generate schema: `$schema = Schema::fromClass(User::class)` +3. Add validation rules: `$schema->properties(Schema::string('name')->minLength(2))` +4. Use for API documentation and validation +5. Export to JSON for client-side validation + +### Handling Complex Conditional Validation +1. Define base properties: `->properties(Schema::string('payment_method'), Schema::string('card_number'))` +2. Add oneOf for mutually exclusive options: `->oneOf(creditCardSchema, bankTransferSchema, cryptoSchema)` +3. Each option validates specific required fields +4. Validate payment data against schema +5. Ensure only one payment method is valid + +## Integration + +Cortex JSON Schema integrates with: +- **PHP 8.3+**: Built with modern PHP features and strict typing +- **JSON Schema Standard**: Generates valid JSON Schema Draft-07, Draft 2019-09, and Draft 2020-12 +- **MCP (Model Context Protocol)**: Available as MCP server at `https://docs.cortexphp.com/mcp` for AI assistants (Cursor, Claude Desktop, VS Code Cline) +- **API Frameworks**: Validate request/response data in any PHP API framework +- **Configuration Management**: Validate application configuration files +- **Form Validation**: Generate validation rules for frontend forms +- **Database Validation**: Validate data before persistence +- **External Tools**: Export schemas for use with other JSON Schema validators + +## Context + +### JSON Schema Versions +- **Draft-07**: Basic features, widely supported +- **Draft 2019-09**: Adds $defs, unevaluatedProperties, deprecated, UUID, Duration formats +- **Draft 2020-12**: Latest version, default in Cortex, includes all modern features + +### Key Concepts +- **Fluent API**: Method chaining for readable schema building +- **Type Safety**: PHP 8.3+ strict typing prevents runtime errors +- **Composition**: Build complex schemas from simple, reusable components +- **Validation**: Two modes - quick boolean checks or detailed error reporting +- **Code Generation**: Extract schemas from existing PHP code via reflection + +### Common Use Cases +- API request/response validation +- Configuration file validation +- Form validation (backend and frontend) +- Data migration and transformation +- API documentation generation +- Payment processing validation +- User profile management +- Dynamic form generation +- State machine validation +- Enum-based validation for status codes and options + +### Best Practices +- Use docblocks with @var and @param annotations for code generation +- Leverage backed enums for clear validation constraints +- Define reusable components with addDefinition() for maintainability +- Use conditional validation (if/then/else) for complex business logic +- Combine pattern properties with additionalProperties for flexible schemas +- Specify schema versions explicitly for version-specific features +- Use format validation for common data types (email, URI, date, etc.) +- Apply validation rules programmatically after code generation + +--- + +> For additional documentation and navigation, see: https://docs.cortexphp.com/llms.txt diff --git a/tests/fixtures/skills/create-rule/SKILL.md b/tests/fixtures/skills/create-rule/SKILL.md new file mode 100644 index 0000000..d92e48e --- /dev/null +++ b/tests/fixtures/skills/create-rule/SKILL.md @@ -0,0 +1,34 @@ +--- +name: create-rule +description: Create Cursor rules for persistent AI guidance +author: cortex +version: 1.0.0 +--- + +# Create Rule Skill + +When the user wants to create a rule, follow these instructions. + +## Steps + +1. Understand the user's requirements +2. Create a RULE.md file in the appropriate location +3. Add the rule content following the standard format + +## Format + +Rules should follow this structure: + +```markdown +# Rule Name + +Description of the rule. + +## When to Apply + +Conditions for when this rule applies. + +## Instructions + +Detailed instructions for the rule. +``` diff --git a/tests/fixtures/skills/no-frontmatter/SKILL.md b/tests/fixtures/skills/no-frontmatter/SKILL.md new file mode 100644 index 0000000..6e18970 --- /dev/null +++ b/tests/fixtures/skills/no-frontmatter/SKILL.md @@ -0,0 +1,7 @@ +# Simple Skill + +This skill has no frontmatter, so the name should be inferred from the directory. + +## Instructions + +Follow these simple instructions. diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..42777d4 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,117 @@ +{ + "compilerOptions": { + /* Visit https://aka.ms/tsconfig to read more about this file */ + + /* Projects */ + // "incremental": true, /* Save .tsbuildinfo files to allow for incremental compilation of projects. */ + // "composite": true, /* Enable constraints that allow a TypeScript project to be used with project references. */ + // "tsBuildInfoFile": "./.tsbuildinfo", /* Specify the path to .tsbuildinfo incremental compilation file. */ + // "disableSourceOfProjectReferenceRedirect": true, /* Disable preferring source files instead of declaration files when referencing composite projects. */ + // "disableSolutionSearching": true, /* Opt a project out of multi-project reference checking when editing. */ + // "disableReferencedProjectLoad": true, /* Reduce the number of projects loaded automatically by TypeScript. */ + + /* Language and Environment */ + "target": "ESNext" /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */, + // "lib": [], /* Specify a set of bundled library declaration files that describe the target runtime environment. */ + // "jsx": "preserve", /* Specify what JSX code is generated. */ + // "experimentalDecorators": true, /* Enable experimental support for legacy experimental decorators. */ + // "emitDecoratorMetadata": true, /* Emit design-type metadata for decorated declarations in source files. */ + // "jsxFactory": "", /* Specify the JSX factory function used when targeting React JSX emit, e.g. 'React.createElement' or 'h'. */ + // "jsxFragmentFactory": "", /* Specify the JSX Fragment reference used for fragments when targeting React JSX emit e.g. 'React.Fragment' or 'Fragment'. */ + // "jsxImportSource": "", /* Specify module specifier used to import the JSX factory functions when using 'jsx: react-jsx*'. */ + // "reactNamespace": "", /* Specify the object invoked for 'createElement'. This only applies when targeting 'react' JSX emit. */ + // "noLib": true, /* Disable including any library files, including the default lib.d.ts. */ + // "useDefineForClassFields": true, /* Emit ECMAScript-standard-compliant class fields. */ + // "moduleDetection": "auto", /* Control what method is used to detect module-format JS files. */ + + /* Modules */ + "module": "ESNext" /* Specify what module code is generated. */, + // "rootDir": "./", /* Specify the root folder within your source files. */ + "moduleResolution": "bundler" /* Specify how TypeScript looks up a file from a given module specifier. */, + // "baseUrl": "./", /* Specify the base directory to resolve non-relative module names. */ + // "paths": {}, /* Specify a set of entries that re-map imports to additional lookup locations. */ + // "rootDirs": [], /* Allow multiple folders to be treated as one when resolving modules. */ + // "typeRoots": [], /* Specify multiple folders that act like './node_modules/@types'. */ + // "types": [], /* Specify type package names to be included without being referenced in a source file. */ + // "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */ + // "moduleSuffixes": [], /* List of file name suffixes to search when resolving a module. */ + // "allowImportingTsExtensions": true, /* Allow imports to include TypeScript file extensions. Requires '--moduleResolution bundler' and either '--noEmit' or '--emitDeclarationOnly' to be set. */ + // "rewriteRelativeImportExtensions": true, /* Rewrite '.ts', '.tsx', '.mts', and '.cts' file extensions in relative import paths to their JavaScript equivalent in output files. */ + // "resolvePackageJsonExports": true, /* Use the package.json 'exports' field when resolving package imports. */ + // "resolvePackageJsonImports": true, /* Use the package.json 'imports' field when resolving imports. */ + // "customConditions": [], /* Conditions to set in addition to the resolver-specific defaults when resolving imports. */ + // "noUncheckedSideEffectImports": true, /* Check side effect imports. */ + // "resolveJsonModule": true, /* Enable importing .json files. */ + // "allowArbitraryExtensions": true, /* Enable importing files with any extension, provided a declaration file is present. */ + // "noResolve": true, /* Disallow 'import's, 'require's or ''s from expanding the number of files TypeScript should add to a project. */ + + /* JavaScript Support */ + "allowJs": true /* Allow JavaScript files to be a part of your program. Use the 'checkJS' option to get errors from these files. */, + // "checkJs": true, /* Enable error reporting in type-checked JavaScript files. */ + // "maxNodeModuleJsDepth": 1, /* Specify the maximum folder depth used for checking JavaScript files from 'node_modules'. Only applicable with 'allowJs'. */ + + /* Emit */ + // "declaration": true, /* Generate .d.ts files from TypeScript and JavaScript files in your project. */ + // "declarationMap": true, /* Create sourcemaps for d.ts files. */ + // "emitDeclarationOnly": true, /* Only output d.ts files and not JavaScript files. */ + // "sourceMap": true, /* Create source map files for emitted JavaScript files. */ + // "inlineSourceMap": true, /* Include sourcemap files inside the emitted JavaScript. */ + "noEmit": true /* Disable emitting files from a compilation. */, + // "outFile": "./", /* Specify a file that bundles all outputs into one JavaScript file. If 'declaration' is true, also designates a file that bundles all .d.ts output. */ + // "outDir": "./", /* Specify an output folder for all emitted files. */ + // "removeComments": true, /* Disable emitting comments. */ + // "importHelpers": true, /* Allow importing helper functions from tslib once per project, instead of including them per-file. */ + // "downlevelIteration": true, /* Emit more compliant, but verbose and less performant JavaScript for iteration. */ + // "sourceRoot": "", /* Specify the root path for debuggers to find the reference source code. */ + // "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */ + // "inlineSources": true, /* Include source code in the sourcemaps inside the emitted JavaScript. */ + // "emitBOM": true, /* Emit a UTF-8 Byte Order Mark (BOM) in the beginning of output files. */ + // "newLine": "crlf", /* Set the newline character for emitting files. */ + // "stripInternal": true, /* Disable emitting declarations that have '@internal' in their JSDoc comments. */ + // "noEmitHelpers": true, /* Disable generating custom helper functions like '__extends' in compiled output. */ + // "noEmitOnError": true, /* Disable emitting files if any type checking errors are reported. */ + // "preserveConstEnums": true, /* Disable erasing 'const enum' declarations in generated code. */ + // "declarationDir": "./", /* Specify the output directory for generated declaration files. */ + + /* Interop Constraints */ + "isolatedModules": true /* Ensure that each file can be safely transpiled without relying on other imports. */, + // "verbatimModuleSyntax": true, /* Do not transform or elide any imports or exports not marked as type-only, ensuring they are written in the output file's format based on the 'module' setting. */ + // "isolatedDeclarations": true, /* Require sufficient annotation on exports so other tools can trivially generate declaration files. */ + // "allowSyntheticDefaultImports": true, /* Allow 'import x from y' when a module doesn't have a default export. */ + "esModuleInterop": true /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables 'allowSyntheticDefaultImports' for type compatibility. */, + // "preserveSymlinks": true, /* Disable resolving symlinks to their realpath. This correlates to the same flag in node. */ + "forceConsistentCasingInFileNames": true /* Ensure that casing is correct in imports. */, + + /* Type Checking */ + "strict": true /* Enable all strict type-checking options. */, + "noImplicitAny": true /* Enable error reporting for expressions and declarations with an implied 'any' type. */, + // "strictNullChecks": true, /* When type checking, take into account 'null' and 'undefined'. */ + // "strictFunctionTypes": true, /* When assigning functions, check to ensure parameters and the return values are subtype-compatible. */ + // "strictBindCallApply": true, /* Check that the arguments for 'bind', 'call', and 'apply' methods match the original function. */ + // "strictPropertyInitialization": true, /* Check for class properties that are declared but not set in the constructor. */ + // "strictBuiltinIteratorReturn": true, /* Built-in iterators are instantiated with a 'TReturn' type of 'undefined' instead of 'any'. */ + // "noImplicitThis": true, /* Enable error reporting when 'this' is given the type 'any'. */ + // "useUnknownInCatchVariables": true, /* Default catch clause variables as 'unknown' instead of 'any'. */ + // "alwaysStrict": true, /* Ensure 'use strict' is always emitted. */ + // "noUnusedLocals": true, /* Enable error reporting when local variables aren't read. */ + // "noUnusedParameters": true, /* Raise an error when a function parameter isn't read. */ + // "exactOptionalPropertyTypes": true, /* Interpret optional property types as written, rather than adding 'undefined'. */ + // "noImplicitReturns": true, /* Enable error reporting for codepaths that do not explicitly return in a function. */ + // "noFallthroughCasesInSwitch": true, /* Enable error reporting for fallthrough cases in switch statements. */ + // "noUncheckedIndexedAccess": true, /* Add 'undefined' to a type when accessed using an index. */ + // "noImplicitOverride": true, /* Ensure overriding members in derived classes are marked with an override modifier. */ + // "noPropertyAccessFromIndexSignature": true, /* Enforces using indexed accessors for keys declared using an indexed type. */ + // "allowUnusedLabels": true, /* Disable error reporting for unused labels. */ + // "allowUnreachableCode": true, /* Disable error reporting for unreachable code. */ + + /* Completeness */ + // "skipDefaultLibCheck": true, /* Skip type checking .d.ts files that are included with TypeScript. */ + "skipLibCheck": true /* Skip type checking all .d.ts files. */, + "baseUrl": ".", + "paths": { + "@/*": ["./resources/js/*"] + }, + "jsx": "react-jsx" + }, + "include": ["resources/js/**/*.ts", "resources/js/**/*.d.ts", "resources/js/**/*.tsx"] +} diff --git a/vite.config.ts b/vite.config.ts new file mode 100644 index 0000000..b0af01a --- /dev/null +++ b/vite.config.ts @@ -0,0 +1,34 @@ +import { wayfinder } from '@laravel/vite-plugin-wayfinder'; +import tailwindcss from '@tailwindcss/vite'; +import react from '@vitejs/plugin-react'; +import laravel from 'laravel-vite-plugin'; +import { defineConfig } from 'vite'; +import path from "node:path"; + +const testbenchCommand = path.join(__dirname, 'vendor', 'bin', 'testbench'); +const testbenchPublicDir = path.join(__dirname, 'vendor', 'orchestra', 'testbench-core', 'laravel', 'public'); +const resourcesDir = path.join(__dirname, 'resources'); + +export default defineConfig({ + plugins: [ + laravel({ + input: ['resources/css/app.css', 'resources/js/app.tsx'], + ssr: 'resources/js/ssr.tsx', + refresh: true, + hotFile: path.join(testbenchPublicDir, 'hot'), + }), + react({ + babel: { + plugins: ['babel-plugin-react-compiler'], + }, + }), + tailwindcss(), + wayfinder({ + path: resourcesDir + '/js', + command: `${testbenchCommand} wayfinder:generate`, + }), + ], + esbuild: { + jsx: 'automatic', + }, +}); diff --git a/workbench/app/Providers/CortexServiceProvider.php b/workbench/app/Providers/CortexServiceProvider.php index d71beeb..0376526 100644 --- a/workbench/app/Providers/CortexServiceProvider.php +++ b/workbench/app/Providers/CortexServiceProvider.php @@ -5,11 +5,14 @@ use Cortex\Cortex; use Cortex\Agents\Agent; use Cortex\JsonSchema\Schema; +use function Cortex\Support\tool; +use Cortex\Memory\Stores\CacheStore; +use Cortex\Agents\Prebuilt\SkillsAgent; + use Illuminate\Support\ServiceProvider; -use Cortex\ModelInfo\Enums\ModelFeature; use Cortex\LLM\Data\Messages\UserMessage; +use Cortex\LLM\Enums\StructuredOutputMode; use Cortex\LLM\Data\Messages\SystemMessage; - use function Orchestra\Testbench\package_path; class CortexServiceProvider extends ServiceProvider @@ -32,59 +35,174 @@ public function boot(): void package_path('workbench/resources/views/prompts'), ); - // Cortex::registerAgent(new Agent( - // name: 'holiday_generator', - // prompt: 'Invent a new holiday and describe its traditions. Max 3 sentences.', - // llm: Cortex::llm('lmstudio/openai/gpt-oss-20b')->withTemperature(1.5), - // output: [ - // Schema::string('name')->required(), - // Schema::string('description')->required(), - // ], - // )); - - // Cortex::registerAgent(new Agent( - // name: 'quote_of_the_day', - // prompt: 'Generate a quote of the day about {topic}.', - // llm: 'lmstudio/openai/gpt-oss-20b', - // output: [ - // Schema::string('quote') - // ->description('Do not include the author in the quote. Just a single sentence.') - // ->required(), - // Schema::string('author')->required(), - // ], - // )); - - // Cortex::registerAgent(new Agent( - // name: 'comedian', - // prompt: Cortex::prompt() - // ->builder() - // ->messages([ - // new SystemMessage('You are a comedian.'), - // new UserMessage('Tell me a joke about {topic}.'), - // ]) - // ->metadata( - // provider: 'lmstudio', - // model: 'gpt-oss:20b', - // parameters: [ - // 'temperature' => 1.5, - // ], - // structuredOutput: Schema::object()->properties( - // Schema::string('setup')->required(), - // Schema::string('punchline')->required(), - // ), - // ), - // )); + Cortex::registerAgent(new Agent( + id: 'holiday_generator', + prompt: 'Invent a new holiday and describe its traditions. Max 3 sentences.', + llm: Cortex::llm('lmstudio/openai/gpt-oss-20b')->withTemperature(1.5), + output: [ + Schema::string('name')->required(), + Schema::string('description')->required(), + ], + )); + + Cortex::registerAgent(new Agent( + id: 'quote_of_the_day', + prompt: 'Generate a quote of the day about {topic}.', + llm: 'lmstudio/openai/gpt-oss-20b', + output: [ + Schema::string('quote') + ->description('Do not include the author in the quote. Just a single sentence.') + ->required(), + Schema::string('author') + ->required(), + ], + )); + + Cortex::registerAgent(new Agent( + id: 'comedian', + prompt: Cortex::prompt() + ->builder() + ->messages([ + new SystemMessage('You are a comedian.'), + new UserMessage('Tell me a joke about {topic}.'), + ]) + ->metadata( + // provider: 'anthropic', + // model: 'claude-haiku-4-5', + provider: 'lmstudio', + model: 'openai/gpt-oss-20b', + // provider: 'ollama', + // model: 'gpt-oss:20b', + parameters: [ + 'max_tokens' => 100, + 'max_output_tokens' => 100, + ], + structuredOutputMode: StructuredOutputMode::Json, + structuredOutput: Schema::object()->properties( + Schema::string('setup')->required(), + Schema::string('punchline')->required(), + ), + ), + )); + + $translationAgent = new Agent( + id: 'translation_agent', + prompt: Cortex::prompt([ + new UserMessage( + <<required(), + ], + ); + + $storyIdeaGenerator = new Agent( + id: 'story_idea_generator', + prompt: 'Generate a story idea about {topic}. Maximum 100 words.', + llm: 'lmstudio/openai/gpt-oss-20b', + ); + + $storyWriter = new Agent( + id: 'story_writer', + prompt: 'You are a story writer. You only write short stories. Call the `get_random_story_topic` tool to get a story topic and the `flesh_out_story_idea` tool to flesh out a story idea.', + llm: 'anthropic/claude-3-7-sonnet-20250219', + tools: [ + $storyTopicGenerator->asTool('get_random_story_topic', 'Generate a random story topic.'), + $storyIdeaGenerator->asTool('flesh_out_story_idea', 'Flesh out a story idea about a given topic.'), + ], + ); + + Cortex::registerAgent($storyWriter); Cortex::registerAgent(new Agent( - name: 'generic', + id: 'generic', + name: 'Generic Assistant', + description: 'A helpful assistant that can answer questions.', prompt: 'You are a helpful assistant.', - llm: 'lmstudio/openai/gpt-oss-20b' + llm: 'lmstudio/openai/gpt-oss-20b', + // llm: 'ollama/gpt-oss:20b', + tools: [ + // $translationAgent->asTool('translate', 'Translate text from one language to another.'), + // $storyIdeaGenerator->asTool('generate_story_idea', 'Generate a story idea about a given topic.'), + ], )); Cortex::registerAgent(new Agent( - name: 'code_generator', + id: 'image_processor', + name: 'Image Processor', + description: 'A helpful assistant that can process images.', + llm: 'lmstudio/qwen/qwen3-vl-8b', + )); + + Cortex::registerAgent(new Agent( + id: 'ollama_anthropic', + name: 'Ollama Anthropic Assistant', + description: 'A helpful assistant that can answer questions.', + prompt: 'You are a helpful assistant.', + llm: Cortex::llm('ollama/gpt-oss:20b') + ->withMaxTokens(2048) + ->withParameters([ + 'thinking' => [ + 'type' => 'enabled', + 'budget_tokens' => 1024, + ], + ]), + )); + + Cortex::registerAgent(new Agent( + id: 'generic_thinking', + name: 'Thinking Assistant', + description: 'A helpful assistant that can think and reason about the user\'s question.', + prompt: 'You are a helpful assistant.', + // llm: Cortex::llm('anthropic/claude-3-7-sonnet-20250219') + llm: Cortex::llm('lmstudio/openai/gpt-oss-20b') + // llm: Cortex::llm('ollama/gpt-oss:20b') + ->withMaxTokens(2048) + ->withParameters([ + 'thinking' => [ + 'type' => 'enabled', + 'budget_tokens' => 1024, + ], + ]) + )); + + Cortex::registerAgent(new Agent( + id: 'generic_anthropic', + prompt: 'You are a helpful assistant.', + llm: 'anthropic/claude-3-7-sonnet-20250219' + )); + + Cortex::registerAgent(new Agent( + id: 'code_generator', prompt: Cortex::prompt()->factory('blade')->make('example'), - // prompt: 'blade/example', )); + + Cortex::registerAgent(new Agent( + id: 'medical_assistant', + name: 'Medical Assistant', + description: 'Answer medical questions.', + llm: 'lmstudio/medgemma-27b-text-it-mlx', + )); + + $skillsAgent = new SkillsAgent() + ->withSkillsDirectory(package_path('tests/fixtures/skills')) + // ->withLLM(Cortex::llm('anthropic/claude-haiku-4-5')->ignoreFeatures()) + ->withLLM(Cortex::llm('ollama/glm-4.6:cloud')->ignoreFeatures()) + ->build(); + + Cortex::registerAgent($skillsAgent); } } diff --git a/workbench/resources/views/playground.blade.php b/workbench/resources/views/playground.blade.php new file mode 100644 index 0000000..e69de29 diff --git a/workbench/routes/web.php b/workbench/routes/web.php index 86a06c5..329fc42 100644 --- a/workbench/routes/web.php +++ b/workbench/routes/web.php @@ -2,6 +2,6 @@ use Illuminate\Support\Facades\Route; -Route::get('/', function () { - return view('welcome'); -}); +// Route::get('/', function () { +// return view('welcome'); +// });
      + ), + td: ({ className, ...props }) => ( + + ), + tr: ({ className, ...props }) => ( +