252 lines
26 KiB
PHP
252 lines
26 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
// Image services: registry, shape mapping, request builders, custom parameters, price resolution, trusted addresses, key-free settings for the browser.
|
|
|
|
function imgAssert(bool $condition, string $message): void {
|
|
if (!$condition) {
|
|
fwrite(STDERR, "FAIL: {$message}\n");
|
|
exit(1);
|
|
}
|
|
}
|
|
|
|
function imgSame($expected, $actual, string $message): void {
|
|
imgAssert($expected === $actual, $message . ' (expected ' . json_encode($expected) . ', got ' . json_encode($actual) . ')');
|
|
}
|
|
|
|
$root = dirname(__DIR__);
|
|
$site = sys_get_temp_dir() . '/raiju-imgsvc-' . bin2hex(random_bytes(4));
|
|
mkdir($site . '/content', 0755, true);
|
|
register_shutdown_function(static fn() => 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('<html>', '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";
|