From 14304143af13d9bdeea4e04835346e1632fffcb1 Mon Sep 17 00:00:00 2001 From: KernelDeimos Date: Thu, 3 Apr 2025 17:45:25 -0400 Subject: [PATCH] dev: migrate image generation to use cost service --- src/backend/src/api/APIError.js | 10 +- .../src/modules/puterai/AIInterfaceService.js | 1 + .../puterai/OpenAIImageGenerationService.js | 100 ++++++++++++++++-- .../selfhosted/PermissiveCreditService.js | 3 +- .../database/sqlite_setup/0037_cost.sql | 11 ++ 5 files changed, 115 insertions(+), 10 deletions(-) create mode 100644 src/backend/src/services/database/sqlite_setup/0037_cost.sql diff --git a/src/backend/src/api/APIError.js b/src/backend/src/api/APIError.js index 74aadbced..195516662 100644 --- a/src/backend/src/api/APIError.js +++ b/src/backend/src/api/APIError.js @@ -216,7 +216,9 @@ module.exports = class APIError { }, 'internal_error': { status: 500, - message: 'An internal error occurred.', + message: ({ message }) => message + ? 'An internal error occurred: ' + quot(message) + : 'An internal error occurred.', }, 'response_timeout': { status: 504, @@ -348,6 +350,12 @@ module.exports = class APIError { status: 503, message: 'System-wide rate limit exceeded. Please try again later.', }, + + // New cost system + 'insufficient_funds': { + status: 402, + message: 'Available funding is insufficient for this request.', + }, // auth 'token_missing': { diff --git a/src/backend/src/modules/puterai/AIInterfaceService.js b/src/backend/src/modules/puterai/AIInterfaceService.js index eadddc0b3..3926b6cb6 100644 --- a/src/backend/src/modules/puterai/AIInterfaceService.js +++ b/src/backend/src/modules/puterai/AIInterfaceService.js @@ -95,6 +95,7 @@ class AIInterfaceService extends BaseService { description: 'Generate an image from a prompt.', parameters: { prompt: { type: 'string' }, + quality: { type: 'string' }, }, result_choices: [ { diff --git a/src/backend/src/modules/puterai/OpenAIImageGenerationService.js b/src/backend/src/modules/puterai/OpenAIImageGenerationService.js index ac717f2d2..6d36a705f 100644 --- a/src/backend/src/modules/puterai/OpenAIImageGenerationService.js +++ b/src/backend/src/modules/puterai/OpenAIImageGenerationService.js @@ -18,6 +18,7 @@ */ // METADATA // {"ai-commented":{"service":"claude"}} +const APIError = require("../../api/APIError"); const BaseService = require("../../services/BaseService"); const { TypedValue } = require("../../services/drivers/meta/Runtime"); const { Context } = require("../../util/context"); @@ -34,6 +35,25 @@ class OpenAIImageGenerationService extends BaseService { static MODULES = { openai: require('openai'), } + + _construct () { + this.models_ = { + 'dall-e-3': { + '1024x1024': 0.04, + '1024x1792': 0.08, + '1792x1024': 0.08, + 'hd:1024x1024': 0.08, + 'hd:1024x1792': 0.12, + 'hd:1792x1024': 0.12, + }, + 'dall-e-2': { + '1024x1024': 0.02, + '512x512': 0.018, + '256x256': 0.016, + }, + }; + } + /** * Initializes the OpenAI client with API credentials from config * @private @@ -67,7 +87,7 @@ class OpenAIImageGenerationService extends BaseService { * @returns {Promise} URL of the generated image * @throws {Error} If prompt is not a string or ratio is invalid */ - async generate ({ prompt, test_mode }) { + async generate ({ prompt, quality, test_mode }) { if ( test_mode ) { return new TypedValue({ $: 'string:url:web', @@ -76,6 +96,7 @@ class OpenAIImageGenerationService extends BaseService { } const url = await this.generate(prompt, { + quality, ratio: this.constructor.RATIO_SQUARE, }); @@ -96,6 +117,7 @@ class OpenAIImageGenerationService extends BaseService { async generate (prompt, { ratio, model, + quality, }) { if ( typeof prompt !== 'string' ) { throw new Error('`prompt` must be a string'); @@ -106,7 +128,42 @@ class OpenAIImageGenerationService extends BaseService { } model = model ?? 'dall-e-3'; - + + if ( ! this.models_[model] ) { + throw APIError.create('field_invalid', null, { + key: 'model', + expected: 'one of: ' + + Object.keys(this.models_).join(', '), + got: model, + }); + } + + if ( quality && quality !== 'standard' && quality !== 'hd' ) { + throw APIError.create('field_invalid', null, { + key: 'quality', + expected: 'one of: standard, hd', + got: quality, + }); + } + + console.log('SPECIFIED QUALITY:', quality); + + const size = `${ratio.w}x${ratio.h}`; + const price_key = (quality === 'hd' ? 'hd:' : '') + size; + if ( ! this.models_[model][price_key] ) { + throw APIError.create('field_invalid', null, { + key: 'size', + expected: 'one of: standard, hd', + got: quality, + }); + } + + if ( ! this.models_[model][size] ) { + throw APIError.create('internal_error', null, { + message: `price of ${size} not known for model ${model}` + }); + } + const user_private_uid = Context.get('actor')?.private_uid ?? 'UNKNOWN'; if ( user_private_uid === 'UNKNOWN' ) { this.errors.report('chat-completion-service:unknown-user', { @@ -115,13 +172,40 @@ class OpenAIImageGenerationService extends BaseService { trace: true, }); } + + const svc_cost = this.services.get('cost'); + const usageAllowed = await svc_cost.get_funding_allowed({ + minimum: this.models_[model][price_key] + * 100 // $ USD to cents USD + * Math.pow(10,6) // cents to microcents + }); + + if ( ! usageAllowed ) { + throw APIError.create('insufficient_funds'); + } - const result = - await this.openai.images.generate({ - user: user_private_uid, - prompt, - size: `${ratio.w}x${ratio.h}`, - }); + const result = await this.openai.images.generate({ + user: user_private_uid, + prompt, + size, + }); + + // Tiny base64 result for testing + // const result = { + // data: [ + // { + // url: 'data:image/png;base64,' + + // 'iVBORw0KGgoAAAANSUhEUgAAAAgAAAAICAIAAABLbSncAAAA' + + // '2ElEQVR4nADIADf/AkRgiOi4oaIHfdeNCE2vFMURlKdHdb/H' + + // '4wRTROeyGdCpn089i13t42v73DQSsCwSDAsEBLH783BZu1si' + + // 'LkiwqfGwHAC/8bL0NggaA47QKDuRDp0NRgtALj8W+mSm9BIH' + + // 'PMGYegR+bu/c85wWQGLYrjLhis9E8AE1F/AFbCMA53+9d73t' + + // '/QKPbbdLHZY8wB4OewzT8CrCBG3RE7kyWAXuJvaHHHzFhbIN' + + // '1hryGU5vvwD6liTD3hytRktVRRAaRi71k2PYCro6AlYBAAD/' + + // '/wWtWjI5xEefAAAAAElFTkSuQmCC' + // } + // ] + // }; const spending_meta = { model, diff --git a/src/backend/src/modules/selfhosted/PermissiveCreditService.js b/src/backend/src/modules/selfhosted/PermissiveCreditService.js index ffba55fd5..9dcb8c937 100644 --- a/src/backend/src/modules/selfhosted/PermissiveCreditService.js +++ b/src/backend/src/modules/selfhosted/PermissiveCreditService.js @@ -14,7 +14,8 @@ class PermissiveCreditService extends BaseService { _init () { const svc_event = this.services.get('event'); svc_event.on(`credit.check-available`, (_, event) => { - event.available = Number.MAX_SAFE_INTEGER; + event.available = 4 * Math.pow(10,6); + // event.available = Number.MAX_SAFE_INTEGER; }); } } diff --git a/src/backend/src/services/database/sqlite_setup/0037_cost.sql b/src/backend/src/services/database/sqlite_setup/0037_cost.sql new file mode 100644 index 000000000..8204cf4c3 --- /dev/null +++ b/src/backend/src/services/database/sqlite_setup/0037_cost.sql @@ -0,0 +1,11 @@ +CREATE TABLE `per_user_credit` ( + `id` INTEGER PRIMARY KEY AUTOINCREMENT, + `user_id` INTEGER NOT NULL UNIQUE, + `amount` int NOT NULL, + + -- NOTE: "BIGINT UNSIGNED" + `last_updated_at` INTEGER NOT NULL, + + FOREIGN KEY("user_id") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE CASCADE, + FOREIGN KEY("app_id") REFERENCES "apps" ("id") ON DELETE SET NULL ON UPDATE CASCADE +);