Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions lib/AppInfo/Application.php
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
use OCA\OpenAi\Capabilities;
use OCA\OpenAi\Listener\TaskProcessingProviderListener;
use OCA\OpenAi\Notification\Notifier;
use OCA\OpenAi\TaskProcessing\ImageToImageTaskType;
use OCP\AppFramework\App;
use OCP\AppFramework\Bootstrap\IBootContext;
use OCP\AppFramework\Bootstrap\IBootstrap;
Expand Down Expand Up @@ -103,6 +104,10 @@ public function register(IRegistrationContext $context): void {
// classes. They are built per (service, model, task type) instead.
$context->registerEventListener(GetTaskProcessingProvidersEvent::class, TaskProcessingProviderListener::class);

if (!class_exists('OCP\\TaskProcessing\\TaskTypes\\ImageToImage')) {
$context->registerTaskProcessingTaskType(ImageToImageTaskType::class);
}

$context->registerCapability(Capabilities::class);
$context->registerNotifierService(Notifier::class);
}
Expand Down
251 changes: 245 additions & 6 deletions lib/Service/OpenAiAPIService.php
Original file line number Diff line number Diff line change
Expand Up @@ -936,6 +936,241 @@ public function requestImageCreation(
return $apiResponse;
}

/**
* @param ServiceConfig $service
* @return bool
*/
public function isLocalAIService(ServiceConfig $service): bool {
$serviceUrl = $service->getUrl();
$url = rtrim($serviceUrl, '/');
// LocalAI urls always have a v1
if (!str_ends_with($url, '/v1')) {
return false;
}
$cacheKey = 'localai_service_' . base64_encode($url);
$wellKnownUrl = substr($url, 0, -2) . '.well-known/localai.json';
$cache = $this->cacheFactory->createLocal();
$result = $cache->get($cacheKey);
if ($result !== null) {
return $result;
}
$this->logger->debug('Checking if service is a LocalAI service at URL: ' . $url, ['app' => Application::APP_ID]);
try {
$wellKnownService = $this->client->get($wellKnownUrl, ['http_errors' => false, 'nextcloud' => ['allow_local_address' => true]]);
if ($wellKnownService->getStatusCode() !== 200) {
$result = false;
} else {
$jsonResponse = json_decode($wellKnownService->getBody(), true);
$result = $jsonResponse !== null;
}
} catch (Exception $e) {
$this->logger->warning('Could not check if service is a LocalAI service at URL: ' . $url . '. Error: ' . $e->getMessage(), ['app' => Application::APP_ID]);
$result = false;
}
$cache->set($cacheKey, $result);
return $result;
}

/**
* @param string|null $userId
* @param string $prompt
* @param list<array{content: string, mimeType: string}> $images
* @param string $model
* @param string $size
* @return array
* @throws Exception
* @throws UserFacingProcessingException
*/
public function requestImageEdit(
?string $userId,
ServiceConfig $service,
string $prompt,
array $images,
string $model,
string $size = Application::DEFAULT_DEFAULT_IMAGE_SIZE,
): array {
if ($this->isQuotaExceeded($userId, Application::QUOTA_TYPE_IMAGE, $service)) {
throw new Exception($this->l10n->t('Image generation quota exceeded'), Http::STATUS_TOO_MANY_REQUESTS);
}

$modelParam = $this->modelParam($service, $model, Application::DEFAULT_IMAGE_MODEL_ID);

if ($service->isUsingOpenRouter()) {
$apiResponse = $this->requestOpenRouterImageEdit($userId, $service, $prompt, $images, $modelParam, $size);
} elseif ($service->isUsingIonos()) {
$apiResponse = $this->requestIonosImageEdit($userId, $service, $prompt, $images, $modelParam, $size);
} elseif ($this->isLocalAIService($service)) {
$apiResponse = $this->requestLocalAiImageEdit($userId, $service, $prompt, $images, $modelParam, $size);
} else {
// Default to OpenAI
$apiResponse = $this->requestOpenAiImageEdit($userId, $service, $prompt, $images, $modelParam, $size);
}

if (!isset($apiResponse['data']) || !is_array($apiResponse['data'])) {
$this->logger->warning('OpenAI image edit error', ['api_response' => $apiResponse]);
throw new Exception($this->l10n->t('Unknown image generation error'), Http::STATUS_INTERNAL_SERVER_ERROR);
}

try {
$this->createQuotaUsage($userId ?? '', Application::QUOTA_TYPE_IMAGE, 1, $service);
} catch (DBException $e) {
$this->logger->warning('Could not create quota usage for user: ' . $userId . ' and quota type: ' . Application::QUOTA_TYPE_IMAGE . '. Error: ' . $e->getMessage(), ['app' => Application::APP_ID]);
}

return $apiResponse;
}

/**
* @param list<array{content: string, mimeType: string}> $images
* @return array
* @throws Exception
*/
private function requestOpenAiImageEdit(
?string $userId,
ServiceConfig $service,
string $prompt,
array $images,
?string $model,
string $size,
): array {
$params = [
'prompt' => $prompt,
'size' => $size,
'n' => 1,
];
if ($model !== null) {
$params['model'] = $model;
}
foreach ($images as $index => $image) {
$mimeType = $image['mimeType'];
$extension = match ($mimeType) {
'image/jpeg' => 'jpg',
'image/webp' => 'webp',
'image/gif' => 'gif',
default => 'png',
};
$name = 'image_' . ($index);
$params[$name] = [
'name' => 'image[]',
'contents' => $image['content'],
'filename' => $name . '.' . $extension,
'headers' => [
'Content-Type' => $mimeType,
],
];
}

return $this->request($userId, $service, 'images/edits', $params, 'POST', 'multipart/form-data');
}

/**
* @param list<array{content: string, mimeType: string}> $images
* @return array
* @throws Exception
* @throws UserFacingProcessingException
*/
private function requestIonosImageEdit(
?string $userId,
ServiceConfig $service,
string $prompt,
array $images,
?string $model,
string $size,
): array {
if (count($images) > 1) {
throw new UserFacingProcessingException(
'IONOS image editing supports only one input image',
0,
null,
$this->l10n->t('Only one input image is supported.'),
);
}

$image = $images[0];
$params = [
'prompt' => $prompt,
'size' => $size,
'n' => 1,
'url' => 'data:' . $image['mimeType'] . ';base64,' . base64_encode($image['content']),
];
if ($model !== null) {
$params['model'] = $model;
}

return $this->request($userId, $service, 'images/edits', $params, 'POST', 'multipart/form-data');
}

/**
* OpenRouter image edit path using the unified /images API with input_references.
*
* @param list<array{content: string, mimeType: string}> $images
* @return array
* @throws Exception
*/
private function requestOpenRouterImageEdit(
?string $userId,
ServiceConfig $service,
string $prompt,
array $images,
?string $model,
string $size,
): array {
$inputReferences = [];
foreach ($images as $image) {
$inputReferences[] = [
'type' => 'image_url',
'image_url' => [
'url' => 'data:' . $image['mimeType'] . ';base64,' . base64_encode($image['content']),
],
];
}

$params = [
'prompt' => $prompt,
'size' => $size,
'n' => 1,
'input_references' => $inputReferences,
];
if ($model !== null) {
$params['model'] = $model;
}

return $this->request($userId, $service, 'images', $params, 'POST');
}

/**
* LocalAI and other OpenAI-compatible image edit path via /images/generations.
*
* @param list<array{content: string, mimeType: string}> $images
* @return array
* @throws Exception
*/
private function requestLocalAiImageEdit(
?string $userId,
ServiceConfig $service,
string $prompt,
array $images,
?string $model,
string $size,
): array {
$refImages = [];
foreach ($images as $image) {
$refImages[] = base64_encode($image['content']);
}

$params = [
'prompt' => $prompt,
'size' => $size,
'n' => 1,
'ref_images' => $refImages,
];
if ($model !== null) {
$params['model'] = $model;
}

return $this->request($userId, $service, 'images/generations', $params, 'POST');
}

/**
* @param string|null $userId
* @return array
Expand Down Expand Up @@ -1153,12 +1388,16 @@ public function request(
if ($contentType === 'multipart/form-data') {
$multipart = [];
foreach ($params as $key => $value) {
$part = [
'name' => $key,
'contents' => $value,
];
if ($key === 'file') {
$part['filename'] = 'file.mp3';
if (is_array($value) && array_key_exists('contents', $value)) {
$part = $value;
} else {
$part = [
'name' => $key,
'contents' => $value,
];
if ($key === 'file') {
$part['filename'] = 'file.mp3';
}
}
$multipart[] = $part;
}
Expand Down
4 changes: 4 additions & 0 deletions lib/Service/ServiceConfig.php
Original file line number Diff line number Diff line change
Expand Up @@ -277,6 +277,10 @@ public function isUsingMistral(): bool {
return str_starts_with(strtolower($this->url), 'https://api.mistral.ai');
}

public function isUsingIonos(): bool {
return str_starts_with(strtolower($this->url), 'https://openai.inference.de-txl.ionos.com');
}

public function getApiKey(): string {
return $this->apiKey;
}
Expand Down
Loading
Loading