* Fix OpenRouter support by using laravel/ai default provider config. PR #216 patched Lab::OpenRouter into every agent match, but laravel/ai already resolves config('ai.default') — including openrouter — when agents omit provider(). Those matches also forced unknown providers to Gemini and BrandAnalyzerRunner checked a non-existent services.openrouter key. Remove the duplicated provider() overrides, gate availability on ai.providers.*.key, and document OPENROUTER_API_KEY. Co-authored-by: Paulo Castellano <hello@paulocastellano.com> * chore(deps): bump laravel/ai to v0.10.3 for native OpenRouter support v0.5.1's OpenRouter driver only covered text/embeddings via the legacy Prism gateway. v0.10.3 ships a native OpenRouter gateway with image, audio (TTS/STT), and web search support, and drops prism-php/prism as a dependency. ai-sdk-development skill docs refreshed via boost:update to match the installed version. * fix: honor AI_IMAGE_PROVIDER instead of hardcoding OpenAI's gpt-image-2 AiImageClient always passed model: 'gpt-image-2' to Image::of()->generate(), so AI_IMAGE_PROVIDER silently did nothing for any provider other than OpenAI — gemini/xai/openrouter would fail against an OpenAI-only model id and quietly fall back to a stock photo. Usage recording was hardcoded to provider 'openai' too, so credits were billed against the wrong model whenever a different provider actually ran. Drop the hardcoded model so generation falls through to the SDK's own config('ai.default_for_images') + per-provider default model, and read the actual provider/model back off the response's meta for usage recording and source_meta instead of assuming OpenAI. * refactor: extract AiImageClient into single-purpose steps, fix uncaught exception generate() built the prompt, called the SDK, and unpacked the response all in one block, with bytes extraction happening after the try/catch — so a response with an empty images collection threw an uncaught RuntimeException from ImageResponse::firstImage() instead of returning null as documented. Split into cleanKeywords(), buildPrompt(), resolveBrandContext(), and toResult(), and moved response unpacking inside the try block so any malformed response is treated as a failure like everything else. Added a regression test with an empty-images fake response. * fix: drop hardcoded default_text_model, resolve model per provider default_text_model was the only per-modality model override in ai.php — image, audio, transcription, embeddings, and reranking all just pick a provider and let it use its own default model. Text had a config-pinned model on top, forced into every agent's model() and into every usage log's model field regardless of which provider actually ran. Switching AI_TEXT_PROVIDER (e.g. to openrouter) kept sending OpenAI's model id to whichever provider ended up handling the request. Removed model() from all six agents so laravel/ai resolves the model from the active provider's own default (OpenAI's default is already 'gpt-5.4', so no behavior change there). Usage-recording call sites now read the actual provider/model back off the response's meta instead of assuming config('ai.default')/default_text_model. StreamPostContent needed the then() callback since broadcast()'s StreamableAgentResponse doesn't expose meta directly. * refactor: drop AiConfiguration wrapper, use data_get() for array reads AiConfiguration was a one-line static helper used by only two call sites, with no laravel/ai equivalent to lean on (confirmed AiManager and the Provider base class expose no isConfigured()/hasKey() check — the package's model is try-then-catch, not pre-flight checks). Inlined the filled(config(...)) check directly into HandleInertiaRequests and BrandAnalyzerRunner instead of keeping a class around one line of logic. Also swapped direct array-key reads for data_get() per project convention across every file touched by the recent AI provider/model fixes (agents' budget arrays, RunGenerateNode/StreamPostCreation's humanizer merge, RegeneratePostMediaImage's baseContext/copy/rendered access). Write/assignment sites (`$x['key'] = ...`) are left as-is — data_get() only reads. * feat: enhance AI configuration with new providers and options Added strict types declaration and updated Azure OpenAI API version. Introduced new 'bedrock' provider configuration with AWS credentials and role assumptions. Enhanced existing providers with additional options, including image deployment for Azure and OpenAI, and updated URLs for Gemini and Ollama. Added support for an 'openai-compatible' driver to broaden integration capabilities. * fix: remove trailing newline in AI configuration file * feat: add support for OpenRouter and ElevenLabs API keys in configuration Updated the production Docker Compose file and example environment file to include commented-out entries for OPENROUTER_API_KEY and ELEVENLABS_API_KEY. This enhances the configuration options for AI providers, allowing for easier integration of additional services. * feat: allow per-provider model overrides for every AI modality Adds a `models` array to each provider block, wired to env vars, so self-hosted operators can pin a specific text/image/audio/transcription/ embeddings/reranking model instead of relying on the package's built-in default for that provider. Only added for the modalities each provider actually implements (verified against laravel/ai's Provider classes). Azure is left untouched — it resolves models via deployment names (AZURE_OPENAI_DEPLOYMENT etc.), not raw model strings, which was already wired before this change. --------- Co-authored-by: Cursor Agent <cursoragent@cursor.com>
This commit is contained in:
parent
a1a9172448
commit
eb52b6b699
29 changed files with 559 additions and 327 deletions
|
|
@ -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
|
||||
// ...
|
||||
```
|
||||
|
|
|
|||
|
|
@ -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
|
||||
// ...
|
||||
```
|
||||
|
|
|
|||
|
|
@ -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
|
||||
// ...
|
||||
```
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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', [
|
||||
|
|
|
|||
|
|
@ -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 [
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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'],
|
||||
|
|
|
|||
|
|
@ -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'),
|
||||
|
|
|
|||
|
|
@ -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'),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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'],
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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<int, string> $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<int, string> $keywords
|
||||
* @return array<int, string>
|
||||
*/
|
||||
private function cleanKeywords(array $keywords): array
|
||||
{
|
||||
return collect($keywords)
|
||||
->map(fn (string $keyword) => trim($keyword))
|
||||
->filter()
|
||||
->values()
|
||||
->all();
|
||||
}
|
||||
|
||||
return $bytes !== '' ? $bytes : null;
|
||||
/**
|
||||
* @param array<int, string> $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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
107
composer.lock
generated
107
composer.lock
generated
|
|
@ -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",
|
||||
|
|
|
|||
105
config/ai.php
105
config/ai.php
|
|
@ -1,5 +1,7 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
return [
|
||||
|
||||
/*
|
||||
|
|
@ -14,7 +16,6 @@
|
|||
*/
|
||||
|
||||
'default' => 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')],
|
||||
],
|
||||
],
|
||||
],
|
||||
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
<html lang="en">
|
||||
<head>
|
||||
<title>OpenRouter Co</title>
|
||||
<meta name="description" content="Terse blurb.">
|
||||
</head>
|
||||
<body><main><p>OpenRouter Co ships AI through one key.</p></main></body>
|
||||
</html>
|
||||
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 <html lang> 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([
|
||||
|
|
|
|||
|
|
@ -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 = [
|
||||
|
|
|
|||
|
|
@ -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([
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
|
|
|
|||
36
tests/Unit/Services/Brand/BrandAnalyzerRunnerTest.php
Normal file
36
tests/Unit/Services/Brand/BrandAnalyzerRunnerTest.php
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Ai\Agents\BrandAnalyzer;
|
||||
use App\Ai\Agents\PostContentGenerator;
|
||||
use App\Ai\Agents\PostContentHumanizer;
|
||||
use App\Ai\Agents\PostContentReviewer;
|
||||
use App\Ai\Agents\PostContentStreamer;
|
||||
use App\Ai\Agents\PostImageRegenerator;
|
||||
use App\Services\Brand\BrandAnalyzerRunner;
|
||||
|
||||
test('isAvailable follows ai.providers for the configured default including openrouter', function () {
|
||||
$runner = app(BrandAnalyzerRunner::class);
|
||||
|
||||
config()->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,
|
||||
]);
|
||||
Loading…
Reference in a new issue