.apiKey. A service of OpenAI, OpenRouter or Kie without * an own key uses the chat key of the same provider. Keys never reach the browser, the audit log or job files. */ require_once __DIR__ . '/agent/net.php'; const RAIJU_AI_IMAGE_SERVICE_IDS = ['openai-compatible', 'openrouter', 'kie', 'fal', 'comet', 'custom']; const RAIJU_AI_IMAGE_RESERVED_PARAMS = ['prompt', 'model', 'n', 'contents', 'messages', 'input', 'image', 'images', 'image_urls', 'num_images']; // --------------------------------------------------------------------------- // Registry and settings // --------------------------------------------------------------------------- function raijuAiImageServiceRegistry(): array { static $registry; return $registry ??= json_decode((string)file_get_contents(__DIR__ . '/image-services.json'), true, 512, JSON_THROW_ON_ERROR); } /** The registry reduced to what the settings page needs (no URLs of the test hooks, no secrets). */ function raijuAiImageServicesForUi(): array { $services = []; foreach (raijuAiImageServiceRegistry()['services'] as $id => $entry) { $models = []; foreach ((array)($entry['models'] ?? []) as $model) { $models[] = [ 'id' => (string)$model['id'], 'label' => (string)($model['label'] ?? $model['id']), 'verified' => $model['verified'] ?? false, 'note' => (string)($model['note'] ?? ''), 'aspects' => (array)($model['aspects'] ?? []), 'resolutions' => (array)($model['resolutions'] ?? []), 'references' => !empty($model['references']), 'price' => $model['price'] ?? null, ]; } $services[$id] = [ 'label' => (string)$entry['label'], 'adapter' => (string)$entry['adapter'], 'catalogModels' => !empty($entry['catalogModels']), 'keyFrom' => $entry['keyFrom'] ?? null, 'defaultModel' => (string)($entry['defaultModel'] ?? ''), 'docs' => (string)($entry['docs'] ?? ''), 'models' => $models, 'defaults' => ['verified' => $entry['defaults']['verified'] ?? true, 'note' => (string)($entry['defaults']['note'] ?? '')], ]; } return ['services' => $services, 'ids' => RAIJU_AI_IMAGE_SERVICE_IDS]; } /** The configured image service with defaults filled in. */ function raijuAiImageServiceConfig(array $settings): array { $cfg = is_array($settings['imageService'] ?? null) ? $settings['imageService'] : []; $provider = (string)($cfg['provider'] ?? 'inherit'); if ($provider !== 'inherit' && !in_array($provider, RAIJU_AI_IMAGE_SERVICE_IDS, true)) $provider = 'inherit'; $custom = is_array($cfg['custom'] ?? null) ? $cfg['custom'] : []; return [ 'provider' => $provider, 'model' => raijuAiCleanModelAllowEmpty((string)($cfg['model'] ?? '')), 'models' => is_array($cfg['models'] ?? null) ? $cfg['models'] : [], 'custom' => [ 'baseUrl' => rtrim(trim((string)($custom['baseUrl'] ?? '')), '/'), 'sizeMode' => in_array($custom['sizeMode'] ?? '', ['size', 'aspect_ratio', 'none'], true) ? $custom['sizeMode'] : 'size', 'authStyle' => ($custom['authStyle'] ?? '') === 'x-api-key' ? 'x-api-key' : 'bearer', 'extraParams' => is_array($custom['extraParams'] ?? null) ? $custom['extraParams'] : [], ], ]; } /** Base URL of a service. Tests may redirect fal and Comet to a loopback mock (RAIJU_AGENT_TEST_NET.imageBases, CLI and built-in server only). */ function raijuAiImageServiceBaseUrl(string $id, array $settings): string { $test = raijuNetTestConfig(); if ($test !== null && isset($test['raw']['imageBases'][$id])) return rtrim((string)$test['raw']['imageBases'][$id], '/'); if ($id === 'custom') return raijuAiImageServiceConfig($settings)['custom']['baseUrl']; return rtrim((string)(raijuAiImageServiceRegistry()['services'][$id]['baseUrl'] ?? ''), '/'); } /** * The key of the chat provider that an image service of the same name may use when it has none of its own. Only * when that chat provider talks to the service's own host: a key for another OpenAI-compatible host (Groq, Mistral, * a local gateway) is never sent to api.openai.com. '' when there is none. */ function raijuAiImageChatKey(array $settings, string $serviceId): string { $from = raijuAiImageServiceRegistry()['services'][$serviceId]['keyFrom'] ?? null; if (empty($from)) return ''; $credentials = (array)($settings['providerCredentials'][$from] ?? []); $key = trim((string)($credentials['apiKey'] ?? '')); if ($key === '') return ''; $chatHost = raijuAiBaseUrlHost((string)($credentials['baseUrl'] ?? '')); $serviceHost = raijuAiBaseUrlHost((string)(raijuAiImageServiceRegistry()['services'][$serviceId]['baseUrl'] ?? '')); return $chatHost !== '' && $chatHost === $serviceHost ? $key : ''; } /** Spec of a model: the registry entry, or the defaults of the service for a model it does not list. */ function raijuAiImageModelSpec(string $serviceId, string $model): array { $service = raijuAiImageServiceRegistry()['services'][$serviceId] ?? []; foreach ((array)($service['models'] ?? []) as $entry) { if (strtolower((string)$entry['id']) === strtolower($model)) return $entry + ['known' => true]; } return (array)($service['defaults'] ?? []) + ['known' => false]; } /** * The image service in effect for these (full) settings. * * @return array{id:string,inherit:bool,adapter:string,baseUrl:string,apiKey:string,organization:string,model:string,keySource:string,supported:bool,reason:string,spec:array,authStyle:string,extraParams:array,sizeMode:string} */ function raijuAiImageServiceResolve(array $settings): array { $cfg = raijuAiImageServiceConfig($settings); $registry = raijuAiImageServiceRegistry()['services']; if ($cfg['provider'] === 'inherit') { $id = raijuAiProviderKey($settings); $adapter = ['openai-compatible' => 'openai-images', 'openrouter' => 'openrouter-chat', 'kie' => 'kie-task'][$id] ?? ''; $key = trim((string)($settings['apiKey'] ?? '')); $service = [ 'id' => $id, 'inherit' => true, 'adapter' => $adapter, 'baseUrl' => rtrim((string)($settings['baseUrl'] ?? ''), '/'), 'apiKey' => $key, 'organization' => (string)($settings['organization'] ?? ''), 'model' => (string)($settings['imageModel'] ?? ''), 'keySource' => $key !== '' ? 'chat' : 'none', 'supported' => $adapter !== '', 'reason' => $adapter === '' ? 'chat_provider_without_images' : '', 'spec' => [], 'authStyle' => 'bearer', 'extraParams' => $id === 'kie' ? $cfg['custom']['extraParams'] : [], 'sizeMode' => 'size', ]; return raijuAiImageServiceWithModel($service, $service['model'], $settings); } $id = $cfg['provider']; $entry = $registry[$id]; $own = trim((string)($settings['imageServices'][$id]['apiKey'] ?? '')); $chatKey = raijuAiImageChatKey($settings, $id); $key = $own !== '' ? $own : $chatKey; $model = $cfg['model'] !== '' ? $cfg['model'] : raijuAiCleanModelAllowEmpty((string)($cfg['models'][$id] ?? '')); if ($model === '') { $model = !empty($entry['catalogModels']) ? (string)(raijuAiModelCatalog()[$id]['images'][0]['value'] ?? '') : (string)($entry['defaultModel'] ?? ''); } $reason = ''; if ($id === 'custom' && raijuAiImageServiceBaseUrl($id, $settings) === '') $reason = 'custom_base_url_missing'; $service = [ 'id' => $id, 'inherit' => false, 'adapter' => (string)$entry['adapter'], 'baseUrl' => '', 'apiKey' => $key, 'organization' => $own === '' && $chatKey !== '' && $id === 'openai-compatible' ? (string)($settings['providerCredentials']['openai-compatible']['organization'] ?? '') : '', 'model' => $model, 'keySource' => $own !== '' ? 'own' : ($chatKey !== '' ? 'chat' : 'none'), 'supported' => $reason === '', 'reason' => $reason, 'spec' => [], 'authStyle' => $id === 'custom' ? $cfg['custom']['authStyle'] : 'bearer', 'extraParams' => in_array($id, ['kie', 'fal', 'comet', 'custom'], true) ? $cfg['custom']['extraParams'] : [], 'sizeMode' => $cfg['custom']['sizeMode'], ]; return raijuAiImageServiceWithModel($service, $model, $settings); } /** * The service with a model put in: the spec of that model, the adapter it needs (a model may name its own, as Comet's GPT * Image does) and the address the adapter talks to (a model may add a path to the service's address). */ function raijuAiImageServiceWithModel(array $service, string $model, array $settings = []): array { $entry = raijuAiImageServiceRegistry()['services'][$service['id']] ?? []; $spec = !empty($entry['catalogModels']) || $entry === [] ? [] : raijuAiImageModelSpec($service['id'], $model); $service['model'] = $model; $service['spec'] = $spec; if (!$service['inherit']) { $service['adapter'] = (string)($spec['adapter'] ?? $entry['adapter'] ?? ''); $service['baseUrl'] = rtrim(raijuAiImageServiceBaseUrl($service['id'], $settings), '/') . (string)($spec['basePath'] ?? ''); } return $service; } /** The chat settings with the credentials and model of the image service put in; the existing image functions run on this. */ function raijuAiImageEffectiveSettings(array $settings, array $service): array { if (!$service['inherit']) { $settings['provider'] = $service['id']; $settings['baseUrl'] = $service['baseUrl']; $settings['apiKey'] = $service['apiKey']; $settings['organization'] = $service['organization']; } $settings['imageModel'] = $service['model']; $settings['authStyle'] = $service['authStyle']; $settings['imageExtraParams'] = $service['extraParams']; return $settings; } /** * Whether a service without a key may still work: a local service the admin allowed (an Ollama-style gateway). Only * services whose address the admin chose can be local; the fixed hosts of fal.ai, Comet and the others never are, so * they are not looked up in DNS (this runs whenever the settings are loaded). */ function raijuAiImageServiceMayBeKeyless(array $settings, array $service): bool { return !empty($settings['allowLocalProvider']) && ($service['inherit'] || $service['id'] === 'custom') && raijuAiIsLocalBaseUrl($service['baseUrl']); } /** Whether images can be generated with this service: adapter present, model and key (or a permitted local address) set. */ function raijuAiImageServiceUsable(array $settings): bool { $service = raijuAiImageServiceResolve($settings); if (!$service['supported'] || $service['model'] === '') return false; return $service['apiKey'] !== '' || raijuAiImageServiceMayBeKeyless($settings, $service); } /** Whether the image service takes reference images (edit endpoint, inline data or multipart upload). */ function raijuAiImageServiceSupportsReferences(array $service): bool { return match ($service['adapter']) { 'openai-images' => $service['inherit'] || $service['id'] === 'openai-compatible' || !empty($service['spec']['references']), 'openrouter-chat' => true, 'kie-task', 'gemini-generate' => !empty($service['spec']['references']), 'fal-queue' => !empty($service['spec']['references']['editModel']), default => false, }; } /** The effective image model for these settings (what the classic pages, the copilot and the agent report). */ function raijuAiEffectiveImageModel(array $settings): string { return raijuAiImageServiceResolve($settings)['model']; } /** Why images are unavailable, as a short code for the UI and messages; '' when they are available. */ function raijuAiImageUnavailableReason(array $settings): string { $service = raijuAiImageServiceResolve($settings); if (!$service['supported']) return $service['reason']; if ($service['model'] === '') return 'model_missing'; if ($service['apiKey'] === '' && !raijuAiImageServiceMayBeKeyless($settings, $service)) return 'key_missing'; return ''; } /** What the browser may know: the choice, the key source and availability of every service. Never a key. */ function raijuAiImageServicePublic(array $settings): array { $cfg = raijuAiImageServiceConfig($settings); $services = []; foreach (RAIJU_AI_IMAGE_SERVICE_IDS as $id) { $entry = raijuAiImageServiceRegistry()['services'][$id]; $own = trim((string)($settings['imageServices'][$id]['apiKey'] ?? '')) !== ''; $chat = raijuAiImageChatKey($settings, $id) !== ''; $services[$id] = ['hasApiKey' => $own, 'chatKeyAvailable' => $chat, 'keySource' => $own ? 'own' : ($chat ? 'chat' : 'none')]; } $active = raijuAiImageServiceResolve($settings); $priceTable = raijuAiImagePriceTable($settings, $active, $active['model']); return [ 'provider' => $cfg['provider'], 'model' => $cfg['model'], 'models' => $cfg['models'], 'custom' => $cfg['custom'], 'services' => $services, 'active' => [ 'id' => $active['id'], 'inherit' => $active['inherit'], 'adapter' => $active['adapter'], 'model' => $active['model'], 'keySource' => $active['keySource'], 'usable' => raijuAiImageServiceUsable($settings), 'reason' => raijuAiImageUnavailableReason($settings), 'verified' => $active['spec']['verified'] ?? true, 'note' => (string)($active['spec']['note'] ?? ''), 'known' => $active['spec']['known'] ?? true, 'priceTable' => $priceTable, ], ]; } /** Extra request parameters from the settings: a JSON object (or array), limited in size, depth and key names. */ function raijuAiCleanImageExtraParams($input): array { if (is_string($input)) { $input = trim($input); if ($input === '') return []; $decoded = json_decode($input, true); if (!is_array($decoded)) throw new RuntimeException('The custom image parameters must be a JSON object.'); $input = $decoded; } if (!is_array($input) || !$input) return []; if ($input !== [] && array_is_list($input)) throw new RuntimeException('The custom image parameters must be a JSON object.'); $json = json_encode($input, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); if ($json === false || strlen($json) > 4000) throw new RuntimeException('The custom image parameters are too long.'); $depth = static function ($value, int $level) use (&$depth): int { if (!is_array($value)) return $level; $max = $level; foreach ($value as $item) $max = max($max, $depth($item, $level + 1)); return $max; }; if ($depth($input, 1) > 4) throw new RuntimeException('The custom image parameters are nested too deeply.'); foreach (array_keys($input) as $key) { if (!is_string($key) || !preg_match('/^[A-Za-z0-9_.-]{1,40}$/', $key)) throw new RuntimeException('Invalid name in the custom image parameters.'); if (in_array(strtolower($key), RAIJU_AI_IMAGE_RESERVED_PARAMS, true)) throw new RuntimeException('"' . $key . '" is set by Raiju and cannot be a custom image parameter.'); } return $input; } /** * Validated image service settings from the settings form, and the keys that were typed. * * @return array{imageService:array,keys:array} * @throws RuntimeException */ function raijuAiImageServiceFromInput(array $input, array $existing, bool $allowLocal): array { $before = raijuAiImageServiceConfig($existing); $in = is_array($input['imageService'] ?? null) ? $input['imageService'] : []; $provider = (string)($in['provider'] ?? $before['provider']); if ($provider !== 'inherit' && !in_array($provider, RAIJU_AI_IMAGE_SERVICE_IDS, true)) throw new RuntimeException('Unsupported image service.'); $model = array_key_exists('model', $in) ? raijuAiCleanModelAllowEmpty((string)$in['model']) : $before['model']; $models = $before['models']; foreach ($models as $key => $value) { if (!in_array($key, RAIJU_AI_IMAGE_SERVICE_IDS, true) || !is_string($value)) unset($models[$key]); } if ($provider !== 'inherit') $models[$provider] = $model; $inCustom = is_array($in['custom'] ?? null) ? $in['custom'] : []; $custom = $before['custom']; if (array_key_exists('baseUrl', $inCustom)) $custom['baseUrl'] = rtrim(trim((string)$inCustom['baseUrl']), '/'); if (isset($inCustom['sizeMode']) && in_array($inCustom['sizeMode'], ['size', 'aspect_ratio', 'none'], true)) $custom['sizeMode'] = $inCustom['sizeMode']; if (isset($inCustom['authStyle'])) $custom['authStyle'] = $inCustom['authStyle'] === 'x-api-key' ? 'x-api-key' : 'bearer'; if (array_key_exists('extraParams', $inCustom)) $custom['extraParams'] = raijuAiCleanImageExtraParams($inCustom['extraParams']); // A stored address of a service that is not in use must not block saving other settings (for example after the // local-provider permission was withdrawn); it is checked when the custom service is chosen and again before every use. if ($provider === 'custom' && $custom['baseUrl'] !== '') { if (!filter_var($custom['baseUrl'], FILTER_VALIDATE_URL)) throw new RuntimeException('Invalid image service base URL.'); raijuAiValidateBaseUrl($custom['baseUrl'], $allowLocal); } $keys = []; foreach (RAIJU_AI_IMAGE_SERVICE_IDS as $id) { $typed = trim((string)($input['imageServices'][$id]['apiKey'] ?? '')); if ($typed !== '' && !raijuAiIsMaskedSecret($typed)) $keys[$id] = $typed; } return ['imageService' => ['provider' => $provider, 'model' => $model, 'models' => $models, 'custom' => $custom], 'keys' => $keys]; } // --------------------------------------------------------------------------- // Price // --------------------------------------------------------------------------- /** Key of a per-model image price: service id and lower-cased model id. */ function raijuAiImagePriceKey(string $serviceId, string $model): string { return $serviceId . '|' . strtolower(trim($model)); } /** Steps of the resolution and quality tables a price entry may carry. */ const RAIJU_AI_IMAGE_PRICE_RESOLUTIONS = ['0.5K', '1K', '2K', '4K']; const RAIJU_AI_IMAGE_PRICE_QUALITIES = ['auto', 'low', 'medium', 'high']; /** A price in its own currency as euro cents. */ function raijuAiImageToCents(array $settings, float $amount, string $currency): float { return raijuAiRoundCents($currency === 'EUR' ? $amount * 100 : $amount / raijuAiFxRate($settings)['usdPerEur'] * 100); } /** * What an image of this model costs, as the table the price rests on, in euro cents: a price for any image * ('default'), and optional prices by resolution step and by quality, which take precedence when the request has * that step or quality. Order: the admin's entry for the model, the registry's, then the default price per image of * the settings (pricing.imageCentsPerRequest), which keeps installs from before per-model prices guarded. * * @return array{source:string,currency:string,default:?float,byResolution:array,byQuality:array}|null */ function raijuAiImagePriceTable(array $settings, array $service, string $model): ?array { $candidates = [ ['override', $settings['pricing']['imageModels'][raijuAiImagePriceKey($service['id'], $model)] ?? null], ['catalog', $service['spec']['price'] ?? null], ]; foreach ($candidates as [$source, $entry]) { if (!is_array($entry)) continue; $currency = ($entry['currency'] ?? 'USD') === 'EUR' ? 'EUR' : 'USD'; $table = ['source' => $source, 'currency' => $currency, 'default' => null, 'byResolution' => [], 'byQuality' => []]; if (isset($entry['perImage']) && is_numeric($entry['perImage'])) $table['default'] = raijuAiImageToCents($settings, (float)$entry['perImage'], $currency); foreach (['byResolution' => RAIJU_AI_IMAGE_PRICE_RESOLUTIONS, 'byQuality' => RAIJU_AI_IMAGE_PRICE_QUALITIES] as $field => $allowed) { foreach ((array)($entry[$field] ?? []) as $step => $amount) { if (in_array($step, $allowed, true) && is_numeric($amount)) $table[$field][$step] = raijuAiImageToCents($settings, (float)$amount, $currency); } } if ($table['default'] !== null || $table['byResolution'] || $table['byQuality']) return $table; } $default = (float)($settings['pricing']['imageCentsPerRequest'] ?? 0); return $default > 0 ? ['source' => 'default', 'currency' => 'EUR', 'default' => raijuAiRoundCents($default), 'byResolution' => [], 'byQuality' => []] : null; } /** * The price of one image in euro cents for a request, or null when none is known ("price not set"). * $request: resolution ("1K", "2K", "4K") and quality ("low", "medium", "high", "auto") of the request; a price by * resolution comes first, then by quality, then the price for any image. A table without a match and without a * price for any image falls back to the default price of the settings. * * @return array{source:string,cents:float,currency:string,basis:string}|null */ function raijuAiImagePrice(array $settings, array $service, string $model, array $request = []): ?array { $table = raijuAiImagePriceTable($settings, $service, $model); $resolution = (string)($request['resolution'] ?? ''); $quality = (string)($request['quality'] ?? ''); if ($table !== null) { if ($resolution !== '' && isset($table['byResolution'][$resolution])) return ['source' => $table['source'], 'cents' => $table['byResolution'][$resolution], 'currency' => $table['currency'], 'basis' => 'resolution:' . $resolution]; if ($quality !== '' && isset($table['byQuality'][$quality])) return ['source' => $table['source'], 'cents' => $table['byQuality'][$quality], 'currency' => $table['currency'], 'basis' => 'quality:' . $quality]; if ($table['default'] !== null) return ['source' => $table['source'], 'cents' => $table['default'], 'currency' => $table['currency'], 'basis' => 'any']; } $default = (float)($settings['pricing']['imageCentsPerRequest'] ?? 0); return $default > 0 ? ['source' => 'default', 'cents' => raijuAiRoundCents($default), 'currency' => 'EUR', 'basis' => 'any'] : null; } /** * The resolution step the request will ask the provider for ("1K", "2K", "4K"), or '' when the model has none. * Prices by resolution are looked up with it. */ function raijuAiImageRequestResolution(array $service, string $model, array $options): string { $scale = $options['imageScale'] ?? null; if ($service['adapter'] === 'openrouter-chat') { return (string)(raijuAiOpenRouterImageConfig((string)($options['size'] ?? 'auto'), (string)($options['aspectRatio'] ?? 'auto'), $scale, $model)['image_size'] ?? ''); } $spec = $service['spec']; return !empty($spec['resolutions']) ? raijuAiImageResolutionFor($scale, (array)$spec['resolutions']) : ''; } /** Validated per-model image prices from the settings form. A null or empty entry removes one; those not mentioned stay. */ function raijuAiCleanImagePriceOverrides($input, array $existing): array { $result = $existing; $amount = static fn($value) => round(max(0.0, min(100000.0, (float)$value)), 6); foreach ((is_array($input) ? $input : []) as $key => $entry) { $key = strtolower((string)$key); if (!preg_match('/^(openai-compatible|openrouter|anthropic|kie|fal|comet|custom)\|[a-z0-9._:\/-]{1,120}$/', $key)) continue; if (!is_array($entry)) { unset($result[$key]); continue; } $clean = ['currency' => ($entry['currency'] ?? 'USD') === 'EUR' ? 'EUR' : 'USD']; if (isset($entry['perImage']) && $entry['perImage'] !== '' && is_numeric($entry['perImage'])) $clean = ['perImage' => $amount($entry['perImage'])] + $clean; foreach (['byResolution' => RAIJU_AI_IMAGE_PRICE_RESOLUTIONS, 'byQuality' => RAIJU_AI_IMAGE_PRICE_QUALITIES] as $field => $allowed) { foreach ((is_array($entry[$field] ?? null) ? $entry[$field] : []) as $step => $value) { if (in_array($step, $allowed, true) && $value !== '' && $value !== null && is_numeric($value)) $clean[$field][$step] = $amount($value); } } if (count($clean) === 1) unset($result[$key]); else $result[$key] = $clean; } return $result; } // --------------------------------------------------------------------------- // Shape: aspect ratio, size, scale, format // --------------------------------------------------------------------------- /** The ratio a request asks for as "w:h" or 'auto', from the aspect ratio or else the pixel size. */ function raijuAiImageRequestedRatio(array $p): string { $ratio = raijuAiCleanAspectRatio((string)($p['aspectRatio'] ?? 'auto')); if ($ratio !== 'auto') return $ratio; if (preg_match('/^(\d+)x(\d+)$/', (string)($p['size'] ?? ''), $m) && (int)$m[1] > 0 && (int)$m[2] > 0) { $gcd = raijuAiGcd((int)$m[1], (int)$m[2]); return (int)((int)$m[1] / $gcd) . ':' . (int)((int)$m[2] / $gcd); } return 'auto'; } /** The supported ratio closest to a requested one (by logarithmic distance). */ function raijuAiImageNearestAspect(string $ratio, array $supported): string { if (!$supported) return $ratio; if (!preg_match('/^(\d+):(\d+)$/', $ratio, $m) || (int)$m[2] === 0 || (int)$m[1] === 0) return (string)$supported[0]; $requested = (int)$m[1] / (int)$m[2]; $best = (string)$supported[0]; $distance = PHP_FLOAT_MAX; foreach ($supported as $candidate) { [$w, $h] = array_map('intval', explode(':', (string)$candidate, 2)); if ($w < 1 || $h < 1) continue; $d = abs(log($requested) - log($w / $h)); if ($d < $distance) { $distance = $d; $best = (string)$candidate; } } return $best; } /** The resolution step ("1K", "2K", "4K") for a long-edge scale, limited to what the model offers. */ function raijuAiImageResolutionFor($scale, array $offered): string { $wanted = raijuAiKieResolution($scale ?? 2048); if (!$offered || in_array($wanted, $offered, true)) return $wanted; $order = ['0.5K' => 0, '1K' => 1, '2K' => 2, '4K' => 3]; $best = (string)$offered[0]; foreach ($offered as $candidate) { if (($order[$candidate] ?? 9) <= ($order[$wanted] ?? 0) && ($order[$candidate] ?? 0) >= ($order[$best] ?? 0)) $best = (string)$candidate; } return $best; } /** Pixel size for a generic OpenAI-style endpoint from a ratio. */ function raijuAiImageCustomSize(string $ratio): string { if (!preg_match('/^(\d+):(\d+)$/', $ratio, $m) || (int)$m[2] === 0) return '1024x1024'; $value = (int)$m[1] / (int)$m[2]; return $value > 1.2 ? '1536x1024' : ($value < 0.83 ? '1024x1536' : '1024x1024'); } function raijuAiImageAuthHeader(array $settings): string { $key = trim((string)($settings['apiKey'] ?? '')); if ($key === '') return ''; return ($settings['authStyle'] ?? 'bearer') === 'x-api-key' ? 'x-api-key: ' . $key : 'Authorization: Bearer ' . $key; } /** Poll interval of the queue adapters; tests shorten it (RAIJU_AGENT_TEST_NET.imagePollMs). */ function raijuAiImagePollMicros(): int { $test = raijuNetTestConfig(); return $test !== null && isset($test['raw']['imagePollMs']) ? max(1, (int)$test['raw']['imagePollMs']) * 1000 : 2000000; } // --------------------------------------------------------------------------- // HTTP helpers // --------------------------------------------------------------------------- /** The message of a provider error body in any of the usual shapes (never the request, so never a key). */ function raijuAiProviderErrorMessage($data, string $fallback = 'AI provider error'): string { if (!is_array($data)) return $fallback; $candidate = $data['error']['message'] ?? $data['message'] ?? $data['detail'] ?? $data['error'] ?? $data['msg'] ?? null; if (is_array($candidate)) { $first = $candidate[0] ?? $candidate; $candidate = is_array($first) ? ($first['msg'] ?? $first['message'] ?? null) : $first; } $text = is_string($candidate) ? trim($candidate) : ''; return $text !== '' ? mb_substr($text, 0, 1000, 'UTF-8') : $fallback; } /** GET a JSON document from a provider. Throws a RuntimeException carrying the HTTP status as code. */ function raijuAiImageGetJson(array $settings, string $url, array $headers): array { if (!function_exists('curl_init')) throw new RuntimeException('This image service requires cURL support.'); $ch = curl_init($url); $buffer = ''; curl_setopt_array($ch, [ CURLOPT_HTTPHEADER => $headers, CURLOPT_CONNECTTIMEOUT => 10, CURLOPT_TIMEOUT => min(60, raijuAiRequestTimeout($settings)), CURLOPT_FOLLOWLOCATION => false, CURLOPT_NOSIGNAL => true, CURLOPT_RETURNTRANSFER => false, CURLOPT_WRITEFUNCTION => static function ($ch, string $chunk) use (&$buffer): int { $buffer .= $chunk; return strlen($buffer) > 8 * 1024 * 1024 ? 0 : strlen($chunk); }, ]); $ok = curl_exec($ch); $status = (int)curl_getinfo($ch, CURLINFO_HTTP_CODE); $error = curl_error($ch); unset($ch); if ($ok === false) throw new RuntimeException($error !== '' ? 'The image service did not answer: ' . $error : 'The image service did not answer.'); $data = json_decode($buffer, true); if ($status < 200 || $status >= 300) throw new RuntimeException(raijuAiProviderErrorMessage($data, 'The image service answered with HTTP ' . $status . '.'), $status); if (!is_array($data)) throw new RuntimeException('The image service returned invalid data.'); return $data; } /** Whether a URL the provider handed back points at the service itself (same scheme, host and port): only then does the key go with it. */ function raijuAiImageTrustedUrl(string $url, string $baseUrl): bool { $a = parse_url($url); $b = parse_url($baseUrl); if (!is_array($a) || !is_array($b) || isset($a['user']) || isset($a['pass'])) return false; $origin = static fn(array $u): array => [strtolower((string)($u['scheme'] ?? '')), strtolower((string)($u['host'] ?? '')), (int)($u['port'] ?? (($u['scheme'] ?? '') === 'https' ? 443 : 80))]; return $origin($a) === $origin($b); } /** The bytes of an image the provider returned as a data URI or an address. Addresses are fetched safely (public hosts only). */ function raijuAiImageFetchBinary(array $img, string $source): string { if (preg_match('#^data:image/[a-zA-Z0-9.+-]+;base64,#', $source)) { $binary = base64_decode(substr($source, strpos($source, ',') + 1), true); if ($binary === false || $binary === '') throw new RuntimeException('The image service returned invalid image data.'); return $binary; } return raijuAiDownloadProviderImage($source, (string)($img['baseUrl'] ?? '')); } // --------------------------------------------------------------------------- // Live model lists (fal.ai, Comet API) // --------------------------------------------------------------------------- const RAIJU_AI_IMAGE_LIVE_SERVICES = ['fal', 'comet']; const RAIJU_AI_IMAGE_LIVE_TTL = 86400; function raijuAiImageModelsCachePath(): string { return dirname(RAIJU_AI_SETTINGS_PATH) . '/ai-image-models-cache.json'; } /** * The image models a service lists itself, for the model suggestions. fal.ai: the public model search of its platform API * (category text-to-image, active, up to 3 pages); Comet API: the public model catalog, entries of model_type "image". * Cached for a day in content/ai-image-models-cache.json; a stale cache is served when the service does not answer. * The browser never talks to the provider; fal.ai gets the service's key when one is saved (it only raises the rate limit). * * @return array{service:string,models:array,fetchedAt:int,cached:bool} * @throws RuntimeException */ function raijuAiImageLiveModels(array $settings, string $service, bool $refresh = false): array { if (!in_array($service, RAIJU_AI_IMAGE_LIVE_SERVICES, true)) throw new RuntimeException('This image service has no model list.'); $path = raijuAiImageModelsCachePath(); $cache = is_file($path) ? json_decode((string)file_get_contents($path), true) : []; $entry = is_array($cache[$service] ?? null) ? $cache[$service] : null; $fresh = $entry !== null && (time() - (int)($entry['fetchedAt'] ?? 0)) < RAIJU_AI_IMAGE_LIVE_TTL && is_array($entry['models'] ?? null); if ($fresh && !$refresh) return ['service' => $service, 'models' => $entry['models'], 'fetchedAt' => (int)$entry['fetchedAt'], 'cached' => true]; try { $models = $service === 'fal' ? raijuAiImageLiveFal($settings) : raijuAiImageLiveComet(); } catch (RuntimeException $e) { if ($entry !== null && is_array($entry['models'] ?? null)) return ['service' => $service, 'models' => $entry['models'], 'fetchedAt' => (int)($entry['fetchedAt'] ?? 0), 'cached' => true]; throw $e; } $cache = is_array($cache) ? $cache : []; $cache[$service] = ['fetchedAt' => time(), 'models' => $models]; raijuJsonAtomicWrite($path, $cache); return ['service' => $service, 'models' => $models, 'fetchedAt' => $cache[$service]['fetchedAt'], 'cached' => false]; } /** GET a JSON list from a service's public API through the hardened fetch (public hosts, size and type limits). */ function raijuAiImageLiveGet(string $url, array $headers = []): array { $test = raijuNetTestConfig(); $host = (string)parse_url($url, PHP_URL_HOST); try { $response = raijuNetSafeFetch($url, ($test['options'] ?? []) + [ 'allowedHosts' => [$host], 'allowedContentTypes' => ['application/json'], 'maxBytes' => 3 * 1024 * 1024, 'timeout' => 20, 'accept' => 'application/json', 'userAgent' => 'raiju-cms/1.0', 'headers' => $headers, ]); } catch (RaijuNetException $e) { throw new RuntimeException($e->httpStatus === 429 ? 'The service is busy. Try again later.' : 'The model list could not be loaded.'); } $data = json_decode($response['body'], true); if (!is_array($data)) throw new RuntimeException('The service returned an invalid model list.'); return $data; } /** Base URL of a model list. Tests redirect it (RAIJU_AGENT_TEST_NET.imageListBases, CLI and built-in server only). */ function raijuAiImageLiveBase(string $service): string { $test = raijuNetTestConfig(); if ($test !== null && isset($test['raw']['imageListBases'][$service])) return rtrim((string)$test['raw']['imageListBases'][$service], '/'); return $service === 'fal' ? 'https://api.fal.ai' : 'https://api.cometapi.com'; } function raijuAiImageLiveFal(array $settings): array { $key = trim((string)($settings['imageServices']['fal']['apiKey'] ?? '')); if ($key === '') return raijuAiImageLiveFalPages([]); try { return raijuAiImageLiveFalPages(['Authorization: Key ' . $key]); } catch (RuntimeException $e) { // The key only raises the rate limit: a refused key must not take the list away. return raijuAiImageLiveFalPages([]); } } function raijuAiImageLiveFalPages(array $headers): array { $models = []; $cursor = ''; for ($page = 0; $page < 3; $page++) { $query = ['category' => 'text-to-image', 'status' => 'active', 'limit' => 100] + ($cursor !== '' ? ['cursor' => $cursor] : []); $data = raijuAiImageLiveGet(raijuAiImageLiveBase('fal') . '/v1/models?' . http_build_query($query), $headers); foreach ((array)($data['models'] ?? []) as $model) { $id = is_array($model) ? (string)($model['endpoint_id'] ?? '') : ''; if (!preg_match('#^[A-Za-z0-9][A-Za-z0-9._-]{0,60}(/[A-Za-z0-9._-]{1,60}){1,3}$#', $id)) continue; $name = trim(strip_tags((string)($model['metadata']['display_name'] ?? ''))); $models[$id] = ['id' => $id, 'label' => 'fal.ai: ' . ($name !== '' ? mb_substr($name, 0, 80, 'UTF-8') : $id)]; } $cursor = !empty($data['has_more']) && is_string($data['next_cursor'] ?? null) ? (string)$data['next_cursor'] : ''; if ($cursor === '') break; } return raijuAiImageLiveSorted($models); } function raijuAiImageLiveComet(): array { $data = raijuAiImageLiveGet(raijuAiImageLiveBase('comet') . '/api/models'); $models = []; foreach ((array)($data['data'] ?? []) as $model) { if (!is_array($model) || strtolower((string)($model['model_type'] ?? '')) !== 'image') continue; $id = (string)($model['id'] ?? ''); if (!preg_match('#^[A-Za-z0-9][A-Za-z0-9._:/-]{0,119}$#', $id)) continue; $models[$id] = ['id' => $id, 'label' => 'Comet: ' . $id]; } return raijuAiImageLiveSorted($models); } function raijuAiImageLiveSorted(array $models): array { $list = array_values($models); usort($list, static fn($a, $b) => strcasecmp($a['id'], $b['id'])); return array_slice($list, 0, 300); } // --------------------------------------------------------------------------- // Adapters // --------------------------------------------------------------------------- /** * Run the adapter of the service. * * @param array $img the effective settings (raijuAiImageEffectiveSettings) * @param array $service raijuAiImageServiceResolve() * @param array $p prompt, model, count, size, aspectRatio, imageScale, quality, outputFormat, outputCompression, moderation, referencePaths, filenameHint, storageDir * @return array{0:string[],1:string[]} stored image paths and the texts the provider returned alongside */ function raijuAiImageAdapterRun(array $img, array $service, array $p): array { return match ($service['adapter']) { 'openai-images' => raijuAiImageAdapterOpenAi($img, $service, $p), 'openrouter-chat' => raijuAiGenerateOpenRouterImages($img, $p), 'kie-task' => raijuAiGenerateKieImages($img, $p), 'fal-queue' => raijuAiImageAdapterFal($img, $service, $p), 'comet-task' => raijuAiImageAdapterCometTask($img, $service, $p), 'gemini-generate' => raijuAiImageAdapterGemini($img, $service, $p), default => throw new RuntimeException('This image service is not supported.'), }; } function raijuAiImageStore(string $binary, array $p, int $index): string { return raijuAiStoreGeneratedImage($binary, (string)($p['filenameHint'] ?? 'ai-image'), (string)($p['size'] ?? 'auto'), $index, (string)($p['outputFormat'] ?? ''), (int)($p['outputCompression'] ?? 75), (string)($p['storageDir'] ?? '')); } /** OpenAI and compatible services: /images/generations, or /images/edits (multipart) with reference images. */ function raijuAiImageAdapterOpenAi(array $img, array $service, array $p): array { $spec = $service['spec']; $custom = !$service['inherit'] && $service['id'] === 'custom'; // A service with its own parameter set for the OpenAI protocol (Comet's GPT Image): the spec names what it takes. $own = !$service['inherit'] && !empty($spec['openai']); $model = (string)$p['model']; $count = (int)$p['count']; $isGptImage = (bool)preg_match('/^(gpt-image|chatgpt-image)/i', $model); $perRequest = $own || $custom ? max(1, (int)($spec['maxCount'] ?? $count)) : $count; $ratio = raijuAiImageRequestedRatio($p); $references = array_slice((array)$p['referencePaths'], 0, max(1, (int)($spec['references']['max'] ?? 16))); $build = function (int $n) use ($p, $service, $spec, $custom, $own, $model, $isGptImage, $ratio): array { $body = ['model' => $model, 'prompt' => substr((string)$p['prompt'], 0, 8000), 'n' => $n]; if ($custom) { if ($service['sizeMode'] === 'size') $body['size'] = raijuAiImageCustomSize($ratio); elseif ($service['sizeMode'] === 'aspect_ratio' && $ratio !== 'auto') $body['aspect_ratio'] = raijuAiImageNearestAspect($ratio, (array)($spec['aspects'] ?? [])); return $body + $service['extraParams']; } if ($own) { $body['size'] = raijuAiImageCustomSize($ratio); $params = (array)$spec['openai']['params']; if (in_array('quality', $params, true) && in_array($p['quality'], ['low', 'medium', 'high', 'auto'], true)) $body['quality'] = (string)$p['quality']; if (in_array('output_format', $params, true)) $body['output_format'] = (string)$p['outputFormat']; if (in_array('output_compression', $params, true) && in_array($p['outputFormat'], ['jpeg', 'webp'], true)) $body['output_compression'] = (int)$p['outputCompression']; return $body + $service['extraParams']; } $body['size'] = (string)$p['size']; if ($isGptImage) { $body['output_format'] = (string)$p['outputFormat']; $body['quality'] = (string)$p['quality']; $body['moderation'] = (string)$p['moderation']; if (in_array($p['outputFormat'], ['jpeg', 'webp'], true)) $body['output_compression'] = (int)$p['outputCompression']; } return $body; }; $paths = []; $revised = []; for ($left = $count; $left > 0; $left -= $perRequest) { try { $body = $build(min($perRequest, $left)); if ($references) { $files = []; foreach ($references as $index => $path) $files[count($references) > 1 ? 'image[' . $index . ']' : 'image'] = $path; $response = raijuAiProviderMultipartRequest($img, '/images/edits', $body, $files); } elseif ($custom || $own) { $headers = ['Content-Type: application/json', 'Accept: application/json']; $auth = raijuAiImageAuthHeader($img); if ($auth !== '') $headers[] = $auth; $response = raijuAiCompatRequest($img, rtrim($img['baseUrl'], '/') . '/images/generations', $headers, $body); } else { $response = raijuAiProviderRequest($img, '/images/generations', $body); } $items = is_array($response['data'] ?? null) ? $response['data'] : []; if (!$items) throw new RuntimeException('AI provider returned no image.'); $before = count($paths); foreach ($items as $item) { if (!is_array($item)) continue; if (!empty($item['revised_prompt'])) $revised[] = trim((string)$item['revised_prompt']); if (!empty($item['b64_json'])) { $binary = base64_decode((string)$item['b64_json'], true); if ($binary === false) throw new RuntimeException('AI provider returned invalid image data.'); } elseif (!empty($item['url'])) { $binary = raijuAiDownloadProviderImage((string)$item['url'], (string)$img['baseUrl']); } else { continue; } $paths[] = raijuAiImageStore($binary, $p, count($paths) + 1); } if (count($paths) === $before) throw new RuntimeException('AI provider returned no usable image data.'); } catch (Throwable $e) { if (!$paths) throw $e; raijuAiAudit('image-partial', false, ['requested' => $count, 'created' => count($paths), 'message' => $e->getMessage()]); break; } } return [$paths, $revised]; } /** The input of one fal request. Parameters come from the spec of the model; custom parameters of the settings are added. */ function raijuAiFalInput(array $spec, array $service, array $p, array $references, int $n): array { $input = ['prompt' => raijuAiUtf8Prefix((string)$p['prompt'], 8000)]; $ratio = raijuAiImageRequestedRatio($p); $aspects = (array)($spec['aspects'] ?? []); if ($ratio !== 'auto' && $aspects) { $nearest = raijuAiImageNearestAspect($ratio, $aspects); if (!empty($spec['aspectParam'])) $input[$spec['aspectParam']] = $nearest; elseif (!empty($spec['sizeParam']) && !empty($spec['sizePresets'][$nearest])) $input[$spec['sizeParam']] = $spec['sizePresets'][$nearest]; } if (!empty($spec['countParam']) && $n > 1) $input[$spec['countParam']] = $n; if (!empty($spec['formatParam']) && !empty($spec['formats'])) { $format = (string)$p['outputFormat']; $input[$spec['formatParam']] = in_array($format, $spec['formats'], true) ? $format : (in_array('png', $spec['formats'], true) ? 'png' : (string)$spec['formats'][0]); } if (!empty($spec['resolutionParam']) && !empty($spec['resolutions'])) $input[$spec['resolutionParam']] = raijuAiImageResolutionFor($p['imageScale'] ?? null, (array)$spec['resolutions']); if (!empty($spec['qualityParam']) && !empty($spec['qualities']) && in_array((string)$p['quality'], $spec['qualities'], true)) $input[$spec['qualityParam']] = (string)$p['quality']; if ($references) { $input[(string)($spec['references']['param'] ?? 'image_urls')] = array_map('raijuAiReferenceImageDataUrl', $references); } return $input + $service['extraParams']; } /** * The queue part of the adapters that submit tasks and poll them (fal.ai, Comet's Seedream). What the request ledger * already knows is not submitted again; tasks with an id are polled until they finish or the request times out; the * result addresses are downloaded; the ledger is cleaned afterwards, so a stopped job resumes without paying twice. * * @param int[] $batches images per task * @param callable $submit fn(int $index, int $n): array the task: id plus whatever poll needs * @param callable $poll fn(array $task): array ['state' => 'waiting'|'running'|'done', 'urls' => string[]]; a RuntimeException with a 4xx code is final, anything else is tried again */ function raijuAiImageTaskRun(array $img, array $p, string $model, array $batches, callable $submit, callable $poll): array { $requestId = $GLOBALS['raijuAiRequestId'] ?? ''; $entry = raijuAiLoadUsage()['reservations'][$requestId] ?? []; if ($entry && (($entry['provider'] ?? '') !== ($img['provider'] ?? '') || ($entry['baseUrl'] ?? '') !== ($img['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'], []]; $saved = $entry['tasks'] ?? []; $tasks = []; foreach ($batches as $index => $n) { if (isset($saved[$index])) { if (empty($saved[$index]['id'])) throw new RuntimeException('Provider submission is uncertain. Check the provider before starting another request.'); if (!in_array($saved[$index]['state'] ?? '', ['success', 'fail'], true)) $tasks[$index] = true; continue; } $saved[$index] = ['state' => 'submitting', 'model' => $model]; if ($requestId !== '') raijuAiRequestPatch($requestId, ['tasks' => $saved]); $task = $submit($index, $n); if (empty($task['id'])) throw new RuntimeException('The image service did not return a request ID.'); $saved[$index] = $task + ['state' => 'waiting', 'model' => $model]; if ($requestId !== '') raijuAiRequestPatch($requestId, ['tasks' => $saved]); $tasks[$index] = true; } $deadline = microtime(true) + raijuAiRequestTimeout($img); $urls = []; $failures = []; foreach ($saved as $task) { if (($task['state'] ?? '') === 'success' && !empty($task['urls'])) array_push($urls, ...$task['urls']); } while ($tasks && microtime(true) < $deadline) { foreach (array_keys($tasks) as $index) { try { $result = $poll($saved[$index]); if (($result['state'] ?? '') === 'done') { $saved[$index]['state'] = 'success'; $saved[$index]['urls'] = array_values((array)$result['urls']); array_push($urls, ...$saved[$index]['urls']); unset($tasks[$index]); } else { $saved[$index]['state'] = ($result['state'] ?? '') === 'running' ? 'running' : 'waiting'; } } catch (RuntimeException $e) { // A failed request is final; a hiccup of the connection is tried again until the deadline. $code = (int)$e->getCode(); if ($code >= 400 && $code < 500 && !in_array($code, [408, 429], true)) { $saved[$index]['state'] = 'fail'; $failures[] = $e->getMessage(); unset($tasks[$index]); } } if ($requestId !== '') raijuAiRequestPatch($requestId, ['tasks' => $saved, 'updatedAt' => gmdate('c')]); } if ($tasks) usleep(raijuAiImagePollMicros()); } if ($tasks) throw new RuntimeException('The image service did not finish in time. The provider jobs may still be running.'); if (!$urls) throw new RuntimeException($failures[0] ?? 'The image service returned no usable image.'); $paths = []; foreach (array_slice($urls, 0, (int)$p['count']) as $url) $paths[] = raijuAiImageStore(raijuAiImageFetchBinary($img, $url), $p, count($paths) + 1); if ($requestId !== '') { // The signed result addresses are of no use once the images are stored: do not keep them in the ledger. foreach ($saved as $index => $task) unset($saved[$index]['urls']); raijuAiRequestPatch($requestId, ['outputs' => $paths, 'tasks' => $saved]); } return [$paths, []]; } /** fal.ai queue: submit, poll the status URL, fetch the result URL, download. */ function raijuAiImageAdapterFal(array $img, array $service, array $p): array { $spec = $service['spec']; $base = rtrim((string)$img['baseUrl'], '/'); $headers = ['Content-Type: application/json', 'Accept: application/json', 'Authorization: Key ' . $img['apiKey']]; $getHeaders = ['Accept: application/json', 'Authorization: Key ' . $img['apiKey']]; $references = array_slice((array)$p['referencePaths'], 0, max(1, (int)($spec['references']['max'] ?? 8))); $endpoint = (string)$p['model']; if ((array)$p['referencePaths']) { if (empty($spec['references']['editModel'])) throw new RuntimeException('This image model does not take reference images. Choose a model with an edit endpoint or remove the reference images.'); $endpoint = (string)$spec['references']['editModel']; } $perTask = max(1, (int)($spec['maxCount'] ?? 1)); $batches = []; for ($left = (int)$p['count']; $left > 0; $left -= $perTask) $batches[] = min($perTask, $left); $submit = function (int $index, int $n) use ($img, $service, $spec, $p, $references, $base, $headers, $endpoint): array { $response = raijuAiExecuteJsonRequest($img, $base . '/' . $endpoint, $headers, raijuAiFalInput($spec, $service, $p, $references, $n)); $id = trim((string)($response['request_id'] ?? '')); $statusUrl = (string)($response['status_url'] ?? ''); $resultUrl = (string)($response['response_url'] ?? ''); $root = preg_replace('#^(fal-ai/[^/]+|[^/]+/[^/]+).*$#', '$1', $endpoint); // Only addresses of the service itself are followed (the key goes with them); otherwise use the documented pattern. if (!raijuAiImageTrustedUrl($statusUrl, $base)) $statusUrl = $base . '/' . $root . '/requests/' . rawurlencode($id) . '/status'; if (!raijuAiImageTrustedUrl($resultUrl, $base)) $resultUrl = $base . '/' . $root . '/requests/' . rawurlencode($id); return ['id' => $id, 'statusUrl' => $statusUrl, 'responseUrl' => $resultUrl]; }; $poll = function (array $task) use ($img, $getHeaders): array { $status = raijuAiImageGetJson($img, (string)$task['statusUrl'], $getHeaders); $state = strtoupper((string)($status['status'] ?? '')); if ($state !== 'COMPLETED') return ['state' => $state === 'IN_PROGRESS' ? 'running' : 'waiting']; if (!empty($status['error'])) throw new RuntimeException(raijuAiProviderErrorMessage($status, 'The image service reported an error.'), 422); $result = raijuAiImageGetJson($img, (string)$task['responseUrl'], $getHeaders); $found = []; foreach ((array)($result['images'] ?? (isset($result['image']) ? [$result['image']] : [])) as $image) { $url = is_array($image) ? (string)($image['url'] ?? '') : (string)$image; if (preg_match('#^(https?://|data:image/)#i', $url)) $found[] = $url; } if (!$found) throw new RuntimeException('The image service returned no image.', 422); return ['state' => 'done', 'urls' => $found]; }; return raijuAiImageTaskRun($img, $p, $endpoint, $batches, $submit, $poll); } /** Comet API's asynchronous images route (Seedream): create a task, poll it, download. One image per task. */ function raijuAiImageAdapterCometTask(array $img, array $service, array $p): array { $spec = $service['spec']; $base = rtrim((string)$img['baseUrl'], '/'); $headers = ['Content-Type: application/json', 'Accept: application/json', 'Authorization: Bearer ' . $img['apiKey']]; $getHeaders = ['Accept: application/json', 'Authorization: Bearer ' . $img['apiKey']]; if ((array)$p['referencePaths']) throw new RuntimeException('This image model does not take reference images on Comet.'); $size = raijuAiImageResolutionFor($p['imageScale'] ?? null, (array)($spec['resolutions'] ?? ['2K'])); $batches = array_fill(0, (int)$p['count'], 1); $submit = function (int $index, int $n) use ($img, $service, $p, $base, $headers, $size): array { $body = ['model' => (string)$p['model'], 'prompt' => raijuAiUtf8Prefix((string)$p['prompt'], 8000), 'size' => $size, 'response_format' => 'url', 'watermark' => false, 'async' => true]; $response = raijuAiExecuteJsonRequest($img, $base . '/images/generations', $headers, $body + $service['extraParams']); return ['id' => trim((string)($response['data']['task_id'] ?? $response['task_id'] ?? ''))]; }; $poll = function (array $task) use ($img, $base, $getHeaders): array { $response = raijuAiImageGetJson($img, $base . '/images/generations/' . rawurlencode((string)$task['id']), $getHeaders); $data = is_array($response['data'] ?? null) ? $response['data'] : []; $status = strtolower((string)($data['status'] ?? '')); if ($status === 'failure' || $status === 'failed') throw new RuntimeException(raijuAiProviderErrorMessage(['message' => $data['fail_reason'] ?? ''], 'The image service reported an error.'), 422); if ($status !== 'success') return ['state' => 'waiting']; $url = (string)($data['data'][0]['url'] ?? ''); if (!preg_match('#^https?://#i', $url)) throw new RuntimeException('The image service returned no image.', 422); return ['state' => 'done', 'urls' => [$url]]; }; return raijuAiImageTaskRun($img, $p, (string)$p['model'], $batches, $submit, $poll); } /** The body of one Gemini generateContent request (Comet API). */ function raijuAiGeminiBody(array $spec, array $service, array $p, array $references): array { $parts = [['text' => raijuAiUtf8Prefix((string)$p['prompt'], 8000)]]; foreach (array_slice($references, 0, max(1, (int)($spec['references']['max'] ?? 8))) as $path) { $dataUrl = raijuAiReferenceImageDataUrl((string)$path); [$meta, $data] = explode(',', $dataUrl, 2); $parts[] = ['inline_data' => ['mime_type' => substr($meta, 5, strpos($meta, ';') - 5), 'data' => $data]]; } $image = []; $ratio = raijuAiImageRequestedRatio($p); if ($ratio !== 'auto' && !empty($spec['aspectParam'])) $image[$spec['aspectParam']] = raijuAiImageNearestAspect($ratio, (array)($spec['aspects'] ?? [])); if (!empty($spec['resolutionParam']) && !empty($spec['resolutions'])) $image[$spec['resolutionParam']] = raijuAiImageResolutionFor($p['imageScale'] ?? null, (array)$spec['resolutions']); $config = ['responseModalities' => ['TEXT', 'IMAGE']]; if ($image) $config['imageConfig'] = $image; return ['contents' => [['role' => 'user', 'parts' => $parts]], 'generationConfig' => $config] + $service['extraParams']; } /** The image and texts of a Gemini response. Thought parts are skipped; the last remaining image part is the result. */ function raijuAiGeminiExtract(array $response): array { $binary = null; $texts = []; foreach ((array)($response['candidates'][0]['content']['parts'] ?? []) as $part) { if (!is_array($part) || !empty($part['thought'])) continue; $inline = $part['inlineData'] ?? $part['inline_data'] ?? null; if (is_array($inline) && !empty($inline['data'])) { $decoded = base64_decode((string)$inline['data'], true); if ($decoded === false || $decoded === '') throw new RuntimeException('The image service returned invalid image data.'); $binary = $decoded; } elseif (is_string($part['text'] ?? null) && trim($part['text']) !== '') { $texts[] = trim($part['text']); } } return [$binary, $texts]; } /** Gemini generateContent (Comet API): one request per image. A later failure after the first image ends the run with what exists. */ function raijuAiImageAdapterGemini(array $img, array $service, array $p): array { $spec = $service['spec']; $url = rtrim((string)$img['baseUrl'], '/') . '/v1beta/models/' . rawurlencode((string)$p['model']) . ':generateContent'; $headers = ['Content-Type: application/json', 'Accept: application/json', 'Authorization: Bearer ' . $img['apiKey']]; $references = (array)$p['referencePaths']; if ($references && empty($spec['references'])) throw new RuntimeException('This image model does not take reference images.'); $body = raijuAiGeminiBody($spec, $service, $p, $references); $paths = []; $texts = []; $img['limits']['requestTimeoutSeconds'] = max(300, raijuAiRequestTimeout($img)); for ($i = 0; $i < (int)$p['count']; $i++) { try { $response = raijuAiExecuteJsonRequest($img, $url, $headers, $body); [$binary, $found] = raijuAiGeminiExtract($response); if ($binary === null) throw new RuntimeException($found ? 'The image service answered with text only: ' . raijuAiUtf8Prefix($found[0], 200) : 'The image service returned no image.'); $paths[] = raijuAiImageStore($binary, $p, count($paths) + 1); $texts = array_merge($texts, $found); } catch (Throwable $e) { if (!$paths) throw $e; raijuAiAudit('image-partial', false, ['requested' => (int)$p['count'], 'created' => count($paths), 'message' => $e->getMessage()]); break; } } return [$paths, $texts]; }