Raiju/includes/ai/ai-helper.php
2026-10-25 06:05:42 +03:00

2878 lines
128 KiB
PHP

<?php
/**
* raiju AI gateway.
*
* Centralizes provider configuration, usage limits, audit logging, and media
* storage so CMS features do not talk to AI vendors directly.
*/
require_once dirname(__DIR__) . '/json-store.php';
require_once dirname(__DIR__) . '/ai-labels.php';
require_once __DIR__ . '/agent/net.php';
require_once __DIR__ . '/agent/tool-gateway.php';
require_once __DIR__ . '/request-ledger.php';
require_once __DIR__ . '/model-catalog.php';
require_once __DIR__ . '/model-capabilities.php';
require_once __DIR__ . '/pricing.php';
require_once __DIR__ . '/image-services.php';
require_once __DIR__ . '/settings-store.php';
if (!defined('RAIJU_AI_USAGE_PATH')) {
define('RAIJU_AI_USAGE_PATH', dirname(__DIR__, 2) . '/content/ai-usage.json');
}
if (!defined('RAIJU_AI_AUDIT_DIR')) {
define('RAIJU_AI_AUDIT_DIR', dirname(__DIR__, 2) . '/content/ai-audit');
}
if (!defined('RAIJU_AI_IMAGE_HISTORY_PATH')) {
define('RAIJU_AI_IMAGE_HISTORY_PATH', dirname(__DIR__, 2) . '/content/ai-image-history.json');
}
if (!defined('RAIJU_AI_IMAGE_JOBS_DIR')) {
define('RAIJU_AI_IMAGE_JOBS_DIR', dirname(__DIR__, 2) . '/content/ai-image-jobs');
}
if (!defined('RAIJU_AI_GENERATED_IMAGE_DIR')) {
define('RAIJU_AI_GENERATED_IMAGE_DIR', dirname(__DIR__, 2) . '/assets/images/generated');
}
function raijuAiDefaults(): array {
return [
'enabled' => 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<int,array{kind:string,level:string,used:int|float,limit:int,percent:int}>
*/
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));
}