192 lines
7.5 KiB
PHP
192 lines
7.5 KiB
PHP
<?php
|
|
declare(strict_types=1);
|
|
|
|
require_once __DIR__ . '/providers/AnthropicProvider.php';
|
|
require_once __DIR__ . '/providers/FakeProvider.php';
|
|
|
|
/** Baut eine PDO-Verbindung aus config.php auf. */
|
|
function ai_backend_db(array $config): PDO
|
|
{
|
|
$db = $config['db'];
|
|
$dsn = "mysql:host={$db['host']};dbname={$db['name']};charset=utf8mb4";
|
|
return new PDO($dsn, $db['user'], $db['pass'], [
|
|
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
|
|
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
|
|
PDO::ATTR_EMULATE_PREPARES => false,
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* Liest den Authorization-Header — auf vielen Apache/PHP-FPM-Setups landet er NICHT in
|
|
* $_SERVER['HTTP_AUTHORIZATION'] (Header wird von PHP-FPM standardmäßig verworfen), sondern nur
|
|
* in $_SERVER['REDIRECT_HTTP_AUTHORIZATION'] (nach der .htaccess-Weiterleitung, siehe dort) oder
|
|
* ist nur über getallheaders() erreichbar. Alle drei Quellen abklappern, statt sich auf eine zu
|
|
* verlassen — genau das war die Ursache für "Login klappt, danach sofort Session abgelaufen".
|
|
*/
|
|
function ai_backend_authorization_header(): string
|
|
{
|
|
if (!empty($_SERVER['HTTP_AUTHORIZATION'])) {
|
|
return $_SERVER['HTTP_AUTHORIZATION'];
|
|
}
|
|
if (!empty($_SERVER['REDIRECT_HTTP_AUTHORIZATION'])) {
|
|
return $_SERVER['REDIRECT_HTTP_AUTHORIZATION'];
|
|
}
|
|
if (function_exists('getallheaders')) {
|
|
foreach (getallheaders() as $name => $value) {
|
|
if (strcasecmp($name, 'Authorization') === 0) {
|
|
return $value;
|
|
}
|
|
}
|
|
}
|
|
return '';
|
|
}
|
|
|
|
/**
|
|
* Bearer-Token aus dem Authorization-Header lesen, gegen tokens.token_hash prüfen
|
|
* (SHA-256, der Klartext wird nie gespeichert) und den zugehörigen aktiven User zurückgeben.
|
|
* Sendet bei fehlendem/ungültigem/abgelaufenem Token selbst eine 401-Antwort und beendet das Skript.
|
|
*/
|
|
function ai_backend_authenticate(PDO $pdo): array
|
|
{
|
|
$header = ai_backend_authorization_header();
|
|
if (!preg_match('/^Bearer\s+(.+)$/i', $header, $m)) {
|
|
ai_backend_fail(401, 'Kein gültiges Token übermittelt.');
|
|
}
|
|
$tokenHash = hash('sha256', $m[1]);
|
|
|
|
$stmt = $pdo->prepare(
|
|
'SELECT u.* FROM users u
|
|
JOIN tokens t ON t.user_id = u.id
|
|
WHERE t.token_hash = ? AND t.expires_at > NOW() AND u.is_active = 1
|
|
LIMIT 1'
|
|
);
|
|
$stmt->execute([$tokenHash]);
|
|
$user = $stmt->fetch();
|
|
if (!$user) {
|
|
ai_backend_fail(401, 'Ungültiges oder abgelaufenes Token.');
|
|
}
|
|
return $user;
|
|
}
|
|
|
|
/** Einheitliche Fehlerantwort als JSON, beendet danach das Skript. */
|
|
function ai_backend_fail(int $httpStatus, string $message): never
|
|
{
|
|
http_response_code($httpStatus);
|
|
header('Content-Type: application/json');
|
|
echo json_encode(['error' => $message]);
|
|
exit;
|
|
}
|
|
|
|
/**
|
|
* Dekodiert eine JSON-Antwort des LLM. Modelle setzen trotz entsprechender Anweisung gelegentlich
|
|
* Markdown-Codezäune oder einen kurzen Begleittext um das eigentliche JSON. Diese rein
|
|
* syntaktischen Zusätze sollen eine ansonsten gültige Antwort nicht unbrauchbar machen.
|
|
*/
|
|
function ai_backend_decode_json_response(string $content): ?array
|
|
{
|
|
$content = trim($content, "\xEF\xBB\xBF \t\n\r\0\x0B");
|
|
$candidates = [$content];
|
|
|
|
if (preg_match('/```(?:json)?\s*([\s\S]*?)\s*```/i', $content, $match) === 1) {
|
|
$candidates[] = trim($match[1]);
|
|
}
|
|
|
|
$firstBrace = strpos($content, '{');
|
|
$lastBrace = strrpos($content, '}');
|
|
if ($firstBrace !== false && $lastBrace !== false && $lastBrace >= $firstBrace) {
|
|
$candidates[] = substr($content, $firstBrace, $lastBrace - $firstBrace + 1);
|
|
}
|
|
|
|
foreach (array_unique($candidates) as $candidate) {
|
|
$decoded = json_decode($candidate, true);
|
|
if (is_array($decoded) && json_last_error() === JSON_ERROR_NONE) {
|
|
return $decoded;
|
|
}
|
|
}
|
|
|
|
error_log(sprintf(
|
|
'LLM JSON parse failed: %s; length=%d; sha256=%s',
|
|
json_last_error_msg(),
|
|
strlen($content),
|
|
hash('sha256', $content)
|
|
));
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Ruft das konfigurierte LLM (oder den FakeProvider für lokale Tests, siehe README.md) auf und
|
|
* verrechnet die echten Token-Kosten gegen das Guthaben des Nutzers. Gemeinsame Logik für jeden
|
|
* Endpunkt, der einen LLM-Call abrechnet (plan.php, explain.php) — die SELECT-FOR-UPDATE-
|
|
* Absicherung gegen Race Conditions bei gleichzeitigen Anfragen desselben Nutzers soll nicht an
|
|
* mehreren Stellen gepflegt werden müssen. Gibt bei Erfolg das Provider-Ergebnis unverändert
|
|
* zurück (inkl. "content", das der Aufrufer je nach Endpunkt selbst auswertet).
|
|
*/
|
|
function ai_backend_call_and_charge(PDO $pdo, array $config, array $user, string $systemPrompt, string $userContent): array
|
|
{
|
|
$useFake = getenv('AI_BACKEND_FAKE_PROVIDER') === '1';
|
|
if ($useFake) {
|
|
$provider = new FakeProvider();
|
|
$modelKey = 'fake';
|
|
} else {
|
|
$providerName = $config['llm_provider'];
|
|
if ($providerName !== 'anthropic') {
|
|
ai_backend_fail(500, "Provider '$providerName' ist nicht implementiert.");
|
|
}
|
|
$modelKey = $config['anthropic']['model'];
|
|
$provider = new AnthropicProvider($config['anthropic']['api_key'], $modelKey);
|
|
}
|
|
|
|
try {
|
|
$result = $provider->sendMessage($systemPrompt, $userContent, $config['max_output_tokens']);
|
|
} catch (RuntimeException $e) {
|
|
ai_backend_fail(502, $e->getMessage());
|
|
}
|
|
|
|
$defaultPricing = ['input' => 0, 'output' => 0, 'cache_write' => 0, 'cache_read' => 0];
|
|
$pricing = $config['pricing'][$modelKey] ?? ($useFake ? $defaultPricing : null);
|
|
if ($pricing === null) {
|
|
ai_backend_fail(500, "Kein Preis für Modell '$modelKey' konfiguriert.");
|
|
}
|
|
$pricing += $defaultPricing; // fehlende cache_write/cache_read in älteren config.php-Einträgen -> 0
|
|
|
|
$cacheCreationTokens = $result['cacheCreationInputTokens'] ?? 0;
|
|
$cacheReadTokens = $result['cacheReadInputTokens'] ?? 0;
|
|
$cost = ($result['inputTokens'] / 1_000_000 * $pricing['input'])
|
|
+ ($result['outputTokens'] / 1_000_000 * $pricing['output'])
|
|
+ ($cacheCreationTokens / 1_000_000 * $pricing['cache_write'])
|
|
+ ($cacheReadTokens / 1_000_000 * $pricing['cache_read']);
|
|
|
|
// Guthaben abziehen und Transaktion protokollieren — mit Zeilensperre, damit zwei gleichzeitige
|
|
// Anfragen desselben Nutzers das Guthaben nicht versehentlich unter 0 drücken können.
|
|
$pdo->beginTransaction();
|
|
try {
|
|
$stmt = $pdo->prepare('SELECT balance_usd FROM users WHERE id = ? FOR UPDATE');
|
|
$stmt->execute([$user['id']]);
|
|
$currentBalance = (float) $stmt->fetchColumn();
|
|
|
|
if ($currentBalance - $cost < 0) {
|
|
$pdo->rollBack();
|
|
ai_backend_fail(402, 'Guthaben würde durch diese Anfrage negativ werden.');
|
|
}
|
|
|
|
$newBalance = $currentBalance - $cost;
|
|
$pdo->prepare('UPDATE users SET balance_usd = ? WHERE id = ?')->execute([$newBalance, $user['id']]);
|
|
$pdo->prepare(
|
|
'INSERT INTO transactions
|
|
(user_id, type, model, input_tokens, output_tokens,
|
|
cache_creation_input_tokens, cache_read_input_tokens, cost_usd, balance_after)
|
|
VALUES (?, "usage", ?, ?, ?, ?, ?, ?, ?)'
|
|
)->execute([
|
|
$user['id'], $modelKey, $result['inputTokens'], $result['outputTokens'],
|
|
$cacheCreationTokens, $cacheReadTokens, $cost, $newBalance,
|
|
]);
|
|
|
|
$pdo->commit();
|
|
} catch (Throwable $e) {
|
|
$pdo->rollBack();
|
|
throw $e;
|
|
}
|
|
|
|
return $result;
|
|
}
|