diff --git a/package-lock.json b/package-lock.json
index d4b8917b8..3f3be4208 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -14597,7 +14597,9 @@
}
},
"node_modules/together-ai": {
- "version": "0.32.0",
+ "version": "0.33.0",
+ "resolved": "https://registry.npmjs.org/together-ai/-/together-ai-0.33.0.tgz",
+ "integrity": "sha512-2JdxYwbw+Xw2bW2PHBGqbMTtYsQHoWO9UXvdwIfQkde/swoKp2x/hpxEjtTERzrMP4O5SdDPGxsjfcPXewDJ9A==",
"license": "Apache-2.0",
"bin": {
"together-ai": "bin/cli"
@@ -15830,7 +15832,7 @@
"svg-captcha": "^1.4.0",
"svgo": "^3.0.2",
"tiktoken": "^1.0.16",
- "together-ai": "^0.32.0",
+ "together-ai": "^0.33.0",
"tweetnacl": "^1.0.3",
"ua-parser-js": "^1.0.38",
"uglify-js": "^3.17.4",
diff --git a/src/backend/package.json b/src/backend/package.json
index 7b49e15be..352a1c1e0 100644
--- a/src/backend/package.json
+++ b/src/backend/package.json
@@ -82,7 +82,7 @@
"svg-captcha": "^1.4.0",
"svgo": "^3.0.2",
"tiktoken": "^1.0.16",
- "together-ai": "^0.32.0",
+ "together-ai": "^0.33.0",
"tweetnacl": "^1.0.3",
"ua-parser-js": "^1.0.38",
"uglify-js": "^3.17.4",
diff --git a/src/backend/src/modules/ai/PuterAIChatModule.js b/src/backend/src/modules/ai/PuterAIChatModule.js
index 77ca70db4..9bdbb4bd9 100644
--- a/src/backend/src/modules/ai/PuterAIChatModule.js
+++ b/src/backend/src/modules/ai/PuterAIChatModule.js
@@ -22,17 +22,14 @@ import { AdvancedBase } from '@heyputer/putility';
import config from '../../config.js';
import { AIInterfaceService } from '../../services/ai/AIInterfaceService.js';
import { AIChatService } from '../../services/ai/chat/AIChatService.js';
-import { GeminiImageGenerationService } from '../../services/ai/image/GeminiImageGenerationService.js';
-import { OpenAIImageGenerationService } from '../../services/ai/image/OpenAIImageGenerationService.js';
-import { TogetherImageGenerationService } from '../../services/ai/image/TogetherImageGenerationService.js';
+import { AIImageGenerationService } from '../../services/ai/image/AIImageGenerationService.js';
import { AWSTextractService } from '../../services/ai/ocr/AWSTextractService.js';
import { ElevenLabsVoiceChangerService } from '../../services/ai/sts/ElevenLabsVoiceChangerService.js';
import { OpenAISpeechToTextService } from '../../services/ai/stt/OpenAISpeechToTextService.js';
import { AWSPollyService } from '../../services/ai/tts/AWSPollyService.js';
import { ElevenLabsTTSService } from '../../services/ai/tts/ElevenLabsTTSService.js';
import { OpenAITTSService } from '../../services/ai/tts/OpenAITTSService.js';
-import { OpenAIVideoGenerationService } from '../../services/ai/video/OpenAIVideoGenerationService.js';
-import { TogetherVideoGenerationService } from '../../services/ai/video/TogetherVideoGenerationService.js';
+// import { AIVideoGenerationService } from '../../services/ai/video/AIVideoGenerationService.js';
/**
* PuterAIModule class extends AdvancedBase to manage and register various AI services.
@@ -57,8 +54,13 @@ export class PuterAIModule extends AdvancedBase {
// completion ai service
services.registerService('ai-chat', AIChatService);
- // TODO DS: centralize other service types too
+ // image generation ai service
+ services.registerService('ai-image', AIImageGenerationService);
+ // video generation ai service
+ // services.registerService('ai-video', AIVideoGenerationService);
+
+ // TODO DS: centralize other service types too
// TODO: services should govern their own availability instead of the module deciding what to register
if ( config?.services?.['aws-textract']?.aws ) {
@@ -78,25 +80,9 @@ export class PuterAIModule extends AdvancedBase {
if ( config?.services?.openai || config?.openai ) {
- services.registerService('openai-image-generation', OpenAIImageGenerationService);
-
- services.registerService('openai-video-generation', OpenAIVideoGenerationService);
-
services.registerService('openai-tts', OpenAITTSService);
services.registerService('openai-speech2txt', OpenAISpeechToTextService);
}
-
- if ( config?.services?.['together-ai'] ) {
-
- services.registerService('together-image-generation', TogetherImageGenerationService);
-
- services.registerService('together-video-generation', TogetherVideoGenerationService);
- }
-
- if ( config?.services?.['gemini'] ) {
-
- services.registerService('gemini-image-generation', GeminiImageGenerationService);
- }
}
-}
\ No newline at end of file
+}
diff --git a/src/backend/src/services/ChatAPIService.js b/src/backend/src/services/ChatAPIService.js
index 71564037c..1cadc48a9 100644
--- a/src/backend/src/services/ChatAPIService.js
+++ b/src/backend/src/services/ChatAPIService.js
@@ -50,7 +50,7 @@ class ChatAPIService extends BaseService {
})();
// Register the router with the Express app
- app.use('/puterai/chat', router);
+ app.use('/puterai', router);
// Install endpoints
this.install_chat_endpoints_({ router });
@@ -66,7 +66,7 @@ class ChatAPIService extends BaseService {
const Endpoint = this.require('Endpoint');
// Endpoint to list available AI chat models
Endpoint({
- route: '/models',
+ route: '/chat/models',
methods: ['GET'],
handler: async (req, res) => {
try {
@@ -89,7 +89,7 @@ class ChatAPIService extends BaseService {
// Endpoint to get detailed information about available AI chat models
Endpoint({
- route: '/models/details',
+ route: '/chat/models/details',
methods: ['GET'],
handler: async (req, res) => {
try {
@@ -109,6 +109,48 @@ class ChatAPIService extends BaseService {
}
},
}).attach(router);
+
+ Endpoint({
+ route: '/image/models',
+ methods: ['GET'],
+ handler: async (req, res) => {
+ try {
+ // Use SUService to access AIImageGenerationService as system user
+ const svc_su = this.services.get('su');
+ const models = await svc_su.sudo(async () => {
+ const svc_imageGen = this.services.get('ai-image');
+ // Return the simple model list which contains basic model information
+ return svc_imageGen.list();
+ });
+ // Return the list of models
+ res.json({ models });
+ } catch ( error ) {
+ this.log.error('Error fetching image models:', error);
+ throw APIError.create('internal_server_error');
+ }
+ },
+ }).attach(router);
+
+ Endpoint({
+ route: '/image/models/details',
+ methods: ['GET'],
+ handler: async (req, res) => {
+ try {
+ // Use SUService to access AIImageGenerationService as system user
+ const svc_su = this.services.get('su');
+ const models = await svc_su.sudo(async () => {
+ const svc_imageGen = this.services.get('ai-image');
+ // Return the detailed model list which includes cost and capability information
+ return svc_imageGen.models();
+ });
+ // Return the detailed list of models
+ res.json({ models });
+ } catch ( error ) {
+ this.log.error('Error fetching image model details:', error);
+ throw APIError.create('internal_server_error');
+ }
+ },
+ }).attach(router);
}
}
diff --git a/src/backend/src/services/ChatAPIService.test.js b/src/backend/src/services/ChatAPIService.test.js
index 520de1955..222d81a4c 100644
--- a/src/backend/src/services/ChatAPIService.test.js
+++ b/src/backend/src/services/ChatAPIService.test.js
@@ -124,7 +124,11 @@ describe('ChatAPIService', () => {
// Verify
expect(mockEndpoint).toHaveBeenCalledWith(expect.objectContaining({
- route: '/models',
+ route: '/chat/models',
+ methods: ['GET'],
+ }));
+ expect(mockEndpoint).toHaveBeenCalledWith(expect.objectContaining({
+ route: '/image/models',
methods: ['GET'],
}));
});
@@ -138,7 +142,11 @@ describe('ChatAPIService', () => {
// Verify
expect(mockEndpoint).toHaveBeenCalledWith(expect.objectContaining({
- route: '/models/details',
+ route: '/chat/models/details',
+ methods: ['GET'],
+ }));
+ expect(mockEndpoint).toHaveBeenCalledWith(expect.objectContaining({
+ route: '/image/models/details',
methods: ['GET'],
}));
});
diff --git a/src/backend/src/services/PuterSiteService.js b/src/backend/src/services/PuterSiteService.js
index 9d194933a..68e27ef6f 100644
--- a/src/backend/src/services/PuterSiteService.js
+++ b/src/backend/src/services/PuterSiteService.js
@@ -112,7 +112,6 @@ class PuterSiteService extends BaseService {
root_dir_id: this.config.devtest_directory,
};
}
- console.log('???', subdomain, options);
const rows = await this.db.read(`SELECT * FROM subdomains WHERE ${
options.is_custom_domain ? 'domain' : 'subdomain'
} = ? LIMIT 1`,
diff --git a/src/backend/src/services/ai/chat/providers/TogetherAiProvider/TogetherAIProvider.ts b/src/backend/src/services/ai/chat/providers/TogetherAiProvider/TogetherAIProvider.ts
index f6fc0eaea..8d0165f67 100644
--- a/src/backend/src/services/ai/chat/providers/TogetherAiProvider/TogetherAIProvider.ts
+++ b/src/backend/src/services/ai/chat/providers/TogetherAiProvider/TogetherAIProvider.ts
@@ -46,9 +46,9 @@ export class TogetherAIProvider implements IChatProvider {
let models: IChatModel[] | undefined = kv.get(this.#kvKey);
if ( models ) return models;
- const api_models = await this.#together.models.list();
+ const apiModels = await this.#together.models.list();
models = [];
- for ( const model of api_models ) {
+ for ( const model of apiModels ) {
if ( model.type === 'chat' || model.type === 'code' || model.type === 'language' || model.type === 'moderation' ) {
models.push({
id: `togetherai:${model.id}`,
diff --git a/src/backend/src/services/ai/image/.gitignore b/src/backend/src/services/ai/image/.gitignore
new file mode 100644
index 000000000..aa4a6da26
--- /dev/null
+++ b/src/backend/src/services/ai/image/.gitignore
@@ -0,0 +1,2 @@
+*.js
+*.js.map
\ No newline at end of file
diff --git a/src/backend/src/services/ai/image/AIImageGenerationService.ts b/src/backend/src/services/ai/image/AIImageGenerationService.ts
new file mode 100644
index 000000000..11247615a
--- /dev/null
+++ b/src/backend/src/services/ai/image/AIImageGenerationService.ts
@@ -0,0 +1,263 @@
+/*
+ * Copyright (C) 2024-present Puter Technologies Inc.
+ *
+ * This file is part of Puter.
+ *
+ * Puter is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published
+ * by the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ */
+
+// METADATA // {"ai-commented":{"service":"claude"}}
+import { APIError } from '../../../api/APIError.js';
+import { ErrorService } from '../../../modules/core/ErrorService.js';
+import { Context } from '../../../util/context.js';
+import BaseService from '../../BaseService.js';
+import { BaseDatabaseAccessService } from '../../database/BaseDatabaseAccessService.js';
+import { DB_WRITE } from '../../database/consts.js';
+import { DriverService } from '../../drivers/DriverService.js';
+import { TypedValue } from '../../drivers/meta/Runtime.js';
+import { EventService } from '../../EventService.js';
+import { MeteringService } from '../../MeteringService/MeteringService.js';
+import { GeminiImageGenerationProvider } from './providers/GeminiImageGenerationProvider/GeminiImageGenerationProvider.js';
+import { OpenAiImageGenerationProvider } from './providers/OpenAiImageGenerationProvider/OpenAiImageGenerationProvider.js';
+import { TogetherImageGenerationProvider } from './providers/TogetherImageGenerationProvider/TogetherImageGenerationProvider.js';
+import { IGenerateParams, IImageModel, IImageProvider } from './providers/types.js';
+
+export class AIImageGenerationService extends BaseService {
+
+ static SERVICE_NAME = 'ai-image';
+
+ static DEFAULT_PROVIDER = 'openai-image-generation';
+
+ get meteringService (): MeteringService {
+ return this.services.get('meteringService').meteringService;
+ }
+
+ get db (): BaseDatabaseAccessService {
+ return this.services.get('database').get(DB_WRITE, 'ai-service');
+ }
+
+ get errorService (): ErrorService {
+ return this.services.get('error-service');
+ }
+
+ get eventService (): EventService {
+ return this.services.get('event');
+ }
+
+ get driverService (): DriverService {
+ return this.services.get('driver');
+ }
+
+ getProvider (name: string): IImageProvider | undefined {
+ return this.#providers[name];
+ }
+
+ #providers: Record = {};
+ #modelIdMap: Record = {};
+
+ /** Driver interfaces */
+ static IMPLEMENTS = {
+ ['driver-capabilities']: {
+ supports_test_mode (iface: string, method_name: string) {
+ return iface === 'puter-image-generation' &&
+ method_name === 'generate';
+ },
+ },
+ ['puter-image-generation']: {
+
+ async generate (...parameters: Parameters) {
+ return (this as unknown as AIImageGenerationService).generate(...parameters);
+ },
+ },
+ };
+
+ getModel ({ modelId, provider}: { modelId: string, provider?: string }) {
+ const models = this.#modelIdMap[modelId];
+
+ if ( ! provider ) {
+ return models[0];
+ }
+ const model = models.find(m => m.provider === provider);
+ return model ?? models[0];
+ }
+
+ private async registerProviders () {
+
+ const openAiConfig = this.config.providers?.['openai-image-generation'] || this.global_config?.services?.['openai'] || this.global_config?.openai;
+ if ( openAiConfig && (openAiConfig.apiKey || openAiConfig.secret_key) ) {
+ this.#providers['openai-image-generation'] = new OpenAiImageGenerationProvider({ apiKey: openAiConfig.apiKey || openAiConfig.secret_key }, this.meteringService, this.errorService);
+ }
+
+ const geminiConfig = this.config.providers?.['gemini-image-generation'] || this.global_config?.services?.gemini;
+ if ( geminiConfig && (geminiConfig.apiKey || geminiConfig.secret_key) ) {
+ this.#providers['gemini-image-generation'] = new GeminiImageGenerationProvider({ apiKey: geminiConfig.apiKey || geminiConfig.secret_key }, this.meteringService, this.errorService);
+ }
+
+ const togetherConfig = this.config.providers?.['together-image-generation'] || this.global_config?.services?.['together-ai'];
+ if ( togetherConfig && (togetherConfig.apiKey || togetherConfig.secret_key) ) {
+ this.#providers['together-image-generation'] = new TogetherImageGenerationProvider({ apiKey: togetherConfig.apiKey || togetherConfig.secret_key }, this.meteringService, this.errorService, this.eventService);
+ }
+
+ // emit event for extensions to add providers
+ const extensionProviders = {} as Record;
+ await this.eventService.emit('ai.image.registerProviders', extensionProviders);
+ for ( const providerName in extensionProviders ) {
+ if ( this.#providers[providerName] ) {
+ console.warn('AIChatService: provider name conflict for ', providerName, ' registering with -extension suffix');
+ this.#providers[`${providerName}-extension`] = extensionProviders[providerName];
+ continue;
+ }
+ this.#providers[providerName] = extensionProviders[providerName];
+ }
+ }
+
+ protected async '__on_boot.consolidation' () {
+ // register chat providers here
+ await this.registerProviders();
+
+ // build model id map
+ for ( const providerName in this.#providers ) {
+ const provider = this.#providers[providerName];
+
+ // alias all driver requests to go here to support legacy routing
+ this.driverService.register_service_alias(AIImageGenerationService.SERVICE_NAME,
+ providerName,
+ { iface: 'puter-image-generation' });
+
+ // build model id map
+ for ( const model of await provider.models() ) {
+ if ( ! this.#modelIdMap[model.id] ) {
+ this.#modelIdMap[model.id] = [];
+ }
+ this.#modelIdMap[model.id].push({ ...model, provider: providerName });
+ if ( model.aliases ) {
+ for ( const alias of model.aliases ) {
+ // join arrays which are aliased the same
+ if ( ! this.#modelIdMap[alias] ) {
+ this.#modelIdMap[alias] = this.#modelIdMap[model.id];
+ continue;
+ }
+ if ( this.#modelIdMap[alias] !== this.#modelIdMap[model.id] ) {
+ this.#modelIdMap[alias].push({ ...model, provider: providerName });
+ this.#modelIdMap[model.id] = this.#modelIdMap[alias];
+ continue;
+ }
+ }
+ }
+ this.#modelIdMap[model.id].sort((a, b) => a.costs[a.index_cost_key || Object.keys(a.costs)[0]] - b.costs[b.index_cost_key || Object.keys(b.costs)[0]]);
+ }
+ }
+ }
+
+ models () {
+ return Object.entries(this.#modelIdMap)
+ .map(([_, models]) => models)
+ .flat()
+ .sort((a, b) => {
+ if ( a.provider === b.provider ) {
+ return a.id.localeCompare(b.id);
+ }
+ return a.provider!.localeCompare(b.provider!);
+ });
+ }
+
+ list () {
+ return Object.keys(this.#modelIdMap).sort();
+ }
+
+ async generate (parameters: IGenerateParams) {
+ const clientDriverCall = Context.get('client_driver_call');
+ let { test_mode: testMode, intended_service: legacyProviderName } = clientDriverCall as { test_mode?: boolean; response_metadata: Record; intended_service?: string };
+
+ const configuredProviders = Object.keys(this.#providers);
+ if ( configuredProviders.length === 0 ) {
+ throw new Error('no image generation providers configured');
+ }
+
+ let intendedProvider = (parameters.provider || (legacyProviderName === AIImageGenerationService.SERVICE_NAME ? '' : legacyProviderName)) ?? '';
+
+ if ( !parameters.model && !intendedProvider ) {
+ intendedProvider = configuredProviders.includes(AIImageGenerationService.DEFAULT_PROVIDER)
+ ? AIImageGenerationService.DEFAULT_PROVIDER
+ : configuredProviders[0];
+ }
+
+ if ( intendedProvider && !this.#providers[intendedProvider] ) {
+ intendedProvider = configuredProviders[0];
+ }
+
+ if ( !parameters.model && intendedProvider ) {
+ parameters.model = this.#providers[intendedProvider].getDefaultModel();
+ }
+
+ const model = parameters.model ? this.getModel({ modelId: parameters.model, provider: intendedProvider }) : undefined;
+
+ if ( ! model ) {
+ const availableModelsUrl = `${this.global_config.origin }/puterai/image/models`;
+
+ throw APIError.create('field_invalid', undefined, {
+ key: 'model',
+ expected: `a valid model name from ${availableModelsUrl}`,
+ got: model,
+ });
+ }
+
+ // call model provider;
+ const provider = this.#providers[model.provider!];
+ if ( ! provider ) {
+ throw new Error(`no provider found for model ${model.id}`);
+ }
+
+ if ( model.allowedRatios?.length ) {
+ if ( parameters.ratio ) {
+ const isValidRatio = model.allowedRatios.some(r => r.w === parameters.ratio!.w && r.h === parameters.ratio!.h);
+ if ( ! isValidRatio ) {
+ parameters.ratio = model.allowedRatios[0];
+ }
+ } else {
+ parameters.ratio = model.allowedRatios[0];
+ }
+ }
+
+ if ( ! parameters.ratio ) {
+ parameters.ratio = { w: 1024, h: 1024 };
+ }
+
+ if ( model.allowedQualityLevels?.length ) {
+ if ( parameters.quality ) {
+ if ( ! model.allowedQualityLevels.includes(parameters.quality) ) {
+ parameters.quality = model.allowedQualityLevels[0];
+ }
+ } else {
+ parameters.quality = model.allowedQualityLevels[0];
+ }
+ }
+
+ const url = await provider.generate({
+ ...parameters,
+ model: model.id,
+ provider: model.provider,
+ test_mode: testMode,
+ });
+
+ const isDataUrl = url.startsWith('data:');
+ const image = new TypedValue({
+ $: isDataUrl ? 'string:url:data' : 'string:url:web',
+ content_type: 'image',
+ }, url);
+
+ return image;
+
+ }
+}
diff --git a/src/backend/src/services/ai/image/GeminiImageGenerationService.js b/src/backend/src/services/ai/image/GeminiImageGenerationService.js
deleted file mode 100644
index 05fafb30a..000000000
--- a/src/backend/src/services/ai/image/GeminiImageGenerationService.js
+++ /dev/null
@@ -1,257 +0,0 @@
-/*
- * Copyright (C) 2024-present Puter Technologies Inc.
- *
- * This file is part of Puter.
- *
- * Puter is free software: you can redistribute it and/or modify
- * it under the terms of the GNU Affero General Public License as published
- * by the Free Software Foundation, either version 3 of the License, or
- * (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU Affero General Public License for more details.
- *
- * You should have received a copy of the GNU Affero General Public License
- * along with this program. If not, see .
- */
-
-// METADATA // {"ai-commented":{"service":"claude"}}
-const APIError = require('../../../api/APIError');
-const BaseService = require('../../BaseService');
-const { TypedValue } = require('../../drivers/meta/Runtime');
-const { Context } = require('../../../util/context');
-const { GoogleGenAI } = require('@google/genai');
-
-/**
-* Service class for generating images using Gemini's API
-* Extends BaseService to provide image generation capabilities through
-* the puter-image-generation interface.
-*/
-class GeminiImageGenerationService extends BaseService {
- /** @type {import('../../MeteringService/MeteringService').MeteringService} */
- get meteringService () {
- return this.services.get('meteringService').meteringService;
- }
- static MODULES = {
- };
-
- _construct () {
- this.models_ = {
- 'gemini-2.5-flash-image-preview': {
- '1024x1024': 0.039,
- },
- 'gemini-3-pro-image-preview': {
- '1024x1024': 0.156,
- },
- };
- }
-
- /**
- * Initializes the Gemini client with API credentials from config
- * @private
- * @async
- * @returns {Promise}
- */
- async _init () {
- this.genAI = new GoogleGenAI({ apiKey: this.global_config.services.gemini.apiKey });
- }
-
- static IMPLEMENTS = {
- ['driver-capabilities']: {
- supports_test_mode (iface, method_name) {
- return iface === 'puter-image-generation' &&
- method_name === 'generate';
- },
- },
- ['puter-image-generation']: {
- /**
- * Generates an image using Gemini's gemini-2.5-flash-image-preview
- * @param {string} prompt - The text description of the image to generate
- * @param {Object} options - Generation options
- * @param {Object} options.ratio - Image dimensions ratio object with w/h properties
- * @param {string} [options.model='gemini-2.5-flash-image-preview'] - The model to use for generation
- * @param {string} [options.input_image] - Base64 encoded input image for image-to-image generation
- * @param {string} [options.input_image_mime_type] - MIME type of the input image
- * @returns {Promise} URL of the generated image
- * @throws {Error} If prompt is not a string or ratio is invalid
- */
- async generate (params) {
- const { prompt, quality, test_mode, model, ratio, input_image, input_image_mime_type } = params;
-
- if ( test_mode ) {
- return new TypedValue({
- $: 'string:url:web',
- content_type: 'image',
- }, 'https://puter-sample-data.puter.site/image_example.png');
- }
-
- const url = await this.generate(prompt, {
- quality,
- ratio: ratio || this.constructor.RATIO_SQUARE,
- model,
- input_image,
- input_image_mime_type,
- });
-
- // Determine if this is a data URL or web URL
- const isDataUrl = url.startsWith('data:');
- const image = new TypedValue({
- $: isDataUrl ? 'string:url:data' : 'string:url:web',
- content_type: 'image',
- }, url);
-
- return image;
- },
- },
- };
-
- static RATIO_SQUARE = { w: 1024, h: 1024 };
-
- async generate (prompt, {
- ratio,
- model,
- input_image,
- input_image_mime_type,
- }) {
- if ( typeof prompt !== 'string' ) {
- throw new Error('`prompt` must be a string');
- }
-
- if ( !ratio || !this._validate_ratio(ratio, model) ) {
- throw new Error(`\`ratio\` must be a valid ratio for model ${ model}`);
- }
-
- // Validate input image if provided
- if ( input_image && !input_image_mime_type ) {
- throw new Error('`input_image_mime_type` is required when `input_image` is provided');
- }
-
- if ( input_image_mime_type && !input_image ) {
- throw new Error('`input_image` is required when `input_image_mime_type` is provided');
- }
-
- if ( input_image_mime_type && !this._validate_image_mime_type(input_image_mime_type) ) {
- throw new Error('`input_image_mime_type` must be a valid image MIME type (image/png, image/jpeg, image/webp)');
- }
-
- // Somewhat sane defaults
- model = model ?? 'gemini-2.5-flash-image-preview';
-
- if ( ! this.models_[model] ) {
- throw APIError.create('field_invalid', null, {
- key: 'model',
- expected: `one of: ${
- Object.keys(this.models_).join(', ')}`,
- got: model,
- });
- }
-
- const price_key = `${ratio.w}x${ratio.h}`;
- if ( ! this.models_[model][price_key] ) {
- const availableSizes = Object.keys(this.models_[model]);
- throw APIError.create('field_invalid', null, {
- key: 'size/quality combination',
- expected: `one of: ${ availableSizes.join(', ')}`,
- got: price_key,
- });
- }
-
- const actor = Context.get('actor');
- const user_private_uid = actor?.private_uid ?? 'UNKNOWN';
- if ( user_private_uid === 'UNKNOWN' ) {
- this.errors.report('chat-completion-service:unknown-user', {
- message: 'failed to get a user ID for a Gemini request',
- alarm: true,
- trace: true,
- });
- }
-
- const usageType = `gemini:${model}:${price_key}`;
-
- const usageAllowed = await this.meteringService.hasEnoughCreditsFor(actor, usageType, 1);
-
- if ( ! usageAllowed ) {
- throw APIError.create('insufficient_funds');
- }
-
- // Construct the prompt based on whether we have an input image
- let contents;
- if ( input_image && input_image_mime_type ) {
- // Image-to-image generation
- contents = [
- { text: `Generate a picture of dimensions ${parseInt(ratio.w)}x${parseInt(ratio.h)} with the prompt: ${prompt}` },
- {
- inlineData: {
- mimeType: input_image_mime_type,
- data: input_image,
- },
- },
- ];
- } else {
- // Text-to-image generation
- contents = `Generate a picture of dimensions ${parseInt(ratio.w)}x${parseInt(ratio.h)} with the prompt: ${prompt}`;
- }
-
- const response = await this.genAI.models.generateContent({
- model,
- contents,
- });
-
- // Metering usage tracking
- // Gemini usage: always 1 image, resolution, cost, model
- this.meteringService.incrementUsage(actor, usageType, 1);
- let url = undefined;
- for ( const part of response.candidates[0].content.parts ) {
- if ( part.text ) {
- // do nothing here
- } else if ( part.inlineData ) {
- const imageData = part.inlineData.data;
- url = `data:image/png;base64,${ imageData}`;
- }
- }
-
- if ( ! url ) {
- throw new Error('Failed to extract image URL from Gemini response');
- }
-
- return url;
- }
-
- /**
- * Get valid ratios for a specific model
- * @param {string} model - The model name
- * @returns {Array