diff --git a/apps/api/src/app/endpoints/ai/ai.controller.spec.ts b/apps/api/src/app/endpoints/ai/ai.controller.spec.ts index 69a396e30d..ad6dfa8cf1 100644 --- a/apps/api/src/app/endpoints/ai/ai.controller.spec.ts +++ b/apps/api/src/app/endpoints/ai/ai.controller.spec.ts @@ -1,4 +1,5 @@ import { HAS_PERMISSION_KEY } from '@ghostfolio/api/decorators/has-permission.decorator'; +import { CallerFacingError } from '@ghostfolio/api/errors/caller-facing.error'; import { CustomThrottlerGuard } from '@ghostfolio/api/guards/custom-throttler.guard'; import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard'; import { ApiService } from '@ghostfolio/api/services/api/api.service'; @@ -18,6 +19,7 @@ import { THROTTLER_TTL } from '@nestjs/throttler/dist/throttler.constants'; import type { Response } from 'express'; +import { StatusCodes } from 'http-status-codes'; import { EventEmitter } from 'node:events'; import { AiController } from './ai.controller'; @@ -207,5 +209,30 @@ describe('AiController', () => { expect(abortSignal.aborted).toBe(false); }); + + it('Gives the status 400 with the message of an error for the caller', async () => { + jest + .mocked(aiService.getActivitiesFromFile) + .mockRejectedValueOnce( + new CallerFacingError('The file has too many activities') + ); + + await expect( + aiController.getActivitiesFromFile(createResponse(), pdfFile) + ).rejects.toMatchObject({ + response: { message: ['The file has too many activities'] }, + status: StatusCodes.BAD_REQUEST + }); + }); + + it('Passes on every other error', async () => { + const error = new Error('Model error'); + + jest.mocked(aiService.getActivitiesFromFile).mockRejectedValueOnce(error); + + await expect( + aiController.getActivitiesFromFile(createResponse(), pdfFile) + ).rejects.toBe(error); + }); }); }); diff --git a/apps/api/src/app/endpoints/ai/ai.controller.ts b/apps/api/src/app/endpoints/ai/ai.controller.ts index 538f102793..acf2c93789 100644 --- a/apps/api/src/app/endpoints/ai/ai.controller.ts +++ b/apps/api/src/app/endpoints/ai/ai.controller.ts @@ -2,6 +2,7 @@ import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorat import { Impersonation } from '@ghostfolio/api/decorators/impersonation.decorator'; import { RequiresScope } from '@ghostfolio/api/decorators/requires-scope.decorator'; import { FilterDto } from '@ghostfolio/api/dtos/filter.dto'; +import { CallerFacingError } from '@ghostfolio/api/errors/caller-facing.error'; import { CustomThrottlerGuard } from '@ghostfolio/api/guards/custom-throttler.guard'; import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard'; import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.interceptor'; @@ -68,7 +69,8 @@ export class AiController { * without saving anything. The client imports the activities which the user * selects with POST api/v1/import. The daily limit applies to each user * instead of each IP address. The models stop if the client closes the - * request before the response. + * request before the response. An error whose message is written for the + * caller gives the status 400 with this message. */ @HasPermission(permissions.importActivitiesWithAi) @HttpCode(StatusCodes.OK) @@ -137,14 +139,28 @@ export class AiController { } }); - return this.aiService.getActivitiesFromFile({ - abortSignal: AbortSignal.any([ - abortController.signal, - AbortSignal.timeout(AI_ACTIVITIES_TIMEOUT) - ]), - file: { mediaType, buffer: file.buffer }, - user: this.request.user - }); + try { + return await this.aiService.getActivitiesFromFile({ + abortSignal: AbortSignal.any([ + abortController.signal, + AbortSignal.timeout(AI_ACTIVITIES_TIMEOUT) + ]), + file: { mediaType, buffer: file.buffer }, + user: this.request.user + }); + } catch (error) { + if (error instanceof CallerFacingError) { + throw new HttpException( + { + error: getReasonPhrase(StatusCodes.BAD_REQUEST), + message: [error.message] + }, + StatusCodes.BAD_REQUEST + ); + } + + throw error; + } } @Get('prompt/:mode') diff --git a/apps/api/src/app/endpoints/ai/ai.service.spec.ts b/apps/api/src/app/endpoints/ai/ai.service.spec.ts index 5beb9a1f10..095d0df874 100644 --- a/apps/api/src/app/endpoints/ai/ai.service.spec.ts +++ b/apps/api/src/app/endpoints/ai/ai.service.spec.ts @@ -1,4 +1,5 @@ import { McpService } from '@ghostfolio/api/app/endpoints/mcp/mcp.service'; +import { CallerFacingError } from '@ghostfolio/api/errors/caller-facing.error'; import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; import { PortfolioTableService } from '@ghostfolio/api/services/portfolio-table/portfolio-table.service'; import { PropertyService } from '@ghostfolio/api/services/property/property.service'; @@ -15,7 +16,7 @@ import type { UserWithSettings } from '@ghostfolio/common/types'; import { Logger } from '@nestjs/common'; import { createOpenRouter } from '@openrouter/ai-sdk-provider'; import { DataSource, Type as ActivityType } from '@prisma/client'; -import { generateText, isStepCount, ToolSet } from 'ai'; +import { generateText, isStepCount, NoObjectGeneratedError, ToolSet } from 'ai'; import { z } from 'zod'; import { ACTIVITY_ROWS_OUTPUT, ROW_MATCHES_OUTPUT } from './ai.schemas'; @@ -34,6 +35,19 @@ jest.mock('ai', () => { isStepCount: jest.fn((stepCount: number) => { return { stepCount }; }), + NoObjectGeneratedError: class MockNoObjectGeneratedError extends Error { + public readonly finishReason: string; + + public constructor({ finishReason }: { finishReason: string }) { + super('No object generated'); + + this.finishReason = finishReason; + } + + public static isInstance(error: unknown) { + return error instanceof MockNoObjectGeneratedError; + } + }, Output: { object: jest.fn((options: unknown) => { return options; @@ -194,6 +208,69 @@ describe('AiService', () => { expect(instructions).toContain('the locale de-CH'); expect(instructions).toContain('Today is 2026-10-11'); }); + + it('Gives an error for the caller if a cut output reaches the maximum number of output tokens', async () => { + jest.spyOn(Logger.prototype, 'warn').mockImplementation(); + + jest.mocked(generateText).mockRejectedValueOnce( + new NoObjectGeneratedError({ + finishReason: 'length' + } as ConstructorParameters[0]) + ); + + await expect( + aiService.getActivityRowsFromFile({ + file: { + buffer: Buffer.from('%PDF-1.7'), + mediaType: 'application/pdf' + }, + locale: 'de-CH' + }) + ).rejects.toThrow(CallerFacingError); + + expect(Logger.prototype.warn).toHaveBeenCalled(); + }); + + it('Gives an error for the caller if an output without any text reaches the maximum number of output tokens', async () => { + jest.spyOn(Logger.prototype, 'warn').mockImplementation(); + + jest.mocked(generateText).mockResolvedValueOnce({ + finishReason: 'length', + get output() { + throw new Error('No output generated'); + } + } as unknown as Awaited>); + + await expect( + aiService.getActivityRowsFromFile({ + file: { + buffer: Buffer.from('%PDF-1.7'), + mediaType: 'application/pdf' + }, + locale: 'de-CH' + }) + ).rejects.toThrow(CallerFacingError); + + expect(Logger.prototype.warn).toHaveBeenCalled(); + }); + + it('Passes on every other error of the vision model', async () => { + const error = new NoObjectGeneratedError({ + finishReason: 'stop' + } as ConstructorParameters[0]); + + jest.mocked(generateText).mockRejectedValueOnce(error); + + await expect( + aiService.getActivityRowsFromFile({ + file: { + buffer: Buffer.from('%PDF-1.7'), + mediaType: 'application/pdf' + }, + locale: 'de-CH' + }) + ).rejects.toBe(error); + }); }); describe('getActivitiesFromFile', () => { @@ -404,6 +481,35 @@ describe('AiService', () => { expect(activities[0].currency).toBe('CHF'); }); + it('Gives the absolute values of a negative fee, quantity and unit price', async () => { + mockRowMatches({ + assets: [ + { currency: 'USD', dataSource: 'YAHOO', id: 0, symbol: 'AAPL' } + ] + }); + + const { activities } = await aiService.getActivitiesFromRows({ + accounts, + userCurrency, + userId, + rows: [ + createActivityRow({ + accountName: null, + fee: -1, + quantity: -10, + type: ActivityType.SELL, + unitPrice: -230 + }) + ] + }); + + expect(activities[0]).toMatchObject({ + fee: 1, + quantity: 10, + unitPrice: 230 + }); + }); + it('Gives a row whose asset has no match as a row without a match', async () => { const row = createActivityRow({ accountName: null }); diff --git a/apps/api/src/app/endpoints/ai/ai.service.ts b/apps/api/src/app/endpoints/ai/ai.service.ts index dbbaf51292..02ebc93aae 100644 --- a/apps/api/src/app/endpoints/ai/ai.service.ts +++ b/apps/api/src/app/endpoints/ai/ai.service.ts @@ -4,6 +4,7 @@ import { } from '@ghostfolio/api/app/endpoints/mcp/mcp.schemas'; import { McpService } from '@ghostfolio/api/app/endpoints/mcp/mcp.service'; import { ActivityToImport } from '@ghostfolio/api/app/endpoints/mcp/types/activity-to-import.type'; +import { CallerFacingError } from '@ghostfolio/api/errors/caller-facing.error'; import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; import { PortfolioTableService } from '@ghostfolio/api/services/portfolio-table/portfolio-table.service'; import { PropertyService } from '@ghostfolio/api/services/property/property.service'; @@ -43,6 +44,7 @@ import { FilePart, generateText, isStepCount, + NoObjectGeneratedError, Output, ProviderMetadata, TextPart, @@ -137,7 +139,9 @@ export class AiService { * the tool search-asset-profiles, and to the accounts of the user. A row * which does not give a valid activity, for example because its asset has * no match, is a row without a match. An account id which is not the id of - * an account of the user is ignored. + * an account of the user is ignored. The fee, the quantity and the unit + * price are taken as absolute values, as a broker can give a sell or a fee + * as a negative number. */ public async getActivitiesFromRows({ abortSignal, @@ -202,16 +206,11 @@ export class AiService { const rowsWithoutMatch: ActivityRow[] = []; for (const row of rows) { - const { - accountName, - currency, - date, - fee, - name, - quantity, - type, - unitPrice - } = row; + const { accountName, currency, date, name, type } = row; + + const fee = Math.abs(row.fee); + const quantity = Math.abs(row.quantity); + const unitPrice = Math.abs(row.unitPrice); const account = accounts.find(({ id }) => { return id === accountIdByName.get(accountName); @@ -265,7 +264,10 @@ export class AiService { * OpenRouter is not covered by the zero data retention. The model gets no * tools and copies the asset and the account as printed, hence the rows * still have to be matched to asset profiles and accounts. A row above the - * maximum number of activities is left out. + * maximum number of activities is left out. An output which reaches the + * maximum number of output tokens is cut, hence it gives an error for the + * caller. A cut output without any text is not parsed at all, hence the + * finish reason is checked as well. */ public async getActivityRowsFromFile({ abortSignal, @@ -283,7 +285,7 @@ export class AiService { ? [{ text: file.buffer.toString('utf8'), type: 'text' }] : [{ data: file.buffer, mediaType: file.mediaType, type: 'file' }]; - const { output, steps, totalUsage } = await generateText({ + const result = await generateText({ abortSignal, instructions: [ 'You read the activities of a portfolio from a file, for example a CSV export or a statement of a broker, a confirmation of a trade or a screenshot.', @@ -310,8 +312,23 @@ export class AiService { } }), output: Output.object({ schema: ACTIVITY_ROWS_OUTPUT }) + }).catch((error: unknown) => { + if ( + NoObjectGeneratedError.isInstance(error) && + error.finishReason === 'length' + ) { + throw this.getOutputTokenLimitError(); + } + + throw error; }); + if (result.finishReason === 'length') { + throw this.getOutputTokenLimitError(); + } + + const { output, steps, totalUsage } = result; + this.logger.log( `Read ${output.rows.length} rows from a file in ${this.getSecondsSince(startTime)} seconds with ${totalUsage.totalTokens} tokens for ${this.getCost(steps)} USD` ); @@ -445,6 +462,20 @@ export class AiService { ); } + /** + * Logs and gives the error of an output of the vision model which reaches + * the maximum number of output tokens + */ + private getOutputTokenLimitError() { + this.logger.warn( + `Stopped reading a file at the maximum of ${AiService.VISION_MAXIMUM_OUTPUT_TOKEN_COUNT} output tokens` + ); + + return new CallerFacingError( + 'The file has too many activities for Ghostfolio AI. Split it into smaller files.' + ); + } + /** * Matches the assets and the account names with the agent model. The last * step allows no tool call, so that the model always gives the matches. An