Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -171,12 +171,14 @@ 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
Expand Down
10 changes: 0 additions & 10 deletions app/Ai/Agents/BrandAnalyzer.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -27,15 +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');
Expand Down
10 changes: 0 additions & 10 deletions app/Ai/Agents/PostContentGenerator.php
Original file line number Diff line number Diff line change
Expand Up @@ -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)]
Expand Down Expand Up @@ -110,15 +109,6 @@ public function schema(JsonSchema $schema): array
];
}

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');
Expand Down
10 changes: 0 additions & 10 deletions app/Ai/Agents/PostContentHumanizer.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;

/**
Expand Down Expand Up @@ -73,15 +72,6 @@ public function schema(JsonSchema $schema): array
];
}

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');
Expand Down
10 changes: 0 additions & 10 deletions app/Ai/Agents/PostContentReviewer.php
Original file line number Diff line number Diff line change
Expand Up @@ -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)]
Expand Down Expand Up @@ -44,15 +43,6 @@ public function schema(JsonSchema $schema): array
];
}

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');
Expand Down
10 changes: 0 additions & 10 deletions app/Ai/Agents/PostContentStreamer.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;

/**
Expand Down Expand Up @@ -42,15 +41,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');
Expand Down
10 changes: 0 additions & 10 deletions app/Ai/Agents/PostImageRegenerator.php
Original file line number Diff line number Diff line change
Expand Up @@ -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)]
Expand Down Expand Up @@ -48,15 +47,6 @@ public function schema(JsonSchema $schema): array
];
}

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');
Expand Down
3 changes: 2 additions & 1 deletion app/Http/Middleware/App/HandleInertiaRequests.php
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
use App\Http\Resources\App\HandleInertiaRequests\AuthPlanResource;
use App\Http\Resources\App\HandleInertiaRequests\AuthUserResource;
use App\Http\Resources\App\HandleInertiaRequests\AuthWorkspaceResource;
use App\Support\AiConfiguration;
use Illuminate\Http\Request;
use Inertia\Middleware;

Expand Down Expand Up @@ -57,7 +58,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' => AiConfiguration::isTextProviderConfigured(),
'selfHosted' => $isSelfHosted,
'googleAuthEnabled' => config('trypost.google_auth_enabled'),
'githubAuthEnabled' => config('trypost.github_auth_enabled'),
Expand Down
7 changes: 2 additions & 5 deletions app/Services/Brand/BrandAnalyzerRunner.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
namespace App\Services\Brand;

use App\Ai\Agents\BrandAnalyzer;
use App\Support\AiConfiguration;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Str;
use League\HTMLToMarkdown\HtmlConverter;
Expand All @@ -16,11 +17,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 AiConfiguration::isTextProviderConfigured();
}

public function analyze(string $bodyHtml): ?LlmBrandAnalysis
Expand Down
26 changes: 26 additions & 0 deletions app/Support/AiConfiguration.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
<?php

declare(strict_types=1);

namespace App\Support;

/**
* Helpers for reading the Laravel AI SDK provider configuration.
*
* Agents intentionally do not override provider() — Promptable falls back to
* config('ai.default'), which already includes first-class drivers such as
* OpenRouter. Availability checks must therefore look at ai.providers.*.key,
* not the legacy services.* mirrors.
*/
final class AiConfiguration
{
/**
* Whether the configured default text provider has an API key.
*/
public static function isTextProviderConfigured(): bool
{
$provider = (string) config('ai.default');

return filled(config("ai.providers.{$provider}.key"));
}
}
42 changes: 37 additions & 5 deletions tests/Feature/Ai/AutofillBrandTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
Expand Down Expand Up @@ -354,7 +356,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([
Expand Down Expand Up @@ -390,8 +392,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'.
Expand Down Expand Up @@ -448,7 +480,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([
Expand Down
2 changes: 1 addition & 1 deletion tests/Feature/WorkspaceControllerTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -567,7 +567,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([
Expand Down
35 changes: 35 additions & 0 deletions tests/Unit/Services/Brand/BrandAnalyzerRunnerTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
<?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 so laravel/ai uses config ai.default', function (string $agent) {
expect(method_exists($agent, 'provider'))->toBeFalse();
})->with([
BrandAnalyzer::class,
PostContentGenerator::class,
PostContentHumanizer::class,
PostContentReviewer::class,
PostContentStreamer::class,
PostImageRegenerator::class,
]);
32 changes: 32 additions & 0 deletions tests/Unit/Support/AiConfigurationTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
<?php

declare(strict_types=1);

use App\Support\AiConfiguration;

test('isTextProviderConfigured is true when the default provider has a key', function (string $provider) {
config()->set('ai.default', $provider);
config()->set("ai.providers.{$provider}.key", 'fake-key');

expect(AiConfiguration::isTextProviderConfigured())->toBeTrue();
})->with([
'openai',
'gemini',
'anthropic',
'openrouter',
]);

test('isTextProviderConfigured is false when the default provider key is empty', function () {
config()->set('ai.default', 'openrouter');
config()->set('ai.providers.openrouter.key', '');

expect(AiConfiguration::isTextProviderConfigured())->toBeFalse();
});

test('isTextProviderConfigured ignores keys on non-default providers', function () {
config()->set('ai.default', 'openrouter');
config()->set('ai.providers.openrouter.key', '');
config()->set('ai.providers.openai.key', 'fake-key');

expect(AiConfiguration::isTextProviderConfigured())->toBeFalse();
});