Route Afia through the platform AI relay when no local API key is set.
Deploy Ladill Frontdesk / deploy (push) Successful in 45s
Deploy Ladill Frontdesk / deploy (push) Successful in 45s
Frontdesk now calls ladill.com/api/afia/chat using IDENTITY_API_KEY_FRONTDESK so production does not need a separate OpenAI key in the frontdesk .env. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -31,6 +31,12 @@ BILLING_API_KEY_FRONTDESK=
|
|||||||
IDENTITY_API_URL=https://ladill.com/api
|
IDENTITY_API_URL=https://ladill.com/api
|
||||||
IDENTITY_API_KEY_FRONTDESK=
|
IDENTITY_API_KEY_FRONTDESK=
|
||||||
|
|
||||||
|
# Afia AI assistant (uses platform relay via IDENTITY_API_KEY when AFIA_API_KEY is unset)
|
||||||
|
AFIA_ENABLED=true
|
||||||
|
# AFIA_API_KEY=
|
||||||
|
# AFIA_PLATFORM_API_URL=https://ladill.com/api
|
||||||
|
# AFIA_PLATFORM_API_KEY=
|
||||||
|
|
||||||
# Platform events webhook (user/org lifecycle from the monolith)
|
# Platform events webhook (user/org lifecycle from the monolith)
|
||||||
SERVICE_EVENTS_INBOUND_SECRET=
|
SERVICE_EVENTS_INBOUND_SECRET=
|
||||||
|
|
||||||
|
|||||||
@@ -12,7 +12,11 @@ class AfiaService
|
|||||||
{
|
{
|
||||||
public function enabled(): bool
|
public function enabled(): bool
|
||||||
{
|
{
|
||||||
return (bool) config('afia.enabled', true) && (string) config('afia.api_key', '') !== '';
|
if (! (bool) config('afia.enabled', true)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->hasLocalKey() || $this->hasPlatformRelay();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -25,6 +29,19 @@ class AfiaService
|
|||||||
throw new RuntimeException('Afia is not configured.');
|
throw new RuntimeException('Afia is not configured.');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if ($this->hasLocalKey()) {
|
||||||
|
return $this->chatLocally($message, $history, $context);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->chatViaPlatform($message, $history, $context);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<int, array{role: string, text: string}> $history
|
||||||
|
* @param array<string, mixed> $context
|
||||||
|
*/
|
||||||
|
private function chatLocally(string $message, array $history, array $context): string
|
||||||
|
{
|
||||||
$provider = (string) config('afia.provider', 'openai');
|
$provider = (string) config('afia.provider', 'openai');
|
||||||
$model = (string) config('afia.model', 'gpt-4o-mini');
|
$model = (string) config('afia.model', 'gpt-4o-mini');
|
||||||
$apiKey = (string) config('afia.api_key');
|
$apiKey = (string) config('afia.api_key');
|
||||||
@@ -44,6 +61,49 @@ class AfiaService
|
|||||||
: $this->viaOpenAi($model, $apiKey, $messages);
|
: $this->viaOpenAi($model, $apiKey, $messages);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<int, array{role: string, text: string}> $history
|
||||||
|
* @param array<string, mixed> $context
|
||||||
|
*/
|
||||||
|
private function chatViaPlatform(string $message, array $history, array $context): string
|
||||||
|
{
|
||||||
|
$base = rtrim((string) config('afia.platform_api_url', ''), '/');
|
||||||
|
$token = (string) config('afia.platform_api_key', '');
|
||||||
|
|
||||||
|
$res = Http::withToken($token)->acceptJson()->timeout(50)->post($base.'/afia/chat', [
|
||||||
|
'product' => (string) config('afia.product', 'frontdesk'),
|
||||||
|
'message' => $message,
|
||||||
|
'history' => $history,
|
||||||
|
'system_prompt' => $this->systemPrompt($context),
|
||||||
|
]);
|
||||||
|
|
||||||
|
if ($res->status() === 503) {
|
||||||
|
throw new RuntimeException('Afia is not configured on the platform.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($res->failed()) {
|
||||||
|
throw new RuntimeException('Platform Afia relay failed: '.$res->status());
|
||||||
|
}
|
||||||
|
|
||||||
|
$reply = trim((string) $res->json('reply', ''));
|
||||||
|
if ($reply === '') {
|
||||||
|
throw new RuntimeException('Platform Afia relay returned an empty response.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return $reply;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function hasLocalKey(): bool
|
||||||
|
{
|
||||||
|
return (string) config('afia.api_key', '') !== '';
|
||||||
|
}
|
||||||
|
|
||||||
|
private function hasPlatformRelay(): bool
|
||||||
|
{
|
||||||
|
return rtrim((string) config('afia.platform_api_url', ''), '/') !== ''
|
||||||
|
&& (string) config('afia.platform_api_key', '') !== '';
|
||||||
|
}
|
||||||
|
|
||||||
private function viaOpenAi(string $model, string $apiKey, array $messages): string
|
private function viaOpenAi(string $model, string $apiKey, array $messages): string
|
||||||
{
|
{
|
||||||
$res = Http::withToken($apiKey)->acceptJson()->timeout(45)
|
$res = Http::withToken($apiKey)->acceptJson()->timeout(45)
|
||||||
@@ -63,7 +123,7 @@ class AfiaService
|
|||||||
|
|
||||||
private function viaAnthropic(string $model, string $apiKey, array $messages): string
|
private function viaAnthropic(string $model, string $apiKey, array $messages): string
|
||||||
{
|
{
|
||||||
$system = $messages[0]['content'];
|
$system = $messages[0]['content'] ?? '';
|
||||||
$turns = array_values(array_filter($messages, fn ($m) => $m['role'] !== 'system'));
|
$turns = array_values(array_filter($messages, fn ($m) => $m['role'] !== 'system'));
|
||||||
|
|
||||||
$res = Http::withHeaders([
|
$res = Http::withHeaders([
|
||||||
|
|||||||
@@ -6,5 +6,9 @@ return [
|
|||||||
'enabled' => (bool) env('AFIA_ENABLED', true),
|
'enabled' => (bool) env('AFIA_ENABLED', true),
|
||||||
'provider' => env('AFIA_PROVIDER', 'openai'), // openai | anthropic
|
'provider' => env('AFIA_PROVIDER', 'openai'), // openai | anthropic
|
||||||
'model' => env('AFIA_MODEL', 'gpt-4o-mini'),
|
'model' => env('AFIA_MODEL', 'gpt-4o-mini'),
|
||||||
|
// Optional direct provider key. When empty, Afia uses the platform relay below.
|
||||||
'api_key' => env('AFIA_API_KEY', env('OPENAI_API_KEY')),
|
'api_key' => env('AFIA_API_KEY', env('OPENAI_API_KEY')),
|
||||||
|
// Platform relay — uses the monolith's configured AI keys (same pattern as Billing).
|
||||||
|
'platform_api_url' => env('AFIA_PLATFORM_API_URL', env('IDENTITY_API_URL', 'https://ladill.com/api')),
|
||||||
|
'platform_api_key' => env('AFIA_PLATFORM_API_KEY', env('IDENTITY_API_KEY_FRONTDESK')),
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -110,7 +110,11 @@ Alpine.data('afia', (config = {}) => ({
|
|||||||
body: JSON.stringify({ message: text, history }),
|
body: JSON.stringify({ message: text, history }),
|
||||||
});
|
});
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
|
if (! res.ok) {
|
||||||
|
this.messages.push({ role: 'assistant', text: data.message || data.reply || 'Sorry, I could not respond.' });
|
||||||
|
} else {
|
||||||
this.messages.push({ role: 'assistant', text: data.reply || data.message || 'Sorry, I could not respond.' });
|
this.messages.push({ role: 'assistant', text: data.reply || data.message || 'Sorry, I could not respond.' });
|
||||||
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
this.messages.push({ role: 'assistant', text: 'Network error — please try again.' });
|
this.messages.push({ role: 'assistant', text: 'Network error — please try again.' });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ use App\Models\Organization;
|
|||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Services\Afia\AfiaService;
|
use App\Services\Afia\AfiaService;
|
||||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Illuminate\Support\Facades\Http;
|
||||||
use Tests\TestCase;
|
use Tests\TestCase;
|
||||||
|
|
||||||
class FrontdeskAfiaTest extends TestCase
|
class FrontdeskAfiaTest extends TestCase
|
||||||
@@ -75,7 +76,11 @@ class FrontdeskAfiaTest extends TestCase
|
|||||||
|
|
||||||
public function test_afia_chat_returns_service_unavailable_when_disabled(): void
|
public function test_afia_chat_returns_service_unavailable_when_disabled(): void
|
||||||
{
|
{
|
||||||
config(['afia.enabled' => false, 'afia.api_key' => '']);
|
config([
|
||||||
|
'afia.enabled' => false,
|
||||||
|
'afia.api_key' => '',
|
||||||
|
'afia.platform_api_key' => '',
|
||||||
|
]);
|
||||||
|
|
||||||
$this->actingAs($this->user)
|
$this->actingAs($this->user)
|
||||||
->postJson(route('frontdesk.ai.chat'), [
|
->postJson(route('frontdesk.ai.chat'), [
|
||||||
@@ -83,4 +88,27 @@ class FrontdeskAfiaTest extends TestCase
|
|||||||
])
|
])
|
||||||
->assertStatus(503);
|
->assertStatus(503);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function test_afia_chat_uses_platform_relay_when_local_key_missing(): void
|
||||||
|
{
|
||||||
|
config([
|
||||||
|
'afia.enabled' => true,
|
||||||
|
'afia.api_key' => '',
|
||||||
|
'afia.platform_api_url' => 'https://platform.test/api',
|
||||||
|
'afia.platform_api_key' => 'frontdesk-relay-key',
|
||||||
|
]);
|
||||||
|
|
||||||
|
Http::fake([
|
||||||
|
'platform.test/api/afia/chat' => Http::response([
|
||||||
|
'reply' => 'Open Devices and register a Visitor Kiosk.',
|
||||||
|
]),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->actingAs($this->user)
|
||||||
|
->postJson(route('frontdesk.ai.chat'), [
|
||||||
|
'message' => 'How do I set up a kiosk?',
|
||||||
|
])
|
||||||
|
->assertOk()
|
||||||
|
->assertJsonPath('reply', 'Open Devices and register a Visitor Kiosk.');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user