Clean-room copy of the reusable engines from comiida, with all instance data, secrets, dependencies, and build output excluded: - app/ Astro theme skeleton (no comiida blog posts; hero image -> placeholder) - api/ SeedProject PHP framework (no vendor/.env/config.php) - content-pipeline/ engine only (scripts/admin/prompts; empty runtime state) - astroagent.config.json + app/.astroagent/skills Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SYHWLHihq3v9nxNwoPCKSn
172 lines
6.2 KiB
PHP
172 lines
6.2 KiB
PHP
<?php
|
|
|
|
namespace App\LLM;
|
|
|
|
/**
|
|
* Google Gemini provider implementation.
|
|
*
|
|
* Config keys (from sp_orgs_meta metval JSON):
|
|
* secretKey — required — Google AI API key
|
|
* model — optional — default: gemini-2.5-flash
|
|
* max_tokens — optional — default: 4096
|
|
* temperature — optional — default: 0.7
|
|
*
|
|
* Note: Gemini uses a different API structure from OpenAI-compatible providers.
|
|
* Messages use 'parts' arrays and roles are 'user' / 'model' (not 'assistant').
|
|
*
|
|
* Usage:
|
|
* $llm = LLMManager::forOrg($orgId, 'gemini');
|
|
* $res = $llm->sendPrompt([['role' => 'user', 'content' => 'Hello']]);
|
|
* echo $res['content'];
|
|
*/
|
|
class svcGemini extends LLMProvider
|
|
{
|
|
private const API_BASE = 'https://generativelanguage.googleapis.com/v1beta/models';
|
|
private const API_CHAT = ':generateContent';
|
|
private const API_STREAM = ':streamGenerateContent';
|
|
|
|
private static array $availableModels = [
|
|
['id' => 'gemini-2.5-flash', 'label' => 'Gemini 2.5 Flash'],
|
|
['id' => 'gemini-2.5-flash-lite', 'label' => 'Gemini 2.5 Flash Lite'],
|
|
['id' => 'gemini-2.0-flash', 'label' => 'Gemini 2.0 Flash'],
|
|
['id' => 'gemini-1.5-pro', 'label' => 'Gemini 1.5 Pro'],
|
|
['id' => 'gemini-1.5-flash', 'label' => 'Gemini 1.5 Flash'],
|
|
];
|
|
|
|
public function __construct(array $config)
|
|
{
|
|
if (empty($config['secretKey'])) {
|
|
throw new \Exception('Gemini secretKey is required');
|
|
}
|
|
|
|
$this->apiKey = $config['secretKey'];
|
|
$this->model = $config['model'] ?? 'gemini-2.5-flash';
|
|
$this->maxTokens = (int)($config['max_tokens'] ?? 4096);
|
|
$this->temperature = (float)($config['temperature'] ?? 0.7);
|
|
}
|
|
|
|
/**
|
|
* Send a chat prompt and return the full response.
|
|
*/
|
|
public function sendPrompt(array $messages, array $options = []): array
|
|
{
|
|
try {
|
|
$model = $options['model'] ?? $this->model;
|
|
$url = self::API_BASE . '/' . $model . self::API_CHAT . '?key=' . $this->apiKey;
|
|
$payload = $this->buildPayload($messages, $options);
|
|
|
|
$response = $this->curlPost($url, $this->headers(), $payload);
|
|
|
|
$content = $response['candidates'][0]['content']['parts'][0]['text'] ?? '';
|
|
$usage = $response['usageMetadata'] ?? [];
|
|
|
|
return $this->successResponse($content, [
|
|
'prompt_tokens' => $usage['promptTokenCount'] ?? 0,
|
|
'completion_tokens' => $usage['candidatesTokenCount'] ?? 0,
|
|
'total_tokens' => $usage['totalTokenCount'] ?? 0,
|
|
]);
|
|
|
|
} catch (\Exception $e) {
|
|
return $this->errorResponse($e->getMessage());
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Stream a chat prompt, calling $callback with each text chunk.
|
|
*
|
|
* @param callable $callback function(string $chunk)
|
|
*/
|
|
public function streamPrompt(array $messages, array $options, callable $callback): array
|
|
{
|
|
try {
|
|
$model = $options['model'] ?? $this->model;
|
|
$url = self::API_BASE . '/' . $model . self::API_STREAM . '?key=' . $this->apiKey . '&alt=sse';
|
|
$payload = $this->buildPayload($messages, $options);
|
|
|
|
$this->curlStream($url, $this->headers(), $payload, function(array $chunk) use ($callback) {
|
|
$text = $chunk['candidates'][0]['content']['parts'][0]['text'] ?? '';
|
|
if ($text !== '') {
|
|
$callback($text);
|
|
}
|
|
});
|
|
|
|
return ['success' => true, 'error' => ''];
|
|
|
|
} catch (\Exception $e) {
|
|
return ['success' => false, 'error' => $e->getMessage()];
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Return supported Gemini models.
|
|
*/
|
|
public function getModels(): array
|
|
{
|
|
return self::$availableModels;
|
|
}
|
|
|
|
/**
|
|
* Validate the API key with a minimal request.
|
|
*/
|
|
public function testConnection(): array
|
|
{
|
|
try {
|
|
$start = microtime(true);
|
|
$model = $this->model;
|
|
$url = self::API_BASE . '/' . $model . self::API_CHAT . '?key=' . $this->apiKey;
|
|
$payload = [
|
|
'contents' => [['role' => 'user', 'parts' => [['text' => 'hi']]]],
|
|
'generationConfig' => ['maxOutputTokens' => 1],
|
|
];
|
|
|
|
$this->curlPost($url, $this->headers(), $payload);
|
|
|
|
$latency = (int)((microtime(true) - $start) * 1000);
|
|
|
|
return ['success' => true, 'latency_ms' => $latency, 'error' => ''];
|
|
|
|
} catch (\Exception $e) {
|
|
return ['success' => false, 'latency_ms' => 0, 'error' => $e->getMessage()];
|
|
}
|
|
}
|
|
|
|
// ─── Private Helpers ─────────────────────────────────────────────────────
|
|
|
|
private function headers(): array
|
|
{
|
|
return ['Content-Type: application/json'];
|
|
}
|
|
|
|
private function buildPayload(array $messages, array $options): array
|
|
{
|
|
$maxTokens = (int)($options['max_tokens'] ?? $this->maxTokens);
|
|
$temperature = (float)($options['temperature'] ?? $this->temperature);
|
|
|
|
// Convert OpenAI-style messages to Gemini format
|
|
// Roles: 'user' stays 'user', 'assistant' becomes 'model'
|
|
$contents = [];
|
|
foreach ($messages as $msg) {
|
|
$contents[] = [
|
|
'role' => $msg['role'] === 'assistant' ? 'model' : 'user',
|
|
'parts' => [['text' => $msg['content']]],
|
|
];
|
|
}
|
|
|
|
$payload = [
|
|
'contents' => $contents,
|
|
'generationConfig' => [
|
|
'maxOutputTokens' => $maxTokens,
|
|
'temperature' => $temperature,
|
|
],
|
|
];
|
|
|
|
// Gemini uses a top-level 'systemInstruction' for system prompts
|
|
if ($this->systemPrompt !== '') {
|
|
$payload['systemInstruction'] = [
|
|
'parts' => [['text' => $this->systemPrompt]],
|
|
];
|
|
}
|
|
|
|
return $payload;
|
|
}
|
|
}
|