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; }