false, 'provider' => 'openai-compatible', 'baseUrl' => 'https://api.openai.com/v1', 'apiKey' => '', 'providerCredentials' => [ 'openai-compatible' => [ 'baseUrl' => 'https://api.openai.com/v1', 'apiKey' => '', 'organization' => '' ], 'openrouter' => [ 'baseUrl' => 'https://openrouter.ai/api/v1', 'apiKey' => '', 'organization' => '' ], 'anthropic' => [ 'baseUrl' => 'https://api.anthropic.com/v1', 'apiKey' => '', 'organization' => '' ], 'kie' => [ 'baseUrl' => 'https://api.kie.ai', 'apiKey' => '', 'organization' => '' ] ], 'chatModel' => raijuAiModelCatalog()['openai-compatible']['text']['chat'], 'textModel' => raijuAiModelCatalog()['openai-compatible']['text']['text'], // Reasoning effort per purpose: recommended | low | medium | high. Installs from before this setting existed load as "legacy" (nothing sent). 'reasoning' => ['chat' => 'recommended', 'text' => 'recommended'], // Models and effort remembered per provider, so switching the provider restores that provider's choices. 'providerModels' => [], 'imageModel' => raijuAiModelCatalog()['openai-compatible']['images'][0]['value'], // The image service is chosen apart from the chat provider. "inherit" keeps the chat provider's image models (see includes/ai/image-services.php). 'imageService' => [ 'provider' => 'inherit', 'model' => '', 'models' => [], 'custom' => ['baseUrl' => '', 'sizeMode' => 'size', 'authStyle' => 'bearer', 'extraParams' => []] ], // Keys of the image services ({id: {apiKey}}); they are stored in ai-secrets.json and never leave the server. 'imageServices' => [], 'organization' => '', 'allowLocalProvider' => false, 'assistantForceEnglish' => false, 'assistantSurfaces' => [ 'visualEditor' => true, 'dashboard' => true ], 'features' => [ 'backendAssistant' => true, 'seoTextGeneration' => true, 'imageGeneration' => true ], 'limits' => [ 'monthlyBudgetCents' => 1000, 'dailyRequests' => 300, 'dailyTextRequests' => 250, 'dailyImageRequests' => 10, 'maxInputTokens' => 64000, 'maxOutputTokens' => 16000, 'requestTimeoutSeconds' => 300 ], 'pricing' => [ // Prices per provider and model: {"provider|model": {input, output, cacheRead?, cacheWrite?, currency: USD|EUR, migrated?}}. // Without an entry the price catalog applies; without that, the cost is reported as unknown. 'models' => [], // Price per image by "service|model": {perImage, currency: USD|EUR}. Without one the registry price, then imageCentsPerRequest applies. 'imageModels' => [], 'usdPerEur' => 0, 'usdPerEurAsOf' => '', 'imageCentsPerRequest' => 5 ], 'systemPrompts' => [ 'assistant' => 'You are the raiju CMS assistant. Answer clearly and practically. If a task needs admin access, explain where to do it in the raiju dashboard. Do not invent settings that do not exist.', 'seo' => 'You write concise, search-friendly website copy. Return only the requested text unless a format is explicitly requested.' ] ]; } function raijuAiNormalizeProviderCredentials(array $settings, array $defaults = []): array { $defaults = $defaults ?: raijuAiDefaults(); $credentials = $defaults['providerCredentials']; if (is_array($settings['providerCredentials'] ?? null)) { $credentials = array_replace_recursive($credentials, $settings['providerCredentials']); } $activeProvider = (string)($settings['provider'] ?? $defaults['provider']); if (!isset($credentials[$activeProvider])) { $activeProvider = $defaults['provider']; } if (!empty($settings['apiKey']) && empty($credentials[$activeProvider]['apiKey'])) { $credentials[$activeProvider]['apiKey'] = (string)$settings['apiKey']; } if (!empty($settings['baseUrl'])) { $credentials[$activeProvider]['baseUrl'] = rtrim((string)$settings['baseUrl'], '/'); } if (!empty($settings['organization']) && empty($credentials[$activeProvider]['organization'])) { $credentials[$activeProvider]['organization'] = (string)$settings['organization']; } foreach ($credentials as $provider => $providerCredentials) { $credentials[$provider] = [ 'baseUrl' => rtrim((string)($providerCredentials['baseUrl'] ?? $defaults['providerCredentials'][$provider]['baseUrl'] ?? ''), '/'), 'apiKey' => (string)($providerCredentials['apiKey'] ?? ''), 'organization' => (string)($providerCredentials['organization'] ?? '') ]; } return $credentials; } function raijuAiResolveProviderSettings(array $settings): array { $defaults = raijuAiDefaults(); $provider = (string)($settings['provider'] ?? $defaults['provider']); if (!isset($defaults['providerCredentials'][$provider])) { $provider = $defaults['provider']; $settings['provider'] = $provider; } $settings['providerCredentials'] = raijuAiNormalizeProviderCredentials($settings, $defaults); $credentials = $settings['providerCredentials'][$provider] ?? $defaults['providerCredentials'][$provider]; $settings['baseUrl'] = rtrim((string)($credentials['baseUrl'] ?? $defaults['providerCredentials'][$provider]['baseUrl']), '/'); $settings['apiKey'] = (string)($credentials['apiKey'] ?? ''); $settings['organization'] = (string)($credentials['organization'] ?? ''); // The models and reasoning levels of the active provider (earlier installs have none per provider: the flat values stay). $slot = $settings['providerModels'][$provider] ?? null; if (is_array($slot)) { foreach (['chatModel', 'textModel'] as $field) { if (array_key_exists($field, $slot)) $settings[$field] = (string)$slot[$field]; } if (is_array($slot['reasoning'] ?? null)) $settings['reasoning'] = array_replace((array)($settings['reasoning'] ?? []), $slot['reasoning']); } return $settings; } function raijuAiLoadSettings(bool $public = false): array { $settings = raijuAiDefaults(); $loaded = raijuAiReadStoredSettings(); if ($loaded) { $settings = array_replace_recursive($settings, $loaded); // Saved before the reasoning setting existed: keep the former request behavior until someone chooses a level. if (!isset($loaded['reasoning'])) { $settings['reasoning'] = ['chat' => 'legacy', 'text' => 'legacy']; } } $settings = raijuAiResolveProviderSettings($settings); if ($settings['textModel'] === '') { $settings['textModel'] = $settings['chatModel']; } // The former global price pair becomes overrides for the models in use, marked as carried over. if ($loaded && !isset($loaded['pricing']['models']) && isset($loaded['pricing']['inputCentsPerMillion'])) { $settings['pricing']['models'] = raijuAiMigrateLegacyPricing($loaded, raijuAiProviderKey($settings), [$settings['chatModel'], $settings['textModel']]); } if ($public) { // The image service: the choice and, per service, only whether a key exists and where it comes from. // Computed first: it reads the keys that are removed below. $imageStatus = raijuAiImageServicePublic($settings); $settings['imageService'] = array_intersect_key($imageStatus, array_flip(['provider', 'model', 'models', 'custom'])); $settings['imageServiceStatus'] = array_intersect_key($imageStatus, array_flip(['services', 'active'])); $settings['hasApiKey'] = trim((string)($settings['apiKey'] ?? '')) !== ''; foreach ($settings['providerCredentials'] as $provider => $credentials) { $settings['providerCredentials'][$provider] = [ 'baseUrl' => (string)($credentials['baseUrl'] ?? ''), 'organization' => (string)($credentials['organization'] ?? ''), 'hasApiKey' => trim((string)($credentials['apiKey'] ?? '')) !== '' ]; } unset($settings['apiKey']); unset($settings['imageServices']); $settings['pricingInfo'] = raijuAiPricingForUi($settings); // The agent, research and stock sections: limits and rights yes, keys never. require_once __DIR__ . '/agent/agent-config.php'; $settings = array_replace($settings, raijuAgentSettingsForPublic($settings)); } return $settings; } function raijuAiSaveSettings(array $input, array $existing = []): array { $defaults = raijuAiDefaults(); $existing = $existing ?: raijuAiLoadSettings(false); $provider = (string)($input['provider'] ?? $existing['provider'] ?? $defaults['provider']); if (!in_array($provider, ['openai-compatible', 'openrouter', 'anthropic', 'kie'], true)) { throw new RuntimeException('Unsupported AI provider.'); } $providerCredentials = raijuAiNormalizeProviderCredentials($existing, $defaults); $currentProviderCredentials = $providerCredentials[$provider] ?? $defaults['providerCredentials'][$provider]; $baseUrl = rtrim(trim((string)($input['baseUrl'] ?? $currentProviderCredentials['baseUrl'] ?? $defaults['providerCredentials'][$provider]['baseUrl'])), '/'); if ($baseUrl !== '' && !filter_var($baseUrl, FILTER_VALIDATE_URL)) { throw new RuntimeException('Invalid AI base URL.'); } $allowLocal = !empty($input['allowLocalProvider']); if ($baseUrl !== '') { raijuAiValidateBaseUrl($baseUrl, $allowLocal); } $apiKey = trim((string)($input['apiKey'] ?? '')); // A masked placeholder is never a key: only a real entry replaces the stored one. if (raijuAiIsMaskedSecret($apiKey)) $apiKey = ''; $newApiKeyProvided = $apiKey !== ''; $hadProviderApiKey = !empty($currentProviderCredentials['apiKey']); if ($apiKey === '' && !empty($currentProviderCredentials['apiKey'])) { $apiKey = (string)$currentProviderCredentials['apiKey']; } if (!empty($input['clearApiKey'])) { $apiKey = ''; } $organization = substr(trim((string)($input['organization'] ?? $currentProviderCredentials['organization'] ?? '')), 0, 120); $providerCredentials[$provider] = [ 'baseUrl' => $baseUrl, 'apiKey' => $apiKey, 'organization' => $organization ]; $imageModel = array_key_exists('imageModel', $input) ? raijuAiCleanModelAllowEmpty((string)$input['imageModel']) : raijuAiCleanModel((string)$defaults['imageModel']); if ($provider === 'openrouter') { $imageModel = raijuAiNormalizeOpenRouterImageModel($imageModel); } elseif ($provider === 'kie') { $imageModel = raijuAiNormalizeKieImageModel($imageModel); } // Models and reasoning levels of this provider; an empty model falls back to the provider's preset. $presets = raijuAiProviderModelDefaults($provider, $baseUrl); // What this provider had before: the flat values when it is the active one, otherwise its remembered slot. $before = $provider === ($existing['provider'] ?? '') ? $existing : (array)($existing['providerModels'][$provider] ?? []); $chatModel = raijuAiCleanModel((string)($input['chatModel'] ?? $before['chatModel'] ?? ''), $presets['chatModel']); $textModel = raijuAiCleanModel((string)($input['textModel'] ?? $input['chatModel'] ?? $before['textModel'] ?? ''), $presets['textModel']); if ($textModel === '') $textModel = $chatModel; $existingReasoning = (array)($before['reasoning'] ?? []); $reasoning = []; foreach (RAIJU_AI_PURPOSES as $purpose) { $reasoning[$purpose] = raijuAiCleanReasoningMode( $input['reasoning'][$purpose] ?? $existingReasoning[$purpose] ?? 'recommended', ($existingReasoning[$purpose] ?? '') === 'legacy', $purpose === 'text' ? $textModel : $chatModel, $provider, $baseUrl ); } $providerModels = (array)($existing['providerModels'] ?? []); $providerModels[$provider] = ['chatModel' => $chatModel, 'textModel' => $textModel, 'reasoning' => $reasoning]; $priceOverrides = raijuAiCleanPriceOverrides($input['pricing']['models'] ?? null, (array)($existing['pricing']['models'] ?? [])); if (!isset($input['pricing']['models']) && isset($input['pricing']['inputCentsPerMillion'])) { // Older callers still send the former global price pair: it becomes overrides for the chosen models. $priceOverrides = array_replace($priceOverrides, raijuAiMigrateLegacyPricing(['pricing' => $input['pricing']], $provider, [$chatModel, $textModel])); } $usdPerEur = (float)($input['pricing']['usdPerEur'] ?? $existing['pricing']['usdPerEur'] ?? 0); $usdPerEur = $usdPerEur >= 0.01 && $usdPerEur <= 1000 ? round($usdPerEur, 6) : 0.0; $usdPerEurAsOf = $usdPerEur > 0 ? (preg_match('/^\d{4}-\d{2}-\d{2}$/', (string)($input['pricing']['usdPerEurAsOf'] ?? '')) ? (string)$input['pricing']['usdPerEurAsOf'] : gmdate('Y-m-d')) : ''; if ($usdPerEur > 0 && $usdPerEur === (float)($existing['pricing']['usdPerEur'] ?? 0) && !isset($input['pricing']['usdPerEurAsOf'])) { $usdPerEurAsOf = (string)($existing['pricing']['usdPerEurAsOf'] ?? $usdPerEurAsOf); } // The image service and its keys: a masked placeholder never replaces a stored key, an empty entry keeps it. $imageInput = raijuAiImageServiceFromInput($input, $existing, $allowLocal); $imageServices = []; foreach (RAIJU_AI_IMAGE_SERVICE_IDS as $imageId) { $imageKey = $imageInput['keys'][$imageId] ?? trim((string)($existing['imageServices'][$imageId]['apiKey'] ?? '')); if ($imageKey !== '') $imageServices[$imageId] = ['apiKey' => $imageKey]; } $newImageKey = array_diff_key($imageInput['keys'], array_filter($existing['imageServices'] ?? [], fn($entry) => trim((string)($entry['apiKey'] ?? '')) !== '')); $settings = [ 'enabled' => !empty($input['enabled']), 'provider' => $provider, 'baseUrl' => $baseUrl, 'apiKey' => $apiKey, 'providerCredentials' => $providerCredentials, 'chatModel' => $chatModel, 'textModel' => $textModel, 'reasoning' => $reasoning, 'providerModels' => $providerModels, 'imageModel' => $imageModel, 'imageService' => $imageInput['imageService'], 'imageServices' => $imageServices, 'organization' => $organization, 'allowLocalProvider' => $allowLocal, 'assistantForceEnglish' => !empty($input['assistantForceEnglish']), 'assistantSurfaces' => [ 'visualEditor' => !array_key_exists('assistantSurfaces', $input) || !empty($input['assistantSurfaces']['visualEditor']), 'dashboard' => !array_key_exists('assistantSurfaces', $input) || !empty($input['assistantSurfaces']['dashboard']) ], 'features' => [ 'backendAssistant' => !empty($input['features']['backendAssistant']), 'seoTextGeneration' => !empty($input['features']['seoTextGeneration']), 'imageGeneration' => !empty($input['features']['imageGeneration']) ], 'limits' => [ 'monthlyBudgetCents' => raijuAiClampInt($input['limits']['monthlyBudgetCents'] ?? 1000, 0, 1000000), 'dailyRequests' => raijuAiClampInt($input['limits']['dailyRequests'] ?? 300, 0, 10000), 'dailyTextRequests' => raijuAiClampInt($input['limits']['dailyTextRequests'] ?? 250, 0, 10000), 'dailyImageRequests' => raijuAiClampInt($input['limits']['dailyImageRequests'] ?? 10, 0, 1000), 'maxInputTokens' => raijuAiClampInt($input['limits']['maxInputTokens'] ?? 64000, 100, 200000), 'maxOutputTokens' => raijuAiClampInt($input['limits']['maxOutputTokens'] ?? 16000, 16, 32000), 'requestTimeoutSeconds' => raijuAiClampInt($input['limits']['requestTimeoutSeconds'] ?? 300, 5, 600) ], 'pricing' => [ 'models' => $priceOverrides, 'imageModels' => raijuAiCleanImagePriceOverrides($input['pricing']['imageModels'] ?? null, (array)($existing['pricing']['imageModels'] ?? [])), 'usdPerEur' => $usdPerEur, 'usdPerEurAsOf' => $usdPerEurAsOf, 'imageCentsPerRequest' => raijuAiClampInt($input['pricing']['imageCentsPerRequest'] ?? $existing['pricing']['imageCentsPerRequest'] ?? 5, 0, 100000) ], 'systemPrompts' => [ 'assistant' => substr(trim((string)($input['systemPrompts']['assistant'] ?? $defaults['systemPrompts']['assistant'])), 0, 4000), 'seo' => substr(trim((string)($input['systemPrompts']['seo'] ?? $defaults['systemPrompts']['seo'])), 0, 4000) ] ]; require_once __DIR__ . '/agent/agent-config.php'; $settings = array_merge($settings, raijuAgentSettingsForSave($input, $existing)); if ($newApiKeyProvided && !$hadProviderApiKey && empty($input['clearApiKey'])) { $settings['enabled'] = true; $settings['assistantSurfaces']['visualEditor'] = true; $settings['assistantSurfaces']['dashboard'] = true; $settings['features']['backendAssistant'] = true; $settings['features']['seoTextGeneration'] = true; $settings['features']['imageGeneration'] = true; } // A first key for an image service switches image generation on, like a first chat key does. if ($newImageKey) { $settings['features']['imageGeneration'] = true; } // The keys go to content/ai-secrets.json, everything else to content/ai-settings.json. if (!raijuAiWriteStoredSettings($settings)) { throw new RuntimeException('Could not save AI settings.'); } return raijuAiLoadSettings(true); } function raijuAiUsageSummary(): array { $usage = raijuAiLoadUsage(); $today = gmdate('Y-m-d'); $month = gmdate('Y-m'); return [ 'reservations' => array_diff_key(raijuAiReservationSummary($usage), ['unresolved' => true]), 'today' => $usage['days'][$today] ?? raijuAiEmptyUsageBucket(), 'month' => $usage['months'][$month] ?? raijuAiEmptyUsageBucket(), 'updatedAt' => $usage['updatedAt'] ?? null, 'warnings' => raijuAiLimitWarnings(raijuAiLoadSettings(false), $usage) ]; } function raijuAiChat(array $messages, array $options = []): array { return raijuAiWithRequest(fn() => raijuAiChatExecute($messages, $options), $options['_requestId'] ?? null); } function raijuAiChatExecute(array $messages, array $options = []): array { $settings = raijuAiLoadSettings(false); raijuAiEnsureEnabled($settings); $feature = $options['feature'] ?? 'backendAssistant'; if ($feature !== '') { raijuAiEnsureFeature($settings, $feature); } $cleanMessages = raijuAiCleanMessages($messages); if (!$cleanMessages) { throw new RuntimeException('No AI messages provided.'); } $inputTokens = raijuAiEstimateTokens(json_encode($cleanMessages, JSON_UNESCAPED_UNICODE)); $maxInput = (int)$settings['limits']['maxInputTokens']; if ($inputTokens > $maxInput) { throw new RuntimeException('AI input is too long for the configured limit.'); } $model = (string)($options['model'] ?? $settings['chatModel']); $plan = raijuAiReasoningPlan($settings, $model, raijuAiOptionPurpose($options)); $maxOutput = raijuAiOutputBudget($settings, $options, $plan); $reservation = raijuAiReservationCost($settings, $model, $inputTokens, $maxOutput); raijuAiAssertWithinLimits($settings, 'text', $reservation['cents'], ['model' => $model, 'priced' => $reservation['priced']]); $body = raijuAiApplyReasoning([ 'model' => $model, 'messages' => $cleanMessages, 'max_tokens' => $maxOutput, 'temperature' => isset($options['temperature']) ? (float)$options['temperature'] : 0.3, 'stream' => false ], $plan, raijuAiIsAnthropicSettings($settings) ? 'anthropic' : 'chat'); $started = microtime(true); $response = raijuAiProviderRequest($settings, '/chat/completions', $body); $choice = $response['choices'][0] ?? []; $message = is_array($choice['message'] ?? null) ? $choice['message'] : []; $text = trim((string)($message['content'] ?? '')); if ($text === '') { if (!empty($message['reasoning']) || (($choice['finish_reason'] ?? '') === 'length')) { throw new RuntimeException('AI provider did not return a final answer before the output limit. Increase the max output tokens or use a non-reasoning model for this feature.'); } throw new RuntimeException('AI provider returned an empty response.'); } $usage = raijuAiSettleText($settings, $body['model'], $response['usage'] ?? null, $inputTokens, $text); raijuAiAudit('chat', true, raijuAiAuditUsage($body['model'], $usage, $plan) + [ 'durationMs' => (int)round((microtime(true) - $started) * 1000) ]); return [ 'text' => $text, 'usage' => raijuAiPublicUsage($usage), 'limits' => raijuAiUsageSummary() ]; } /** * Stream a chat completion, invoking $onDelta with each text fragment as it * arrives. Returns the same result shape as raijuAiChat(). Falls back to a * buffered request (one delta with the full text) when streaming is not * possible, so callers never need a second code path. */ function raijuAiChatStream(array $messages, array $options, callable $onDelta): array { return raijuAiWithRequest(fn() => raijuAiChatStreamExecute($messages, $options, $onDelta), $options['_requestId'] ?? null); } function raijuAiChatStreamExecute(array $messages, array $options, callable $onDelta): array { $settings = raijuAiLoadSettings(false); raijuAiEnsureEnabled($settings); $feature = $options['feature'] ?? 'backendAssistant'; if ($feature !== '') { raijuAiEnsureFeature($settings, $feature); } if (!function_exists('curl_init') || raijuAiIsKieSettings($settings)) { $result = raijuAiChat($messages, $options); $onDelta((string)$result['text']); return $result; } $cleanMessages = raijuAiCleanMessages($messages); if (!$cleanMessages) { throw new RuntimeException('No AI messages provided.'); } $inputTokens = raijuAiEstimateTokens(json_encode($cleanMessages, JSON_UNESCAPED_UNICODE)); if ($inputTokens > (int)$settings['limits']['maxInputTokens']) { throw new RuntimeException('AI input is too long for the configured limit.'); } $model = trim((string)($options['model'] ?? '')) ?: (string)$settings['chatModel']; $plan = raijuAiReasoningPlan($settings, $model, raijuAiOptionPurpose($options)); $maxOutput = raijuAiOutputBudget($settings, $options, $plan); $reservation = raijuAiReservationCost($settings, $model, $inputTokens, $maxOutput); raijuAiAssertWithinLimits($settings, 'text', $reservation['cents'], ['model' => $model, 'priced' => $reservation['priced']]); $temperature = isset($options['temperature']) ? (float)$options['temperature'] : 0.3; if (raijuAiIsAnthropicSettings($settings)) { $url = rtrim((string)$settings['baseUrl'], '/') . '/messages'; $headers = ['Content-Type: application/json', 'Accept: text/event-stream', 'anthropic-version: 2023-06-01']; $key = trim((string)($settings['apiKey'] ?? '')); if ($key !== '') { $headers[] = 'x-api-key: ' . $key; } $system = []; $chatMessages = []; foreach ($cleanMessages as $message) { if ($message['role'] === 'system') { $system[] = $message['content']; continue; } $chatMessages[] = $message; } while ($chatMessages && $chatMessages[0]['role'] !== 'user') { array_shift($chatMessages); } if (!$chatMessages) { throw new RuntimeException('No AI messages provided.'); } $body = [ 'model' => $model, 'max_tokens' => $maxOutput, 'messages' => $chatMessages, 'temperature' => min(1.0, max(0.0, $temperature)), 'stream' => true ]; if ($system) { $body['system'] = implode("\n\n", $system); } $body = raijuAiApplyReasoning($body, $plan, 'anthropic'); } else { $url = rtrim((string)$settings['baseUrl'], '/') . '/chat/completions'; $headers = ['Content-Type: application/json', 'Accept: text/event-stream']; $key = trim((string)($settings['apiKey'] ?? '')); if ($key !== '') { $headers[] = 'Authorization: Bearer ' . $key; } if (!empty($settings['organization'])) { $headers[] = 'OpenAI-Organization: ' . $settings['organization']; } $body = [ 'model' => $model, 'messages' => $cleanMessages, 'max_tokens' => $maxOutput, 'temperature' => $temperature, 'stream' => true ]; // OpenAI itself reports the usage of a stream only on request. if (raijuAiBaseUrlHost((string)$settings['baseUrl']) === 'api.openai.com') { $body['stream_options'] = ['include_usage' => true]; } $body = raijuAiApplyModelQuirks($settings, raijuAiApplyReasoning($body, $plan, 'chat')); } $timeout = raijuAiRequestTimeout($settings); raijuAiExtendExecutionTime($timeout + 10); $started = microtime(true); $state = [ 'buffer' => '', 'raw' => '', 'text' => '', 'usage' => [] ]; $parseLine = static function (string $line) use (&$state, $onDelta): void { $line = trim($line); if ($line === '' || !str_starts_with($line, 'data:')) { return; } $payload = trim(substr($line, 5)); if ($payload === '' || $payload === '[DONE]') { return; } $chunk = json_decode($payload, true); if (!is_array($chunk)) { return; } $delta = ''; // OpenAI-compatible chunk shape. $choice = $chunk['choices'][0] ?? null; if (is_array($choice) && is_string($choice['delta']['content'] ?? null)) { $delta = $choice['delta']['content']; } if (is_array($chunk['usage'] ?? null)) { $state['usage'] = array_replace($state['usage'], $chunk['usage']); } // Anthropic Messages API event shapes. $type = (string)($chunk['type'] ?? ''); if ($type === 'content_block_delta' && (string)($chunk['delta']['type'] ?? '') === 'text_delta') { $delta = (string)($chunk['delta']['text'] ?? ''); } elseif ($type === 'message_start') { $state['usage'] = array_replace($state['usage'], (array)($chunk['message']['usage'] ?? [])); } elseif ($type === 'message_delta') { $state['usage'] = array_replace($state['usage'], (array)($chunk['usage'] ?? [])); } if ($delta !== '') { $state['text'] .= $delta; $onDelta($delta); } }; $ch = curl_init($url); curl_setopt_array($ch, [ CURLOPT_POST => true, CURLOPT_POSTFIELDS => json_encode($body, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), CURLOPT_HTTPHEADER => $headers, CURLOPT_RETURNTRANSFER => false, CURLOPT_CONNECTTIMEOUT => min(10, $timeout), CURLOPT_TIMEOUT => $timeout, CURLOPT_HTTP_VERSION => CURL_HTTP_VERSION_1_1, CURLOPT_NOSIGNAL => true, CURLOPT_WRITEFUNCTION => static function ($ch, string $chunk) use (&$state, $parseLine): int { $state['raw'] .= $chunk; $state['buffer'] .= $chunk; while (($pos = strpos($state['buffer'], "\n")) !== false) { $parseLine(substr($state['buffer'], 0, $pos)); $state['buffer'] = substr($state['buffer'], $pos + 1); } return strlen($chunk); } ]); raijuAiMarkDispatched(); $ok = curl_exec($ch); $status = (int)curl_getinfo($ch, CURLINFO_HTTP_CODE); $curlError = curl_error($ch); unset($ch); if ($state['buffer'] !== '') { $parseLine($state['buffer']); } if ($status < 200 || $status >= 300) { $data = json_decode($state['raw'], true); $message = is_array($data) ? ($data['error']['message'] ?? $data['message'] ?? 'AI provider error') : 'AI provider error'; raijuAiAudit('provider-error', false, ['status' => $status, 'message' => $message]); throw new RuntimeException($message); } if ($ok === false && $state['text'] === '') { throw new RuntimeException($curlError !== '' ? 'AI provider request failed: ' . $curlError : 'AI provider request failed.'); } if ($state['text'] === '') { // Provider ignored stream=true and returned one JSON document. $data = json_decode($state['raw'], true); if (is_array($data)) { $text = trim((string)($data['choices'][0]['message']['content'] ?? '')); if ($text === '' && is_array($data['content'] ?? null)) { foreach ($data['content'] as $block) { if (is_array($block) && (string)($block['type'] ?? '') === 'text') { $text .= (string)($block['text'] ?? ''); } } } if ($text !== '') { $state['text'] = $text; $onDelta($text); } } } if ($state['text'] === '') { throw new RuntimeException('AI provider returned an empty response.'); } $usage = raijuAiSettleText($settings, $model, $state['usage'] ?: null, $inputTokens, $state['text']); raijuAiAudit('chat', true, raijuAiAuditUsage($model, $usage, $plan) + [ 'streamed' => true, 'durationMs' => (int)round((microtime(true) - $started) * 1000) ]); return [ 'text' => $state['text'], 'usage' => raijuAiPublicUsage($usage), 'limits' => raijuAiUsageSummary() ]; } function raijuAiGenerateText(string $prompt, array $options = []): array { $settings = raijuAiLoadSettings(false); $feature = $options['feature'] ?? 'seoTextGeneration'; $system = (string)($options['system'] ?? $settings['systemPrompts']['seo']); $messages = [ ['role' => 'system', 'content' => $system], ['role' => 'user', 'content' => $prompt] ]; $model = trim((string)($options['model'] ?? '')); return raijuAiChat($messages, [ 'feature' => $feature, 'purpose' => in_array($options['purpose'] ?? '', RAIJU_AI_PURPOSES, true) ? $options['purpose'] : 'text', 'model' => $model !== '' ? $model : ($settings['textModel'] ?: $settings['chatModel']), 'maxOutputTokens' => $options['maxOutputTokens'] ?? null, 'temperature' => $options['temperature'] ?? 0.4 ]); } function raijuAiGenerateImage(string $prompt, array $options = []): array { return raijuAiWithRequest(fn() => raijuAiGenerateImageExecute($prompt, $options), $options['_requestId'] ?? null); } function raijuAiGenerateImageExecute(string $prompt, array $options = []): array { $settings = raijuAiLoadSettings(false); $service = raijuAiImageServiceResolve($settings); raijuAiEnsureImageReady($settings, $service); $prompt = trim($prompt); if ($prompt === '') { throw new RuntimeException('Image prompt is required.'); } if (raijuAiEstimateTokens($prompt) > (int)$settings['limits']['maxInputTokens']) { throw new RuntimeException('Image prompt is too long for the configured limit.'); } $count = raijuAiClampInt($options['count'] ?? 1, 1, 10); $model = (string)($options['model'] ?? $service['model']); if ($service['adapter'] === 'openrouter-chat') { $model = raijuAiNormalizeOpenRouterImageModel($model); } elseif ($service['adapter'] === 'kie-task') { $model = raijuAiNormalizeKieImageModel($model); } else { $model = raijuAiCleanModelAllowEmpty($model); } if (trim($model) === '') { throw new RuntimeException('Image model is missing.'); } // The model decides the spec, the adapter and the address; from here on the image service's credentials stand in for the chat provider's. $service = raijuAiImageServiceWithModel($service, $model, $settings); $img = raijuAiImageEffectiveSettings($settings, $service); $isGptImageModel = (bool)preg_match('/^(gpt-image|chatgpt-image)/i', $model); $isGptImage2Model = (bool)preg_match('/^gpt-image-2/i', $model); $size = (string)($options['size'] ?? 'auto'); $allowedSizes = ($isGptImageModel && !$isGptImage2Model) ? ['auto', '1024x1024', '1024x1536', '1536x1024'] : ['1024x1024', '1024x1536', '1536x1024', '512x512', '256x256', '1792x1024', '1024x1792']; if ($service['adapter'] === 'openrouter-chat' && !raijuAiIsValidGptImage2Size($size)) { $size = 'auto'; } elseif ($isGptImage2Model && !raijuAiIsValidGptImage2Size($size)) { $size = 'auto'; } elseif (!$isGptImage2Model && !in_array($size, $allowedSizes, true)) { $size = $isGptImageModel ? 'auto' : '1024x1024'; } $outputFormat = strtolower((string)($options['outputFormat'] ?? 'webp')); if (!in_array($outputFormat, ['png', 'jpeg', 'webp'], true)) { $outputFormat = 'webp'; } $quality = strtolower((string)($options['quality'] ?? 'auto')); if (!in_array($quality, ['auto', 'low', 'medium', 'high'], true)) { $quality = 'auto'; } $moderation = strtolower((string)($options['moderation'] ?? 'auto')); if (!in_array($moderation, ['auto', 'low'], true)) { $moderation = 'auto'; } $outputCompression = max(0, min(100, (int)($options['outputCompression'] ?? 75))); $aspectRatio = raijuAiCleanAspectRatio((string)($options['aspectRatio'] ?? 'auto')); // The price follows what the request asks for: resolution step and quality (a model may price them differently). $resolution = raijuAiImageRequestResolution($service, $model, ['imageScale' => $options['imageScale'] ?? null, 'size' => $size, 'aspectRatio' => $aspectRatio]); $price = raijuAiImagePrice($settings, $service, $model, ['resolution' => $resolution, 'quality' => $quality]); $cost = $price ? raijuAiRoundCents($price['cents'] * $count) : 0.0; $priceBasis = $price ? ['priced' => true, 'source' => $price['source'], 'currency' => $price['currency'], 'centsPerImage' => $price['cents'], 'basis' => $price['basis'], 'count' => $count] : ['priced' => false]; raijuAiAssertWithinLimits($img, 'image', $cost, ['model' => $model, 'priced' => $price !== null, 'service' => $service['id']]); $started = microtime(true); $referencePaths = raijuAiCollectReferenceImagePaths($options); raijuAiAudit('image-references-collected', true, [ 'service' => $service['id'], 'usesOpenRouter' => $service['adapter'] === 'openrouter-chat', 'usesKie' => $service['adapter'] === 'kie-task', 'referenceImagePathsIn' => count(is_array($options['referenceImagePaths'] ?? null) ? $options['referenceImagePaths'] : []), 'referenceMediaPathsIn' => count(is_array($options['referenceMediaPaths'] ?? null) ? $options['referenceMediaPaths'] : []), 'collected' => count($referencePaths) ]); [$paths, $revisedPrompts] = raijuAiImageAdapterRun($img, $service, [ 'model' => $model, 'prompt' => $prompt, 'count' => $count, 'size' => $size, 'aspectRatio' => $aspectRatio, 'imageScale' => $options['imageScale'] ?? null, 'quality' => $quality, 'outputFormat' => $outputFormat, 'outputCompression' => $outputCompression, 'moderation' => $moderation, 'referencePaths' => $referencePaths, 'filenameHint' => $options['filenameHint'] ?? 'ai-image', 'storageDir' => (string)($options['storageDir'] ?? '') ]); raijuAiRecordUsage('image', raijuAiEstimateTokens($prompt), 0, $cost, ['model' => $model, 'priced' => $price !== null, 'basis' => $priceBasis + ['service' => $service['id']]]); $durationMs = (int)round((microtime(true) - $started) * 1000); raijuAiAudit('image', true, [ 'provider' => $service['id'], 'model' => $model, 'count' => count($paths), 'estimatedCostCents' => $cost, 'priced' => $price !== null, 'paths' => $paths, 'durationMs' => $durationMs ]); $actualMeta = raijuAiActualImageMeta($paths); $historyItem = raijuAiRecordImageHistory([ 'status' => 'success', 'model' => $model, 'prompt' => $prompt, 'revisedPrompt' => implode("\n\n", array_values(array_unique(array_filter($revisedPrompts)))), 'size' => $actualMeta['size'] ?? $size, 'aspectRatio' => $aspectRatio, 'quality' => $quality, 'format' => $actualMeta['format'] ?? $outputFormat, 'moderation' => $moderation, 'compression' => $outputCompression, 'count' => count($paths), 'referenceImages' => raijuAiPublicReferenceList($options), 'outputs' => $paths, 'error' => '', 'estimatedCostCents' => $cost, 'durationMs' => $durationMs ]); return [ 'path' => $paths[0], 'paths' => $paths, 'historyItem' => $historyItem, 'usage' => ['estimatedCostCents' => $cost, 'priced' => $price !== null], 'limits' => raijuAiUsageSummary() ]; } /** Images need AI on, the feature on and a usable image service (adapter, model, key); the chat provider's key is not required. */ function raijuAiEnsureImageReady(array $settings, array $service): void { if (empty($settings['enabled'])) { throw new RuntimeException('AI features are disabled.'); } raijuAiEnsureFeature($settings, 'imageGeneration'); if (!$service['supported']) { throw new RuntimeException($service['reason'] === 'chat_provider_without_images' ? 'The chat provider has no image API. Choose an image service in the AI settings (Images).' : 'The image service is not set up completely. Check the AI settings (Images).'); } if ($service['baseUrl'] === '') { throw new RuntimeException('AI base URL is missing.'); } raijuAiValidateBaseUrl($service['baseUrl'], !empty($settings['allowLocalProvider'])); if ($service['apiKey'] === '' && !raijuAiImageServiceMayBeKeyless($settings, $service)) { throw new RuntimeException('The API key of the image service is missing.'); } } function raijuAiEnsureEnabled(array $settings): void { if (empty($settings['enabled'])) { throw new RuntimeException('AI features are disabled.'); } if (trim((string)($settings['apiKey'] ?? '')) === '' && !raijuAiIsLocalBaseUrl((string)$settings['baseUrl'])) { throw new RuntimeException('AI API key is missing.'); } } function raijuAiEnsureFeature(array $settings, string $feature): void { if (empty($settings['features'][$feature])) { throw new RuntimeException('This AI feature is disabled.'); } } /** $meta: model (the one the request is made with) and priced (false when no price is known and the reservation is 0). */ function raijuAiAssertWithinLimits(array $settings, string $type, float|int $estimatedCostCents, array $meta = []): void { $id = $GLOBALS['raijuAiRequestId'] ?? ''; if ($id !== '') { raijuAiReserve($settings, $type, (float)$estimatedCostCents, $id, $meta); return; } raijuAiCheckLimits($settings, $type, (float)$estimatedCostCents, raijuAiReservedUsage(raijuAiLoadUsage())); } /** The purpose of a call (chat or text); calls that name none are chat calls. */ function raijuAiOptionPurpose(array $options): string { return in_array($options['purpose'] ?? '', RAIJU_AI_PURPOSES, true) ? $options['purpose'] : 'chat'; } /** Output limit of a call: the requested size plus thinking headroom, never above the configured limit. */ function raijuAiOutputBudget(array $settings, array $options, array $plan): int { $limit = (int)$settings['limits']['maxOutputTokens']; $requested = raijuAiClampInt($options['maxOutputTokens'] ?? $limit, 16, $limit); return raijuAiClampInt($requested + (int)$plan['headroom'], 16, $limit); } /** * Settle a finished text request: normalize the reported usage (an estimate when the provider reported none), * price it with the model that was used, and record it. The price basis is stored with the entry. */ function raijuAiSettleText(array $settings, string $model, $rawUsage, int $estimatedInput, string $outputText): array { $usage = is_array($rawUsage) ? raijuAiNormalizeUsage($rawUsage) : null; $estimated = $usage === null; if ($usage === null) { $usage = ['input' => 0, 'cacheRead' => 0, 'cacheWrite' => 0, 'cacheWrite1h' => 0, 'output' => 0, 'reasoning' => 0]; } if (raijuAiPromptTokens($usage) === 0) { $usage['input'] = $estimatedInput; $estimated = true; } if ($usage['output'] === 0) { $usage['output'] = raijuAiEstimateTokens($outputText); $estimated = true; } $cost = raijuAiCostForUsage($settings, $model, $usage); $inputTotal = raijuAiUsageInputTotal($usage); raijuAiRecordUsage('text', $inputTotal, $usage['output'], $cost['cents'], [ 'model' => $model, 'priced' => $cost['priced'], 'basis' => $cost['basis'], 'estimated' => $estimated ]); return [ 'inputTokens' => $inputTotal, 'outputTokens' => $usage['output'], 'cacheReadTokens' => $usage['cacheRead'], 'cacheWriteTokens' => $usage['cacheWrite'] + $usage['cacheWrite1h'], 'reasoningTokens' => $usage['reasoning'], 'estimatedCostCents' => $cost['cents'], 'priced' => $cost['priced'], 'usageEstimated' => $estimated, 'basis' => $cost['basis'] ]; } /** Audit fields of a settled text request. An unknown price is logged as null, never as 0. */ function raijuAiAuditUsage(string $model, array $usage, array $plan): array { return [ 'model' => $model, 'inputTokens' => $usage['inputTokens'], 'outputTokens' => $usage['outputTokens'], 'cacheReadTokens' => $usage['cacheReadTokens'], 'cacheWriteTokens' => $usage['cacheWriteTokens'], 'reasoningTokens' => $usage['reasoningTokens'], 'effort' => $plan['effort'], 'usageEstimated' => $usage['usageEstimated'], 'estimatedCostCents' => $usage['priced'] ? $usage['estimatedCostCents'] : null, 'priceBasis' => $usage['basis'] ]; } /** What callers and the UI get back about the usage of a request. */ function raijuAiPublicUsage(array $usage): array { return [ 'inputTokens' => $usage['inputTokens'], 'outputTokens' => $usage['outputTokens'], 'reasoningTokens' => $usage['reasoningTokens'], 'estimatedCostCents' => $usage['estimatedCostCents'], 'priced' => $usage['priced'], 'usageEstimated' => $usage['usageEstimated'] ]; } /** * Limits are guides, not hard walls. A request that starts below a limit may finish, and a request that * belongs to an agent run already under way (second turn on, reading steps) may run up to 10 % past a * daily request limit or the monthly budget so a run is not cut off in the middle. The first request of a * run and every standalone request need room below the limit; image requests stay strict. */ const RAIJU_AI_LIMIT_TOLERANCE = 0.10; function raijuAiIsAgentContinuation(): bool { $id = (string)($GLOBALS['raijuAiRequestId'] ?? ''); return preg_match('/^agent_(read_)?run_[a-z0-9]+_(\d+)$/', $id, $match) === 1 && (!empty($match[1]) || (int)$match[2] > 1); } function raijuAiCheckLimits(array $settings, string $type, float|int $estimatedCostCents, array $usage): void { $today = gmdate('Y-m-d'); $month = gmdate('Y-m'); $dayBucket = $usage['days'][$today] ?? raijuAiEmptyUsageBucket(); $monthBucket = $usage['months'][$month] ?? raijuAiEmptyUsageBucket(); $limits = $settings['limits']; $continuation = raijuAiIsAgentContinuation(); $room = static fn(int $limit): int => $continuation ? max($limit + 1, (int)ceil($limit * (1 + RAIJU_AI_LIMIT_TOLERANCE))) : $limit; if ((int)$limits['dailyRequests'] > 0 && ($dayBucket['requests'] + 1) > $room((int)$limits['dailyRequests'])) { throw new RuntimeException('Daily AI request limit reached.'); } if ($type === 'text' && (int)$limits['dailyTextRequests'] > 0 && ($dayBucket['textRequests'] + 1) > $room((int)$limits['dailyTextRequests'])) { throw new RuntimeException('Daily AI text request limit reached.'); } if ($type === 'image' && (int)$limits['dailyImageRequests'] > 0 && ($dayBucket['imageRequests'] + 1) > (int)$limits['dailyImageRequests']) { throw new RuntimeException('Daily AI image request limit reached.'); } $budget = (int)$limits['monthlyBudgetCents']; if ($budget > 0) { $spent = (float)$monthBucket['estimatedCostCents']; $ceiling = (float)floor($budget * (1 + RAIJU_AI_LIMIT_TOLERANCE)); // Spent includes what running requests have reserved. The worst case of this request only has to fit under the ceiling. if (($continuation ? $spent >= $ceiling : $spent >= $budget) || ($spent + $estimatedCostCents) > $ceiling) { throw new RuntimeException('Monthly AI budget limit reached.'); } } } /** * Limits that are nearly or fully used, for the warning bars (dashboard, settings, assistant). * * @return array */ function raijuAiLimitWarnings(array $settings, array $usage): array { $usage = raijuAiReservedUsage($usage); $day = $usage['days'][gmdate('Y-m-d')] ?? raijuAiEmptyUsageBucket(); $month = $usage['months'][gmdate('Y-m')] ?? raijuAiEmptyUsageBucket(); $limits = (array)($settings['limits'] ?? []); $warnings = []; foreach ([ ['month', (float)$month['estimatedCostCents'], (int)($limits['monthlyBudgetCents'] ?? 0)], ['requests', (int)$day['requests'], (int)($limits['dailyRequests'] ?? 0)], ['text', (int)$day['textRequests'], (int)($limits['dailyTextRequests'] ?? 0)], ['images', (int)$day['imageRequests'], (int)($limits['dailyImageRequests'] ?? 0)], ] as [$kind, $used, $limit]) { if ($limit <= 0) continue; $percent = (int)floor($used * 100 / $limit); if (is_float($used)) $used = $used == floor($used) ? (int)$used : raijuAiRoundCents($used); if ($used >= $limit) $level = 'reached'; elseif ($percent >= 80) $level = 'warning'; else continue; $warnings[] = ['kind' => $kind, 'level' => $level, 'used' => $used, 'limit' => $limit, 'percent' => $percent]; } return $warnings; } /** * Book a finished request. $meta: model, priced, basis (price basis), estimated (usage was not reported). * An unpriced request counts as a request but adds no cost and is tallied in unpricedRequests (text) or unpricedImageRequests. */ function raijuAiRecordUsage(string $type, int $inputTokens, int $outputTokens, float|int $costCents, array $meta = []): void { $costCents = (float)$costCents; $saved = raijuJsonUpdate(RAIJU_AI_USAGE_PATH, function (array &$usage) use ($type, $inputTokens, $outputTokens, $costCents, $meta): void { $id = $GLOBALS['raijuAiRequestId'] ?? ''; if ($id !== '' && ($usage['reservations'][$id]['status'] ?? '') === 'settled') return; if ($id !== '' && isset($usage['reservations'][$id])) { $usage['reservations'][$id]['status'] = 'settled'; $usage['reservations'][$id]['actualEstimatedCents'] = $costCents; $usage['reservations'][$id]['updatedAt'] = gmdate('c'); if (!empty($meta['model'])) $usage['reservations'][$id]['model'] = (string)$meta['model']; if (isset($meta['basis'])) $usage['reservations'][$id]['priceBasis'] = $meta['basis']; if (isset($meta['priced'])) $usage['reservations'][$id]['priced'] = (bool)$meta['priced']; } $requestDate = $usage['reservations'][$id]['createdAt'] ?? gmdate('c'); $today = substr($requestDate, 0, 10); $month = substr($requestDate, 0, 7); foreach ([['days', $today], ['months', $month]] as $ref) { [$group, $key] = $ref; $usage[$group][$key] = array_replace(raijuAiEmptyUsageBucket(), $usage[$group][$key] ?? []); $usage[$group][$key]['requests']++; $usage[$group][$key][$type === 'image' ? 'imageRequests' : 'textRequests']++; $usage[$group][$key]['inputTokens'] += $inputTokens; $usage[$group][$key]['outputTokens'] += $outputTokens; $usage[$group][$key]['estimatedCostCents'] = raijuAiAddCents($usage[$group][$key]['estimatedCostCents'], $costCents); if (isset($meta['priced']) && !$meta['priced']) $usage[$group][$key][$type === 'image' ? 'unpricedImageRequests' : 'unpricedRequests']++; } $usage['updatedAt'] = gmdate('c'); raijuAiPruneUsage($usage); }, ['days' => [], 'months' => [], 'updatedAt' => null]); if (!$saved) throw new RuntimeException('Could not save AI usage.'); } function raijuAiAudit(string $action, bool $success, array $meta = []): void { if (!is_dir(RAIJU_AI_AUDIT_DIR)) { mkdir(RAIJU_AI_AUDIT_DIR, 0755, true); } $file = RAIJU_AI_AUDIT_DIR . '/' . gmdate('Y-m-d') . '.jsonl'; $entry = [ 'time' => gmdate('c'), 'action' => $action, 'success' => $success, 'user' => $_SESSION['admin_username'] ?? ($_SESSION['admin_user_id'] ?? ''), 'meta' => $meta ]; file_put_contents($file, json_encode($entry, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) . "\n", FILE_APPEND | LOCK_EX); } function raijuAiLoadImageHistory(int $offset = 0, int $limit = 12): array { $history = ['items' => []]; if (is_file(RAIJU_AI_IMAGE_HISTORY_PATH)) { $loaded = json_decode((string)file_get_contents(RAIJU_AI_IMAGE_HISTORY_PATH), true); if (is_array($loaded) && is_array($loaded['items'] ?? null)) { $history = ['items' => $loaded['items']]; } } $offset = max(0, $offset); $limit = raijuAiClampInt($limit, 1, 48); $items = array_slice($history['items'], $offset, $limit); return [ 'items' => $items, 'offset' => $offset, 'limit' => $limit, 'total' => count($history['items']), 'hasMore' => ($offset + count($items)) < count($history['items']) ]; } function raijuAiRecordImageHistory(array $entry): array { $item = [ 'id' => 'img_' . gmdate('Ymd_His') . '_' . substr(bin2hex(random_bytes(4)), 0, 8), 'createdAt' => gmdate('c'), 'status' => in_array(($entry['status'] ?? ''), ['success', 'error'], true) ? $entry['status'] : 'success', 'model' => substr((string)($entry['model'] ?? ''), 0, 120), 'prompt' => raijuAiUtf8Prefix((string)($entry['prompt'] ?? ''), 8000), 'revisedPrompt' => raijuAiUtf8Prefix((string)($entry['revisedPrompt'] ?? ''), 8000), 'size' => substr((string)($entry['size'] ?? ''), 0, 40), 'aspectRatio' => substr((string)($entry['aspectRatio'] ?? ''), 0, 20), 'quality' => substr((string)($entry['quality'] ?? ''), 0, 40), 'format' => substr((string)($entry['format'] ?? ''), 0, 20), 'moderation' => substr((string)($entry['moderation'] ?? ''), 0, 20), 'compression' => isset($entry['compression']) ? (int)$entry['compression'] : null, 'count' => raijuAiClampInt($entry['count'] ?? 0, 0, 100), 'referenceImages' => raijuAiCleanPublicPathList($entry['referenceImages'] ?? []), 'outputs' => raijuAiCleanPublicPathList($entry['outputs'] ?? []), 'error' => raijuAiUtf8Prefix((string)($entry['error'] ?? ''), 1000), 'estimatedCostCents' => max(0, (int)($entry['estimatedCostCents'] ?? 0)), 'durationMs' => max(0, (int)($entry['durationMs'] ?? 0)), 'user' => (string)($_SESSION['admin_username'] ?? ($_SESSION['admin_user_id'] ?? '')) ]; foreach ($item['outputs'] as $output) { if (str_starts_with((string)$output, '/content/ai-agent/staging/')) { return $item; // images of an agent run are not in the library yet; the history lists library images only } } $saved = raijuJsonUpdate(RAIJU_AI_IMAGE_HISTORY_PATH, function (array &$history) use ($item): void { array_unshift($history['items'], $item); }, ['items' => []]); if (!$saved) throw new RuntimeException('Could not save image history.'); return $item; } function raijuAiClearImageHistory(): void { raijuAiSaveImageHistory(['items' => []]); } function raijuAiEnsureImageJobsDir(): void { if (!is_dir(RAIJU_AI_IMAGE_JOBS_DIR)) { mkdir(RAIJU_AI_IMAGE_JOBS_DIR, 0755, true); } } function raijuAiImageJobPath(string $id): string { $id = preg_replace('/[^a-zA-Z0-9_-]/', '', $id); if ($id === '') { throw new RuntimeException('Invalid image job ID.'); } return RAIJU_AI_IMAGE_JOBS_DIR . '/' . $id . '.json'; } function raijuAiCreateImageJob(string $kind, array $payload): array { raijuAiEnsureImageJobsDir(); $id = 'job_' . gmdate('Ymd_His') . '_' . substr(bin2hex(random_bytes(5)), 0, 10); $jobDir = RAIJU_AI_IMAGE_JOBS_DIR . '/' . $id; if (!is_dir($jobDir)) { mkdir($jobDir, 0755, true); } $payload = raijuAiPersistImageJobReferences($payload, $jobDir); $job = [ 'id' => $id, 'kind' => in_array($kind, ['dashboard', 'copilot', 'agent'], true) ? $kind : 'dashboard', 'status' => 'queued', 'createdAt' => gmdate('c'), 'updatedAt' => gmdate('c'), 'startedAt' => '', 'finishedAt' => '', 'attempts' => 0, 'user' => (string)($_SESSION['admin_username'] ?? ($_SESSION['admin_user_id'] ?? '')), 'payload' => $payload, 'result' => null, 'error' => '' ]; raijuAiSaveImageJob($job); return raijuAiPublicImageJob($job); } function raijuAiPersistImageJobReferences(array $payload, string $jobDir): array { $options = is_array($payload['options'] ?? null) ? $payload['options'] : []; $paths = $options['referenceImagePaths'] ?? []; if (!is_array($paths)) { $paths = [$paths]; } $names = $options['referenceImageNames'] ?? []; if (!is_array($names)) { $names = [$names]; } $savedPaths = []; $savedNames = []; foreach ($paths as $index => $path) { $path = (string)$path; if ($path === '' || !is_file($path)) { continue; } $info = @getimagesize($path); if ($info === false || !in_array($info[2], [IMAGETYPE_JPEG, IMAGETYPE_PNG, IMAGETYPE_WEBP], true)) { continue; } $ext = match ($info[2]) { IMAGETYPE_JPEG => 'jpg', IMAGETYPE_PNG => 'png', IMAGETYPE_WEBP => 'webp', default => 'img' }; $target = $jobDir . '/reference-' . ($index + 1) . '.' . $ext; if (@copy($path, $target)) { $savedPaths[] = $target; $name = trim((string)($names[$index] ?? basename($path))); $savedNames[] = substr($name !== '' ? $name : basename($target), 0, 180); } } $options['referenceImagePaths'] = $savedPaths; $options['referenceImageNames'] = $savedNames; $payload['options'] = $options; return $payload; } function raijuAiSaveImageJob(array $job): void { raijuAiEnsureImageJobsDir(); $job['updatedAt'] = gmdate('c'); if (!raijuJsonAtomicWrite(raijuAiImageJobPath((string)$job['id']), $job)) { throw new RuntimeException('Could not save image job.'); } } function raijuAiLoadImageJob(string $id): array { $path = raijuAiImageJobPath($id); if (!is_file($path)) { throw new RuntimeException('Image job not found.'); } $job = json_decode((string)file_get_contents($path), true); if (!is_array($job) || empty($job['id'])) { throw new RuntimeException('Invalid image job.'); } return $job; } function raijuAiImageJobTimestamp($value): int { if (!$value) { return 0; } $timestamp = strtotime((string)$value); return $timestamp === false ? 0 : $timestamp; } function raijuAiImageJobStaleAfterSeconds(): int { $settings = raijuAiLoadSettings(true); $timeout = (int)($settings['limits']['requestTimeoutSeconds'] ?? 300); return raijuAiClampInt($timeout + 60, 180, 900); } function raijuAiRefreshImageJobState(array $job): array { if (($job['status'] ?? '') !== 'running') return $job; $worker = fopen(raijuAiImageJobPath($job['id']) . '.worker.lock', 'c'); if (!$worker) return $job; if (!flock($worker, LOCK_EX | LOCK_NB)) { fclose($worker); return $job; } try { raijuJsonUpdate(raijuAiImageJobPath($job['id']), static function (&$current) use (&$job) { $started = raijuAiImageJobTimestamp($current['startedAt'] ?? ''); if (($current['status'] ?? '') === 'running' && $started > 0 && time() - $started > raijuAiImageJobStaleAfterSeconds()) { $entry = raijuAiLoadUsage()['reservations']['image_' . $current['id']] ?? []; $resumable = !empty($entry['tasks']) && !array_filter($entry['tasks'], fn($task) => empty($task['id'])); $current['status'] = $resumable ? 'queued' : 'error'; $current['error'] = $resumable ? '' : 'Worker stopped. Provider outcome is uncertain; check the request before retrying.'; $current['updatedAt'] = gmdate('c'); } $job = $current; }); } finally { flock($worker, LOCK_UN); fclose($worker); } return $job; } function raijuAiListImageJobs(bool $openOnly = false, int $limit = 20, ?string $user = null): array { raijuAiEnsureImageJobsDir(); $jobs = []; $user = $user !== null ? trim($user) : null; foreach (glob(RAIJU_AI_IMAGE_JOBS_DIR . '/job_*.json') ?: [] as $file) { $job = json_decode((string)file_get_contents($file), true); if (!is_array($job) || empty($job['id'])) { continue; } $job = raijuAiRefreshImageJobState($job); if ($user !== null && (string)($job['user'] ?? '') !== $user) { continue; } if ($openOnly && !in_array((string)($job['status'] ?? ''), ['queued', 'running'], true)) { continue; } $jobs[] = $job; } usort($jobs, fn($a, $b) => strcmp((string)($b['createdAt'] ?? ''), (string)($a['createdAt'] ?? ''))); $limit = raijuAiClampInt($limit, 1, 100); return array_map('raijuAiPublicImageJob', array_slice($jobs, 0, $limit)); } function raijuAiPublicImageJob(array $job): array { $payload = is_array($job['payload'] ?? null) ? $job['payload'] : []; $options = is_array($payload['options'] ?? null) ? $payload['options'] : []; $result = is_array($job['result'] ?? null) ? $job['result'] : null; return [ 'id' => (string)($job['id'] ?? ''), 'kind' => (string)($job['kind'] ?? ''), 'status' => (string)($job['status'] ?? 'queued'), 'createdAt' => (string)($job['createdAt'] ?? ''), 'updatedAt' => (string)($job['updatedAt'] ?? ''), 'startedAt' => (string)($job['startedAt'] ?? ''), 'finishedAt' => (string)($job['finishedAt'] ?? ''), 'prompt' => raijuAiUtf8Prefix((string)($payload['prompt'] ?? $payload['instruction'] ?? ''), 8000), 'model' => substr((string)($options['model'] ?? ''), 0, 120), 'count' => (int)($options['count'] ?? 1), 'result' => $result, 'error' => raijuAiUtf8Prefix((string)($job['error'] ?? ''), 1000) ]; } function raijuAiMarkImageJobRunning(string $id, ?bool &$claimed = null): array { $claimed = false; $result = []; $saved = raijuJsonUpdate(raijuAiImageJobPath($id), function (array &$job) use (&$claimed, &$result): void { if (empty($job['id'])) throw new RuntimeException('Image job not found.'); if (($job['status'] ?? '') === 'queued' && (int)($job['retryAt'] ?? 0) <= time()) { $job['status'] = 'running'; $job['startedAt'] = gmdate('c'); $job['updatedAt'] = gmdate('c'); $job['attempts'] = (int)($job['attempts'] ?? 0) + 1; $job['error'] = ''; $claimed = true; } $result = $job; }); if (!$saved) throw new RuntimeException('Could not claim image job.'); return $result; } function raijuAiFinishImageJob(string $id, array $result): array { $job = raijuAiLoadImageJob($id); $job['status'] = 'success'; $job['finishedAt'] = gmdate('c'); $job['result'] = $result; $job['error'] = ''; raijuAiSaveImageJob($job); return raijuAiPublicImageJob($job); } function raijuAiFailImageJob(string $id, string $message): array { $job = raijuAiLoadImageJob($id); $job['status'] = 'error'; $job['finishedAt'] = gmdate('c'); $job['error'] = raijuAiUtf8Prefix($message, 1000); raijuAiSaveImageJob($job); return raijuAiPublicImageJob($job); } function raijuAiSaveImageHistory(array $history): void { $saved = raijuJsonUpdate(RAIJU_AI_IMAGE_HISTORY_PATH, function (array &$stored) use ($history): void { $stored = ['items' => array_values(is_array($history['items'] ?? null) ? $history['items'] : [])]; }, ['items' => []]); if (!$saved) throw new RuntimeException('Could not save image history.'); } function raijuAiCleanPublicPathList($paths): array { if (!is_array($paths)) { $paths = [$paths]; } $clean = []; foreach ($paths as $path) { $path = trim((string)$path); if ($path === '' || strpos($path, '..') !== false || preg_match('#[:\x00]#', $path)) { continue; } if (str_starts_with($path, 'upload:')) { $clean[] = substr($path, 0, 200); continue; } if ($path[0] !== '/') { $path = '/' . $path; } if (str_starts_with($path, '/assets/images/')) { $clean[] = substr($path, 0, 400); } } return array_values(array_unique($clean)); } function raijuAiPublicReferenceList(array $options): array { $mediaPaths = $options['referenceMediaPaths'] ?? []; if (!is_array($mediaPaths)) { $mediaPaths = [$mediaPaths]; } if (!empty($options['referenceMediaPath'])) { $mediaPaths[] = $options['referenceMediaPath']; } $references = raijuAiCleanPublicPathList($mediaPaths); $uploadedNames = $options['referenceImageNames'] ?? []; if (!is_array($uploadedNames)) { $uploadedNames = [$uploadedNames]; } foreach ($uploadedNames as $name) { $name = trim((string)$name); if ($name !== '') { $references[] = 'upload:' . substr(basename($name), 0, 180); } } return array_values(array_unique($references)); } function raijuAiProviderRequest(array $settings, string $path, array $body): array { if (trim((string)($settings['baseUrl'] ?? '')) === '') { throw new RuntimeException('AI base URL is missing.'); } if (raijuAiIsAnthropicSettings($settings)) { if ($path !== '/chat/completions') { throw new RuntimeException('The Anthropic provider supports chat and text features only. Use an OpenAI-compatible or OpenRouter provider for image generation.'); } return raijuAiAnthropicChatRequest($settings, $body); } if (raijuAiIsKieSettings($settings)) { if ($path !== '/chat/completions') { throw new RuntimeException('Unsupported Kie.ai provider request.'); } return raijuAiKieChatRequest($settings, $body); } $url = rtrim((string)$settings['baseUrl'], '/') . $path; $headers = [ 'Content-Type: application/json', 'Accept: application/json' ]; $key = trim((string)($settings['apiKey'] ?? '')); if ($key !== '') { $headers[] = 'Authorization: Bearer ' . $key; } if (!empty($settings['organization'])) { $headers[] = 'OpenAI-Organization: ' . $settings['organization']; } return raijuAiCompatRequest($settings, $url, $headers, $body); } /** * The model ids the configured provider offers (GET {baseUrl}/models), for the settings dropdowns. * Only chat-like models are kept; the list is a convenience, any model name can still be typed. * * @return string[] * @throws RuntimeException */ function raijuAiListProviderModels(array $settings): array { $baseUrl = rtrim((string)($settings['baseUrl'] ?? ''), '/'); if ($baseUrl === '') throw new RuntimeException('AI base URL is missing.'); raijuAiValidateBaseUrl($baseUrl, !empty($settings['allowLocalProvider'])); if (raijuAiIsKieSettings($settings)) return []; $headers = ['Accept: application/json']; $key = trim((string)($settings['apiKey'] ?? '')); if (raijuAiIsAnthropicSettings($settings)) { $headers[] = 'anthropic-version: 2023-06-01'; if ($key !== '') $headers[] = 'x-api-key: ' . $key; } elseif ($key !== '') { $headers[] = 'Authorization: Bearer ' . $key; } if (!empty($settings['organization'])) $headers[] = 'OpenAI-Organization: ' . $settings['organization']; if (!function_exists('curl_init')) throw new RuntimeException('Listing models requires cURL support.'); $ch = curl_init($baseUrl . '/models' . (raijuAiIsAnthropicSettings($settings) ? '?limit=1000' : '')); curl_setopt_array($ch, [CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => $headers, CURLOPT_CONNECTTIMEOUT => 8, CURLOPT_TIMEOUT => 20, CURLOPT_FOLLOWLOCATION => false, CURLOPT_MAXFILESIZE => 5 * 1024 * 1024, CURLOPT_NOSIGNAL => true]); $raw = curl_exec($ch); $status = (int)curl_getinfo($ch, CURLINFO_HTTP_CODE); $error = curl_error($ch); unset($ch); if ($raw === false) throw new RuntimeException($error !== '' ? $error : 'The provider did not answer.'); $data = json_decode((string)$raw, true); if ($status < 200 || $status >= 300) { throw new RuntimeException(is_array($data) ? (string)($data['error']['message'] ?? $data['message'] ?? 'AI provider error') : 'AI provider error (HTTP ' . $status . ')'); } $ids = []; foreach ((array)($data['data'] ?? $data['models'] ?? []) as $model) { $id = is_array($model) ? (string)($model['id'] ?? $model['name'] ?? '') : (string)$model; $id = preg_replace('#^models/#', '', trim($id)) ?? ''; if ($id === '' || strlen($id) > 120 || !preg_match('/^[A-Za-z0-9._:\/-]+$/', $id)) continue; if (preg_match('/embed|whisper|tts|dall-e|moderation|image|audio|realtime|transcribe|speech|sora|search-preview|rerank/i', $id)) continue; $ids[$id] = true; } $ids = array_keys($ids); sort($ids, SORT_NATURAL | SORT_FLAG_CASE); return array_slice($ids, 0, 300); } function raijuAiIsAnthropicSettings(array $settings): bool { return (string)($settings['provider'] ?? '') === 'anthropic' || stripos((string)($settings['baseUrl'] ?? ''), 'api.anthropic.com') !== false; } function raijuAiIsKieSettings(array $settings): bool { return (string)($settings['provider'] ?? '') === 'kie' || stripos((string)($settings['baseUrl'] ?? ''), 'api.kie.ai') !== false; } /** Translate Kie.ai's provider-specific chat APIs to Raiju's completion shape. */ function raijuAiKieChatRequest(array $settings, array $body): array { $model = trim((string)($body['model'] ?? '')) ?: 'gpt-5-6-luna'; $baseUrl = rtrim((string)($settings['baseUrl'] ?? 'https://api.kie.ai'), '/'); $headers = ['Content-Type: application/json', 'Accept: application/json']; $key = trim((string)($settings['apiKey'] ?? '')); if ($key !== '') $headers[] = 'Authorization: Bearer ' . $key; if ($model === 'claude-sonnet-5') { return raijuAiKieClaudeChatRequest($settings, $headers, $body, $baseUrl . '/claude/v1/messages'); } if ($model === 'gemini-3-5-flash') { $request = $body; $request['model'] = $model; $request['stream'] = false; return raijuAiExecuteJsonRequest($settings, $baseUrl . '/gemini-3-5-flash-openai/v1/chat/completions', $headers, $request); } $input = []; foreach ((array)($body['messages'] ?? []) as $message) { if (!is_array($message)) continue; $content = []; foreach (is_array($message['content'] ?? null) ? $message['content'] : [['type' => 'text', 'text' => (string)($message['content'] ?? '')]] as $part) { if (!is_array($part)) continue; if (($part['type'] ?? '') === 'text') $content[] = ['type' => 'input_text', 'text' => (string)($part['text'] ?? '')]; if (($part['type'] ?? '') === 'image_url') { $url = is_array($part['image_url'] ?? null) ? (string)($part['image_url']['url'] ?? '') : (string)($part['image_url'] ?? ''); if ($url !== '') $content[] = ['type' => 'input_image', 'image_url' => $url]; } } if ($content) $input[] = ['role' => (string)($message['role'] ?? 'user'), 'content' => $content]; } $response = raijuAiExecuteJsonRequest($settings, $baseUrl . '/codex/v1/responses', $headers, [ 'model' => $model, 'input' => $input, 'max_output_tokens' => max(1, (int)($body['max_tokens'] ?? $settings['limits']['maxOutputTokens'] ?? 4096)), 'reasoning' => ['effort' => 'low'], 'stream' => false ]); $text = ''; foreach ((array)($response['output'] ?? []) as $output) { if (!is_array($output) || ($output['type'] ?? '') !== 'message') continue; foreach ((array)($output['content'] ?? []) as $part) { if (is_array($part) && ($part['type'] ?? '') === 'output_text') $text .= (string)($part['text'] ?? ''); } } return ['object' => 'chat.completion', 'model' => $model, 'choices' => [['message' => ['role' => 'assistant', 'content' => $text], 'finish_reason' => 'stop']], 'usage' => ['prompt_tokens' => (int)($response['usage']['input_tokens'] ?? 0), 'completion_tokens' => (int)($response['usage']['output_tokens'] ?? 0)]]; } function raijuAiKieClaudeChatRequest(array $settings, array $headers, array $body, string $url): array { $system = []; $messages = []; foreach ((array)($body['messages'] ?? []) as $message) { if (!is_array($message)) continue; $role = (string)($message['role'] ?? ''); $content = $message['content'] ?? ''; if ($role === 'system') { $system[] = is_string($content) ? $content : ''; continue; } if (in_array($role, ['user', 'assistant'], true) && $content !== '') $messages[] = ['role' => $role, 'content' => $content]; } $request = ['model' => 'claude-sonnet-5', 'messages' => $messages, 'max_tokens' => max(1, (int)($body['max_tokens'] ?? 4096)), 'stream' => false]; if ($system) $request['system'] = implode("\n\n", array_filter($system)); $response = raijuAiExecuteJsonRequest($settings, $url, $headers, $request); $text = ''; foreach ((array)($response['content'] ?? []) as $part) if (is_array($part) && ($part['type'] ?? '') === 'text') $text .= (string)($part['text'] ?? ''); return ['object' => 'chat.completion', 'model' => (string)($response['model'] ?? 'claude-sonnet-5'), 'choices' => [['message' => ['role' => 'assistant', 'content' => $text], 'finish_reason' => ($response['stop_reason'] ?? '') === 'max_tokens' ? 'length' : 'stop']], 'usage' => ['prompt_tokens' => (int)($response['usage']['input_tokens'] ?? 0), 'completion_tokens' => (int)($response['usage']['output_tokens'] ?? 0)]]; } /** Messages API endpoint and headers; shared by the text mapping and the tool gateway. */ function raijuAiAnthropicRequestTarget(array $settings): array { $headers = [ 'Content-Type: application/json', 'Accept: application/json', 'anthropic-version: 2023-06-01' ]; $key = trim((string)($settings['apiKey'] ?? '')); if ($key !== '') { $headers[] = 'x-api-key: ' . $key; } return [rtrim((string)$settings['baseUrl'], '/') . '/messages', $headers]; } /** * Translate an OpenAI-style chat request to the Anthropic Messages API and * map the response back, so all callers keep one response shape. */ function raijuAiAnthropicChatRequest(array $settings, array $body): array { [$url, $headers] = raijuAiAnthropicRequestTarget($settings); $system = []; $messages = []; foreach (($body['messages'] ?? []) as $message) { if (!is_array($message)) { continue; } $role = (string)($message['role'] ?? ''); $content = (string)($message['content'] ?? ''); if ($content === '') { continue; } if ($role === 'system') { $system[] = $content; continue; } if (in_array($role, ['user', 'assistant'], true)) { $messages[] = ['role' => $role, 'content' => $content]; } } while ($messages && $messages[0]['role'] !== 'user') { array_shift($messages); } if (!$messages) { throw new RuntimeException('No AI messages provided.'); } $anthropicBody = [ 'model' => (string)($body['model'] ?? ''), 'max_tokens' => max(1, (int)($body['max_tokens'] ?? 1024)), 'messages' => $messages ]; if ($system) { $anthropicBody['system'] = implode("\n\n", $system); } if (isset($body['temperature'])) { $anthropicBody['temperature'] = min(1.0, max(0.0, (float)$body['temperature'])); } if (is_array($body['output_config'] ?? null)) { $anthropicBody['output_config'] = $body['output_config']; } $response = raijuAiExecuteJsonRequest($settings, $url, $headers, $anthropicBody); $text = ''; foreach ((is_array($response['content'] ?? null) ? $response['content'] : []) as $block) { if (is_array($block) && (string)($block['type'] ?? '') === 'text') { $text .= (string)($block['text'] ?? ''); } } $stopReason = (string)($response['stop_reason'] ?? ''); return [ 'object' => 'chat.completion', 'model' => (string)($response['model'] ?? $anthropicBody['model']), 'choices' => [[ 'message' => ['role' => 'assistant', 'content' => $text], 'finish_reason' => $stopReason === 'max_tokens' ? 'length' : 'stop' ]], // The reported usage as is (cache fields included), plus the OpenAI names for readers that expect them. 'usage' => (is_array($response['usage'] ?? null) ? $response['usage'] : []) + [ 'prompt_tokens' => (int)($response['usage']['input_tokens'] ?? 0), 'completion_tokens' => (int)($response['usage']['output_tokens'] ?? 0) ] ]; } if (!class_exists('RaijuAiProviderRejected', false)) { /** The provider answered with an HTTP error: it rejected the request, so nothing was generated. */ class RaijuAiProviderRejected extends RuntimeException { public int $status; public function __construct(string $message, int $status) { parent::__construct($message); $this->status = $status; } } } /** * Per-model request quirks. Newer OpenAI models want max_completion_tokens instead of max_tokens * and some (reasoning models) accept only the default temperature. Providers say so in a 400 * answer; the request is repeated once without the offending parameter (a rejected request has * generated nothing, so this is no ambiguous paid retry) and the finding is remembered per * host and model in content/ai-model-quirks.json. */ function raijuAiQuirksPath(): string { return dirname(RAIJU_AI_SETTINGS_PATH) . '/ai-model-quirks.json'; } function raijuAiQuirksKey(array $settings, array $body): string { return strtolower((string)parse_url((string)($settings['baseUrl'] ?? ''), PHP_URL_HOST)) . '|' . strtolower((string)($body['model'] ?? '')); } /** What is known about a model from earlier rejected requests (see raijuAiStoreQuirk). */ function raijuAiKnownQuirks(array $settings, string $model): array { if (!is_file(raijuAiQuirksPath())) return []; $stored = json_decode((string)file_get_contents(raijuAiQuirksPath()), true); $known = is_array($stored) ? ($stored[raijuAiQuirksKey($settings, ['model' => $model])] ?? null) : null; return is_array($known) ? $known : []; } function raijuAiStoreQuirk(array $settings, string $model, array $learned): void { if (!$learned) return; $key = raijuAiQuirksKey($settings, ['model' => $model]); raijuJsonUpdate(raijuAiQuirksPath(), static function (&$quirks) use ($key, $learned) { if (!is_array($quirks)) $quirks = []; $quirks[$key] = array_replace((array)($quirks[$key] ?? []), $learned); if (count($quirks) > 200) $quirks = array_slice($quirks, -200, null, true); }); } function raijuAiApplyModelQuirks(array $settings, array $body): array { $known = raijuAiKnownQuirks($settings, (string)($body['model'] ?? '')); $official = strtolower((string)parse_url((string)($settings['baseUrl'] ?? ''), PHP_URL_HOST)) === 'api.openai.com'; $wantsCompletionTokens = ($known['tokenParam'] ?? ($official ? 'max_completion_tokens' : 'max_tokens')) === 'max_completion_tokens'; if ($wantsCompletionTokens && isset($body['max_tokens'])) { $body['max_completion_tokens'] = $body['max_tokens']; unset($body['max_tokens']); } elseif (!$wantsCompletionTokens && isset($body['max_completion_tokens'])) { $body['max_tokens'] = $body['max_completion_tokens']; unset($body['max_completion_tokens']); } if (!empty($known['noTemperature'])) unset($body['temperature']); if (!empty($known['noInclude'])) unset($body['include']); return $body; } /** The request without the parameter the provider rejected, or null when this error is not about a parameter. */ function raijuAiAdaptRejectedBody(array $body, RaijuAiProviderRejected $error, array &$quirk): ?array { if (!in_array($error->status, [400, 422], true)) return null; $message = $error->getMessage(); $unsupported = '/unsupported|not supported|does not support|unrecognized|unknown parameter|only the default/i'; if (isset($body['max_tokens']) && preg_match('/max_tokens/i', $message) && preg_match($unsupported, $message)) { $body['max_completion_tokens'] = $body['max_tokens']; unset($body['max_tokens']); $quirk['tokenParam'] = 'max_completion_tokens'; return $body; } if (isset($body['max_completion_tokens']) && preg_match('/max_completion_tokens/i', $message) && preg_match($unsupported, $message)) { $body['max_tokens'] = $body['max_completion_tokens']; unset($body['max_completion_tokens']); $quirk['tokenParam'] = 'max_tokens'; return $body; } if (array_key_exists('include', $body) && preg_match('/\binclude\b|encrypted_content/i', $message) && preg_match($unsupported, $message)) { unset($body['include']); $quirk['noInclude'] = true; return $body; } if (array_key_exists('temperature', $body) && preg_match('/temperature/i', $message) && preg_match($unsupported, $message)) { unset($body['temperature']); $quirk['noTemperature'] = true; return $body; } return null; } /** An OpenAI-compatible JSON request with the model quirks applied and learned. */ function raijuAiCompatRequest(array $settings, string $url, array $headers, array $body): array { $body = raijuAiApplyModelQuirks($settings, $body); $learned = []; for ($attempt = 0; ; $attempt++) { try { $response = raijuAiExecuteJsonRequest($settings, $url, $headers, $body); break; } catch (RaijuAiProviderRejected $error) { $adapted = $attempt < 3 ? raijuAiAdaptRejectedBody($body, $error, $learned) : null; if ($adapted === null) throw $error; $body = $adapted; } } raijuAiStoreQuirk($settings, (string)($body['model'] ?? ''), $learned); return $response; } function raijuAiExecuteJsonRequest(array $settings, string $url, array $headers, array $body): array { $timeout = raijuAiRequestTimeout($settings); raijuAiExtendExecutionTime($timeout + 10); $payload = json_encode($body, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR); $attempts = 0; $streamed = false; do { $attempts++; if (!function_exists('curl_init')) { $context = stream_context_create([ 'http' => [ 'method' => 'POST', 'header' => implode("\r\n", $headers), 'content' => $payload, 'timeout' => $timeout, 'ignore_errors' => true ] ]); raijuAiMarkDispatched(); $raw = file_get_contents($url, false, $context); $status = 0; $responseHeaders = function_exists('http_get_last_response_headers') ? http_get_last_response_headers() : ($http_response_header ?? []); if (isset($responseHeaders[0]) && preg_match('/\s(\d{3})\s/', $responseHeaders[0], $m)) { $status = (int)$m[1]; } } else { [$raw, $status] = raijuAiCurlPost($url, $headers, $payload, $timeout); } $data = json_decode((string)$raw, true); if ($status < 200 || $status >= 300) { $message = raijuAiProviderErrorMessage($data); raijuAiAudit('provider-error', false, ['status' => $status, 'message' => $message]); throw new RaijuAiProviderRejected((string)$message, (int)$status); } if (is_array($data)) { return $data; } $streamed = (bool)preg_match('/(^|\n)\s*data\s*:/', (string)$raw); if ($streamed) { $salvaged = raijuAiAssembleSseResponse((string)$raw); if ($salvaged !== null) { raijuAiAudit('provider-sse-salvaged', true, ['status' => $status, 'attempt' => $attempts]); return $salvaged; } } raijuAiAudit('provider-invalid-json', false, [ 'status' => $status, 'attempt' => $attempts, 'body' => substr((string)$raw, 0, 1000) ]); } while (false); // An uncertain paid POST must never be submitted automatically again. if ($streamed) { throw new RuntimeException('AI provider returned a streaming response although raiju requested JSON. Please retry; if this persists, use one image per request or another image model.'); } throw new RuntimeException('AI provider returned invalid JSON.'); } /** * Rebuild a normal completion payload from an unexpected SSE stream so a * provider that ignores stream=false does not fail the whole request. */ function raijuAiAssembleSseResponse(string $raw): ?array { $content = ''; $images = []; $usage = null; $finishReason = ''; $lastObject = null; $sawChunk = false; foreach (preg_split('/\r?\n/', $raw) ?: [] as $line) { $line = trim($line); if ($line === '' || !str_starts_with($line, 'data:')) { continue; } $payload = trim(substr($line, 5)); if ($payload === '' || $payload === '[DONE]') { continue; } $chunk = json_decode($payload, true); if (!is_array($chunk)) { continue; } $lastObject = $chunk; $choice = $chunk['choices'][0] ?? null; if (!is_array($choice)) { continue; } $sawChunk = true; $delta = is_array($choice['delta'] ?? null) ? $choice['delta'] : (is_array($choice['message'] ?? null) ? $choice['message'] : []); if (is_string($delta['content'] ?? null)) { $content .= $delta['content']; } if (is_array($delta['images'] ?? null)) { $images = array_merge($images, $delta['images']); } if (!empty($choice['finish_reason'])) { $finishReason = (string)$choice['finish_reason']; } if (is_array($chunk['usage'] ?? null)) { $usage = $chunk['usage']; } } if (!$sawChunk) { // Some providers stream one complete JSON object in a single data: line. if (is_array($lastObject) && (is_array($lastObject['choices'] ?? null) || is_array($lastObject['data'] ?? null))) { return $lastObject; } return null; } if ($content === '' && !$images) { return null; } $message = ['role' => 'assistant', 'content' => $content]; if ($images) { $message['images'] = $images; } return [ 'object' => 'chat.completion', 'choices' => [[ 'message' => $message, 'finish_reason' => $finishReason !== '' ? $finishReason : 'stop' ]], 'usage' => is_array($usage) ? $usage : [] ]; } function raijuAiCurlPost(string $url, array $headers, $postFields, int $timeout): array { $attempts = 0; $lastError = ''; $lastPartial = ''; do { $attempts++; $buffer = ''; $ch = curl_init($url); curl_setopt_array($ch, [ CURLOPT_POST => true, CURLOPT_POSTFIELDS => $postFields, CURLOPT_HTTPHEADER => $headers, CURLOPT_RETURNTRANSFER => false, CURLOPT_CONNECTTIMEOUT => min(10, $timeout), CURLOPT_TIMEOUT => $timeout, CURLOPT_HTTP_VERSION => CURL_HTTP_VERSION_1_1, CURLOPT_NOSIGNAL => true, CURLOPT_FRESH_CONNECT => $attempts > 1, CURLOPT_FORBID_REUSE => true, CURLOPT_WRITEFUNCTION => static function ($ch, string $chunk) use (&$buffer): int { $buffer .= $chunk; return strlen($chunk); } ]); raijuAiMarkDispatched(); $raw = curl_exec($ch); $status = (int)curl_getinfo($ch, CURLINFO_HTTP_CODE); $downloadedBytes = 0.0; if (defined('CURLINFO_SIZE_DOWNLOAD_T')) { $downloadedBytes = (float)curl_getinfo($ch, CURLINFO_SIZE_DOWNLOAD_T); } if ($downloadedBytes <= 0) { $downloadedBytes = (float)curl_getinfo($ch, CURLINFO_SIZE_DOWNLOAD); } $lastError = curl_error($ch); unset($ch); if ($raw !== false) { return [$buffer, $status]; } $lastPartial = $buffer; } while (false); // No provider idempotency guarantee for paid POST requests. if ($lastPartial !== '') { raijuAiAudit('provider-curl-partial', false, [ 'message' => $lastError, 'partial' => substr($lastPartial, 0, 1000) ]); } throw new RuntimeException($lastError !== '' ? 'AI provider request failed: ' . $lastError : 'AI provider request failed.'); } function raijuAiShouldRetryCurlError(string $error, float $downloadedBytes): bool { if ($downloadedBytes > 0) { return false; } $error = strtolower($error); return str_contains($error, 'ssl_read') || str_contains($error, 'bad record mac') || str_contains($error, 'connection reset') || str_contains($error, 'server returned nothing') || str_contains($error, 'empty reply'); } function raijuAiProviderMultipartRequest(array $settings, string $path, array $fields, array $files): array { if (!function_exists('curl_init')) { throw new RuntimeException('Image-to-image requires cURL support.'); } if (trim((string)($settings['baseUrl'] ?? '')) === '') { throw new RuntimeException('AI base URL is missing.'); } $url = rtrim((string)$settings['baseUrl'], '/') . $path; $timeout = raijuAiRequestTimeout($settings); raijuAiExtendExecutionTime($timeout + 10); $headers = ['Accept: application/json']; $auth = raijuAiImageAuthHeader($settings); if ($auth !== '') { $headers[] = $auth; } if (!empty($settings['organization'])) { $headers[] = 'OpenAI-Organization: ' . $settings['organization']; } foreach ($files as $field => $pathToFile) { $info = @getimagesize((string)$pathToFile); if ($info === false || !in_array($info[2], [IMAGETYPE_JPEG, IMAGETYPE_PNG, IMAGETYPE_WEBP], true)) { throw new RuntimeException('Reference image is not a supported image.'); } if (filesize((string)$pathToFile) > 15 * 1024 * 1024) { throw new RuntimeException('Reference image is too large.'); } $mime = image_type_to_mime_type($info[2]); $fields[$field] = new CURLFile((string)$pathToFile, $mime, basename((string)$pathToFile)); } [$raw, $status] = raijuAiCurlPost($url, $headers, $fields, $timeout); $data = json_decode((string)$raw, true); if ($status < 200 || $status >= 300) { $message = raijuAiProviderErrorMessage($data); raijuAiAudit('provider-error', false, ['status' => $status, 'message' => $message]); throw new RuntimeException($message); } if (!is_array($data)) { throw new RuntimeException('AI provider returned invalid JSON.'); } return $data; } function raijuAiIsOpenRouterSettings(array $settings): bool { return ($settings['provider'] ?? '') === 'openrouter' || str_contains((string)($settings['baseUrl'] ?? ''), 'openrouter.ai'); } function raijuAiNormalizeKieImageModel(string $model): string { return raijuAiCatalogImageModel('kie', raijuAiCleanModelAllowEmpty($model)); } function raijuAiGenerateKieImages(array $settings, array $options): array { $count = raijuAiClampInt($options['count'] ?? 1, 1, 10); $model = raijuAiNormalizeKieImageModel((string)($options['model'] ?? '')); $baseUrl = rtrim((string)($settings['baseUrl'] ?? 'https://api.kie.ai'), '/'); $headers = ['Content-Type: application/json', 'Accept: application/json']; $key = trim((string)($settings['apiKey'] ?? '')); if ($key !== '') $headers[] = 'Authorization: Bearer ' . $key; // Preserve UTF-8 at the provider's prompt-length boundary. $prompt = (string)($options['prompt'] ?? ''); $prompt = raijuAiUtf8Prefix($prompt, 8000); $requestId = $GLOBALS['raijuAiRequestId'] ?? ''; $entry = raijuAiLoadUsage()['reservations'][$requestId] ?? []; if ($entry && (($entry['provider'] ?? '') !== ($settings['provider'] ?? '') || ($entry['baseUrl'] ?? '') !== ($settings['baseUrl'] ?? ''))) { throw new RuntimeException('Restore the original AI provider settings to resume this request.'); } if (!empty($entry['outputs']) && !array_filter($entry['outputs'], fn($path) => !is_file(dirname(__DIR__, 2) . $path))) return [$entry['outputs'], []]; $savedTasks = $entry['tasks'] ?? []; $references = $entry['references'] ?? []; if (!$savedTasks && !$references) { foreach (array_slice((array)($options['referencePaths'] ?? []), 0, 10) as $path) $references[] = raijuAiKieUploadReference($settings, (string)$path); if ($requestId !== '') raijuAiRequestPatch($requestId, ['references' => $references]); } $tasks = []; for ($index = 0; $index < $count; $index++) { if (isset($savedTasks[$index])) { if (empty($savedTasks[$index]['id'])) throw new RuntimeException('Provider submission is uncertain. Check the provider before starting another request.'); $tasks[$savedTasks[$index]['id']] = $index; continue; } [$taskModel, $input] = raijuAiKieTaskInput(raijuAiImageModelSpec('kie', $model), $model, $prompt, $options, $references, (array)($settings['imageExtraParams'] ?? [])); $savedTasks[$index] = ['state' => 'submitting', 'model' => $taskModel]; if ($requestId !== '') raijuAiRequestPatch($requestId, ['tasks' => $savedTasks]); $response = raijuAiExecuteJsonRequest($settings, $baseUrl . '/api/v1/jobs/createTask', $headers, ['model' => $taskModel, 'input' => $input]); $taskId = trim((string)($response['data']['taskId'] ?? '')); if ($taskId === '') throw new RuntimeException((string)($response['msg'] ?? 'Kie.ai did not return a task ID.')); $savedTasks[$index] = ['id' => $taskId, 'state' => 'waiting', 'model' => $taskModel]; if ($requestId !== '') raijuAiRequestPatch($requestId, ['tasks' => $savedTasks]); $tasks[$taskId] = $index; } $deadline = microtime(true) + raijuAiRequestTimeout($settings); $urls = []; $failures = []; while ($tasks && microtime(true) < $deadline) { foreach (array_keys($tasks) as $taskId) { $record = raijuAiKieGetJson($settings, $baseUrl . '/api/v1/jobs/recordInfo?taskId=' . rawurlencode($taskId), $headers); $data = is_array($record['data'] ?? null) ? $record['data'] : []; $state = strtolower((string)($data['state'] ?? 'waiting')); $savedTasks[$tasks[$taskId]]['state'] = $state; if ($requestId !== '') raijuAiRequestPatch($requestId, ['tasks' => $savedTasks, 'updatedAt' => gmdate('c')]); if ($state === 'success') { $result = $data['resultJson'] ?? []; if (is_string($result)) $result = json_decode($result, true); foreach ((array)(is_array($result) ? ($result['resultUrls'] ?? $result['result_urls'] ?? []) : []) as $url) if (filter_var($url, FILTER_VALIDATE_URL)) $urls[] = $url; unset($tasks[$taskId]); } elseif (in_array($state, ['fail', 'failed', 'error'], true)) { $failures[] = (string)($data['failMsg'] ?? $data['errorMessage'] ?? 'Kie.ai image generation failed.'); unset($tasks[$taskId]); } } if ($tasks) usleep(2000000); } if ($tasks) throw new RuntimeException('Kie.ai image generation timed out. The provider jobs may still be running.'); if (!$urls) throw new RuntimeException($failures[0] ?? 'Kie.ai returned no usable image.'); $paths = []; foreach (array_slice($urls, 0, $count) as $url) { $paths[] = raijuAiStoreGeneratedImage(raijuAiDownloadProviderImage((string)$url, $baseUrl), (string)($options['filenameHint'] ?? 'ai-image'), (string)($options['size'] ?? 'auto'), count($paths) + 1, (string)($options['outputFormat'] ?? ''), (int)($options['outputCompression'] ?? 75), (string)($options['storageDir'] ?? '')); } if ($requestId !== '') raijuAiRequestPatch($requestId, ['outputs' => $paths]); return [$paths, []]; } function raijuAiKieResolution($scale): string { $scale = (int)$scale; return $scale >= 3072 ? '4K' : ($scale >= 1536 ? '2K' : '1K'); } /** * The model and input of one Kie.ai task, from the spec of the model: the id (a separate one when reference images are * sent), the aspect ratio, the resolution step (or the quality that stands for it), the output format and the images. * * @return array{0:string,1:array} */ function raijuAiKieTaskInput(array $spec, string $model, string $prompt, array $options, array $references, array $extra = []): array { $kie = (array)($spec['kie'] ?? []); $taskModel = $references && !empty($kie['editModel']) ? (string)$kie['editModel'] : (string)($kie['model'] ?? $model); $input = ['prompt' => $prompt]; $aspects = (array)($spec['aspects'] ?? []); if (!empty($spec['aspectParam'])) { $ratio = raijuAiImageRequestedRatio($options); $input[$spec['aspectParam']] = $ratio === 'auto' ? (!empty($spec['aspectAuto']) ? 'auto' : (string)($aspects[0] ?? '1:1')) : raijuAiImageNearestAspect($ratio, $aspects); } if (!empty($spec['resolutionParam']) && !empty($spec['resolutions'])) { $step = raijuAiImageResolutionFor($options['imageScale'] ?? 2048, (array)$spec['resolutions']); $input[$spec['resolutionParam']] = (string)($spec['resolutionMap'][$step] ?? $step); } if (!empty($spec['formatParam']) && !empty($spec['formats'])) { $format = strtolower(trim((string)($options['outputFormat'] ?? 'webp'))); $input[$spec['formatParam']] = in_array($format, $spec['formats'], true) ? $format : (string)($spec['formatAliases'][$format] ?? (in_array('png', $spec['formats'], true) ? 'png' : $spec['formats'][0])); } if ($references) $input[(string)($spec['references']['param'] ?? 'image_urls')] = array_values($references); return [$taskModel, $input + $extra]; } function raijuAiKieUploadReference(array $settings, string $path): string { $headers = ['Content-Type: application/json', 'Accept: application/json']; $key = trim((string)($settings['apiKey'] ?? '')); if ($key !== '') $headers[] = 'Authorization: Bearer ' . $key; $response = raijuAiExecuteJsonRequest($settings, 'https://kieai.redpandaai.co/api/file-base64-upload', $headers, ['base64Data' => raijuAiReferenceImageDataUrl($path), 'uploadPath' => 'raiju-references', 'fileName' => 'reference-' . bin2hex(random_bytes(6)) . '.png']); $url = trim((string)($response['data']['fileUrl'] ?? $response['data']['downloadUrl'] ?? '')); if (!filter_var($url, FILTER_VALIDATE_URL)) throw new RuntimeException('Kie.ai could not upload the reference image.'); return $url; } function raijuAiKieGetJson(array $settings, string $url, array $headers): array { return raijuAiImageGetJson($settings, $url, $headers); } function raijuAiGenerateOpenRouterImages(array $settings, array $options): array { $count = raijuAiClampInt($options['count'] ?? 1, 1, 10); $settings['limits']['requestTimeoutSeconds'] = max(480, raijuAiRequestTimeout($settings)); $paths = []; $revisedPrompts = []; for ($i = 0; $i < $count; $i++) { try { $requestBody = raijuAiBuildOpenRouterImageBody($options); $refPartCount = 0; foreach (($requestBody['messages'][0]['content'] ?? []) as $part) { if (is_array($part) && ($part['type'] ?? '') === 'image_url') { $refPartCount++; } } raijuAiAudit('openrouter-image-request', true, [ 'model' => (string)($requestBody['model'] ?? ''), 'referencePathsIn' => count($options['referencePaths'] ?? []), 'imageContentParts' => $refPartCount, 'modalities' => $requestBody['modalities'] ?? null, 'aspectRatioIn' => (string)($options['aspectRatio'] ?? ''), 'sizeIn' => (string)($options['size'] ?? ''), 'imageConfig' => $requestBody['image_config'] ?? null ]); $response = raijuAiProviderRequest($settings, '/chat/completions', $requestBody); [$payloads, $texts] = raijuAiExtractOpenRouterImagePayloads($response); foreach ($texts as $text) { if ($text !== '') { $revisedPrompts[] = $text; } } foreach ($payloads as $payload) { $binary = raijuAiProviderImagePayloadToBinary($payload, (string)($settings['baseUrl'] ?? '')); $paths[] = raijuAiStoreGeneratedImage($binary, (string)($options['filenameHint'] ?? 'ai-image'), (string)($options['size'] ?? 'auto'), count($paths) + 1, (string)($options['outputFormat'] ?? ''), (int)($options['outputCompression'] ?? 70), (string)($options['storageDir'] ?? '')); if (count($paths) >= $count) { break 2; } } } catch (Throwable $e) { if (!$paths) { throw $e; } raijuAiAudit('image-partial', false, [ 'requested' => $count, 'created' => count($paths), 'message' => $e->getMessage() ]); break; } } if (!$paths) { throw new RuntimeException('OpenRouter returned no usable image data.'); } return [$paths, $revisedPrompts]; } function raijuAiBuildOpenRouterImageBody(array $options): array { $model = raijuAiNormalizeOpenRouterImageModel((string)$options['model']); $imageConfig = raijuAiOpenRouterImageConfig( (string)($options['size'] ?? 'auto'), (string)($options['aspectRatio'] ?? 'auto'), $options['imageScale'] ?? null, $model ); $prompt = substr((string)($options['prompt'] ?? ''), 0, 8000); $hasReference = !empty($options['referencePaths']); // image_config.aspect_ratio is the structured control, but image-capable // models (e.g. Gemini) tend to copy a reference image's dimensions and // ignore it. Reinforce the requested ratio in the prompt and, when a // reference is present, explicitly tell the model to override the // reference's framing so the user's selected ratio wins. if (!empty($imageConfig['aspect_ratio'])) { if ($hasReference) { $prompt .= "\n\nOutput the final image in a " . $imageConfig['aspect_ratio'] . " aspect ratio, recomposing the scene to fill that frame. Use the reference image only for subject, style, and content — do not copy its aspect ratio or framing."; } elseif ($imageConfig['aspect_ratio'] !== '1:1') { $prompt .= "\n\nUse a " . $imageConfig['aspect_ratio'] . " aspect ratio for the output image."; } } $content = [ ['type' => 'text', 'text' => $prompt] ]; foreach (($options['referencePaths'] ?? []) as $path) { $content[] = [ 'type' => 'image_url', 'image_url' => ['url' => raijuAiReferenceImageDataUrl((string)$path)] ]; } $body = [ 'model' => $model, 'messages' => [ ['role' => 'user', 'content' => $content] ], 'modalities' => ['image', 'text'], 'stream' => false ]; if ($imageConfig) { $body['image_config'] = $imageConfig; } return $body; } function raijuAiOpenRouterImageConfig(string $size, string $aspectRatio = 'auto', $imageScale = null, string $model = ''): array { $config = []; $aspectRatio = raijuAiCleanAspectRatio($aspectRatio); $maxImageScale = raijuAiOpenRouterImageModelMaxScale($model); if ($aspectRatio !== 'auto') { [$rw, $rh] = array_map('intval', explode(':', $aspectRatio, 2)); $config['aspect_ratio'] = raijuAiNearestOpenRouterAspectRatio($rw, $rh); $scale = raijuAiClampInt($imageScale ?? 2048, 1024, $maxImageScale); $config['image_size'] = $scale <= 1024 ? '1K' : ($scale <= 2048 ? '2K' : '4K'); return $config; } if (preg_match('/^(\d+)x(\d+)$/', $size, $m)) { $width = max(1, (int)$m[1]); $height = max(1, (int)$m[2]); $gcd = raijuAiGcd($width, $height); $config['aspect_ratio'] = raijuAiNearestOpenRouterAspectRatio((int)($width / $gcd), (int)($height / $gcd)); $longEdge = min(max($width, $height), $maxImageScale); $config['image_size'] = $longEdge <= 1024 ? '1K' : ($longEdge <= 2048 ? '2K' : '4K'); } return $config; } function raijuAiOpenRouterImageModelMaxScale(string $model): int { $model = raijuAiNormalizeOpenRouterImageModel($model); if (preg_match('/(^|\/)gpt-5\.4-image-2(?:$|-)|^gpt-image-2(?:$|-)/i', $model)) { return 2048; } return 3840; } function raijuAiCleanAspectRatio(string $ratio): string { $ratio = trim(strtolower($ratio)); if ($ratio === '' || $ratio === 'auto') { return 'auto'; } if (!preg_match('/^([1-9]\d?):([1-9]\d?)$/', $ratio, $m)) { return 'auto'; } $width = (int)$m[1]; $height = (int)$m[2]; if ($width <= 0 || $height <= 0 || max($width / $height, $height / $width) > 3) { return 'auto'; } return $width . ':' . $height; } function raijuAiNearestOpenRouterAspectRatio(int $widthRatio, int $heightRatio): string { $requested = $heightRatio > 0 ? $widthRatio / $heightRatio : 1.0; $supported = ['1:1', '2:3', '3:2', '3:4', '4:3', '4:5', '5:4', '9:16', '16:9', '21:9']; $best = '1:1'; $bestDistance = PHP_FLOAT_MAX; foreach ($supported as $ratio) { [$w, $h] = array_map('intval', explode(':', $ratio, 2)); $distance = abs(log($requested) - log($w / $h)); if ($distance < $bestDistance) { $best = $ratio; $bestDistance = $distance; } } return $best; } function raijuAiGcd(int $a, int $b): int { while ($b !== 0) { $tmp = $b; $b = $a % $b; $a = $tmp; } return max(1, abs($a)); } function raijuAiExtractOpenRouterImagePayloads(array $response): array { $payloads = []; $texts = []; foreach (($response['choices'] ?? []) as $choice) { if (!is_array($choice)) { continue; } $message = is_array($choice['message'] ?? null) ? $choice['message'] : []; foreach (raijuAiExtractOpenRouterMessageImages($message) as $payload) { $payloads[] = $payload; } foreach (raijuAiExtractOpenRouterMessageTexts($message) as $text) { $texts[] = $text; } } return [array_values(array_unique($payloads)), array_values(array_unique($texts))]; } function raijuAiExtractOpenRouterMessageImages(array $message): array { $payloads = []; foreach (($message['images'] ?? []) as $image) { if (!is_array($image)) { continue; } $url = $image['image_url']['url'] ?? $image['imageUrl']['url'] ?? $image['url'] ?? ''; if (is_string($url) && $url !== '') { $payloads[] = $url; } } $content = $message['content'] ?? null; if (is_array($content)) { foreach ($content as $part) { if (!is_array($part)) { continue; } $url = $part['image_url']['url'] ?? $part['imageUrl']['url'] ?? $part['url'] ?? ''; if (is_string($url) && $url !== '') { $payloads[] = $url; } } } return $payloads; } function raijuAiExtractOpenRouterMessageTexts(array $message): array { $texts = []; if (is_string($message['content'] ?? null)) { $texts[] = trim((string)$message['content']); } elseif (is_array($message['content'] ?? null)) { foreach ($message['content'] as $part) { if (is_array($part) && is_string($part['text'] ?? null)) { $texts[] = trim((string)$part['text']); } } } return array_values(array_filter($texts)); } function raijuAiProviderImagePayloadToBinary(string $payload, string $providerBaseUrl = ''): string { if (preg_match('#^data:image/[a-zA-Z0-9.+-]+;base64,#', $payload)) { $base64 = substr($payload, strpos($payload, ',') + 1); $binary = base64_decode($base64, true); if ($binary === false) { throw new RuntimeException('OpenRouter returned invalid image data.'); } return $binary; } return raijuAiDownloadProviderImage($payload, $providerBaseUrl); } function raijuAiIsValidGptImage2Size(string $size): bool { if ($size === 'auto') return true; if (!preg_match('/^(\d{3,4})x(\d{3,4})$/', $size, $m)) return false; $w = (int)$m[1]; $h = (int)$m[2]; if ($w % 16 !== 0 || $h % 16 !== 0 || $w > 3840 || $h > 3840) return false; $pixels = $w * $h; if ($pixels < 655360 || $pixels > 8294400) return false; $ratio = max($w / $h, $h / $w); return $ratio <= 3; } function raijuAiRequestTimeout(array $settings): int { $limits = is_array($settings['limits'] ?? null) ? $settings['limits'] : []; return raijuAiClampInt($limits['requestTimeoutSeconds'] ?? 300, 5, 600); } function raijuAiExtendExecutionTime(int $seconds): void { if (!function_exists('set_time_limit')) { return; } $disabled = array_map('trim', explode(',', (string)ini_get('disable_functions'))); if (in_array('set_time_limit', $disabled, true)) { return; } @set_time_limit(max(5, $seconds)); } function raijuAiUtf8Prefix(string $text, int $maxCharacters): string { if (!preg_match('/\A.{0,' . max(0, $maxCharacters) . '}/us', $text, $match)) { throw new RuntimeException('AI text must contain valid UTF-8.'); } return $match[0]; } function raijuAiCleanMessages(array $messages): array { $clean = []; foreach ($messages as $message) { if (!is_array($message)) { continue; } $role = (string)($message['role'] ?? 'user'); if (!in_array($role, ['system', 'user', 'assistant'], true)) { $role = 'user'; } $content = trim((string)($message['content'] ?? '')); if ($content === '') { continue; } $clean[] = ['role' => $role, 'content' => raijuAiUtf8Prefix($content, 24000)]; } return array_slice($clean, -20); } function raijuAiLoadUsage(): array { if (!is_file(RAIJU_AI_USAGE_PATH)) { return ['days' => [], 'months' => [], 'updatedAt' => null]; } $data = json_decode((string)file_get_contents(RAIJU_AI_USAGE_PATH), true); return is_array($data) ? array_replace(['days' => [], 'months' => [], 'updatedAt' => null], $data) : ['days' => [], 'months' => [], 'updatedAt' => null]; } function raijuAiSaveUsage(array $usage): void { $dir = dirname(RAIJU_AI_USAGE_PATH); if (!is_dir($dir)) { mkdir($dir, 0755, true); } file_put_contents(RAIJU_AI_USAGE_PATH, json_encode($usage, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), LOCK_EX); } function raijuAiPruneUsage(array &$usage): void { foreach (($usage['reservations'] ?? []) as $id => $entry) { if (in_array($entry['status'] ?? '', ['settled', 'released'], true) && strtotime($entry['updatedAt'] ?? '') < time() - 90 * 86400) unset($usage['reservations'][$id]); } $minDay = gmdate('Y-m-d', time() - 90 * 86400); foreach ($usage['days'] as $day => $_bucket) { if ($day < $minDay) { unset($usage['days'][$day]); } } $minMonth = gmdate('Y-m', strtotime('-24 months')); foreach ($usage['months'] as $month => $_bucket) { if ($month < $minMonth) { unset($usage['months'][$month]); } } } function raijuAiEmptyUsageBucket(): array { return [ 'requests' => 0, 'textRequests' => 0, 'imageRequests' => 0, 'inputTokens' => 0, 'outputTokens' => 0, 'estimatedCostCents' => 0, 'unpricedRequests' => 0, 'unpricedImageRequests' => 0 ]; } function raijuAiEstimateTokens(string $text): int { $chars = mb_strlen($text, 'UTF-8'); return max(1, (int)ceil($chars / 4)); } /** Estimated cost in cents of a text request with the chat model (no cache reads); 0 when no price is known. Kept for older callers. */ function raijuAiEstimateTextCost(array $settings, int $inputTokens, int $outputTokens, ?string $model = null): float { return raijuAiCostForUsage($settings, $model ?? (string)$settings['chatModel'], ['input' => $inputTokens, 'output' => $outputTokens])['cents']; } /** * Re-encode raw image bytes into the requested type using GD. Returns null * (caller keeps the original) when GD or the target encoder is unavailable or * decoding fails. JPEG has no alpha, so transparency is flattened onto white. */ function raijuAiConvertImageBinary(string $binary, int $targetType, int $quality): ?string { if (!function_exists('imagecreatefromstring')) { return null; } $encoders = [ IMAGETYPE_JPEG => 'imagejpeg', IMAGETYPE_WEBP => 'imagewebp', IMAGETYPE_PNG => 'imagepng' ]; if (!isset($encoders[$targetType]) || !function_exists($encoders[$targetType])) { return null; } $image = @imagecreatefromstring($binary); if ($image === false) { return null; } $quality = max(0, min(100, $quality)); try { if ($targetType === IMAGETYPE_JPEG) { $width = imagesx($image); $height = imagesy($image); $flattened = imagecreatetruecolor($width, $height); $white = imagecolorallocate($flattened, 255, 255, 255); imagefilledrectangle($flattened, 0, 0, $width, $height, $white); imagecopy($flattened, $image, 0, 0, 0, 0, $width, $height); unset($image); $image = $flattened; } elseif (function_exists('imagealphablending')) { imagealphablending($image, false); imagesavealpha($image, true); } ob_start(); if ($targetType === IMAGETYPE_JPEG) { imagejpeg($image, null, $quality); } elseif ($targetType === IMAGETYPE_WEBP) { imagewebp($image, null, $quality); } else { // PNG quality is a 0-9 compression level (inverse of percentage). imagepng($image, null, (int)round((100 - $quality) / 100 * 9)); } $out = ob_get_clean(); } finally { if ($image instanceof GdImage) { unset($image); } } return is_string($out) && $out !== '' ? $out : null; } function raijuAiStoreGeneratedImage(string $binary, string $hint, string $size = 'auto', int $index = 1, string $requestedFormat = '', int $quality = 70, string $storageDir = ''): string { if (strlen($binary) > 15 * 1024 * 1024) { throw new RuntimeException('Generated image is too large.'); } $info = @getimagesizefromstring($binary); if ($info === false || !in_array($info[2], [IMAGETYPE_JPEG, IMAGETYPE_PNG, IMAGETYPE_WEBP], true)) { throw new RuntimeException('Generated file is not a supported image.'); } // Providers (e.g. OpenRouter) ignore the requested output format and often // return PNG. Convert to the format the admin selected so the saved file // matches the UI choice. Conversion only runs when the target differs and // GD supports it; otherwise the original bytes are kept. $requestedFormat = strtolower(trim($requestedFormat)); $currentType = $info[2]; $targetType = match ($requestedFormat) { 'jpeg', 'jpg' => IMAGETYPE_JPEG, 'webp' => IMAGETYPE_WEBP, 'png' => IMAGETYPE_PNG, default => $currentType }; if ($targetType !== $currentType) { $converted = raijuAiConvertImageBinary($binary, $targetType, $quality); if ($converted !== null) { $binary = $converted; $info = @getimagesizefromstring($binary) ?: $info; } } $ext = match ($info[2]) { IMAGETYPE_JPEG => 'jpg', IMAGETYPE_WEBP => 'webp', default => 'png' }; // Agent runs store into their staging folder (content/ai-agent/staging/...); the library gets the file when the change set is applied. $targetDir = RAIJU_AI_GENERATED_IMAGE_DIR; $publicBase = '/assets/images/generated/'; if ($storageDir !== '') { $stagingRoot = realpath(dirname(__DIR__, 2) . '/content/ai-agent/staging'); $real = realpath($storageDir); if ($stagingRoot === false || $real === false || !str_starts_with($real, $stagingRoot . DIRECTORY_SEPARATOR)) { throw new RuntimeException('Invalid image storage folder.'); } $targetDir = $real; $publicBase = '/content/ai-agent/staging/' . substr($real, strlen($stagingRoot) + 1) . '/'; } if (!is_dir($targetDir)) { mkdir($targetDir, 0755, true); } $slugSource = trim((string)$hint); $slugSource = strtr($slugSource, [ 'Ä' => 'Ae', 'Ö' => 'Oe', 'Ü' => 'Ue', 'ä' => 'ae', 'ö' => 'oe', 'ü' => 'ue', 'ß' => 'ss' ]); if (function_exists('iconv')) { $converted = @iconv('UTF-8', 'ASCII//TRANSLIT//IGNORE', $slugSource); if (is_string($converted) && $converted !== '') { $slugSource = $converted; } } $slugSource = strtolower($slugSource); $slug = strtolower(preg_replace('/[^a-z0-9-]+/', '-', $slugSource)); $slug = trim($slug, '-') ?: 'ai-image'; $slug = substr($slug, 0, 72); $slug = trim($slug, '-') ?: 'ai-image'; $indexSlug = $index > 1 ? '-v' . min(99, $index) : ''; $baseName = $slug . $indexSlug; $filename = $baseName . '.' . $ext; $path = $targetDir . '/' . $filename; $counter = 2; while (file_exists($path)) { $filename = $baseName . '-' . $counter . '.' . $ext; $path = $targetDir . '/' . $filename; $counter++; } if (file_put_contents($path, $binary, LOCK_EX) === false) { throw new RuntimeException('Could not save generated image.'); } if ($storageDir === '') { raijuAiLabelStoredImage($filename); } return $publicBase . $filename; } /** * Mark a stored AI image as "generated" in the media metadata so the disclosure * label renders wherever the image is used. A failed label write is logged, not * fatal: the (paid) image must not be lost, and the Media Library can still set it. */ function raijuAiLabelStoredImage(string $filename): void { if (!raijuSetMediaAiLabel('image', 'generated/' . $filename, 'generated')) { error_log('Raiju: could not store the AI label for generated/' . $filename); } } /** * Inspect the actually-stored output images and return their real dimensions * and format, so history reflects what the provider delivered rather than what * was requested (providers may ignore the requested size/format, e.g. * OpenRouter returning PNG at provider-chosen pixel dimensions). */ function raijuAiActualImageMeta(array $publicPaths): array { $root = dirname(__DIR__, 2); foreach ($publicPaths as $publicPath) { $publicPath = (string)$publicPath; if ($publicPath === '') { continue; } $file = $root . '/' . ltrim($publicPath, '/'); if (!is_file($file)) { continue; } $info = @getimagesize($file); if ($info === false) { continue; } $format = match ($info[2]) { IMAGETYPE_JPEG => 'jpeg', IMAGETYPE_PNG => 'png', IMAGETYPE_WEBP => 'webp', IMAGETYPE_GIF => 'gif', default => strtolower((string)pathinfo($file, PATHINFO_EXTENSION)) }; return [ 'size' => (int)$info[0] . 'x' . (int)$info[1], 'format' => $format ]; } return []; } /** * Download an image a provider returned as an address. Only public hosts are contacted (raijuNetSafeFetch: addresses * checked and pinned, redirects validated, size and type limited). The one exception is the provider the admin * explicitly allowed as local (same origin as $providerBaseUrl, "Allow local provider" on). */ function raijuAiDownloadProviderImage(string $url, string $providerBaseUrl = ''): string { $parts = parse_url($url); if (!is_array($parts) || !in_array($parts['scheme'] ?? '', ['http', 'https'], true)) { throw new RuntimeException('Invalid provider image URL.'); } $settings = raijuAiLoadSettings(false); $provider = parse_url($providerBaseUrl !== '' ? $providerBaseUrl : (string)($settings['baseUrl'] ?? '')) ?: []; $origin = static fn(array $url): array => [strtolower($url['scheme'] ?? ''), strtolower($url['host'] ?? ''), (int)($url['port'] ?? (($url['scheme'] ?? '') === 'https' ? 443 : 80))]; $explicitLocalProvider = !empty($settings['allowLocalProvider']) && $origin($parts) === $origin($provider); if (isset($parts['user']) || isset($parts['pass'])) { throw new RuntimeException('Provider image URL is not allowed.'); } if (!$explicitLocalProvider) { $test = raijuNetTestConfig(); try { $response = raijuNetSafeFetch($url, ($test['options'] ?? []) + [ // Some file hosts label images as octet-stream; the bytes are checked as an image when they are stored. 'allowedContentTypes' => ['image/jpeg', 'image/png', 'image/webp', 'application/octet-stream', 'binary/octet-stream'], 'extraPorts' => [8080, 8443], // image hosts behind the usual alternative web ports 'maxBytes' => 15 * 1024 * 1024, 'timeout' => 45, 'accept' => 'image/jpeg,image/png,image/webp', 'userAgent' => 'raiju-cms/1.0', ]); } catch (RaijuNetException $e) { // The reason is logged without the address: provider URLs can carry signatures. raijuAiAudit('provider-image-download', false, ['reason' => $e->reason, 'status' => $e->httpStatus]); throw new RuntimeException($e->reason === 'blocked_address' || $e->reason === 'blocked_host' ? 'Provider image URL is not allowed.' : 'Could not download provider image.'); } return $response['body']; } if (function_exists('curl_init')) { $ch = curl_init($url); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_FOLLOWLOCATION => false, CURLOPT_TIMEOUT => 45, CURLOPT_MAXFILESIZE => 15 * 1024 * 1024 ]); $binary = curl_exec($ch); $status = (int)curl_getinfo($ch, CURLINFO_HTTP_CODE); if ($binary === false || $status < 200 || $status >= 300) { throw new RuntimeException('Could not download provider image.'); } return $binary; } $context = stream_context_create(['http' => ['timeout' => 45, 'ignore_errors' => false, 'follow_location' => 0]]); $binary = file_get_contents($url, false, $context, 0, 15 * 1024 * 1024); if ($binary === false) { throw new RuntimeException('Could not download provider image.'); } return $binary; } function raijuAiCollectReferenceImagePaths(array $options): array { $paths = []; $uploaded = $options['referenceImagePaths'] ?? []; if (!is_array($uploaded)) { $uploaded = [$uploaded]; } if (!empty($options['referenceImagePath'])) { array_unshift($uploaded, (string)$options['referenceImagePath']); } foreach ($uploaded as $path) { $path = trim((string)$path); if ($path !== '') { $paths[] = $path; } } $mediaPaths = $options['referenceMediaPaths'] ?? []; if (!is_array($mediaPaths)) { $mediaPaths = [$mediaPaths]; } if (!empty($options['referenceMediaPath'])) { $mediaPaths[] = (string)$options['referenceMediaPath']; } foreach ($mediaPaths as $publicPath) { $publicPath = trim((string)$publicPath); if ($publicPath !== '') { $paths[] = raijuAiResolveReferenceMediaPath($publicPath); } } $paths = array_values(array_unique($paths)); if (count($paths) > 16) { throw new RuntimeException('A maximum of 16 reference images is allowed.'); } $totalBytes = 0; foreach ($paths as $path) { raijuAiValidateReferenceImageFile($path); $totalBytes += (int)filesize($path); } if ($totalBytes > 64 * 1024 * 1024) { throw new RuntimeException('Reference images are too large in total.'); } return $paths; } function raijuAiValidateReferenceImageFile(string $path): void { $info = @getimagesize($path); if ($info === false || !in_array($info[2], [IMAGETYPE_JPEG, IMAGETYPE_PNG, IMAGETYPE_WEBP], true)) { throw new RuntimeException('Reference image is not a supported image.'); } if (filesize($path) > 15 * 1024 * 1024) { throw new RuntimeException('Reference image is too large.'); } } function raijuAiReferenceImageDataUrl(string $path): string { raijuAiValidateReferenceImageFile($path); $info = getimagesize($path); $mime = image_type_to_mime_type($info[2]); $binary = file_get_contents($path); if ($binary === false) { throw new RuntimeException('Could not read reference image.'); } return 'data:' . $mime . ';base64,' . base64_encode($binary); } function raijuAiResolveReferenceMediaPath(string $publicPath): string { $publicPath = trim($publicPath); $publicPath = preg_replace('#^(\.\./)+#', '/', $publicPath); if ($publicPath === '' || strpos($publicPath, '..') !== false || preg_match('#[:\x00]#', $publicPath)) { throw new RuntimeException('Invalid reference image path.'); } if ($publicPath[0] !== '/') { $publicPath = '/' . $publicPath; } if (!str_starts_with($publicPath, '/assets/images/')) { throw new RuntimeException('Reference image must come from the media library.'); } $relative = substr($publicPath, strlen('/assets/images/')); if ($relative === '' || str_contains($relative, '\\')) { throw new RuntimeException('Invalid reference image path.'); } $path = dirname(__DIR__, 2) . '/assets/images/' . $relative; $realBase = realpath(dirname(__DIR__, 2) . '/assets/images'); $realPath = realpath($path); if ($realBase === false || $realPath === false || !str_starts_with($realPath, $realBase . DIRECTORY_SEPARATOR)) { throw new RuntimeException('Reference image not found.'); } return $realPath; } function raijuAiValidateBaseUrl(string $url, bool $allowLocal): void { $parts = parse_url($url); if (!is_array($parts) || !in_array($parts['scheme'] ?? '', ['http', 'https'], true) || empty($parts['host'])) { throw new RuntimeException('Invalid AI base URL.'); } if (!$allowLocal && raijuAiIsPrivateHost((string)$parts['host'])) { throw new RuntimeException('Local/private AI provider URLs must be explicitly allowed.'); } } function raijuAiIsLocalBaseUrl(string $url): bool { $host = parse_url($url, PHP_URL_HOST); return is_string($host) && raijuAiIsPrivateHost($host); } /** * True when a configured host is local or non-public. Every resolved address is * checked against the CIDR list in agent/net.php (IPv4 and IPv6); one private * address is enough. Hosts that cannot be resolved are not reported as private. */ function raijuAiIsPrivateHost(string $host): bool { $host = strtolower(trim($host, '[] .')); if ($host === '') { return false; } if ($host === 'localhost' || str_ends_with($host, '.localhost')) { return true; } if (filter_var($host, FILTER_VALIDATE_IP)) { return !raijuNetIsPublicIp($host); } $legacy = raijuNetParseLegacyIpv4($host); if ($legacy !== null) { return !raijuNetIsPublicIp($legacy); } if (preg_match('/^(0x[0-9a-f]*|[0-9]+)(\.(0x[0-9a-f]*|[0-9]+)){0,3}$/i', $host)) { return true; // Numeric notation that is not a valid address: never treat as public. } foreach (raijuNetResolveHost($host) as $ip) { if (!raijuNetIsPublicIp($ip)) { return true; } } return false; } function raijuAiCleanModel(string $model, string $fallback = ''): string { $model = trim($model); if ($model === '') { return $fallback; } return raijuAiCleanModelId($model); } /** A model id as it may be stored and put into a request address: allowed characters only, no empty or dot path segments. */ function raijuAiCleanModelId(string $model): string { $model = (string)preg_replace('/[^A-Za-z0-9._:\/-]/', '', $model); $segments = array_filter(explode('/', $model), static fn($segment) => $segment !== '' && $segment !== '.' && $segment !== '..'); return substr(implode('/', $segments), 0, 120); } /** True for a value that only consists of mask characters, as shown in place of a stored secret. */ function raijuAiIsMaskedSecret(string $value): bool { return $value !== '' && preg_match('/^[\x{2022}\x{25CF}\x{00B7}*\s]+$/u', $value) === 1; } /** * A reasoning mode as it may be stored. "legacy" survives only for a purpose that already was legacy; * a level the model does not support, or any level for a model without a known setting, becomes "recommended". */ function raijuAiCleanReasoningMode($mode, bool $wasLegacy, string $model, string $provider, string $baseUrl): string { $mode = is_string($mode) ? $mode : 'recommended'; if ($mode === 'legacy') return $wasLegacy ? 'legacy' : 'recommended'; if (!in_array($mode, array_merge(['recommended'], RAIJU_AI_EFFORT_LEVELS), true)) return 'recommended'; if ($mode === 'recommended') return 'recommended'; $info = raijuAiModelInfo(['provider' => $provider, 'baseUrl' => $baseUrl], $model); return $info && in_array($mode, $info['effort']['levels'] ?? [], true) ? $mode : 'recommended'; } function raijuAiCleanModelAllowEmpty(string $model): string { $model = trim($model); if ($model === '') { return ''; } return raijuAiCleanModelId($model); } function raijuAiNormalizeOpenRouterImageModel(string $model): string { $model = raijuAiCleanModelAllowEmpty($model); return $model === '' ? '' : raijuAiCatalogImageModel('openrouter', $model); } function raijuAiClampInt($value, int $min, int $max): int { return max($min, min($max, (int)$value)); }