diff --git a/.agents/skills/ai-sdk-development/SKILL.md b/.agents/skills/ai-sdk-development/SKILL.md index 426cec5e..5071ca45 100644 --- a/.agents/skills/ai-sdk-development/SKILL.md +++ b/.agents/skills/ai-sdk-development/SKILL.md @@ -1,6 +1,6 @@ --- name: ai-sdk-development -description: TRIGGER when working with ai-sdk which is Laravel official first-party AI SDK. Activate when building, editing AI agents, chatbots, text generation, image generation, audio/TTS, transcription/STT, embeddings, RAG, vector stores, reranking, structured output, streaming, conversation memory, tools, queueing, broadcasting, and provider failover across OpenAI, Anthropic, Gemini, Azure, Groq, xAI, DeepSeek, Mistral, Ollama, ElevenLabs, Cohere, Jina, and VoyageAI. Invoke when the user references ai-sdk, the `Laravel\Ai\` namespace, or this project's AI features — not for Prism PHP or other AI packages used directly. +description: TRIGGER when working with ai-sdk which is Laravel official first-party AI SDK. Activate when building, editing AI agents, chatbots, text generation, image generation, audio/TTS, transcription/STT, embeddings, RAG, vector stores, reranking, structured output, streaming, conversation memory, tools, queueing, broadcasting, and provider failover across OpenAI, Anthropic, Gemini, Azure, Groq, xAI, DeepSeek, Mistral, Ollama, ElevenLabs, Cohere, Jina, and VoyageAI. Invoke when the user references ai-sdk, the `Laravel\Ai\` namespace, or this project's AI features — not for other AI packages used directly. license: MIT metadata: author: laravel @@ -278,6 +278,20 @@ ### PHP Attributes The `#[UseCheapestModel]` and `#[UseSmartestModel]` attributes are also available for automatic model selection. +The `#[WithoutBroadcasting]` attribute stops the given stream event types from broadcasting (e.g. data-heavy `ToolResult` payloads that exceed the WebSocket frame limit). The events are still streamed and persisted; they just never hit the channel: + +```php +use Laravel\Ai\Attributes\WithoutBroadcasting; +use Laravel\Ai\Streaming\Events\{ToolCall, ToolResult}; + +#[WithoutBroadcasting(ToolResult::class, ToolCall::class)] +class SearchAgent implements Agent, HasTools +{ + use Promptable; + // ... +} +``` + ### Tools Implement the `HasTools` interface and scaffold tools with `php artisan make:tool`: @@ -391,6 +405,38 @@ ## Key Patterns - Artisan commands: `php artisan make:agent`, `php artisan make:tool` - Global helper: `agent()` for anonymous agents +## OpenAI-Compatible Provider + +Point the SDK at any OpenAI-compatible endpoint (LM Studio, vLLM, Together, etc.) with the config-driven `openai-compatible` driver. Define named instances in `config/ai.php`, no code required: + +```php +'my-llm' => [ + 'driver' => 'openai-compatible', + 'url' => env('MY_LLM_URL'), // required + 'key' => env('MY_LLM_API_KEY'), // optional Bearer token + 'models' => [ + 'text' => ['default' => 'some-chat-model'], + 'embeddings' => [ + 'default' => 'some-embedding-model', + 'dimensions' => 1024, // optional; omit to use native dimensions + ], + ], +], +``` + +Reference it by config key (or `Lab::OpenAiCompatible`). A model is required via the corresponding `models` configuration or per-call `model:`: + +```php +agent()->prompt('Hello', provider: 'my-llm', model: 'some-model'); + +Embeddings::for(['Hello'])->generate( + provider: 'my-llm', + model: 'some-embedding-model', +); +``` + +It uses OpenAI-standard shapes and supports text, streaming, tools, structured output, image attachments, and text embeddings. Embedding dimensions are optional; omit them to use the model's native dimensions. For extra request-body fields, implement `HasProviderOptions` — the returned array is merged into the body. + ## Common Pitfalls ### Wrong Namespace @@ -412,19 +458,15 @@ ### Unsupported Provider Capability Calling a capability not supported by a provider throws a `LogicException`. Refer to the provider support table below. -### Never Use Prism Directly - -Use agents and entry-point classes (`Image`, `Audio`, etc.) — not `Prism::text()` directly. The AI SDK wraps Prism internally. - ## Provider Support | Feature | Providers | | ---------- | --------------------------------------------------------------- | -| Text | OpenAI, Anthropic, Gemini, Azure, Groq, xAI, DeepSeek, Mistral, Ollama | +| Text | OpenAI, Anthropic, Gemini, Azure, Groq, xAI, DeepSeek, Mistral, Ollama, OpenRouter, OpenAI-compatible | | Images | OpenAI, Gemini, xAI | | TTS | OpenAI, ElevenLabs | | STT | OpenAI, ElevenLabs, Mistral | -| Embeddings | OpenAI, Gemini, Azure, Cohere, Mistral, Jina, VoyageAI | +| Embeddings | OpenAI, OpenAI-compatible, Gemini, Azure, Cohere, Mistral, Jina, VoyageAI | | Reranking | Cohere, Jina | | Files | OpenAI, Anthropic, Gemini | @@ -436,5 +478,6 @@ ## Provider Support Lab::Anthropic; Lab::OpenAI; Lab::Gemini; +Lab::OpenAiCompatible; // configurable OpenAI-compatible endpoint // ... ``` diff --git a/.claude/skills/ai-sdk-development/SKILL.md b/.claude/skills/ai-sdk-development/SKILL.md index 426cec5e..5071ca45 100644 --- a/.claude/skills/ai-sdk-development/SKILL.md +++ b/.claude/skills/ai-sdk-development/SKILL.md @@ -1,6 +1,6 @@ --- name: ai-sdk-development -description: TRIGGER when working with ai-sdk which is Laravel official first-party AI SDK. Activate when building, editing AI agents, chatbots, text generation, image generation, audio/TTS, transcription/STT, embeddings, RAG, vector stores, reranking, structured output, streaming, conversation memory, tools, queueing, broadcasting, and provider failover across OpenAI, Anthropic, Gemini, Azure, Groq, xAI, DeepSeek, Mistral, Ollama, ElevenLabs, Cohere, Jina, and VoyageAI. Invoke when the user references ai-sdk, the `Laravel\Ai\` namespace, or this project's AI features — not for Prism PHP or other AI packages used directly. +description: TRIGGER when working with ai-sdk which is Laravel official first-party AI SDK. Activate when building, editing AI agents, chatbots, text generation, image generation, audio/TTS, transcription/STT, embeddings, RAG, vector stores, reranking, structured output, streaming, conversation memory, tools, queueing, broadcasting, and provider failover across OpenAI, Anthropic, Gemini, Azure, Groq, xAI, DeepSeek, Mistral, Ollama, ElevenLabs, Cohere, Jina, and VoyageAI. Invoke when the user references ai-sdk, the `Laravel\Ai\` namespace, or this project's AI features — not for other AI packages used directly. license: MIT metadata: author: laravel @@ -278,6 +278,20 @@ ### PHP Attributes The `#[UseCheapestModel]` and `#[UseSmartestModel]` attributes are also available for automatic model selection. +The `#[WithoutBroadcasting]` attribute stops the given stream event types from broadcasting (e.g. data-heavy `ToolResult` payloads that exceed the WebSocket frame limit). The events are still streamed and persisted; they just never hit the channel: + +```php +use Laravel\Ai\Attributes\WithoutBroadcasting; +use Laravel\Ai\Streaming\Events\{ToolCall, ToolResult}; + +#[WithoutBroadcasting(ToolResult::class, ToolCall::class)] +class SearchAgent implements Agent, HasTools +{ + use Promptable; + // ... +} +``` + ### Tools Implement the `HasTools` interface and scaffold tools with `php artisan make:tool`: @@ -391,6 +405,38 @@ ## Key Patterns - Artisan commands: `php artisan make:agent`, `php artisan make:tool` - Global helper: `agent()` for anonymous agents +## OpenAI-Compatible Provider + +Point the SDK at any OpenAI-compatible endpoint (LM Studio, vLLM, Together, etc.) with the config-driven `openai-compatible` driver. Define named instances in `config/ai.php`, no code required: + +```php +'my-llm' => [ + 'driver' => 'openai-compatible', + 'url' => env('MY_LLM_URL'), // required + 'key' => env('MY_LLM_API_KEY'), // optional Bearer token + 'models' => [ + 'text' => ['default' => 'some-chat-model'], + 'embeddings' => [ + 'default' => 'some-embedding-model', + 'dimensions' => 1024, // optional; omit to use native dimensions + ], + ], +], +``` + +Reference it by config key (or `Lab::OpenAiCompatible`). A model is required via the corresponding `models` configuration or per-call `model:`: + +```php +agent()->prompt('Hello', provider: 'my-llm', model: 'some-model'); + +Embeddings::for(['Hello'])->generate( + provider: 'my-llm', + model: 'some-embedding-model', +); +``` + +It uses OpenAI-standard shapes and supports text, streaming, tools, structured output, image attachments, and text embeddings. Embedding dimensions are optional; omit them to use the model's native dimensions. For extra request-body fields, implement `HasProviderOptions` — the returned array is merged into the body. + ## Common Pitfalls ### Wrong Namespace @@ -412,19 +458,15 @@ ### Unsupported Provider Capability Calling a capability not supported by a provider throws a `LogicException`. Refer to the provider support table below. -### Never Use Prism Directly - -Use agents and entry-point classes (`Image`, `Audio`, etc.) — not `Prism::text()` directly. The AI SDK wraps Prism internally. - ## Provider Support | Feature | Providers | | ---------- | --------------------------------------------------------------- | -| Text | OpenAI, Anthropic, Gemini, Azure, Groq, xAI, DeepSeek, Mistral, Ollama | +| Text | OpenAI, Anthropic, Gemini, Azure, Groq, xAI, DeepSeek, Mistral, Ollama, OpenRouter, OpenAI-compatible | | Images | OpenAI, Gemini, xAI | | TTS | OpenAI, ElevenLabs | | STT | OpenAI, ElevenLabs, Mistral | -| Embeddings | OpenAI, Gemini, Azure, Cohere, Mistral, Jina, VoyageAI | +| Embeddings | OpenAI, OpenAI-compatible, Gemini, Azure, Cohere, Mistral, Jina, VoyageAI | | Reranking | Cohere, Jina | | Files | OpenAI, Anthropic, Gemini | @@ -436,5 +478,6 @@ ## Provider Support Lab::Anthropic; Lab::OpenAI; Lab::Gemini; +Lab::OpenAiCompatible; // configurable OpenAI-compatible endpoint // ... ``` diff --git a/.cursor/skills/ai-sdk-development/SKILL.md b/.cursor/skills/ai-sdk-development/SKILL.md index 426cec5e..5071ca45 100644 --- a/.cursor/skills/ai-sdk-development/SKILL.md +++ b/.cursor/skills/ai-sdk-development/SKILL.md @@ -1,6 +1,6 @@ --- name: ai-sdk-development -description: TRIGGER when working with ai-sdk which is Laravel official first-party AI SDK. Activate when building, editing AI agents, chatbots, text generation, image generation, audio/TTS, transcription/STT, embeddings, RAG, vector stores, reranking, structured output, streaming, conversation memory, tools, queueing, broadcasting, and provider failover across OpenAI, Anthropic, Gemini, Azure, Groq, xAI, DeepSeek, Mistral, Ollama, ElevenLabs, Cohere, Jina, and VoyageAI. Invoke when the user references ai-sdk, the `Laravel\Ai\` namespace, or this project's AI features — not for Prism PHP or other AI packages used directly. +description: TRIGGER when working with ai-sdk which is Laravel official first-party AI SDK. Activate when building, editing AI agents, chatbots, text generation, image generation, audio/TTS, transcription/STT, embeddings, RAG, vector stores, reranking, structured output, streaming, conversation memory, tools, queueing, broadcasting, and provider failover across OpenAI, Anthropic, Gemini, Azure, Groq, xAI, DeepSeek, Mistral, Ollama, ElevenLabs, Cohere, Jina, and VoyageAI. Invoke when the user references ai-sdk, the `Laravel\Ai\` namespace, or this project's AI features — not for other AI packages used directly. license: MIT metadata: author: laravel @@ -278,6 +278,20 @@ ### PHP Attributes The `#[UseCheapestModel]` and `#[UseSmartestModel]` attributes are also available for automatic model selection. +The `#[WithoutBroadcasting]` attribute stops the given stream event types from broadcasting (e.g. data-heavy `ToolResult` payloads that exceed the WebSocket frame limit). The events are still streamed and persisted; they just never hit the channel: + +```php +use Laravel\Ai\Attributes\WithoutBroadcasting; +use Laravel\Ai\Streaming\Events\{ToolCall, ToolResult}; + +#[WithoutBroadcasting(ToolResult::class, ToolCall::class)] +class SearchAgent implements Agent, HasTools +{ + use Promptable; + // ... +} +``` + ### Tools Implement the `HasTools` interface and scaffold tools with `php artisan make:tool`: @@ -391,6 +405,38 @@ ## Key Patterns - Artisan commands: `php artisan make:agent`, `php artisan make:tool` - Global helper: `agent()` for anonymous agents +## OpenAI-Compatible Provider + +Point the SDK at any OpenAI-compatible endpoint (LM Studio, vLLM, Together, etc.) with the config-driven `openai-compatible` driver. Define named instances in `config/ai.php`, no code required: + +```php +'my-llm' => [ + 'driver' => 'openai-compatible', + 'url' => env('MY_LLM_URL'), // required + 'key' => env('MY_LLM_API_KEY'), // optional Bearer token + 'models' => [ + 'text' => ['default' => 'some-chat-model'], + 'embeddings' => [ + 'default' => 'some-embedding-model', + 'dimensions' => 1024, // optional; omit to use native dimensions + ], + ], +], +``` + +Reference it by config key (or `Lab::OpenAiCompatible`). A model is required via the corresponding `models` configuration or per-call `model:`: + +```php +agent()->prompt('Hello', provider: 'my-llm', model: 'some-model'); + +Embeddings::for(['Hello'])->generate( + provider: 'my-llm', + model: 'some-embedding-model', +); +``` + +It uses OpenAI-standard shapes and supports text, streaming, tools, structured output, image attachments, and text embeddings. Embedding dimensions are optional; omit them to use the model's native dimensions. For extra request-body fields, implement `HasProviderOptions` — the returned array is merged into the body. + ## Common Pitfalls ### Wrong Namespace @@ -412,19 +458,15 @@ ### Unsupported Provider Capability Calling a capability not supported by a provider throws a `LogicException`. Refer to the provider support table below. -### Never Use Prism Directly - -Use agents and entry-point classes (`Image`, `Audio`, etc.) — not `Prism::text()` directly. The AI SDK wraps Prism internally. - ## Provider Support | Feature | Providers | | ---------- | --------------------------------------------------------------- | -| Text | OpenAI, Anthropic, Gemini, Azure, Groq, xAI, DeepSeek, Mistral, Ollama | +| Text | OpenAI, Anthropic, Gemini, Azure, Groq, xAI, DeepSeek, Mistral, Ollama, OpenRouter, OpenAI-compatible | | Images | OpenAI, Gemini, xAI | | TTS | OpenAI, ElevenLabs | | STT | OpenAI, ElevenLabs, Mistral | -| Embeddings | OpenAI, Gemini, Azure, Cohere, Mistral, Jina, VoyageAI | +| Embeddings | OpenAI, OpenAI-compatible, Gemini, Azure, Cohere, Mistral, Jina, VoyageAI | | Reranking | Cohere, Jina | | Files | OpenAI, Anthropic, Gemini | @@ -436,5 +478,6 @@ ## Provider Support Lab::Anthropic; Lab::OpenAI; Lab::Gemini; +Lab::OpenAiCompatible; // configurable OpenAI-compatible endpoint // ... ``` diff --git a/.env.example b/.env.example index 725a39c1..2ca8be9c 100644 --- a/.env.example +++ b/.env.example @@ -178,14 +178,15 @@ DISCORD_CLIENT_REDIRECT="${APP_URL}/accounts/discord/callback" OPENAI_API_KEY= ANTHROPIC_API_KEY= GEMINI_API_KEY= +OPENROUTER_API_KEY= ELEVENLABS_API_KEY= # AI Provider Selection -# text: openai | anthropic | gemini | xai | groq | mistral | deepseek | ... -# image: openai | gemini | xai +# text: openai | anthropic | gemini | openrouter | xai | groq | mistral | deepseek | ... +# image: openai | gemini | xai | openrouter # audio: openai | elevenlabs +# OpenRouter is a first-class laravel/ai provider (AI_TEXT_PROVIDER=openrouter + OPENROUTER_API_KEY). AI_TEXT_PROVIDER=openai -AI_TEXT_MODEL=gpt-5.4 AI_IMAGE_PROVIDER=openai AI_AUDIO_PROVIDER=elevenlabs diff --git a/app/Actions/Automation/Node/RunGenerateNode.php b/app/Actions/Automation/Node/RunGenerateNode.php index 41e7da30..7e450095 100644 --- a/app/Actions/Automation/Node/RunGenerateNode.php +++ b/app/Actions/Automation/Node/RunGenerateNode.php @@ -114,8 +114,8 @@ public function __invoke(AutomationRun $run, array $config): NodeRunResult workspace: $workspace, promptTokens: $generatorResponse->usage->promptTokens, completionTokens: $generatorResponse->usage->completionTokens, - provider: (string) config('ai.default'), - model: (string) config('ai.default_text_model'), + provider: (string) $generatorResponse->meta->provider, + model: (string) $generatorResponse->meta->model, metadata: ['agent' => 'post_generator', 'format' => $format->value, 'source' => 'automation'], ); @@ -196,28 +196,28 @@ private function humanize(Workspace $workspace, array $structured, GeneratorForm workspace: $workspace, promptTokens: $response->usage->promptTokens, completionTokens: $response->usage->completionTokens, - provider: (string) config('ai.default'), - model: (string) config('ai.default_text_model'), + provider: (string) $response->meta->provider, + model: (string) $response->meta->model, metadata: ['agent' => 'post_humanizer', 'format' => $format->value, 'source' => 'automation'], ); if ($format->isCarousel()) { - $structured['caption'] = data_get($humanized, 'caption', $structured['caption'] ?? ''); - $originalSlides = $structured['slides'] ?? []; + $structured['caption'] = data_get($humanized, 'caption', data_get($structured, 'caption', '')); + $originalSlides = data_get($structured, 'slides', []); $humanizedSlides = data_get($humanized, 'slides', []); foreach ($originalSlides as $i => $slide) { if (isset($humanizedSlides[$i])) { - $originalSlides[$i]['title'] = data_get($humanizedSlides[$i], 'title', $slide['title'] ?? ''); - $originalSlides[$i]['body'] = data_get($humanizedSlides[$i], 'body', $slide['body'] ?? ''); + $originalSlides[$i]['title'] = data_get($humanizedSlides[$i], 'title', data_get($slide, 'title', '')); + $originalSlides[$i]['body'] = data_get($humanizedSlides[$i], 'body', data_get($slide, 'body', '')); } } $structured['slides'] = $originalSlides; } else { - $structured['content'] = data_get($humanized, 'content', $structured['content'] ?? ''); - $structured['image_title'] = data_get($humanized, 'image_title', $structured['image_title'] ?? ''); - $structured['image_body'] = data_get($humanized, 'image_body', $structured['image_body'] ?? ''); + $structured['content'] = data_get($humanized, 'content', data_get($structured, 'content', '')); + $structured['image_title'] = data_get($humanized, 'image_title', data_get($structured, 'image_title', '')); + $structured['image_body'] = data_get($humanized, 'image_body', data_get($structured, 'image_body', '')); } } catch (Throwable $e) { Log::warning('RunGenerateNode: PostContentHumanizer failed, using generator output as-is', [ diff --git a/app/Ai/Agents/BrandAnalyzer.php b/app/Ai/Agents/BrandAnalyzer.php index c1df86cd..348253b9 100644 --- a/app/Ai/Agents/BrandAnalyzer.php +++ b/app/Ai/Agents/BrandAnalyzer.php @@ -9,7 +9,6 @@ use Illuminate\Contracts\JsonSchema\JsonSchema; use Laravel\Ai\Contracts\Agent; use Laravel\Ai\Contracts\HasStructuredOutput; -use Laravel\Ai\Enums\Lab; use Laravel\Ai\Promptable; class BrandAnalyzer implements Agent, HasStructuredOutput @@ -27,20 +26,6 @@ public function instructions(): string ])->render(); } - public function provider(): Lab - { - return match (config('ai.default')) { - 'openai' => Lab::OpenAI, - 'anthropic' => Lab::Anthropic, - default => Lab::Gemini, - }; - } - - public function model(): string - { - return config('ai.default_text_model'); - } - public function schema(JsonSchema $schema): array { return [ diff --git a/app/Ai/Agents/PostContentGenerator.php b/app/Ai/Agents/PostContentGenerator.php index 14a82146..008922f3 100644 --- a/app/Ai/Agents/PostContentGenerator.php +++ b/app/Ai/Agents/PostContentGenerator.php @@ -14,7 +14,6 @@ use Laravel\Ai\Attributes\Temperature; use Laravel\Ai\Contracts\Agent; use Laravel\Ai\Contracts\HasStructuredOutput; -use Laravel\Ai\Enums\Lab; use Laravel\Ai\Promptable; #[Temperature(0.7)] @@ -70,9 +69,9 @@ public function instructions(): string 'format' => $this->format->value, 'slide_count' => $this->slideCount, 'examples' => $examples, - 'hard_max_chars' => $budget['hard_max_chars'], - 'target_chars' => $budget['target_chars'], - 'platform_label' => $budget['platform_label'], + 'hard_max_chars' => data_get($budget, 'hard_max_chars'), + 'target_chars' => data_get($budget, 'target_chars'), + 'platform_label' => data_get($budget, 'platform_label'), ])->render(); } @@ -109,18 +108,4 @@ public function schema(JsonSchema $schema): array 'image_keywords' => $schema->array()->items($schema->string())->description('2-4 search keywords for Unsplash for the single image.')->required(), ]; } - - public function provider(): Lab - { - return match (config('ai.default')) { - 'openai' => Lab::OpenAI, - 'anthropic' => Lab::Anthropic, - default => Lab::Gemini, - }; - } - - public function model(): string - { - return config('ai.default_text_model'); - } } diff --git a/app/Ai/Agents/PostContentHumanizer.php b/app/Ai/Agents/PostContentHumanizer.php index 36eb5701..f1b904b4 100644 --- a/app/Ai/Agents/PostContentHumanizer.php +++ b/app/Ai/Agents/PostContentHumanizer.php @@ -11,7 +11,6 @@ use Laravel\Ai\Attributes\Temperature; use Laravel\Ai\Contracts\Agent; use Laravel\Ai\Contracts\HasStructuredOutput; -use Laravel\Ai\Enums\Lab; use Laravel\Ai\Promptable; /** @@ -45,9 +44,9 @@ public function instructions(): string 'brand_voice_traits' => $this->applyBrandVoice ? ($this->workspace->brand_voice_traits ?? []) : [], 'content_language' => $this->workspace->content_language, 'format' => $this->format->value, - 'hard_max_chars' => $budget['hard_max_chars'], - 'target_chars' => $budget['target_chars'], - 'platform_label' => $budget['platform_label'], + 'hard_max_chars' => data_get($budget, 'hard_max_chars'), + 'target_chars' => data_get($budget, 'target_chars'), + 'platform_label' => data_get($budget, 'platform_label'), ])->render(); } @@ -72,18 +71,4 @@ public function schema(JsonSchema $schema): array 'image_body' => $schema->string()->description('The humanized image overlay body.')->required(), ]; } - - public function provider(): Lab - { - return match (config('ai.default')) { - 'openai' => Lab::OpenAI, - 'anthropic' => Lab::Anthropic, - default => Lab::Gemini, - }; - } - - public function model(): string - { - return config('ai.default_text_model'); - } } diff --git a/app/Ai/Agents/PostContentReviewer.php b/app/Ai/Agents/PostContentReviewer.php index 6bf6252c..0112f06a 100644 --- a/app/Ai/Agents/PostContentReviewer.php +++ b/app/Ai/Agents/PostContentReviewer.php @@ -9,7 +9,6 @@ use Laravel\Ai\Attributes\Temperature; use Laravel\Ai\Contracts\Agent; use Laravel\Ai\Contracts\HasStructuredOutput; -use Laravel\Ai\Enums\Lab; use Laravel\Ai\Promptable; #[Temperature(0.2)] @@ -43,18 +42,4 @@ public function schema(JsonSchema $schema): array ->required(), ]; } - - public function provider(): Lab - { - return match (config('ai.default')) { - 'openai' => Lab::OpenAI, - 'anthropic' => Lab::Anthropic, - default => Lab::Gemini, - }; - } - - public function model(): string - { - return config('ai.default_text_model'); - } } diff --git a/app/Ai/Agents/PostContentStreamer.php b/app/Ai/Agents/PostContentStreamer.php index 3bd800e1..1d298b95 100644 --- a/app/Ai/Agents/PostContentStreamer.php +++ b/app/Ai/Agents/PostContentStreamer.php @@ -8,7 +8,6 @@ use App\Models\Workspace; use Laravel\Ai\Attributes\Temperature; use Laravel\Ai\Contracts\Agent; -use Laravel\Ai\Enums\Lab; use Laravel\Ai\Promptable; /** @@ -41,18 +40,4 @@ public function instructions(): string 'slide_count' => 1, ])->render(); } - - public function provider(): Lab - { - return match (config('ai.default')) { - 'openai' => Lab::OpenAI, - 'anthropic' => Lab::Anthropic, - default => Lab::Gemini, - }; - } - - public function model(): string - { - return config('ai.default_text_model'); - } } diff --git a/app/Ai/Agents/PostImageRegenerator.php b/app/Ai/Agents/PostImageRegenerator.php index 5fe2edc4..bd43ccf0 100644 --- a/app/Ai/Agents/PostImageRegenerator.php +++ b/app/Ai/Agents/PostImageRegenerator.php @@ -9,7 +9,6 @@ use Laravel\Ai\Attributes\Temperature; use Laravel\Ai\Contracts\Agent; use Laravel\Ai\Contracts\HasStructuredOutput; -use Laravel\Ai\Enums\Lab; use Laravel\Ai\Promptable; #[Temperature(0.25)] @@ -47,18 +46,4 @@ public function schema(JsonSchema $schema): array ->required(), ]; } - - public function provider(): Lab - { - return match (config('ai.default')) { - 'openai' => Lab::OpenAI, - 'anthropic' => Lab::Anthropic, - default => Lab::Gemini, - }; - } - - public function model(): string - { - return config('ai.default_text_model'); - } } diff --git a/app/Http/Controllers/App/PostAiReviewController.php b/app/Http/Controllers/App/PostAiReviewController.php index 8912b6ff..2d83a110 100644 --- a/app/Http/Controllers/App/PostAiReviewController.php +++ b/app/Http/Controllers/App/PostAiReviewController.php @@ -32,8 +32,8 @@ public function review(ReviewPostContentRequest $request, Post $post): JsonRespo workspace: $workspace, promptTokens: $result->usage->promptTokens, completionTokens: $result->usage->completionTokens, - provider: (string) config('ai.default'), - model: (string) config('ai.default_text_model'), + provider: (string) $result->meta->provider, + model: (string) $result->meta->model, userId: $request->user()->id, postId: $post->id, metadata: ['agent' => 'post_reviewer'], diff --git a/app/Http/Middleware/App/HandleInertiaRequests.php b/app/Http/Middleware/App/HandleInertiaRequests.php index 12c0168e..fa2c8823 100644 --- a/app/Http/Middleware/App/HandleInertiaRequests.php +++ b/app/Http/Middleware/App/HandleInertiaRequests.php @@ -62,7 +62,7 @@ public function share(Request $request): array 'code' => $code, 'name' => $name, ])->values()->all(), - 'aiEnabled' => ! empty(config('services.gemini.api_key')) || ! empty(config('services.openai.api_key')), + 'aiEnabled' => filled(config('ai.providers.'.config('ai.default').'.key')), 'selfHosted' => $isSelfHosted, 'googleAuthEnabled' => config('trypost.google_auth_enabled'), 'githubAuthEnabled' => config('trypost.github_auth_enabled'), diff --git a/app/Jobs/Ai/RegeneratePostMediaImage.php b/app/Jobs/Ai/RegeneratePostMediaImage.php index 93484636..fd3502e4 100644 --- a/app/Jobs/Ai/RegeneratePostMediaImage.php +++ b/app/Jobs/Ai/RegeneratePostMediaImage.php @@ -137,18 +137,18 @@ private function regenerateSlideCopy(Workspace $workspace, Post $post, array $ba $response = $agent->prompt(json_encode([ 'instruction' => $this->instruction, - 'title' => $baseContext['title'], - 'body' => $baseContext['body'], - 'keywords' => $baseContext['keywords'], - 'language' => $baseContext['language'], + 'title' => data_get($baseContext, 'title'), + 'body' => data_get($baseContext, 'body'), + 'keywords' => data_get($baseContext, 'keywords'), + 'language' => data_get($baseContext, 'language'), ], JSON_THROW_ON_ERROR)); RecordAiUsage::recordText( workspace: $workspace, promptTokens: $response->usage?->promptTokens ?? 0, completionTokens: $response->usage?->completionTokens ?? 0, - provider: (string) config('ai.default'), - model: (string) config('ai.default_text_model'), + provider: (string) $response->meta->provider, + model: (string) $response->meta->model, userId: $this->userId, postId: $post->id, metadata: ['agent' => 'post_image_regenerator'], @@ -182,16 +182,16 @@ private function mergeStructuredCopy(array $baseContext, array $structured): arr $changeMode = $this->resolveChangeMode((string) data_get($structured, 'change_mode', 'both')); $regenerateImage = in_array($changeMode, ['image_only', 'both'], true); $regenerateText = in_array($changeMode, ['text_only', 'both'], true); - $keywords = $this->normalizeKeywords(data_get($structured, 'keywords', $baseContext['keywords'])); + $keywords = $this->normalizeKeywords(data_get($structured, 'keywords', data_get($baseContext, 'keywords'))); return [ 'title' => $regenerateText - ? trim((string) data_get($structured, 'title', $baseContext['title'])) - : $baseContext['title'], + ? trim((string) data_get($structured, 'title', data_get($baseContext, 'title'))) + : data_get($baseContext, 'title'), 'body' => $regenerateText - ? trim((string) data_get($structured, 'body', $baseContext['body'])) - : $baseContext['body'], - 'keywords' => $regenerateImage && $keywords !== [] ? $keywords : $baseContext['keywords'], + ? trim((string) data_get($structured, 'body', data_get($baseContext, 'body'))) + : data_get($baseContext, 'body'), + 'keywords' => $regenerateImage && $keywords !== [] ? $keywords : data_get($baseContext, 'keywords'), 'regenerate_image' => $regenerateImage, 'regenerate_text' => $regenerateText, 'change_mode' => $changeMode, @@ -230,7 +230,7 @@ private function renderRegeneratedImage( } $reusedBackgroundPath = null; - if (! $copy['regenerate_image']) { + if (! data_get($copy, 'regenerate_image')) { $reusedBackgroundPath = (string) data_get($baseContext, 'background_path', ''); if ($reusedBackgroundPath === '') { $reusedBackgroundPath = null; @@ -240,11 +240,11 @@ private function renderRegeneratedImage( $rendered = app(TemplateImageGenerator::class)->render( workspace: $workspace, socialAccount: $socialAccount, - title: $copy['title'], - body: $copy['body'], - imageKeywords: $copy['keywords'], - width: $baseContext['width'], - height: $baseContext['height'], + title: data_get($copy, 'title'), + body: data_get($copy, 'body'), + imageKeywords: data_get($copy, 'keywords'), + width: data_get($baseContext, 'width'), + height: data_get($baseContext, 'height'), backgroundPath: $reusedBackgroundPath, ); @@ -266,7 +266,7 @@ private function replaceMediaOnPost( Workspace $workspace, array $rendered, ): array { - $renderedPath = $rendered['path']; + $renderedPath = data_get($rendered, 'path'); $newBackgroundPath = (string) data_get($rendered, 'source_meta.background_path', ''); $oldBackgroundPath = (string) data_get($target, 'source_meta.background_path', ''); @@ -444,13 +444,15 @@ private function resolveSocialAccount(Post $post, Workspace $workspace): ?Social */ private function buildAiMediaItem(Workspace $workspace, array $rendered): array { + $renderedPath = data_get($rendered, 'path'); + $media = $workspace->media()->create([ 'collection' => 'ai-generated', 'type' => MediaType::Image, - 'path' => $rendered['path'], - 'original_filename' => basename($rendered['path']), + 'path' => $renderedPath, + 'original_filename' => basename($renderedPath), 'mime_type' => 'image/webp', - 'size' => Storage::size($rendered['path']), + 'size' => Storage::size($renderedPath), 'order' => 0, ]); @@ -461,7 +463,7 @@ private function buildAiMediaItem(Workspace $workspace, array $rendered): array 'type' => 'image', 'mime_type' => 'image/webp', 'source' => Source::Ai->value, - 'source_meta' => $rendered['source_meta'], + 'source_meta' => data_get($rendered, 'source_meta'), ]; } } diff --git a/app/Jobs/Ai/StreamPostContent.php b/app/Jobs/Ai/StreamPostContent.php index 898496d0..5bbb379c 100644 --- a/app/Jobs/Ai/StreamPostContent.php +++ b/app/Jobs/Ai/StreamPostContent.php @@ -14,6 +14,8 @@ use Illuminate\Queue\InteractsWithQueue; use Illuminate\Queue\SerializesModels; use Illuminate\Support\Facades\Log; +use Laravel\Ai\Responses\Data\Meta; +use Laravel\Ai\Responses\StreamedAgentResponse; class StreamPostContent implements ShouldQueue { @@ -41,14 +43,20 @@ public function handle(): void $channel = new PrivateChannel("user.{$this->userId}.ai-gen.{$this->generationId}"); try { - $response = $agent->broadcast($this->prompt, $channel, now: true); + /** @var Meta|null $meta */ + $meta = null; + + $response = $agent->broadcast($this->prompt, $channel, now: true) + ->then(function (StreamedAgentResponse $streamed) use (&$meta): void { + $meta = $streamed->meta; + }); RecordAiUsage::recordText( workspace: $workspace, promptTokens: $response->usage?->promptTokens ?? 0, completionTokens: $response->usage?->completionTokens ?? 0, - provider: (string) config('ai.default'), - model: (string) config('ai.default_text_model'), + provider: (string) $meta?->provider, + model: (string) $meta?->model, userId: $this->userId, metadata: ['agent' => 'post_streamer'], ); diff --git a/app/Jobs/Ai/StreamPostCreation.php b/app/Jobs/Ai/StreamPostCreation.php index 00fcbc36..eeeba767 100644 --- a/app/Jobs/Ai/StreamPostCreation.php +++ b/app/Jobs/Ai/StreamPostCreation.php @@ -93,8 +93,8 @@ public function handle(): void workspace: $workspace, promptTokens: $response->usage->promptTokens, completionTokens: $response->usage->completionTokens, - provider: (string) config('ai.default'), - model: (string) config('ai.default_text_model'), + provider: (string) $response->meta->provider, + model: (string) $response->meta->model, userId: $this->userId, metadata: ['agent' => 'post_generator', 'format' => $this->format], ); @@ -160,8 +160,8 @@ private function humanize(Workspace $workspace, array $structured, GeneratorForm workspace: $workspace, promptTokens: $response->usage->promptTokens, completionTokens: $response->usage->completionTokens, - provider: (string) config('ai.default'), - model: (string) config('ai.default_text_model'), + provider: (string) $response->meta->provider, + model: (string) $response->meta->model, userId: $this->userId, metadata: ['agent' => 'post_humanizer', 'format' => $format->value], ); @@ -175,22 +175,22 @@ private function humanize(Workspace $workspace, array $structured, GeneratorForm } if ($format->isCarousel()) { - $structured['caption'] = data_get($humanized, 'caption', $structured['caption'] ?? ''); - $originalSlides = $structured['slides'] ?? []; + $structured['caption'] = data_get($humanized, 'caption', data_get($structured, 'caption', '')); + $originalSlides = data_get($structured, 'slides', []); $humanizedSlides = data_get($humanized, 'slides', []); foreach ($originalSlides as $i => $slide) { if (isset($humanizedSlides[$i])) { - $originalSlides[$i]['title'] = data_get($humanizedSlides[$i], 'title', $slide['title'] ?? ''); - $originalSlides[$i]['body'] = data_get($humanizedSlides[$i], 'body', $slide['body'] ?? ''); + $originalSlides[$i]['title'] = data_get($humanizedSlides[$i], 'title', data_get($slide, 'title', '')); + $originalSlides[$i]['body'] = data_get($humanizedSlides[$i], 'body', data_get($slide, 'body', '')); } } $structured['slides'] = $originalSlides; } else { - $structured['content'] = data_get($humanized, 'content', $structured['content'] ?? ''); - $structured['image_title'] = data_get($humanized, 'image_title', $structured['image_title'] ?? ''); - $structured['image_body'] = data_get($humanized, 'image_body', $structured['image_body'] ?? ''); + $structured['content'] = data_get($humanized, 'content', data_get($structured, 'content', '')); + $structured['image_title'] = data_get($humanized, 'image_title', data_get($structured, 'image_title', '')); + $structured['image_body'] = data_get($humanized, 'image_body', data_get($structured, 'image_body', '')); } return $structured; @@ -251,7 +251,7 @@ private function notifyReady(Workspace $workspace, Post $post): void private function aspectRatioFor(ContentType $type): ?string { $dims = $type->aiImageDimensions(); - $ratio = $dims['width'] / $dims['height']; + $ratio = data_get($dims, 'width') / data_get($dims, 'height'); return match (true) { abs($ratio - 1.0) < 0.01 => '1:1', diff --git a/app/Services/Ai/AiImageClient.php b/app/Services/Ai/AiImageClient.php index fe15e10e..eb2093d6 100644 --- a/app/Services/Ai/AiImageClient.php +++ b/app/Services/Ai/AiImageClient.php @@ -9,19 +9,20 @@ use App\Support\HexColorName; use Illuminate\Support\Facades\Log; use Laravel\Ai\Image; +use Laravel\Ai\Responses\ImageResponse; use Throwable; class AiImageClient { - public const MODEL = 'gpt-image-2'; - private const BRAND_DESCRIPTION_MAX = 200; /** - * Generate raw image bytes via OpenAI gpt-image-2. Returns null on any - * failure so the caller can fall back to a stock photo without throwing. + * Generate an image via the configured AI_IMAGE_PROVIDER (defaults to OpenAI). + * Returns null on any failure so the caller can fall back to a stock photo + * without throwing. * * @param array $keywords + * @return array{bytes: string, provider: string, model: string}|null */ public function generate( array $keywords, @@ -34,34 +35,14 @@ public function generate( ?string $brandDescription = null, string $quality = 'low', int $timeout = 180, - ): ?string { - $clean = array_values(array_filter(array_map('trim', $keywords))); - if ($clean === []) { + ): ?array { + $keywords = $this->cleanKeywords($keywords); + + if ($keywords === []) { return null; } - $palette = $this->buildPaletteContext($brandColor, $backgroundColor, $textColor); - - $brandContext = null; - if ($brandDescription !== null) { - $trimmed = trim($brandDescription); - if ($trimmed !== '') { - $brandContext = mb_strlen($trimmed) > self::BRAND_DESCRIPTION_MAX - ? mb_substr($trimmed, 0, self::BRAND_DESCRIPTION_MAX).'…' - : $trimmed; - } - } - - $prompt = view('prompts.post_image.generator', [ - 'style' => $style->value, - 'scene' => implode(', ', $clean), - 'language_name' => $this->languageName($language), - 'has_brand_palette' => data_get($palette, 'is_defined', false), - 'brand_color_name' => data_get($palette, 'brand_color_name'), - 'background_color_name' => data_get($palette, 'background_color_name'), - 'text_color_name' => data_get($palette, 'text_color_name'), - 'brand_context' => $brandContext, - ])->render(); + $prompt = $this->buildPrompt($keywords, $style, $language, $brandColor, $backgroundColor, $textColor, $brandDescription); try { $builder = Image::of($prompt)->quality($quality)->timeout($timeout); @@ -72,7 +53,7 @@ public function generate( default => $builder->square(), }; - $image = $builder->generate(model: self::MODEL); + return $this->toResult($builder->generate()); } catch (Throwable $e) { Log::warning('AiImageClient: generation failed', [ 'style' => $style->value, @@ -82,10 +63,80 @@ public function generate( return null; } + } - $bytes = (string) $image; + /** + * @param array $keywords + * @return array + */ + private function cleanKeywords(array $keywords): array + { + return collect($keywords) + ->map(fn (string $keyword) => trim($keyword)) + ->filter() + ->values() + ->all(); + } - return $bytes !== '' ? $bytes : null; + /** + * @param array $keywords + */ + private function buildPrompt( + array $keywords, + ImageStyle $style, + string $language, + ?string $brandColor, + ?string $backgroundColor, + ?string $textColor, + ?string $brandDescription, + ): string { + $palette = $this->buildPaletteContext($brandColor, $backgroundColor, $textColor); + + return view('prompts.post_image.generator', [ + 'style' => $style->value, + 'scene' => implode(', ', $keywords), + 'language_name' => $this->languageName($language), + 'has_brand_palette' => data_get($palette, 'is_defined', false), + 'brand_color_name' => data_get($palette, 'brand_color_name'), + 'background_color_name' => data_get($palette, 'background_color_name'), + 'text_color_name' => data_get($palette, 'text_color_name'), + 'brand_context' => $this->resolveBrandContext($brandDescription), + ])->render(); + } + + private function resolveBrandContext(?string $brandDescription): ?string + { + $trimmed = trim((string) $brandDescription); + + if ($trimmed === '') { + return null; + } + + return mb_strlen($trimmed) > self::BRAND_DESCRIPTION_MAX + ? mb_substr($trimmed, 0, self::BRAND_DESCRIPTION_MAX).'…' + : $trimmed; + } + + /** + * Extract the raw image bytes and the provider/model that produced them. + * Called from inside generate()'s try block so a malformed response + * (e.g. no images) is treated as a failure, not an uncaught exception. + * + * @return array{bytes: string, provider: string, model: string}|null + */ + private function toResult(ImageResponse $response): ?array + { + $bytes = (string) $response; + + if ($bytes === '') { + return null; + } + + return [ + 'bytes' => $bytes, + 'provider' => (string) $response->meta->provider, + 'model' => (string) $response->meta->model, + ]; } private function languageName(string $code): string diff --git a/app/Services/Brand/BrandAnalyzerRunner.php b/app/Services/Brand/BrandAnalyzerRunner.php index 2eae3e1c..1f2b7fde 100644 --- a/app/Services/Brand/BrandAnalyzerRunner.php +++ b/app/Services/Brand/BrandAnalyzerRunner.php @@ -16,11 +16,7 @@ final class BrandAnalyzerRunner public function isAvailable(): bool { - return match (config('ai.default')) { - 'openai' => ! empty(config('services.openai.api_key')), - 'gemini' => ! empty(config('services.gemini.api_key')), - default => false, - }; + return filled(config('ai.providers.'.config('ai.default').'.key')); } public function analyze(string $bodyHtml): ?LlmBrandAnalysis diff --git a/app/Services/Image/TemplateImageGenerator.php b/app/Services/Image/TemplateImageGenerator.php index a12b3ff9..b0294f84 100644 --- a/app/Services/Image/TemplateImageGenerator.php +++ b/app/Services/Image/TemplateImageGenerator.php @@ -76,6 +76,8 @@ public function render( $generatedNewBackground = false; $resolvedBackgroundPath = null; $imageData = null; + $imageProvider = null; + $imageModel = null; if (is_string($backgroundPath) && trim($backgroundPath) !== '' && Storage::exists($backgroundPath)) { $resolvedBackgroundPath = $backgroundPath; @@ -83,7 +85,7 @@ public function render( } if (! is_string($imageData) || $imageData === '') { - $imageData = $this->aiImage->generate( + $generated = $this->aiImage->generate( keywords: $imageKeywords, style: $imageStyle, orientation: $orientation, @@ -94,10 +96,14 @@ public function render( brandDescription: $brandDescription, ); - if ($imageData === null) { + if ($generated === null) { return null; } + $imageData = data_get($generated, 'bytes'); + $imageProvider = data_get($generated, 'provider'); + $imageModel = data_get($generated, 'model'); + $generatedNewBackground = true; $resolvedBackgroundPath = $this->storeBackgroundImage($imageData); } @@ -113,8 +119,8 @@ public function render( if ($generatedNewBackground) { RecordAiUsage::recordImage( workspace: $workspace, - provider: 'openai', - model: AiImageClient::MODEL, + provider: $imageProvider, + model: $imageModel, metadata: [ 'image_style' => $imageStyle->value, 'width' => $this->width, @@ -138,7 +144,7 @@ public function render( 'keywords' => array_values($imageKeywords), 'style' => $imageStyle->value, 'language' => $language, - 'model' => AiImageClient::MODEL, + 'model' => $imageModel, 'title' => $title, 'body' => $body, 'width' => $this->width, @@ -639,7 +645,7 @@ private function applyTweetCardImageBackground( default => ImageStyle::DEFAULT, }; - $imageData = $this->aiImage->generate( + $generated = $this->aiImage->generate( keywords: $imageKeywords, style: $imageStyle, orientation: 'portrait', @@ -650,7 +656,7 @@ private function applyTweetCardImageBackground( brandDescription: $workspace->brand_description, ); - if ($imageData === null) { + if ($generated === null) { $brandColor = $workspace->brand_color ?? '#1d9bf0'; [$pr, $pg, $pb] = $this->hexToRgb($brandColor); $pageBg = imagecolorallocate($core, $pr, $pg, $pb); @@ -661,8 +667,8 @@ private function applyTweetCardImageBackground( RecordAiUsage::recordImage( workspace: $workspace, - provider: 'openai', - model: AiImageClient::MODEL, + provider: data_get($generated, 'provider'), + model: data_get($generated, 'model'), metadata: [ 'image_style' => $imageStyle->value, 'width' => $this->width, @@ -670,7 +676,7 @@ private function applyTweetCardImageBackground( ], ); - $photo = $manager->decodeBinary($imageData)->cover($this->width, $this->height); + $photo = $manager->decodeBinary(data_get($generated, 'bytes'))->cover($this->width, $this->height); // Apply Gaussian blur passes to soften the background photo. $photoCoreNative = $photo->core()->native(); diff --git a/compose.prod.yaml b/compose.prod.yaml index ffacc9ea..f15bc57d 100644 --- a/compose.prod.yaml +++ b/compose.prod.yaml @@ -114,6 +114,8 @@ services: # OPENAI_API_KEY: "" # ANTHROPIC_API_KEY: "" # GEMINI_API_KEY: "" + # OPENROUTER_API_KEY: "" + # ELEVENLABS_API_KEY: "" ports: - "8000:80" # app (nginx) - "8080:8080" # Reverb WebSocket diff --git a/composer.json b/composer.json index 8690d44b..f330b815 100644 --- a/composer.json +++ b/composer.json @@ -37,7 +37,7 @@ "google/apiclient": "^2.19", "inertiajs/inertia-laravel": "^3.3.1", "intervention/image": "^4.0", - "laravel/ai": "^0.5.1", + "laravel/ai": "^0.10.3", "laravel/boost": "^2.5", "laravel/cashier": "^16.2", "laravel/framework": "^13.0", diff --git a/composer.lock b/composer.lock index a850881b..de9fa6eb 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "4ba4c5a08545fe4b07331917b736e469", + "content-hash": "0a41ea1ff205782a01973b2ad02fd76c", "packages": [ { "name": "aws/aws-crt-php", @@ -1990,36 +1990,44 @@ }, { "name": "laravel/ai", - "version": "v0.5.1", + "version": "v0.10.3", "source": { "type": "git", "url": "https://github.com/laravel/ai.git", - "reference": "bf16555eebc2d78efcc4fa2367a476a91b2e508b" + "reference": "c3848aae389f45c605eefb0dda5bd5fa7df76eaa" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/ai/zipball/bf16555eebc2d78efcc4fa2367a476a91b2e508b", - "reference": "bf16555eebc2d78efcc4fa2367a476a91b2e508b", + "url": "https://api.github.com/repos/laravel/ai/zipball/c3848aae389f45c605eefb0dda5bd5fa7df76eaa", + "reference": "c3848aae389f45c605eefb0dda5bd5fa7df76eaa", "shasum": "" }, "require": { + "aws/aws-sdk-php": "^3.369.1", "illuminate/console": "^12.0|^13.0", "illuminate/container": "^12.0|^13.0", "illuminate/contracts": "^12.0|^13.0", + "illuminate/database": "^12.0|^13.0", "illuminate/filesystem": "^12.0|^13.0", - "illuminate/json-schema": "^12.0|^13.0", + "illuminate/json-schema": "^12.62|^13.15", "illuminate/support": "^12.0|^13.0", "laravel/prompts": "^0.3.6", "laravel/serializable-closure": "^2.0", - "php": "^8.3", - "prism-php/prism": "^0.100.0" + "php": "^8.3" }, "require-dev": { + "driftingly/rector-laravel": "^2.5", + "laravel/mcp": "^0.8", "laravel/pint": "^1.26", "mockery/mockery": "^1.6.12", "orchestra/testbench": "^10.6|^11.0", "pestphp/pest": "^3.0|^4.0", - "pestphp/pest-plugin-laravel": "^3.0|^4.0" + "pestphp/pest-plugin-laravel": "^3.0|^4.0", + "phpstan/phpstan": "^2.1", + "rector/rector": "^2.5" + }, + "suggest": { + "laravel/mcp": "Required to use MCP client tools or MCP server tools with Laravel AI agents." }, "type": "library", "extra": { @@ -2054,7 +2062,7 @@ "issues": "https://github.com/laravel/ai/issues", "source": "https://github.com/laravel/ai" }, - "time": "2026-04-10T18:49:05+00:00" + "time": "2026-08-06T13:39:04+00:00" }, { "name": "laravel/boost", @@ -5627,85 +5635,6 @@ ], "time": "2026-06-11T16:56:53+00:00" }, - { - "name": "prism-php/prism", - "version": "v0.100.1", - "source": { - "type": "git", - "url": "https://github.com/prism-php/prism.git", - "reference": "5d6cc65b80b19cf3f22744703ac0c727b68cdca8" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/prism-php/prism/zipball/5d6cc65b80b19cf3f22744703ac0c727b68cdca8", - "reference": "5d6cc65b80b19cf3f22744703ac0c727b68cdca8", - "shasum": "" - }, - "require": { - "ext-fileinfo": "*", - "laravel/framework": "^11.0|^12.0|^13.0", - "php": "^8.2" - }, - "require-dev": { - "brianium/paratest": "^7.8.4", - "laravel/mcp": "^0.6.0", - "laravel/pint": "^1.14", - "mockery/mockery": "^1.6", - "orchestra/testbench": "^9|^10|^11", - "pestphp/pest": "^3.0|^4.0", - "pestphp/pest-plugin-arch": "^3.0|^4.0", - "pestphp/pest-plugin-laravel": "^3.0|^4.0", - "phpstan/extension-installer": "^1.3", - "phpstan/phpdoc-parser": "^2.0", - "phpstan/phpstan": "2.1.34", - "phpstan/phpstan-deprecation-rules": "^2.0", - "projektgopher/whisky": "^0.7.0", - "rector/rector": "2.3.3", - "spatie/laravel-ray": "^1.39", - "symplify/rule-doc-generator-contracts": "^11.2" - }, - "type": "library", - "extra": { - "laravel": { - "aliases": { - "PrismServer": "Prism\\Prism\\Facades\\PrismServer" - }, - "providers": [ - "Prism\\Prism\\PrismServiceProvider" - ] - } - }, - "autoload": { - "files": [ - "src/helpers.php" - ], - "psr-4": { - "Prism\\Prism\\": "src/" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "TJ Miller", - "email": "hello@echolabs.dev" - } - ], - "description": "A powerful Laravel package for integrating Large Language Models (LLMs) into your applications.", - "support": { - "issues": "https://github.com/prism-php/prism/issues", - "source": "https://github.com/prism-php/prism/tree/v0.100.1" - }, - "funding": [ - { - "url": "https://github.com/sixlive", - "type": "github" - } - ], - "time": "2026-03-20T20:37:17+00:00" - }, { "name": "psr/cache", "version": "3.0.0", diff --git a/config/ai.php b/config/ai.php index 1a393ff0..8ede5186 100644 --- a/config/ai.php +++ b/config/ai.php @@ -1,5 +1,7 @@ env('AI_TEXT_PROVIDER', 'openai'), - 'default_text_model' => env('AI_TEXT_MODEL', 'gpt-5.4'), 'default_for_images' => env('AI_IMAGE_PROVIDER', 'openai'), 'default_for_audio' => env('AI_AUDIO_PROVIDER', 'openai'), 'default_for_transcription' => env('AI_TRANSCRIPTION_PROVIDER', 'openai'), @@ -55,81 +56,171 @@ 'driver' => 'anthropic', 'key' => env('ANTHROPIC_API_KEY'), 'url' => env('ANTHROPIC_URL', 'https://api.anthropic.com/v1'), + 'models' => [ + 'text' => ['default' => env('ANTHROPIC_TEXT_MODEL')], + ], ], 'azure' => [ 'driver' => 'azure', 'key' => env('AZURE_OPENAI_API_KEY'), 'url' => env('AZURE_OPENAI_URL'), - 'api_version' => env('AZURE_OPENAI_API_VERSION', '2024-10-21'), + 'api_version' => env('AZURE_OPENAI_API_VERSION', '2025-04-01-preview'), 'deployment' => env('AZURE_OPENAI_DEPLOYMENT', 'gpt-4o'), 'embedding_deployment' => env('AZURE_OPENAI_EMBEDDING_DEPLOYMENT', 'text-embedding-3-small'), + 'image_deployment' => env('AZURE_OPENAI_IMAGE_DEPLOYMENT', 'gpt-image-1'), + 'store' => env('AZURE_OPENAI_STORE', true), + ], + + 'bedrock' => [ + 'driver' => 'bedrock', + 'region' => env('AWS_BEDROCK_REGION', 'us-east-1'), + 'key' => env('AWS_BEARER_TOKEN_BEDROCK'), + 'access_key_id' => env('AWS_ACCESS_KEY_ID'), + 'secret_access_key' => env('AWS_SECRET_ACCESS_KEY'), + 'session_token' => env('AWS_SESSION_TOKEN'), + 'use_default_credential_provider' => env('AWS_USE_DEFAULT_CREDENTIALS', true), + 'assume_role' => [ + 'arn' => env('AWS_BEDROCK_ASSUME_ROLE_ARN'), + 'session_name' => env('AWS_BEDROCK_ASSUME_ROLE_SESSION_NAME'), + 'duration_seconds' => env('AWS_BEDROCK_ASSUME_ROLE_DURATION_SECONDS'), + 'external_id' => env('AWS_BEDROCK_ASSUME_ROLE_EXTERNAL_ID'), + ], + 'models' => [ + 'text' => ['default' => env('AWS_BEDROCK_TEXT_MODEL')], + 'image' => ['default' => env('AWS_BEDROCK_IMAGE_MODEL')], + 'embeddings' => ['default' => env('AWS_BEDROCK_EMBEDDINGS_MODEL')], + ], ], 'cohere' => [ 'driver' => 'cohere', 'key' => env('COHERE_API_KEY'), + 'models' => [ + 'embeddings' => ['default' => env('COHERE_EMBEDDINGS_MODEL')], + 'reranking' => ['default' => env('COHERE_RERANKING_MODEL')], + ], ], 'deepseek' => [ 'driver' => 'deepseek', 'key' => env('DEEPSEEK_API_KEY'), + 'models' => [ + 'text' => ['default' => env('DEEPSEEK_TEXT_MODEL')], + ], ], 'eleven' => [ 'driver' => 'eleven', 'key' => env('ELEVENLABS_API_KEY'), + 'models' => [ + 'audio' => ['default' => env('ELEVENLABS_AUDIO_MODEL')], + 'transcription' => ['default' => env('ELEVENLABS_TRANSCRIPTION_MODEL')], + ], ], 'gemini' => [ 'driver' => 'gemini', 'key' => env('GEMINI_API_KEY'), + 'url' => env('GEMINI_URL', 'https://generativelanguage.googleapis.com/v1beta/'), + 'models' => [ + 'text' => ['default' => env('GEMINI_TEXT_MODEL')], + 'image' => ['default' => env('GEMINI_IMAGE_MODEL')], + 'audio' => ['default' => env('GEMINI_AUDIO_MODEL')], + 'transcription' => ['default' => env('GEMINI_TRANSCRIPTION_MODEL')], + 'embeddings' => ['default' => env('GEMINI_EMBEDDINGS_MODEL')], + ], ], 'groq' => [ 'driver' => 'groq', 'key' => env('GROQ_API_KEY'), - 'url' => env('GROQ_URL', 'https://api.groq.com/openai/v1'), + 'models' => [ + 'text' => ['default' => env('GROQ_TEXT_MODEL')], + ], ], 'jina' => [ 'driver' => 'jina', 'key' => env('JINA_API_KEY'), + 'models' => [ + 'embeddings' => ['default' => env('JINA_EMBEDDINGS_MODEL')], + 'reranking' => ['default' => env('JINA_RERANKING_MODEL')], + ], ], 'mistral' => [ 'driver' => 'mistral', 'key' => env('MISTRAL_API_KEY'), - 'url' => env('MISTRAL_URL', 'https://api.mistral.ai/v1'), + 'models' => [ + 'text' => ['default' => env('MISTRAL_TEXT_MODEL')], + 'transcription' => ['default' => env('MISTRAL_TRANSCRIPTION_MODEL')], + 'embeddings' => ['default' => env('MISTRAL_EMBEDDINGS_MODEL')], + ], ], 'ollama' => [ 'driver' => 'ollama', 'key' => env('OLLAMA_API_KEY', ''), - 'url' => env('OLLAMA_BASE_URL', 'http://localhost:11434'), + 'url' => env('OLLAMA_URL', 'http://localhost:11434'), + 'models' => [ + 'text' => ['default' => env('OLLAMA_TEXT_MODEL')], + 'embeddings' => ['default' => env('OLLAMA_EMBEDDINGS_MODEL')], + ], ], 'openai' => [ 'driver' => 'openai', 'key' => env('OPENAI_API_KEY'), 'url' => env('OPENAI_URL', 'https://api.openai.com/v1'), + 'store' => env('OPENAI_STORE', true), + 'models' => [ + 'text' => ['default' => env('OPENAI_TEXT_MODEL')], + 'image' => ['default' => env('OPENAI_IMAGE_MODEL')], + 'audio' => ['default' => env('OPENAI_AUDIO_MODEL')], + 'transcription' => ['default' => env('OPENAI_TRANSCRIPTION_MODEL')], + 'embeddings' => ['default' => env('OPENAI_EMBEDDINGS_MODEL')], + ], + ], + + 'openai-compatible' => [ + 'driver' => 'openai-compatible', + 'url' => env('OPENAI_COMPATIBLE_URL'), + 'key' => env('OPENAI_COMPATIBLE_API_KEY'), + 'models' => [ + 'text' => ['default' => env('OPENAI_COMPATIBLE_TEXT_MODEL')], + 'embeddings' => ['default' => env('OPENAI_COMPATIBLE_EMBEDDINGS_MODEL')], + ], ], 'openrouter' => [ 'driver' => 'openrouter', 'key' => env('OPENROUTER_API_KEY'), + 'models' => [ + 'text' => ['default' => env('OPENROUTER_TEXT_MODEL')], + 'image' => ['default' => env('OPENROUTER_IMAGE_MODEL')], + 'audio' => ['default' => env('OPENROUTER_AUDIO_MODEL')], + 'transcription' => ['default' => env('OPENROUTER_TRANSCRIPTION_MODEL')], + 'embeddings' => ['default' => env('OPENROUTER_EMBEDDINGS_MODEL')], + ], ], 'voyageai' => [ 'driver' => 'voyageai', 'key' => env('VOYAGEAI_API_KEY'), + 'models' => [ + 'embeddings' => ['default' => env('VOYAGEAI_EMBEDDINGS_MODEL')], + 'reranking' => ['default' => env('VOYAGEAI_RERANKING_MODEL')], + ], ], 'xai' => [ 'driver' => 'xai', 'key' => env('XAI_API_KEY'), - 'url' => env('XAI_URL', 'https://api.x.ai/v1'), + 'models' => [ + 'text' => ['default' => env('XAI_TEXT_MODEL')], + 'image' => ['default' => env('XAI_IMAGE_MODEL')], + ], ], ], - ]; diff --git a/docker/.env.docker.example b/docker/.env.docker.example index 0c31fd1a..4266641a 100644 --- a/docker/.env.docker.example +++ b/docker/.env.docker.example @@ -142,10 +142,15 @@ TELEGRAM_WEBHOOK_SECRET= OPENAI_API_KEY= ANTHROPIC_API_KEY= GEMINI_API_KEY= +OPENROUTER_API_KEY= ELEVENLABS_API_KEY= +# AI Provider Selection +# text: openai | anthropic | gemini | openrouter | xai | groq | mistral | deepseek | ... +# image: openai | gemini | xai | openrouter +# audio: openai | elevenlabs +# OpenRouter is a first-class laravel/ai provider (AI_TEXT_PROVIDER=openrouter + OPENROUTER_API_KEY). AI_TEXT_PROVIDER=openai -AI_TEXT_MODEL=gpt-5.4 AI_IMAGE_PROVIDER=openai AI_AUDIO_PROVIDER=elevenlabs diff --git a/tests/Feature/Ai/AutofillBrandTest.php b/tests/Feature/Ai/AutofillBrandTest.php index bb13d9f7..3dd9bfbe 100644 --- a/tests/Feature/Ai/AutofillBrandTest.php +++ b/tests/Feature/Ai/AutofillBrandTest.php @@ -10,8 +10,10 @@ beforeEach(function () { // Run tests without LLM credentials so the deterministic fallback is exercised. - config()->set('services.gemini.api_key', ''); - config()->set('services.openai.api_key', ''); + config()->set('ai.providers.gemini.key', ''); + config()->set('ai.providers.openai.key', ''); + config()->set('ai.providers.openrouter.key', ''); + config()->set('ai.providers.anthropic.key', ''); $this->autofill = fn (string $url) => app(AutofillBrand::class)($url); }); @@ -356,7 +358,7 @@ }); test('when llm is configured, polishes description/tone/language/voice_notes via BrandAnalyzer', function () { - config()->set('services.gemini.api_key', 'fake-key'); + config()->set('ai.providers.gemini.key', 'fake-key'); config()->set('ai.default', 'gemini'); Http::fake([ @@ -392,8 +394,38 @@ expect($result->toArray()['brand_voice_traits'])->toBe(['third_person', 'direct', 'no_hype']); }); +test('openrouter as default text provider enables BrandAnalyzer', function () { + config()->set('ai.default', 'openrouter'); + config()->set('ai.providers.openrouter.key', 'sk-or-v1-test'); + + Http::fake([ + 'example.com' => Http::response(<<<'HTML' + + + OpenRouter Co + + +

OpenRouter Co ships AI through one key.

+ + HTML, 200), + ]); + + BrandAnalyzer::fake([ + [ + 'description' => 'OpenRouter Co ships AI through one key.', + 'language' => 'en', + 'voice_traits' => ['third_person', 'direct'], + ], + ]); + + $result = ($this->autofill)('https://example.com'); + + expect($result->description)->toBe('OpenRouter Co ships AI through one key.'); + expect($result->toArray()['brand_voice_traits'])->toBe(['third_person', 'direct']); +}); + test('LLM language detection wins and carries any supported language, not just en/es/pt-BR', function () { - config()->set('services.gemini.api_key', 'fake-key'); + config()->set('ai.providers.gemini.key', 'fake-key'); config()->set('ai.default', 'gemini'); // The declares "en", so the deterministic extractor yields 'en'. @@ -450,7 +482,7 @@ }); test('falls back to meta tags when BrandAnalyzer throws', function () { - config()->set('services.gemini.api_key', 'fake-key'); + config()->set('ai.providers.gemini.key', 'fake-key'); config()->set('ai.default', 'gemini'); Http::fake([ diff --git a/tests/Feature/Ai/Templates/TweetCardImageRenderTest.php b/tests/Feature/Ai/Templates/TweetCardImageRenderTest.php index cd26ad4b..39645642 100644 --- a/tests/Feature/Ai/Templates/TweetCardImageRenderTest.php +++ b/tests/Feature/Ai/Templates/TweetCardImageRenderTest.php @@ -118,7 +118,7 @@ $aiImageMock = Mockery::mock(AiImageClient::class); $minimalPng = base64_decode('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg=='); - $aiImageMock->shouldReceive('generate')->andReturn($minimalPng); + $aiImageMock->shouldReceive('generate')->andReturn(['bytes' => $minimalPng, 'provider' => 'openai', 'model' => 'gpt-image-2']); $this->app->instance(AiImageClient::class, $aiImageMock); $slides = [ diff --git a/tests/Feature/WorkspaceControllerTest.php b/tests/Feature/WorkspaceControllerTest.php index 55ad9a58..26542a83 100644 --- a/tests/Feature/WorkspaceControllerTest.php +++ b/tests/Feature/WorkspaceControllerTest.php @@ -743,7 +743,7 @@ test('autofillBrand never records AI usage even when the LLM runs', function () { config(['trypost.self_hosted' => false]); - config()->set('services.gemini.api_key', 'fake-key'); + config()->set('ai.providers.gemini.key', 'fake-key'); config()->set('ai.default', 'gemini'); Http::fake([ diff --git a/tests/Unit/Services/Ai/AiImageClientTest.php b/tests/Unit/Services/Ai/AiImageClientTest.php index 6537aae9..822d5462 100644 --- a/tests/Unit/Services/Ai/AiImageClientTest.php +++ b/tests/Unit/Services/Ai/AiImageClientTest.php @@ -4,8 +4,12 @@ use App\Enums\Workspace\ImageStyle; use App\Services\Ai\AiImageClient; +use Illuminate\Support\Collection; use Laravel\Ai\Image; use Laravel\Ai\Prompts\ImagePrompt; +use Laravel\Ai\Responses\Data\Meta; +use Laravel\Ai\Responses\Data\Usage; +use Laravel\Ai\Responses\ImageResponse; test('generate returns null when keywords are empty', function () { Image::fake(); @@ -16,14 +20,32 @@ Image::assertNothingGenerated(); }); -test('generate returns raw bytes when AI succeeds', function () { +test('generate returns bytes plus the resolved provider and model when AI succeeds', function () { $bytes = base64_decode('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg=='); Image::fake([base64_encode($bytes)]); $client = new AiImageClient; - expect($client->generate(['kitchen', 'morning'], ImageStyle::Illustration)) - ->toBe($bytes); + $result = $client->generate(['kitchen', 'morning'], ImageStyle::Illustration); + + expect($result) + ->not->toBeNull() + ->and($result['bytes'])->toBe($bytes) + ->and($result['provider'])->toBe('openai') + ->and($result['model'])->toBe('gpt-image-2'); +}); + +test('generate honours AI_IMAGE_PROVIDER instead of always using OpenAI', function () { + config()->set('ai.default_for_images', 'gemini'); + Image::fake(); + + $client = new AiImageClient; + + $result = $client->generate(['kitchen'], ImageStyle::Illustration); + + expect($result) + ->not->toBeNull() + ->and($result['provider'])->toBe('gemini'); }); test('generate uses style-specific prompt prefix', function () { @@ -184,3 +206,15 @@ expect($client->generate(['x'], ImageStyle::Cinematic))->toBeNull(); }); + +test('generate returns null instead of throwing when the provider responds with no images', function () { + Image::fake(fn () => new ImageResponse( + new Collection, + new Usage, + new Meta('openai', 'gpt-image-2'), + )); + + $client = new AiImageClient; + + expect($client->generate(['x'], ImageStyle::Cinematic))->toBeNull(); +}); diff --git a/tests/Unit/Services/Brand/BrandAnalyzerRunnerTest.php b/tests/Unit/Services/Brand/BrandAnalyzerRunnerTest.php new file mode 100644 index 00000000..c895bc8b --- /dev/null +++ b/tests/Unit/Services/Brand/BrandAnalyzerRunnerTest.php @@ -0,0 +1,36 @@ +set('ai.default', 'openrouter'); + config()->set('ai.providers.openrouter.key', ''); + + expect($runner->isAvailable())->toBeFalse(); + + config()->set('ai.providers.openrouter.key', 'sk-or-v1-test'); + + expect($runner->isAvailable())->toBeTrue(); +}); + +test('agents do not override provider or model so laravel/ai resolves both from the active provider', function (string $agent) { + expect(method_exists($agent, 'provider'))->toBeFalse() + ->and(method_exists($agent, 'model'))->toBeFalse(); +})->with([ + BrandAnalyzer::class, + PostContentGenerator::class, + PostContentHumanizer::class, + PostContentReviewer::class, + PostContentStreamer::class, + PostImageRegenerator::class, +]);