exec('rm -rf ' . escapeshellarg($site))); foreach (['includes', 'admin', 'VERSION'] as $item) exec('cp -R ' . escapeshellarg($root . '/' . $item) . ' ' . escapeshellarg($site . '/')); foreach (['admin/config.php', 'admin/smtp-config.php', 'includes/nav-config.php'] as $local) @unlink($site . '/' . $local); define('SITE_LANG_DEFAULT', 'en'); $_SESSION = ['admin_role' => 'admin', 'admin_user_id' => 'u_admin', 'admin_username' => 'admin']; require_once $site . '/includes/ai/ai-helper.php'; // ---- Registry -------------------------------------------------------------------------------------------------------------------- $registry = raijuAiImageServiceRegistry(); imgSame(RAIJU_AI_IMAGE_SERVICE_IDS, array_keys($registry['services']), 'every service id is in the registry'); $adapters = ['openai-images', 'openrouter-chat', 'kie-task', 'fal-queue', 'gemini-generate']; foreach ($registry['services'] as $id => $entry) { imgAssert(in_array($entry['adapter'], $adapters, true), "{$id} names an adapter that exists"); imgAssert(str_starts_with((string)$entry['label'], 'ai.image_service_'), "{$id} label is a translation key"); foreach ((array)($entry['models'] ?? []) as $model) { imgAssert(isset($model['id'], $model['label']) && array_key_exists('verified', $model) && array_key_exists('price', $model), "{$id}/{$model['id']} has id, label, verified and price"); imgAssert($model['price'] === null || ((isset($model['price']['perImage']) || !empty($model['price']['byResolution']) || !empty($model['price']['byQuality'])) && isset($model['price']['currency'], $model['price']['source'], $model['price']['updatedAt'])), "{$id}/{$model['id']}: a price needs its amounts, currency, source and date"); if ($id === 'kie') imgAssert(isset($model['kie']['model']) && in_array($model['id'], array_column(raijuAiModelCatalog()['kie']['images'], 'value'), true), "{$id}/{$model['id']}: a Kie model has its task id and is in the chat catalog too"); foreach ((array)($model['sizePresets'] ?? []) as $ratio => $preset) imgAssert(in_array($ratio, $model['aspects'], true), "{$id}/{$model['id']}: preset for an unlisted ratio"); } } foreach (['fal', 'comet'] as $id) imgAssert(raijuAiImageModelSpec($id, $registry['services'][$id]['defaultModel'])['known'] === true, "{$id} default model is listed"); imgAssert(raijuAiImageModelSpec('fal', 'fal-ai/unheard-of')['known'] === false && raijuAiImageModelSpec('fal', 'fal-ai/unheard-of')['verified'] === false, 'an unknown model gets the unverified defaults'); $ui = raijuAiImageServicesForUi(); imgAssert(!str_contains(json_encode($ui), 'apiKey'), 'the registry for the browser has no key field'); // ---- Shape --------------------------------------------------------------------------------------------------------------------------- imgSame('16:9', raijuAiImageRequestedRatio(['aspectRatio' => '16:9', 'size' => '1024x1024']), 'the ratio wins over the size'); imgSame('3:2', raijuAiImageRequestedRatio(['aspectRatio' => 'auto', 'size' => '1536x1024']), 'a size becomes its ratio'); imgSame('auto', raijuAiImageRequestedRatio(['aspectRatio' => 'auto', 'size' => 'auto']), 'nothing asked, nothing sent'); imgSame('16:9', raijuAiImageNearestAspect('7:4', ['1:1', '16:9', '4:3']), 'nearest ratio'); imgSame('1:1', raijuAiImageNearestAspect('1:1', ['21:9', '1:1']), 'exact ratio'); imgSame('4K', raijuAiImageResolutionFor(3840, ['1K', '2K', '4K']), '4K'); imgSame('2K', raijuAiImageResolutionFor(3840, ['1K', '2K']), 'the highest step the model has'); imgSame('1K', raijuAiImageResolutionFor(1024, ['1K', '2K']), '1K'); imgSame('1536x1024', raijuAiImageCustomSize('16:9'), 'landscape size'); imgSame('1024x1536', raijuAiImageCustomSize('9:16'), 'portrait size'); imgSame('1024x1024', raijuAiImageCustomSize('1:1'), 'square size'); // ---- Request builders --------------------------------------------------------------------------------------------------------------- $fal = raijuAiImageModelSpec('fal', 'google/nano-banana-2.1'); $service = ['extraParams' => ['seed' => 7]]; $input = raijuAiFalInput($fal, $service, ['prompt' => 'x', 'aspectRatio' => '16:9', 'size' => 'auto', 'outputFormat' => 'webp', 'imageScale' => 2048, 'quality' => 'auto'], [], 3); imgSame(['prompt' => 'x', 'aspect_ratio' => '16:9', 'num_images' => 3, 'output_format' => 'webp', 'resolution' => '2K', 'seed' => 7], $input, 'fal input'); $flux = raijuAiImageModelSpec('fal', 'openai/gpt-image-2.5/flare/text-to-image'); $input = raijuAiFalInput($flux, ['extraParams' => []], ['prompt' => 'y', 'aspectRatio' => '3:2', 'size' => 'auto', 'outputFormat' => 'png', 'quality' => 'high'], [], 1); imgSame(['prompt' => 'y', 'image_size' => 'landscape_4_3', 'output_format' => 'png', 'quality' => 'high'], $input, 'fal input with size presets: nearest ratio, then the preset'); $comet = raijuAiImageModelSpec('comet', 'gemini-nano-banana-2.1'); $body = raijuAiGeminiBody($comet, ['extraParams' => []], ['prompt' => 'z', 'aspectRatio' => '1:1', 'size' => 'auto', 'imageScale' => 1024], []); imgSame(['contents' => [['role' => 'user', 'parts' => [['text' => 'z']]]], 'generationConfig' => ['responseModalities' => ['TEXT', 'IMAGE'], 'imageConfig' => ['aspectRatio' => '1:1', 'imageSize' => '1K']]], $body, 'Gemini body'); [$binary, $texts] = raijuAiGeminiExtract(['candidates' => [['content' => ['parts' => [ ['thought' => true, 'inlineData' => ['data' => base64_encode('thought')]], ['text' => 'hello'], ['inline_data' => ['data' => base64_encode('IMG')]]]]]]]); imgSame('IMG', $binary, 'the thought part is skipped, both spellings of inline data are read'); imgSame(['hello'], $texts, 'texts come along'); [$none] = raijuAiGeminiExtract(['candidates' => [['content' => ['parts' => [['text' => 'only text']]]]]]); imgSame(null, $none, 'a text-only answer has no image'); imgSame('model refused', raijuAiProviderErrorMessage(['detail' => [['msg' => 'model refused']]]), 'fal validation errors'); imgSame('plain', raijuAiProviderErrorMessage(['detail' => 'plain']), 'detail as text'); imgSame('nope', raijuAiProviderErrorMessage(['error' => ['message' => 'nope']]), 'OpenAI errors'); imgSame('fallback', raijuAiProviderErrorMessage('', 'fallback'), 'no body: the fallback'); // ---- Custom parameters ------------------------------------------------------------------------------------------------------------------ imgSame([], raijuAiCleanImageExtraParams(''), 'empty'); imgSame(['a' => 1, 'b' => ['c' => true]], raijuAiCleanImageExtraParams('{"a":1,"b":{"c":true}}'), 'JSON text'); foreach (['[1,2]', '"text"', '{"prompt":"x"}', '{"MODEL":"x"}', '{"bad key":1}', '{"a":{"b":{"c":{"d":{"e":1}}}}}', '{"a":"' . str_repeat('x', 4100) . '"}', 'not json'] as $bad) { $failed = false; try { raijuAiCleanImageExtraParams($bad); } catch (RuntimeException $e) { $failed = true; } imgAssert($failed, 'refused: ' . substr($bad, 0, 40)); } // ---- Addresses the provider hands back ----------------------------------------------------------------------------------------------------- imgAssert(raijuAiImageTrustedUrl('https://queue.fal.run/fal-ai/x/requests/1/status', 'https://queue.fal.run'), 'same origin is followed'); imgAssert(!raijuAiImageTrustedUrl('https://evil.example/status', 'https://queue.fal.run') && !raijuAiImageTrustedUrl('http://queue.fal.run/status', 'https://queue.fal.run') && !raijuAiImageTrustedUrl('https://user:pw@queue.fal.run/x', 'https://queue.fal.run') && !raijuAiImageTrustedUrl('https://queue.fal.run:8443/x', 'https://queue.fal.run'), 'other host, scheme, credentials or port: not followed'); // ---- Resolution and key source --------------------------------------------------------------------------------------------------------------- $base = raijuAiDefaults(); $base['provider'] = 'anthropic'; $base['providerCredentials']['anthropic']['apiKey'] = 'chat-key'; $base['baseUrl'] = 'https://api.anthropic.com/v1'; $base['apiKey'] = 'chat-key'; $resolved = raijuAiImageServiceResolve($base); imgAssert($resolved['inherit'] && !$resolved['supported'] && $resolved['reason'] === 'chat_provider_without_images', 'Anthropic with "inherit" has no images'); imgAssert(!raijuAiImageServiceUsable($base) && raijuAiImageUnavailableReason($base) === 'chat_provider_without_images', '... and says why'); $withFal = $base; $withFal['imageService'] = ['provider' => 'fal', 'model' => '', 'models' => [], 'custom' => []]; imgAssert(!raijuAiImageServiceUsable($withFal) && raijuAiImageUnavailableReason($withFal) === 'key_missing', 'fal without a key'); $withFal['imageServices'] = ['fal' => ['apiKey' => 'fal-key']]; $resolved = raijuAiImageServiceResolve($withFal); imgAssert(raijuAiImageServiceUsable($withFal) && $resolved['model'] === 'google/nano-banana-2.1' && $resolved['keySource'] === 'own' && $resolved['baseUrl'] === 'https://queue.fal.run', 'fal with a key: default model and base URL'); $effective = raijuAiImageEffectiveSettings($withFal, $resolved); imgAssert($effective['provider'] === 'fal' && $effective['apiKey'] === 'fal-key' && $effective['imageModel'] === 'google/nano-banana-2.1' && $effective['chatModel'] === $base['chatModel'], 'effective settings: the image service credentials, everything else from the chat settings'); $openai = $base; $openai['providerCredentials']['openai-compatible']['apiKey'] = 'openai-chat-key'; $openai['imageService'] = ['provider' => 'openai-compatible', 'model' => '', 'models' => [], 'custom' => []]; $resolved = raijuAiImageServiceResolve($openai); imgAssert($resolved['apiKey'] === 'openai-chat-key' && $resolved['keySource'] === 'chat' && $resolved['model'] === 'gpt-image-2' && $resolved['adapter'] === 'openai-images', 'an OpenAI image service uses the OpenAI chat key when it has none of its own'); $foreign = $openai; $foreign['providerCredentials']['openai-compatible']['baseUrl'] = 'https://api.groq.com/openai/v1'; $resolved = raijuAiImageServiceResolve($foreign); imgAssert($resolved['apiKey'] === '' && $resolved['keySource'] === 'none' && raijuAiImageUnavailableReason($foreign) === 'key_missing', 'a chat key for another host is not used for OpenAI images'); imgAssert(raijuAiImageServicePublic($foreign)['services']['openai-compatible']['chatKeyAvailable'] === false, '... and not offered as available'); $openai['imageServices'] = ['openai-compatible' => ['apiKey' => 'own-key']]; imgSame('own-key', raijuAiImageServiceResolve($openai)['apiKey'], 'an own key wins'); $custom = $base; $custom['imageService'] = ['provider' => 'custom', 'model' => 'm', 'models' => [], 'custom' => ['baseUrl' => 'https://img.example.com/v1/', 'sizeMode' => 'bogus', 'authStyle' => 'x-api-key', 'extraParams' => ['a' => 1]]]; $custom['imageServices'] = ['custom' => ['apiKey' => 'k']]; $resolved = raijuAiImageServiceResolve($custom); imgAssert($resolved['baseUrl'] === 'https://img.example.com/v1' && $resolved['sizeMode'] === 'size' && $resolved['authStyle'] === 'x-api-key' && $resolved['extraParams'] === ['a' => 1], 'custom service: trimmed URL, size mode limited to known values'); $custom['imageService']['custom']['baseUrl'] = ''; imgSame('custom_base_url_missing', raijuAiImageUnavailableReason($custom), 'custom without a base URL'); imgSame('x-api-key: k', raijuAiImageAuthHeader(['apiKey' => 'k', 'authStyle' => 'x-api-key']), 'x-api-key header'); imgSame('Authorization: Bearer k', raijuAiImageAuthHeader(['apiKey' => 'k']), 'bearer header'); // ---- Prices ----------------------------------------------------------------------------------------------------------------------------------- $svc = raijuAiImageServiceResolve($withFal); $settings = ['pricing' => ['imageCentsPerRequest' => 0, 'imageModels' => [], 'usdPerEur' => 0.0]]; imgSame(null, raijuAiImagePrice($settings, $svc, 'google/nano-banana-2.1'), 'no price anywhere: unknown, not free'); $settings['pricing']['imageCentsPerRequest'] = 5; $price = raijuAiImagePrice($settings, $svc, 'google/nano-banana-2.1'); imgAssert($price['source'] === 'default' && $price['cents'] === 5.0, 'the default price per image is the fallback'); $settings['pricing']['imageModels']['fal|google/nano-banana-2.1'] = ['perImage' => 0.5, 'currency' => 'EUR']; $price = raijuAiImagePrice($settings, $svc, 'GOOGLE/Nano-Banana-2.1'); imgAssert($price['source'] === 'override' && $price['cents'] === 50.0, 'a price for the model wins (model ids compare in lower case)'); $settings['pricing']['imageModels']['fal|google/nano-banana-2.1'] = ['perImage' => 0.04, 'currency' => 'USD']; $settings['pricing']['usdPerEur'] = 1.0; imgSame(4.0, raijuAiImagePrice($settings, $svc, 'google/nano-banana-2.1')['cents'], 'USD with the own rate'); $withCatalog = $svc; $withCatalog['spec']['price'] = ['perImage' => 0.1, 'currency' => 'EUR', 'source' => 'x', 'updatedAt' => '2026-10-17']; $settings['pricing']['imageModels'] = []; imgAssert(raijuAiImagePrice($settings, $withCatalog, 'm')['source'] === 'catalog' && raijuAiImagePrice($settings, $withCatalog, 'm')['cents'] === 10.0, 'the registry price comes before the default'); imgSame(['fal|google/nano-banana-2.1' => ['perImage' => 0.5, 'currency' => 'EUR']], raijuAiCleanImagePriceOverrides(['fal|google/nano-banana-2.1' => ['perImage' => '0.5', 'currency' => 'EUR'], 'bad' => ['perImage' => 1], 'fal|x y' => ['perImage' => 1]], []), 'only valid keys are kept'); imgSame([], raijuAiCleanImagePriceOverrides(['fal|m' => null, 'fal|n' => ['perImage' => '']], ['fal|m' => ['perImage' => 1, 'currency' => 'USD'], 'fal|n' => ['perImage' => 1, 'currency' => 'USD']]), 'null or empty removes a price'); // ---- Prices by resolution and quality -------------------------------------------------------------------------------------------------- $settings = ['pricing' => ['imageCentsPerRequest' => 5, 'usdPerEur' => 1.0, 'imageModels' => ['fal|google/nano-banana-2.1' => [ 'perImage' => 0.05, 'currency' => 'USD', 'byResolution' => ['1K' => 0.04, '4K' => 0.12], 'byQuality' => ['high' => 0.2]]]]]; $price = static function (array $request) use (&$settings, $svc) { return raijuAiImagePrice($settings, $svc, 'google/nano-banana-2.1', $request); }; imgSame([4.0, 'resolution:1K'], [$price(['resolution' => '1K', 'quality' => 'high'])['cents'], $price(['resolution' => '1K'])['basis']], 'a price for the resolution comes first'); imgSame([12.0, 'resolution:4K'], [$price(['resolution' => '4K'])['cents'], $price(['resolution' => '4K'])['basis']], '4K'); imgSame([20.0, 'quality:high'], [$price(['resolution' => '2K', 'quality' => 'high'])['cents'], $price(['resolution' => '2K', 'quality' => 'high'])['basis']], 'no price for 2K: the one for the quality'); imgSame([5.0, 'any'], [$price(['resolution' => '2K', 'quality' => 'auto'])['cents'], $price(['resolution' => '2K', 'quality' => 'auto'])['basis']], 'no match: the price for any image'); unset($settings['pricing']['imageModels']['fal|google/nano-banana-2.1']['perImage']); imgSame([4.0, 'resolution:1K', 5.0, 'default'], [$price(['resolution' => '1K'])['cents'], $price(['resolution' => '1K'])['basis'], $price(['resolution' => '2K'])['cents'], $price(['resolution' => '2K'])['source']], 'a table without a price for any image: a miss falls back to the default price of the settings'); $settings['pricing']['imageCentsPerRequest'] = 0; imgSame(null, $price(['resolution' => '2K']), 'a miss without any default: price not on file'); $table = raijuAiImagePriceTable($settings, $svc, 'google/nano-banana-2.1'); imgSame(['1K' => 4.0, '4K' => 12.0], $table['byResolution'], 'the table in euro cents (rate 1)'); imgSame(['fal|google/nano-banana-2.1' => ['perImage' => 0.05, 'currency' => 'EUR', 'byResolution' => ['1K' => 0.04, '2K' => 0.06], 'byQuality' => ['low' => 0.01]]], raijuAiCleanImagePriceOverrides(['fal|google/nano-banana-2.1' => ['perImage' => '0.05', 'currency' => 'EUR', 'byResolution' => ['1K' => '0.04', '2K' => 0.06, '8K' => 1, '4K' => ''], 'byQuality' => ['low' => '0.01', 'ultra' => 3]]], []), 'tables are cleaned: known steps, numbers only'); imgSame([], raijuAiCleanImagePriceOverrides(['fal|m' => ['perImage' => '', 'byResolution' => ['1K' => '']]], ['fal|m' => ['perImage' => 1, 'currency' => 'USD']]), 'an entry with no number left removes the price'); imgSame(['fal|m' => ['currency' => 'USD', 'byQuality' => ['high' => 0.2]]], raijuAiCleanImagePriceOverrides(['fal|m' => ['byQuality' => ['high' => 0.2]]], []), 'a table alone is a valid entry'); $resolutionOf = static fn(string $id, string $model, array $options) => raijuAiImageRequestResolution(raijuAiImageServiceResolve(['provider' => 'anthropic', 'baseUrl' => 'x', 'imageService' => ['provider' => $id, 'model' => $model, 'custom' => []]] + $base), $model, $options); imgSame('2K', $resolutionOf('fal', 'google/nano-banana-2.1', ['imageScale' => 2048]), 'fal resolution step'); imgSame('', $resolutionOf('fal', 'fal-ai/flux/schnell', ['imageScale' => 2048]), 'a model without resolutions has none'); imgSame('4K', $resolutionOf('comet', 'gemini-nano-banana-2.1', ['imageScale' => 3840]), 'Comet resolution step'); imgSame('1K', $resolutionOf('kie', 'nano-banana-2-1', ['imageScale' => 1024]), 'Kie Nano Banana resolution step'); imgSame('4K', $resolutionOf('kie', 'gpt-image-2-5-flare', ['imageScale' => 3840]), 'Kie GPT Image 2.5 has resolution steps'); imgSame('1K', $resolutionOf('kie', 'seedream-5-0-pro', ['imageScale' => 1024]) , 'Kie Seedream has 1K and 2K (basic and high)'); imgSame('2K', $resolutionOf('kie', 'seedream-5-0-pro', ['imageScale' => 3840]), '... and nothing above'); imgSame('', $resolutionOf('openai-compatible', 'gpt-image-2', ['imageScale' => 3840]), 'OpenAI has none'); // ---- Model ids and ports ---------------------------------------------------------------------------------------------------------------------- imgSame('fal-ai/other/path', raijuAiCleanModelAllowEmpty('fal-ai/../other/./path'), 'dot segments are removed from model ids'); imgSame('a/b', raijuAiCleanModelId('/a//b/'), 'empty segments too'); imgSame('gpt-image-2', raijuAiCleanModelAllowEmpty('gpt-image-2'), 'ordinary ids are unchanged'); $resolver = static fn(string $host): array => ['93.184.216.34']; $failed = false; try { raijuNetPrepareUrl('https://cdn.example.com:8443/x.png', ['resolver' => $resolver]); } catch (RaijuNetException $e) { $failed = $e->reason === 'port'; } imgAssert($failed, 'other ports are refused by default'); imgSame(8443, raijuNetPrepareUrl('https://cdn.example.com:8443/x.png', ['resolver' => $resolver, 'extraPorts' => [8080, 8443]])['port'], 'a caller can allow the usual alternative ports'); $failed = false; try { raijuNetPrepareUrl('https://cdn.example.com:9999/x.png', ['resolver' => $resolver, 'extraPorts' => [8080, 8443]]); } catch (RaijuNetException $e) { $failed = $e->reason === 'port'; } imgAssert($failed, '... but only those'); // ---- Reference images ------------------------------------------------------------------------------------------------------------------------ $refs = static fn(string $id, string $model) => raijuAiImageServiceSupportsReferences(raijuAiImageServiceResolve(['provider' => 'anthropic', 'baseUrl' => 'x', 'imageService' => ['provider' => $id, 'model' => $model, 'custom' => ['baseUrl' => 'https://x.example/v1']]] + $base)); imgAssert($refs('fal', 'google/nano-banana-2.1') && $refs('fal', 'fal-ai/nano-banana-pro') && !$refs('fal', 'fal-ai/new'), 'fal takes references only where an edit endpoint is known'); imgAssert($refs('comet', 'gemini-nano-banana-2.1') && $refs('kie', 'nano-banana-2-1') && $refs('openrouter', 'google/gemini-nano-banana-2.1') && $refs('openai-compatible', 'gpt-image-2') && $refs('custom', 'm'), 'the other adapters take references'); imgAssert(!raijuAiImageServiceSupportsReferences(raijuAiImageServiceResolve($base)), 'no image service, no references'); // ---- Kie.ai: the task input follows the spec of the model ------------------------------------------------------------------------------------- $kie = static fn(string $m, array $o, array $r = []) => raijuAiKieTaskInput(raijuAiImageModelSpec('kie', $m), $m, 'p', $o, $r); [$task, $input] = $kie('nano-banana-pro', ['aspectRatio' => '16:9', 'imageScale' => 3840, 'outputFormat' => 'webp']); imgSame(['nano-banana-pro', ['prompt' => 'p', 'aspect_ratio' => '16:9', 'resolution' => '4K', 'output_format' => 'png']], [$task, $input], 'Nano Banana Pro: one id, webp becomes png'); [$task, $input] = $kie('nano-banana-2-1', ['aspectRatio' => '1:3', 'imageScale' => 1024, 'outputFormat' => 'jpeg'], ['https://x.example/a.png']); imgSame(['nano-banana-2-1', ['prompt' => 'p', 'aspect_ratio' => '1:4', 'resolution' => '1K', 'output_format' => 'jpg', 'image_input' => ['https://x.example/a.png']]], [$task, $input], 'Nano Banana 2.1: nearest ratio, jpeg becomes jpg, references in image_input'); [$task, $input] = $kie('gpt-image-2-5-flare', ['aspectRatio' => 'auto', 'size' => '1536x1024', 'imageScale' => 2048]); imgSame(['gpt-image-2-5-flare-text-to-image', ['prompt' => 'p', 'aspect_ratio' => '3:2', 'resolution' => '2K']], [$task, $input], 'GPT Image 2.5 Flare: text-to-image id, no format parameter'); [$task, $input] = $kie('gpt-image-2-5-sunburst', ['aspectRatio' => 'auto', 'size' => 'auto'], ['https://x.example/a.png', 'https://x.example/b.png']); imgSame(['gpt-image-2-5-sunburst-image-to-image', 'auto', ['https://x.example/a.png', 'https://x.example/b.png']], [$task, $input['aspect_ratio'], $input['input_urls']], 'Sunburst with references: the image-to-image id and input_urls'); [$task, $input] = $kie('seedream-5-0-pro', ['aspectRatio' => 'auto', 'size' => 'auto', 'imageScale' => 2048, 'outputFormat' => 'webp']); imgSame(['seedream/5-pro-text-to-image', ['prompt' => 'p', 'aspect_ratio' => '1:1', 'quality' => 'high', 'output_format' => 'png']], [$task, $input], 'Seedream: a ratio is required, the resolution is the quality'); [$task, $input] = $kie('seedream-5-0-pro', ['aspectRatio' => '16:9', 'imageScale' => 1024, 'outputFormat' => 'jpeg'], ['https://x.example/a.png']); imgSame(['seedream/5-pro-image-to-image', 'basic', 'jpeg', ['https://x.example/a.png']], [$task, $input['quality'], $input['output_format'], $input['image_urls']], 'Seedream with a reference: image-to-image id, 1K is basic'); imgSame(['gpt-image-2-5-flare', 'nano-banana-2-1', 'seedream-5-0-pro', 'nano-banana-pro'], array_map('raijuAiNormalizeKieImageModel', ['gpt-image-2', 'nano-banana-2', 'seedream/5-pro-text-to-image', 'nano-banana-pro']), 'the former Kie model ids lead to their successors'); imgSame(['nano-banana-pro', 'nano-banana-2-1', 'gpt-image-2-5-flare', 'gpt-image-2-5-sunburst', 'seedream-5-0-pro'], array_column(raijuAiModelCatalog()['kie']['images'], 'value'), 'the Kie models of the chat catalog'); // ---- Comet: the model decides the route $cometFor = static fn(string $m) => raijuAiImageServiceResolve(['provider' => 'anthropic', 'baseUrl' => 'x', 'imageService' => ['provider' => 'comet', 'model' => $m, 'custom' => []]] + $base); $c = $cometFor(''); imgAssert($c['model'] === 'gemini-nano-banana-2.1' && $c['adapter'] === 'gemini-generate' && $c['baseUrl'] === 'https://api.cometapi.com', 'Comet default: Nano Banana 2.1 over the Gemini route'); $c = $cometFor('gpt-image-2.5-sunburst'); imgAssert($c['adapter'] === 'openai-images' && $c['baseUrl'] === 'https://api.cometapi.com/v1' && raijuAiImageServiceSupportsReferences($c), 'Comet GPT Image: the OpenAI images route below /v1, one reference image'); $c = $cometFor('seedream-5-0-pro-260628'); imgAssert($c['adapter'] === 'comet-task' && $c['baseUrl'] === 'https://api.cometapi.com/v1' && !raijuAiImageServiceSupportsReferences($c), 'Comet Seedream: the asynchronous route, no references'); imgAssert($cometFor('gemini-3-pro-image')['spec']['references']['max'] === 14, 'Comet Nano Banana Pro takes 14 references'); // ---- Live model lists ------------------------------------------------------------------------------------------------------------------------ $failed = false; try { raijuAiImageLiveModels($base, 'kie'); } catch (RuntimeException $e) { $failed = true; } imgAssert($failed, 'only fal and Comet have a model list'); imgSame('https://api.fal.ai', raijuAiImageLiveBase('fal'), 'fal list host'); imgSame('https://api.cometapi.com', raijuAiImageLiveBase('comet'), 'Comet list host'); imgSame([['id' => 'a/B', 'label' => 'x'], ['id' => 'c/d', 'label' => 'y']], raijuAiImageLiveSorted(['c/d' => ['id' => 'c/d', 'label' => 'y'], 'a/B' => ['id' => 'a/B', 'label' => 'x']]), 'lists are sorted'); // ---- Settings for the browser carry no key ------------------------------------------------------------------------------------------------ $input = ['imageService' => ['provider' => 'fal', 'model' => 'google/nano-banana-2.1', 'custom' => ['extraParams' => '{"seed":1}']], 'imageServices' => ['fal' => ['apiKey' => 'typed-key'], 'comet' => ['apiKey' => '••••']]]; $parsed = raijuAiImageServiceFromInput($input, $base, false); imgSame(['fal' => 'typed-key'], $parsed['keys'], 'only a real key counts, a mask never'); imgSame(['seed' => 1], $parsed['imageService']['custom']['extraParams'], 'extra parameters parsed'); imgSame(['fal' => 'google/nano-banana-2.1'], $parsed['imageService']['models'], 'the model of the service is remembered'); $failed = false; try { raijuAiImageServiceFromInput(['imageService' => ['provider' => 'nope']], $base, false); } catch (RuntimeException $e) { $failed = true; } imgAssert($failed, 'unknown service refused'); $failed = false; try { raijuAiImageServiceFromInput(['imageService' => ['provider' => 'custom', 'custom' => ['baseUrl' => 'http://127.0.0.1:9/v1']]], $base, false); } catch (RuntimeException $e) { $failed = true; } imgAssert($failed, 'a local base URL needs the permission'); raijuAiImageServiceFromInput(['imageService' => ['provider' => 'custom', 'custom' => ['baseUrl' => 'http://127.0.0.1:9/v1']]], $base, true); echo "PASS image service helpers: registry, shape mapping, request builders, custom parameters, trusted addresses, key sources, prices\n";