From 04de3b3c8267dae872ce427cfa2e05976a832a36 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 1 Aug 2026 13:29:53 +0000 Subject: [PATCH] Fix OpenRouter support by using laravel/ai default provider config. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .env.example | 6 ++- app/Ai/Agents/BrandAnalyzer.php | 10 ----- app/Ai/Agents/PostContentGenerator.php | 10 ----- app/Ai/Agents/PostContentHumanizer.php | 10 ----- app/Ai/Agents/PostContentReviewer.php | 10 ----- app/Ai/Agents/PostContentStreamer.php | 10 ----- app/Ai/Agents/PostImageRegenerator.php | 10 ----- .../Middleware/App/HandleInertiaRequests.php | 3 +- app/Services/Brand/BrandAnalyzerRunner.php | 7 +--- app/Support/AiConfiguration.php | 26 ++++++++++++ tests/Feature/Ai/AutofillBrandTest.php | 42 ++++++++++++++++--- tests/Feature/WorkspaceControllerTest.php | 2 +- .../Brand/BrandAnalyzerRunnerTest.php | 35 ++++++++++++++++ tests/Unit/Support/AiConfigurationTest.php | 32 ++++++++++++++ 14 files changed, 139 insertions(+), 74 deletions(-) create mode 100644 app/Support/AiConfiguration.php create mode 100644 tests/Unit/Services/Brand/BrandAnalyzerRunnerTest.php create mode 100644 tests/Unit/Support/AiConfigurationTest.php diff --git a/.env.example b/.env.example index 4a631e7ee..ecff00379 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/app/Ai/Agents/BrandAnalyzer.php b/app/Ai/Agents/BrandAnalyzer.php index c1df86cd5..dc060cfff 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,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'); diff --git a/app/Ai/Agents/PostContentGenerator.php b/app/Ai/Agents/PostContentGenerator.php index 14a82146e..a082393f3 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)] @@ -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'); diff --git a/app/Ai/Agents/PostContentHumanizer.php b/app/Ai/Agents/PostContentHumanizer.php index 36eb57019..0149d24bf 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; /** @@ -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'); diff --git a/app/Ai/Agents/PostContentReviewer.php b/app/Ai/Agents/PostContentReviewer.php index 6bf6252c0..819d1b439 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)] @@ -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'); diff --git a/app/Ai/Agents/PostContentStreamer.php b/app/Ai/Agents/PostContentStreamer.php index 3bd800e15..e38fff3e4 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; /** @@ -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'); diff --git a/app/Ai/Agents/PostImageRegenerator.php b/app/Ai/Agents/PostImageRegenerator.php index 5fe2edc4e..350c7f5f3 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)] @@ -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'); diff --git a/app/Http/Middleware/App/HandleInertiaRequests.php b/app/Http/Middleware/App/HandleInertiaRequests.php index 167185be8..d1f53dcb7 100644 --- a/app/Http/Middleware/App/HandleInertiaRequests.php +++ b/app/Http/Middleware/App/HandleInertiaRequests.php @@ -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; @@ -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'), diff --git a/app/Services/Brand/BrandAnalyzerRunner.php b/app/Services/Brand/BrandAnalyzerRunner.php index 2eae3e1c0..dfa051dfa 100644 --- a/app/Services/Brand/BrandAnalyzerRunner.php +++ b/app/Services/Brand/BrandAnalyzerRunner.php @@ -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; @@ -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 diff --git a/app/Support/AiConfiguration.php b/app/Support/AiConfiguration.php new file mode 100644 index 000000000..a9b6e0c58 --- /dev/null +++ b/app/Support/AiConfiguration.php @@ -0,0 +1,26 @@ +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); }); @@ -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([ @@ -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' + + + 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'. @@ -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([ diff --git a/tests/Feature/WorkspaceControllerTest.php b/tests/Feature/WorkspaceControllerTest.php index e1354dc58..7b88b51fb 100644 --- a/tests/Feature/WorkspaceControllerTest.php +++ b/tests/Feature/WorkspaceControllerTest.php @@ -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([ diff --git a/tests/Unit/Services/Brand/BrandAnalyzerRunnerTest.php b/tests/Unit/Services/Brand/BrandAnalyzerRunnerTest.php new file mode 100644 index 000000000..5a6425127 --- /dev/null +++ b/tests/Unit/Services/Brand/BrandAnalyzerRunnerTest.php @@ -0,0 +1,35 @@ +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, +]); diff --git a/tests/Unit/Support/AiConfigurationTest.php b/tests/Unit/Support/AiConfigurationTest.php new file mode 100644 index 000000000..10fa02be9 --- /dev/null +++ b/tests/Unit/Support/AiConfigurationTest.php @@ -0,0 +1,32 @@ +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(); +});