Some checks are pending
Raiju CI / PHP ${{ matrix.php }} (8.1) (push) Waiting to run
Raiju CI / PHP ${{ matrix.php }} (8.4) (push) Waiting to run
Raiju CI / PHP ${{ matrix.php }} (8.5) (push) Waiting to run
Raiju CI / Apache routing / PHP-FPM (push) Waiting to run
Raiju CI / Chromium (push) Waiting to run
- Starter content and admin language files: Raiju, not raiju - Test email, backup messages, mailer header and AI prompts use Raiju - Meta generator and footer fallback use Raiju - Technical identifiers (paths, CSS classes, user agents, temp prefixes) unchanged
2878 lines
128 KiB
PHP
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));
|
|
}
|