From b7c9a0e2929ab050d9d486d944abe98e3597ed82 Mon Sep 17 00:00:00 2001 From: Mujahid Abbas Date: Thu, 1 Jan 2026 05:57:03 +0500 Subject: [PATCH] feat: Add multi-provider AI support - Add AiProvider enum with 8 providers (Anthropic, OpenAI, Gemini, Mistral, Groq, DeepSeek, Ollama, OpenRouter) - Add ProviderService for runtime provider detection based on .env config - Add config/providers.php with curated model lists per provider - Add provider/model selection to project creation modal - Add SettingsModal for changing project provider mid-project - Add ResolvesAiProvider trait for jobs (DRY) - Add ManagesProviderSelection trait for Livewire components (DRY) - Update .env.example with all provider API key placeholders Only providers with configured API keys appear in the UI. Users can select from curated models or enter custom model IDs. --- .env.example | 33 ++++- app/Enums/AiProvider.php | 55 ++++++++ app/Jobs/Concerns/ResolvesAiProvider.php | 14 ++ app/Jobs/GeneratePrdJob.php | 14 +- app/Jobs/GenerateTasksJob.php | 14 +- app/Jobs/GenerateTechSpecJob.php | 14 +- .../Concerns/ManagesProviderSelection.php | 122 +++++++++++++++++ app/Livewire/Projects/Index.php | 26 +++- app/Livewire/Projects/SettingsModal.php | 66 +++++++++ app/Services/ProviderService.php | 129 ++++++++++++++++++ config/providers.php | 89 ++++++++++++ .../views/livewire/projects/index.blade.php | 54 +++++++- .../projects/settings-modal.blade.php | 78 +++++++++++ .../livewire/projects/workspace.blade.php | 17 ++- 14 files changed, 677 insertions(+), 48 deletions(-) create mode 100644 app/Enums/AiProvider.php create mode 100644 app/Jobs/Concerns/ResolvesAiProvider.php create mode 100644 app/Livewire/Concerns/ManagesProviderSelection.php create mode 100644 app/Livewire/Projects/SettingsModal.php create mode 100644 app/Services/ProviderService.php create mode 100644 config/providers.php create mode 100644 resources/views/livewire/projects/settings-modal.blade.php diff --git a/.env.example b/.env.example index 95b1d8b..d97d4fc 100644 --- a/.env.example +++ b/.env.example @@ -67,8 +67,37 @@ VITE_APP_NAME="${APP_NAME}" # =========================================== # AI Provider Configuration # =========================================== -# Get your API key from: https://console.anthropic.com/ +# Configure at least one AI provider to use PlanForge. +# Only providers with configured API keys will appear in the UI. + +# Anthropic (Claude) - Recommended +# Get your key from: https://console.anthropic.com/ ANTHROPIC_API_KEY= -# Optional: OpenAI for alternative provider +# OpenAI (GPT) +# Get your key from: https://platform.openai.com/api-keys # OPENAI_API_KEY= + +# Google Gemini +# Get your key from: https://aistudio.google.com/apikey +# GEMINI_API_KEY= + +# Mistral AI +# Get your key from: https://console.mistral.ai/api-keys +# MISTRAL_API_KEY= + +# Groq (Fast inference) +# Get your key from: https://console.groq.com/keys +# GROQ_API_KEY= + +# DeepSeek +# Get your key from: https://platform.deepseek.com/api_keys +# DEEPSEEK_API_KEY= + +# OpenRouter (Access multiple providers with one key) +# Get your key from: https://openrouter.ai/keys +# OPENROUTER_API_KEY= + +# Ollama (Local, self-hosted) +# No API key needed - just set the URL +# OLLAMA_URL=http://localhost:11434 diff --git a/app/Enums/AiProvider.php b/app/Enums/AiProvider.php new file mode 100644 index 0000000..24c1ee4 --- /dev/null +++ b/app/Enums/AiProvider.php @@ -0,0 +1,55 @@ + Provider::Anthropic, + self::OpenAI => Provider::OpenAI, + self::Gemini => Provider::Gemini, + self::Mistral => Provider::Mistral, + self::Groq => Provider::Groq, + self::DeepSeek => Provider::DeepSeek, + self::Ollama => Provider::Ollama, + self::OpenRouter => Provider::OpenRouter, + }; + } + + public function label(): string + { + return match ($this) { + self::Anthropic => 'Anthropic', + self::OpenAI => 'OpenAI', + self::Gemini => 'Google Gemini', + self::Mistral => 'Mistral AI', + self::Groq => 'Groq', + self::DeepSeek => 'DeepSeek', + self::Ollama => 'Ollama (Local)', + self::OpenRouter => 'OpenRouter', + }; + } + + public function configKey(): string + { + return "prism.providers.{$this->value}.api_key"; + } + + public function isLocal(): bool + { + return $this === self::Ollama; + } +} diff --git a/app/Jobs/Concerns/ResolvesAiProvider.php b/app/Jobs/Concerns/ResolvesAiProvider.php new file mode 100644 index 0000000..5746685 --- /dev/null +++ b/app/Jobs/Concerns/ResolvesAiProvider.php @@ -0,0 +1,14 @@ +resolveProvider($provider); + } +} diff --git a/app/Jobs/GeneratePrdJob.php b/app/Jobs/GeneratePrdJob.php index 6b8295f..5c060a7 100644 --- a/app/Jobs/GeneratePrdJob.php +++ b/app/Jobs/GeneratePrdJob.php @@ -17,13 +17,13 @@ use Illuminate\Queue\InteractsWithQueue; use Illuminate\Queue\Middleware\RateLimited; use Illuminate\Queue\SerializesModels; -use Prism\Prism\Enums\Provider; use Prism\Prism\Exceptions\PrismRateLimitedException; use Prism\Prism\Facades\Prism; use Throwable; class GeneratePrdJob implements ShouldBeUnique, ShouldQueue { + use Concerns\ResolvesAiProvider; use Dispatchable, InteractsWithQueue, Queueable, SerializesModels; public int $tries = 5; @@ -171,16 +171,4 @@ public function handle(): void throw $e; } } - - private function resolveProvider(string $provider): Provider - { - return match ($provider) { - 'anthropic' => Provider::Anthropic, - 'openai' => Provider::OpenAI, - 'gemini' => Provider::Gemini, - 'mistral' => Provider::Mistral, - 'groq' => Provider::Groq, - default => Provider::Anthropic, - }; - } } diff --git a/app/Jobs/GenerateTasksJob.php b/app/Jobs/GenerateTasksJob.php index b4451ac..7149fc6 100644 --- a/app/Jobs/GenerateTasksJob.php +++ b/app/Jobs/GenerateTasksJob.php @@ -20,7 +20,6 @@ use Illuminate\Queue\Middleware\RateLimited; use Illuminate\Queue\SerializesModels; use Illuminate\Support\Facades\DB; -use Prism\Prism\Enums\Provider; use Prism\Prism\Exceptions\PrismRateLimitedException; use Prism\Prism\Facades\Prism; use Relaticle\Flowforge\Services\Rank; @@ -28,6 +27,7 @@ class GenerateTasksJob implements ShouldBeUnique, ShouldQueue { + use Concerns\ResolvesAiProvider; use Dispatchable, InteractsWithQueue, Queueable, SerializesModels; public int $tries = 5; @@ -268,18 +268,6 @@ private function handleError(Throwable $e, PlanRunStep $step, PlanRun $run): voi throw $e; } - private function resolveProvider(string $provider): Provider - { - return match ($provider) { - 'anthropic' => Provider::Anthropic, - 'openai' => Provider::OpenAI, - 'gemini' => Provider::Gemini, - 'mistral' => Provider::Mistral, - 'groq' => Provider::Groq, - default => Provider::Anthropic, - }; - } - private function truncate(?string $text, int $length): ?string { if ($text === null) { diff --git a/app/Jobs/GenerateTechSpecJob.php b/app/Jobs/GenerateTechSpecJob.php index 9c0547e..5042cc8 100644 --- a/app/Jobs/GenerateTechSpecJob.php +++ b/app/Jobs/GenerateTechSpecJob.php @@ -17,13 +17,13 @@ use Illuminate\Queue\InteractsWithQueue; use Illuminate\Queue\Middleware\RateLimited; use Illuminate\Queue\SerializesModels; -use Prism\Prism\Enums\Provider; use Prism\Prism\Exceptions\PrismRateLimitedException; use Prism\Prism\Facades\Prism; use Throwable; class GenerateTechSpecJob implements ShouldBeUnique, ShouldQueue { + use Concerns\ResolvesAiProvider; use Dispatchable, InteractsWithQueue, Queueable, SerializesModels; public int $tries = 5; @@ -184,16 +184,4 @@ public function handle(): void throw $e; } } - - private function resolveProvider(string $provider): Provider - { - return match ($provider) { - 'anthropic' => Provider::Anthropic, - 'openai' => Provider::OpenAI, - 'gemini' => Provider::Gemini, - 'mistral' => Provider::Mistral, - 'groq' => Provider::Groq, - default => Provider::Anthropic, - }; - } } diff --git a/app/Livewire/Concerns/ManagesProviderSelection.php b/app/Livewire/Concerns/ManagesProviderSelection.php new file mode 100644 index 0000000..dd8df45 --- /dev/null +++ b/app/Livewire/Concerns/ManagesProviderSelection.php @@ -0,0 +1,122 @@ +selectedProvider); + + if ($provider) { + $this->selectedModel = $this->providerService()->getDefaultModel($provider) ?? ''; + } + + $this->useCustomModel = false; + $this->customModel = ''; + } + + public function updatedSelectedModel(): void + { + $this->useCustomModel = ($this->selectedModel === 'custom'); + } + + #[Computed] + public function availableProviders(): array + { + return $this->providerService()->getProviderOptions(); + } + + #[Computed] + public function modelsForSelectedProvider(): array + { + $provider = AiProvider::tryFrom($this->selectedProvider); + + if (! $provider) { + return []; + } + + return $this->providerService()->getModelsForProvider($provider); + } + + #[Computed] + public function hasProviders(): bool + { + return $this->providerService()->hasAvailableProviders(); + } + + protected function initializeProviderDefaults(): void + { + $service = $this->providerService(); + $defaultProvider = $service->getDefaultProvider(); + + if ($defaultProvider) { + $this->selectedProvider = $defaultProvider->value; + $this->selectedModel = $service->getDefaultModel($defaultProvider) ?? ''; + } + } + + protected function initializeFromProject(string $currentProvider, ?string $currentModel): void + { + $service = $this->providerService(); + + // Set current provider or fall back to default + $this->selectedProvider = $currentProvider ?: ($service->getDefaultProvider()?->value ?? ''); + + // Check if current model is in curated list + $provider = AiProvider::tryFrom($this->selectedProvider); + + if (! $provider) { + return; + } + + $curatedModels = $service->getModelsForProvider($provider); + + if ($currentModel && ! isset($curatedModels[$currentModel])) { + // Current model is custom + $this->useCustomModel = true; + $this->customModel = $currentModel; + $this->selectedModel = 'custom'; + } else { + $this->selectedModel = $currentModel ?: ($service->getDefaultModel($provider) ?? ''); + $this->useCustomModel = false; + $this->customModel = ''; + } + } + + protected function getProviderValidationRules(): array + { + $rules = [ + 'selectedProvider' => 'required', + 'selectedModel' => 'required', + ]; + + if ($this->useCustomModel) { + $rules['customModel'] = 'required|string|min:3|max:100'; + } + + return $rules; + } + + protected function getFinalModel(): string + { + return $this->useCustomModel ? $this->customModel : $this->selectedModel; + } + + protected function providerService(): ProviderService + { + return app(ProviderService::class); + } +} diff --git a/app/Livewire/Projects/Index.php b/app/Livewire/Projects/Index.php index aa104af..230ed27 100644 --- a/app/Livewire/Projects/Index.php +++ b/app/Livewire/Projects/Index.php @@ -3,6 +3,7 @@ namespace App\Livewire\Projects; use App\Enums\ProjectStatus; +use App\Livewire\Concerns\ManagesProviderSelection; use App\Models\Project; use Livewire\Attributes\Layout; use Livewire\Component; @@ -10,35 +11,50 @@ #[Layout('components.layouts.app')] class Index extends Component { + use ManagesProviderSelection; + public string $name = ''; public string $idea = ''; public bool $showCreateModal = false; + public function mount(): void + { + $this->initializeProviderDefaults(); + } + public function openCreateModal(): void { + $this->initializeProviderDefaults(); $this->showCreateModal = true; } public function closeCreateModal(): void { $this->showCreateModal = false; - $this->reset(['name', 'idea']); + $this->reset(['name', 'idea', 'selectedProvider', 'selectedModel', 'useCustomModel', 'customModel']); } public function createProject(): void { - $this->validate([ - 'name' => 'required|min:3|max:255', - 'idea' => 'required|min:10', - ]); + $rules = array_merge( + [ + 'name' => 'required|min:3|max:255', + 'idea' => 'required|min:10', + ], + $this->getProviderValidationRules() + ); + + $this->validate($rules); // For now, use user_id = 1 (test user) since we don't have auth yet $project = Project::create([ 'user_id' => 1, 'name' => $this->name, 'idea' => $this->idea, + 'preferred_provider' => $this->selectedProvider, + 'preferred_model' => $this->getFinalModel(), 'status' => ProjectStatus::Active, ]); diff --git a/app/Livewire/Projects/SettingsModal.php b/app/Livewire/Projects/SettingsModal.php new file mode 100644 index 0000000..13a55d5 --- /dev/null +++ b/app/Livewire/Projects/SettingsModal.php @@ -0,0 +1,66 @@ +projectId = $projectId; + $project = Project::findOrFail($projectId); + + $this->initializeFromProject( + $project->preferred_provider ?? '', + $project->preferred_model + ); + + $this->show = true; + } + + public function close(): void + { + $this->show = false; + $this->reset(['projectId', 'selectedProvider', 'selectedModel', 'useCustomModel', 'customModel']); + } + + public function save(): void + { + $this->validate($this->getProviderValidationRules()); + + $project = Project::findOrFail($this->projectId); + $project->update([ + 'preferred_provider' => $this->selectedProvider, + 'preferred_model' => $this->getFinalModel(), + ]); + + $this->dispatch('settingsSaved'); + $this->close(); + } + + #[Computed] + public function currentProviderLabel(): string + { + $provider = AiProvider::tryFrom($this->selectedProvider); + + return $provider?->label() ?? 'Unknown'; + } + + public function render() + { + return view('livewire.projects.settings-modal'); + } +} diff --git a/app/Services/ProviderService.php b/app/Services/ProviderService.php new file mode 100644 index 0000000..8ceff6a --- /dev/null +++ b/app/Services/ProviderService.php @@ -0,0 +1,129 @@ + + */ + public function getAvailableProviders(): Collection + { + if ($this->cachedAvailableProviders !== null) { + return $this->cachedAvailableProviders; + } + + $this->cachedAvailableProviders = collect(AiProvider::cases()) + ->filter(fn (AiProvider $provider) => $this->isProviderAvailable($provider)); + + return $this->cachedAvailableProviders; + } + + /** + * Get the first available provider as default. + */ + public function getDefaultProvider(): ?AiProvider + { + return $this->getAvailableProviders()->first(); + } + + /** + * Get the default model for a provider. + */ + public function getDefaultModel(AiProvider $provider): ?string + { + return config("providers.{$provider->value}.default"); + } + + /** + * Get curated model list for a provider. + * + * @return array Model ID => Display Name + */ + public function getModelsForProvider(AiProvider $provider): array + { + return config("providers.{$provider->value}.models", []); + } + + /** + * Check if a provider has an API key configured. + */ + public function isProviderAvailable(AiProvider $provider): bool + { + // Ollama doesn't need an API key, just a URL + if ($provider->isLocal()) { + $url = config('prism.providers.ollama.url'); + + return ! empty($url); + } + + $apiKey = config($provider->configKey()); + + return ! empty($apiKey); + } + + /** + * Convert a provider string to Prism Provider enum. + * Falls back to default provider if invalid. + */ + public function resolveProvider(?string $providerString): Provider + { + if (! $providerString) { + return $this->getDefaultProviderOrFail()->toPrismProvider(); + } + + $aiProvider = AiProvider::tryFrom($providerString); + + if (! $aiProvider) { + \Log::warning("Unknown provider '{$providerString}', falling back to default"); + + return $this->getDefaultProviderOrFail()->toPrismProvider(); + } + + return $aiProvider->toPrismProvider(); + } + + /** + * Get default provider or throw if none configured. + */ + private function getDefaultProviderOrFail(): AiProvider + { + $defaultProvider = $this->getDefaultProvider(); + + if (! $defaultProvider) { + throw new \RuntimeException( + 'No AI providers configured. Please add an API key to your .env file.' + ); + } + + return $defaultProvider; + } + + /** + * Get provider options for select dropdown. + * + * @return array Provider value => Label + */ + public function getProviderOptions(): array + { + return $this->getAvailableProviders() + ->mapWithKeys(fn (AiProvider $p) => [$p->value => $p->label()]) + ->toArray(); + } + + /** + * Check if any providers are available. + */ + public function hasAvailableProviders(): bool + { + return $this->getAvailableProviders()->isNotEmpty(); + } +} diff --git a/config/providers.php b/config/providers.php new file mode 100644 index 0000000..8a4f137 --- /dev/null +++ b/config/providers.php @@ -0,0 +1,89 @@ + [ + 'models' => [ + 'claude-sonnet-4-20250514' => 'Claude Sonnet 4 (Recommended)', + 'claude-opus-4-5-20250514' => 'Claude Opus 4.5', + 'claude-3-5-sonnet-20241022' => 'Claude 3.5 Sonnet', + 'claude-3-5-haiku-20241022' => 'Claude 3.5 Haiku (Fast)', + ], + 'default' => 'claude-sonnet-4-20250514', + ], + + 'openai' => [ + 'models' => [ + 'gpt-4o' => 'GPT-4o (Recommended)', + 'gpt-4o-mini' => 'GPT-4o Mini (Fast)', + 'gpt-4-turbo' => 'GPT-4 Turbo', + 'o1' => 'O1 (Reasoning)', + 'o1-mini' => 'O1 Mini', + ], + 'default' => 'gpt-4o', + ], + + 'gemini' => [ + 'models' => [ + 'gemini-2.0-flash-exp' => 'Gemini 2.0 Flash (Recommended)', + 'gemini-1.5-pro' => 'Gemini 1.5 Pro', + 'gemini-1.5-flash' => 'Gemini 1.5 Flash (Fast)', + ], + 'default' => 'gemini-2.0-flash-exp', + ], + + 'mistral' => [ + 'models' => [ + 'mistral-large-latest' => 'Mistral Large (Recommended)', + 'mistral-small-latest' => 'Mistral Small', + 'codestral-latest' => 'Codestral', + ], + 'default' => 'mistral-large-latest', + ], + + 'groq' => [ + 'models' => [ + 'llama-3.3-70b-versatile' => 'Llama 3.3 70B (Recommended)', + 'llama-3.1-70b-versatile' => 'Llama 3.1 70B', + 'mixtral-8x7b-32768' => 'Mixtral 8x7B', + ], + 'default' => 'llama-3.3-70b-versatile', + ], + + 'deepseek' => [ + 'models' => [ + 'deepseek-chat' => 'DeepSeek Chat (Recommended)', + 'deepseek-reasoner' => 'DeepSeek Reasoner', + ], + 'default' => 'deepseek-chat', + ], + + 'ollama' => [ + 'models' => [ + 'llama3.2' => 'Llama 3.2', + 'qwen2.5-coder' => 'Qwen 2.5 Coder', + 'mistral' => 'Mistral', + 'codellama' => 'Code Llama', + ], + 'default' => 'llama3.2', + ], + + 'openrouter' => [ + 'models' => [ + 'anthropic/claude-3.5-sonnet' => 'Claude 3.5 Sonnet', + 'openai/gpt-4o' => 'GPT-4o', + 'google/gemini-pro-1.5' => 'Gemini 1.5 Pro', + 'meta-llama/llama-3.1-70b-instruct' => 'Llama 3.1 70B', + ], + 'default' => 'anthropic/claude-3.5-sonnet', + ], +]; diff --git a/resources/views/livewire/projects/index.blade.php b/resources/views/livewire/projects/index.blade.php index f7b2a36..df54c22 100644 --- a/resources/views/livewire/projects/index.blade.php +++ b/resources/views/livewire/projects/index.blade.php @@ -72,7 +72,7 @@ class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:rin @error('name') {{ $message }} @enderror -
+