Skip to content

Commit 3befe12

Browse files
authored
Merge pull request #2 from MujahidAbbas/feature/multi-provider-support
feat: Add multi-provider AI support
2 parents 9a1fc2c + b7c9a0e commit 3befe12

File tree

14 files changed

+677
-48
lines changed

14 files changed

+677
-48
lines changed

.env.example

Lines changed: 31 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -67,8 +67,37 @@ VITE_APP_NAME="${APP_NAME}"
6767
# ===========================================
6868
# AI Provider Configuration
6969
# ===========================================
70-
# Get your API key from: https://console.anthropic.com/
70+
# Configure at least one AI provider to use PlanForge.
71+
# Only providers with configured API keys will appear in the UI.
72+
73+
# Anthropic (Claude) - Recommended
74+
# Get your key from: https://console.anthropic.com/
7175
ANTHROPIC_API_KEY=
7276

73-
# Optional: OpenAI for alternative provider
77+
# OpenAI (GPT)
78+
# Get your key from: https://platform.openai.com/api-keys
7479
# OPENAI_API_KEY=
80+
81+
# Google Gemini
82+
# Get your key from: https://aistudio.google.com/apikey
83+
# GEMINI_API_KEY=
84+
85+
# Mistral AI
86+
# Get your key from: https://console.mistral.ai/api-keys
87+
# MISTRAL_API_KEY=
88+
89+
# Groq (Fast inference)
90+
# Get your key from: https://console.groq.com/keys
91+
# GROQ_API_KEY=
92+
93+
# DeepSeek
94+
# Get your key from: https://platform.deepseek.com/api_keys
95+
# DEEPSEEK_API_KEY=
96+
97+
# OpenRouter (Access multiple providers with one key)
98+
# Get your key from: https://openrouter.ai/keys
99+
# OPENROUTER_API_KEY=
100+
101+
# Ollama (Local, self-hosted)
102+
# No API key needed - just set the URL
103+
# OLLAMA_URL=http://localhost:11434

app/Enums/AiProvider.php

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
<?php
2+
3+
namespace App\Enums;
4+
5+
use Prism\Prism\Enums\Provider;
6+
7+
enum AiProvider: string
8+
{
9+
case Anthropic = 'anthropic';
10+
case OpenAI = 'openai';
11+
case Gemini = 'gemini';
12+
case Mistral = 'mistral';
13+
case Groq = 'groq';
14+
case DeepSeek = 'deepseek';
15+
case Ollama = 'ollama';
16+
case OpenRouter = 'openrouter';
17+
18+
public function toPrismProvider(): Provider
19+
{
20+
return match ($this) {
21+
self::Anthropic => Provider::Anthropic,
22+
self::OpenAI => Provider::OpenAI,
23+
self::Gemini => Provider::Gemini,
24+
self::Mistral => Provider::Mistral,
25+
self::Groq => Provider::Groq,
26+
self::DeepSeek => Provider::DeepSeek,
27+
self::Ollama => Provider::Ollama,
28+
self::OpenRouter => Provider::OpenRouter,
29+
};
30+
}
31+
32+
public function label(): string
33+
{
34+
return match ($this) {
35+
self::Anthropic => 'Anthropic',
36+
self::OpenAI => 'OpenAI',
37+
self::Gemini => 'Google Gemini',
38+
self::Mistral => 'Mistral AI',
39+
self::Groq => 'Groq',
40+
self::DeepSeek => 'DeepSeek',
41+
self::Ollama => 'Ollama (Local)',
42+
self::OpenRouter => 'OpenRouter',
43+
};
44+
}
45+
46+
public function configKey(): string
47+
{
48+
return "prism.providers.{$this->value}.api_key";
49+
}
50+
51+
public function isLocal(): bool
52+
{
53+
return $this === self::Ollama;
54+
}
55+
}
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
<?php
2+
3+
namespace App\Jobs\Concerns;
4+
5+
use App\Services\ProviderService;
6+
use Prism\Prism\Enums\Provider;
7+
8+
trait ResolvesAiProvider
9+
{
10+
protected function resolveProvider(string $provider): Provider
11+
{
12+
return app(ProviderService::class)->resolveProvider($provider);
13+
}
14+
}

app/Jobs/GeneratePrdJob.php

Lines changed: 1 addition & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -17,13 +17,13 @@
1717
use Illuminate\Queue\InteractsWithQueue;
1818
use Illuminate\Queue\Middleware\RateLimited;
1919
use Illuminate\Queue\SerializesModels;
20-
use Prism\Prism\Enums\Provider;
2120
use Prism\Prism\Exceptions\PrismRateLimitedException;
2221
use Prism\Prism\Facades\Prism;
2322
use Throwable;
2423

2524
class GeneratePrdJob implements ShouldBeUnique, ShouldQueue
2625
{
26+
use Concerns\ResolvesAiProvider;
2727
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
2828

2929
public int $tries = 5;
@@ -171,16 +171,4 @@ public function handle(): void
171171
throw $e;
172172
}
173173
}
174-
175-
private function resolveProvider(string $provider): Provider
176-
{
177-
return match ($provider) {
178-
'anthropic' => Provider::Anthropic,
179-
'openai' => Provider::OpenAI,
180-
'gemini' => Provider::Gemini,
181-
'mistral' => Provider::Mistral,
182-
'groq' => Provider::Groq,
183-
default => Provider::Anthropic,
184-
};
185-
}
186174
}

app/Jobs/GenerateTasksJob.php

Lines changed: 1 addition & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -20,14 +20,14 @@
2020
use Illuminate\Queue\Middleware\RateLimited;
2121
use Illuminate\Queue\SerializesModels;
2222
use Illuminate\Support\Facades\DB;
23-
use Prism\Prism\Enums\Provider;
2423
use Prism\Prism\Exceptions\PrismRateLimitedException;
2524
use Prism\Prism\Facades\Prism;
2625
use Relaticle\Flowforge\Services\Rank;
2726
use Throwable;
2827

2928
class GenerateTasksJob implements ShouldBeUnique, ShouldQueue
3029
{
30+
use Concerns\ResolvesAiProvider;
3131
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
3232

3333
public int $tries = 5;
@@ -268,18 +268,6 @@ private function handleError(Throwable $e, PlanRunStep $step, PlanRun $run): voi
268268
throw $e;
269269
}
270270

271-
private function resolveProvider(string $provider): Provider
272-
{
273-
return match ($provider) {
274-
'anthropic' => Provider::Anthropic,
275-
'openai' => Provider::OpenAI,
276-
'gemini' => Provider::Gemini,
277-
'mistral' => Provider::Mistral,
278-
'groq' => Provider::Groq,
279-
default => Provider::Anthropic,
280-
};
281-
}
282-
283271
private function truncate(?string $text, int $length): ?string
284272
{
285273
if ($text === null) {

app/Jobs/GenerateTechSpecJob.php

Lines changed: 1 addition & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -17,13 +17,13 @@
1717
use Illuminate\Queue\InteractsWithQueue;
1818
use Illuminate\Queue\Middleware\RateLimited;
1919
use Illuminate\Queue\SerializesModels;
20-
use Prism\Prism\Enums\Provider;
2120
use Prism\Prism\Exceptions\PrismRateLimitedException;
2221
use Prism\Prism\Facades\Prism;
2322
use Throwable;
2423

2524
class GenerateTechSpecJob implements ShouldBeUnique, ShouldQueue
2625
{
26+
use Concerns\ResolvesAiProvider;
2727
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
2828

2929
public int $tries = 5;
@@ -184,16 +184,4 @@ public function handle(): void
184184
throw $e;
185185
}
186186
}
187-
188-
private function resolveProvider(string $provider): Provider
189-
{
190-
return match ($provider) {
191-
'anthropic' => Provider::Anthropic,
192-
'openai' => Provider::OpenAI,
193-
'gemini' => Provider::Gemini,
194-
'mistral' => Provider::Mistral,
195-
'groq' => Provider::Groq,
196-
default => Provider::Anthropic,
197-
};
198-
}
199187
}
Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
<?php
2+
3+
namespace App\Livewire\Concerns;
4+
5+
use App\Enums\AiProvider;
6+
use App\Services\ProviderService;
7+
use Livewire\Attributes\Computed;
8+
9+
trait ManagesProviderSelection
10+
{
11+
public string $selectedProvider = '';
12+
13+
public string $selectedModel = '';
14+
15+
public bool $useCustomModel = false;
16+
17+
public string $customModel = '';
18+
19+
public function updatedSelectedProvider(): void
20+
{
21+
$provider = AiProvider::tryFrom($this->selectedProvider);
22+
23+
if ($provider) {
24+
$this->selectedModel = $this->providerService()->getDefaultModel($provider) ?? '';
25+
}
26+
27+
$this->useCustomModel = false;
28+
$this->customModel = '';
29+
}
30+
31+
public function updatedSelectedModel(): void
32+
{
33+
$this->useCustomModel = ($this->selectedModel === 'custom');
34+
}
35+
36+
#[Computed]
37+
public function availableProviders(): array
38+
{
39+
return $this->providerService()->getProviderOptions();
40+
}
41+
42+
#[Computed]
43+
public function modelsForSelectedProvider(): array
44+
{
45+
$provider = AiProvider::tryFrom($this->selectedProvider);
46+
47+
if (! $provider) {
48+
return [];
49+
}
50+
51+
return $this->providerService()->getModelsForProvider($provider);
52+
}
53+
54+
#[Computed]
55+
public function hasProviders(): bool
56+
{
57+
return $this->providerService()->hasAvailableProviders();
58+
}
59+
60+
protected function initializeProviderDefaults(): void
61+
{
62+
$service = $this->providerService();
63+
$defaultProvider = $service->getDefaultProvider();
64+
65+
if ($defaultProvider) {
66+
$this->selectedProvider = $defaultProvider->value;
67+
$this->selectedModel = $service->getDefaultModel($defaultProvider) ?? '';
68+
}
69+
}
70+
71+
protected function initializeFromProject(string $currentProvider, ?string $currentModel): void
72+
{
73+
$service = $this->providerService();
74+
75+
// Set current provider or fall back to default
76+
$this->selectedProvider = $currentProvider ?: ($service->getDefaultProvider()?->value ?? '');
77+
78+
// Check if current model is in curated list
79+
$provider = AiProvider::tryFrom($this->selectedProvider);
80+
81+
if (! $provider) {
82+
return;
83+
}
84+
85+
$curatedModels = $service->getModelsForProvider($provider);
86+
87+
if ($currentModel && ! isset($curatedModels[$currentModel])) {
88+
// Current model is custom
89+
$this->useCustomModel = true;
90+
$this->customModel = $currentModel;
91+
$this->selectedModel = 'custom';
92+
} else {
93+
$this->selectedModel = $currentModel ?: ($service->getDefaultModel($provider) ?? '');
94+
$this->useCustomModel = false;
95+
$this->customModel = '';
96+
}
97+
}
98+
99+
protected function getProviderValidationRules(): array
100+
{
101+
$rules = [
102+
'selectedProvider' => 'required',
103+
'selectedModel' => 'required',
104+
];
105+
106+
if ($this->useCustomModel) {
107+
$rules['customModel'] = 'required|string|min:3|max:100';
108+
}
109+
110+
return $rules;
111+
}
112+
113+
protected function getFinalModel(): string
114+
{
115+
return $this->useCustomModel ? $this->customModel : $this->selectedModel;
116+
}
117+
118+
protected function providerService(): ProviderService
119+
{
120+
return app(ProviderService::class);
121+
}
122+
}

app/Livewire/Projects/Index.php

Lines changed: 21 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,42 +3,58 @@
33
namespace App\Livewire\Projects;
44

55
use App\Enums\ProjectStatus;
6+
use App\Livewire\Concerns\ManagesProviderSelection;
67
use App\Models\Project;
78
use Livewire\Attributes\Layout;
89
use Livewire\Component;
910

1011
#[Layout('components.layouts.app')]
1112
class Index extends Component
1213
{
14+
use ManagesProviderSelection;
15+
1316
public string $name = '';
1417

1518
public string $idea = '';
1619

1720
public bool $showCreateModal = false;
1821

22+
public function mount(): void
23+
{
24+
$this->initializeProviderDefaults();
25+
}
26+
1927
public function openCreateModal(): void
2028
{
29+
$this->initializeProviderDefaults();
2130
$this->showCreateModal = true;
2231
}
2332

2433
public function closeCreateModal(): void
2534
{
2635
$this->showCreateModal = false;
27-
$this->reset(['name', 'idea']);
36+
$this->reset(['name', 'idea', 'selectedProvider', 'selectedModel', 'useCustomModel', 'customModel']);
2837
}
2938

3039
public function createProject(): void
3140
{
32-
$this->validate([
33-
'name' => 'required|min:3|max:255',
34-
'idea' => 'required|min:10',
35-
]);
41+
$rules = array_merge(
42+
[
43+
'name' => 'required|min:3|max:255',
44+
'idea' => 'required|min:10',
45+
],
46+
$this->getProviderValidationRules()
47+
);
48+
49+
$this->validate($rules);
3650

3751
// For now, use user_id = 1 (test user) since we don't have auth yet
3852
$project = Project::create([
3953
'user_id' => 1,
4054
'name' => $this->name,
4155
'idea' => $this->idea,
56+
'preferred_provider' => $this->selectedProvider,
57+
'preferred_model' => $this->getFinalModel(),
4258
'status' => ProjectStatus::Active,
4359
]);
4460

0 commit comments

Comments
 (0)