diff --git a/lib/AppInfo/Application.php b/lib/AppInfo/Application.php index 708d1b35..c34ddfe6 100644 --- a/lib/AppInfo/Application.php +++ b/lib/AppInfo/Application.php @@ -46,8 +46,6 @@ class Application extends App implements IBootstrap { public const DEFAULT_QUOTA_PERIOD = 30; public const MAX_QUOTA_PERIOD_DAYS = 3650; public const MAX_QUOTA_PERIOD_MONTHS = 120; - public const DEFAULT_SUMMARY_SYSTEM_PROMPT = 'You are a helpful assistant that summarizes text in the same language as the text. ' - . 'You should only return the summary without any additional information.'; public const DEFAULT_QUOTA_CONFIG = ['length' => self::DEFAULT_QUOTA_PERIOD, 'unit' => 'day', 'day' => 1]; public const DEFAULT_OPENAI_TEXT_GENERATION_TIME = 10; // seconds diff --git a/lib/Service/OpenAiSettingsService.php b/lib/Service/OpenAiSettingsService.php index 33e17570..8e48a555 100644 --- a/lib/Service/OpenAiSettingsService.php +++ b/lib/Service/OpenAiSettingsService.php @@ -27,7 +27,6 @@ class OpenAiSettingsService { private const ADMIN_CONFIG_TYPES = [ 'quota_period' => 'array', 'usage_storage_time' => 'integer', - 'summary_system_prompt' => 'string', ]; private const USER_CONFIG_TYPES = [ @@ -136,19 +135,6 @@ public function getUsageStorageTime(): int { return $this->appConfig->getValueInt(Application::APP_ID, 'usage_storage_time', Application::DEFAULT_QUOTA_PERIOD, lazy: true); } - /** - * System prompt for the summary provider. Empty falls back to the default unless $raw is true. - */ - public function getSummarySystemPrompt(bool $raw = false): string { - $default = $raw ? '' : Application::DEFAULT_SUMMARY_SYSTEM_PROMPT; - return $this->appConfig->getValueString( - Application::APP_ID, - 'summary_system_prompt', - $default, - lazy: true - ) ?: $default; - } - /** * @param string|null $userId * @return string @@ -160,13 +146,12 @@ public function getUserSTTLanguage(?string $userId): string { /** * Get the instance-wide admin config for the settings page * - * @return array{quota_period: array, usage_storage_time: int, summary_system_prompt: string} + * @return array{quota_period: array, usage_storage_time: int} */ public function getAdminConfig(): array { return [ 'quota_period' => $this->getQuotaPeriod(), 'usage_storage_time' => $this->getUsageStorageTime(), - 'summary_system_prompt' => $this->getSummarySystemPrompt(true), ]; } @@ -233,15 +218,6 @@ public function setUsageStorageTime(int $usageStorageTime): void { $this->appConfig->setValueInt(Application::APP_ID, 'usage_storage_time', $usageStorageTime, lazy: true); } - public function setSummarySystemPrompt(string $summarySystemPrompt): void { - $this->appConfig->setValueString( - Application::APP_ID, - 'summary_system_prompt', - $summarySystemPrompt, - lazy: true - ); - } - /** * @param string $userId * @param string $language @@ -274,9 +250,6 @@ public function setAdminConfig(array $adminConfig): void { if (isset($adminConfig['usage_storage_time'])) { $this->setUsageStorageTime($adminConfig['usage_storage_time']); } - if (isset($adminConfig['summary_system_prompt'])) { - $this->setSummarySystemPrompt($adminConfig['summary_system_prompt']); - } } /** diff --git a/lib/Service/ServiceConfig.php b/lib/Service/ServiceConfig.php index 0bd8b7bf..583be81b 100644 --- a/lib/Service/ServiceConfig.php +++ b/lib/Service/ServiceConfig.php @@ -38,6 +38,8 @@ class ServiceConfig implements JsonSerializable { 'llm_extra_params' => 'string', 'max_tokens' => 'integer', 'chunk_size' => 'integer', + 'system_prompt_summary' => 'string', + 'system_prompt_translate' => 'string', 'multimodal_image_enabled' => 'boolean', 'multimodal_audio_enabled' => 'boolean', 'multimodal_video_enabled' => 'boolean', @@ -83,6 +85,8 @@ public function __construct( private string $llmExtraParams = '', private int $maxTokens = Application::DEFAULT_MAX_NUM_OF_TOKENS, private int $chunkSize = Application::DEFAULT_CHUNK_SIZE, + private string $systemPromptSummary = '', + private string $systemPromptTranslate = '', // these mirror the defaults of the single-service configuration, so // that a migrated service and a newly connected one accept the same // kinds of attachment @@ -150,6 +154,10 @@ public function with(array $values): self { break; case 'chunk_size': $new->chunkSize = (int)$value === 0 ? 0 : max(Application::MIN_CHUNK_SIZE, (int)$value); break; + case 'system_prompt_summary': $new->systemPromptSummary = (string)$value; + break; + case 'system_prompt_translate': $new->systemPromptTranslate = (string)$value; + break; case 'multimodal_image_enabled': $new->multimodalImageEnabled = (bool)$value; break; case 'multimodal_audio_enabled': $new->multimodalAudioEnabled = (bool)$value; @@ -349,6 +357,24 @@ public function getChunkSize(): int { return $this->chunkSize; } + /** + * The admin-configured fallback system prompt for the summary task type. + * An empty string means the built-in default prompt is used. + */ + public function getSystemPromptSummary(): string { + return $this->systemPromptSummary; + } + + /** + * The admin-configured system prompt appended to the built-in translation + * prompt for the translation task type. The built-in prompt is always kept, + * as it enforces the expected JSON response format. + * An empty string means only the built-in prompt is used. + */ + public function getSystemPromptTranslate(): string { + return $this->systemPromptTranslate; + } + public function getMultimodalImageEnabled(): bool { return $this->multimodalImageEnabled; } @@ -466,6 +492,8 @@ public function jsonSerialize(): array { 'llm_extra_params' => $this->llmExtraParams, 'max_tokens' => $this->maxTokens, 'chunk_size' => $this->chunkSize, + 'system_prompt_summary' => $this->systemPromptSummary, + 'system_prompt_translate' => $this->systemPromptTranslate, 'multimodal_image_enabled' => $this->multimodalImageEnabled, 'multimodal_audio_enabled' => $this->multimodalAudioEnabled, 'multimodal_video_enabled' => $this->multimodalVideoEnabled, diff --git a/lib/Service/TranslateService.php b/lib/Service/TranslateService.php index c3190688..fff9dc9e 100644 --- a/lib/Service/TranslateService.php +++ b/lib/Service/TranslateService.php @@ -85,6 +85,12 @@ public function translate( $promptStart = 'Translate the following text to ' . $toLanguage . ': '; } + $systemPrompt = self::SYSTEM_PROMPT; + $adminSystemPrompt = $service->getSystemPromptTranslate(); + if ($adminSystemPrompt !== '') { + $systemPrompt .= PHP_EOL . $adminSystemPrompt; + } + $cache = $this->cacheFactory->createDistributed('integration_openai'); foreach ($chunks as $chunk) { $progress += $increase; @@ -105,12 +111,12 @@ public function translate( if ($service->isUsingOpenAi() || $service->getChatEndpointEnabled()) { $completionsObj = $this->openAiAPIService->createChatCompletion( - $userId, $service, $model, $prompt, TranslateService::SYSTEM_PROMPT, null, 1, $maxTokens, TranslateService::JSON_RESPONSE_FORMAT + $userId, $service, $model, $prompt, $systemPrompt, null, 1, $maxTokens, TranslateService::JSON_RESPONSE_FORMAT ); $completions = $completionsObj['messages']; } else { $completions = $this->openAiAPIService->createCompletion( - $userId, $service, $prompt . PHP_EOL . TranslateService::SYSTEM_PROMPT . PHP_EOL . PHP_EOL, 1, $model, $maxTokens + $userId, $service, $prompt . PHP_EOL . $systemPrompt . PHP_EOL . PHP_EOL, 1, $model, $maxTokens ); } diff --git a/lib/TaskProcessing/ProviderFactory.php b/lib/TaskProcessing/ProviderFactory.php index 2f2949e7..43a2792e 100644 --- a/lib/TaskProcessing/ProviderFactory.php +++ b/lib/TaskProcessing/ProviderFactory.php @@ -12,7 +12,6 @@ use OCA\OpenAi\AppInfo\Application; use OCA\OpenAi\Service\ChunkService; use OCA\OpenAi\Service\OpenAiAPIService; -use OCA\OpenAi\Service\OpenAiSettingsService; use OCA\OpenAi\Service\ServiceConfig; use OCA\OpenAi\Service\ServicesService; use OCA\OpenAi\Service\TranslateService; @@ -36,7 +35,6 @@ class ProviderFactory { public function __construct( private ServicesService $servicesService, private OpenAiAPIService $openAiAPIService, - private OpenAiSettingsService $openAiSettingsService, private ChunkService $chunkService, private TranslateService $translateService, private WatermarkingService $watermarkingService, @@ -82,7 +80,7 @@ private function getTextProviders(ServiceConfig $service, string $model): array new TextToTextProvider($this->openAiAPIService, $this->l, $service, $model), new TextToTextChatProvider($this->openAiAPIService, $this->l, $service, $model), new TextToTextChatWithToolsProvider($this->openAiAPIService, $this->l, $service, $model), - new SummaryProvider($this->openAiAPIService, $this->l, $this->chunkService, $this->openAiSettingsService, $service, $model), + new SummaryProvider($this->openAiAPIService, $this->l, $this->chunkService, $service, $model), new HeadlineProvider($this->openAiAPIService, $this->l, $service, $model), new TopicsProvider($this->openAiAPIService, $this->l, $this->chunkService, $this->logger, $service, $model), new ContextWriteProvider($this->openAiAPIService, $this->chunkService, $this->l, $service, $model), diff --git a/lib/TaskProcessing/SummaryProvider.php b/lib/TaskProcessing/SummaryProvider.php index 63c1e140..e5524c48 100644 --- a/lib/TaskProcessing/SummaryProvider.php +++ b/lib/TaskProcessing/SummaryProvider.php @@ -11,7 +11,6 @@ use OCA\OpenAi\Service\ChunkService; use OCA\OpenAi\Service\OpenAiAPIService; -use OCA\OpenAi\Service\OpenAiSettingsService; use OCA\OpenAi\Service\ServiceConfig; use OCP\IL10N; use OCP\TaskProcessing\EShapeType; @@ -25,11 +24,13 @@ class SummaryProvider implements ISynchronousProvider { use ProviderIdentity; + public const DEFAULT_SYSTEM_PROMPT = 'You are a helpful assistant that summarizes text in the same language as the text. ' + . 'You should only return the summary without any additional information. '; + public function __construct( private OpenAiAPIService $openAiAPIService, private IL10N $l, private ChunkService $chunkService, - private OpenAiSettingsService $openAiSettingsService, private ServiceConfig $service, private string $model, ) { @@ -76,6 +77,11 @@ public function getOptionalInputShape(): array { $this->l->t('The maximum number of words/tokens that can be generated in the completion.'), EShapeType::Number ), + 'system_prompt_summary' => new ShapeDescriptor( + $this->l->t('Summary system prompt'), + $this->l->t('Summary system prompt. When set, it overrides the default prompt and the Format and Complexity options are ignored.'), + EShapeType::Text + ), ]; } @@ -100,6 +106,7 @@ public function getOptionalInputShapeDefaults(): array { 'max_tokens' => $this->service->getMaxTokens(), 'format' => 'auto', 'complexity' => 'medium', + 'system_prompt_summary' => '', ]; } @@ -144,21 +151,36 @@ public function process(?string $userId, array $input, callable $reportProgress) try { $completions = []; - $summarySystemPrompt = $this->openAiSettingsService->getSummarySystemPrompt() . ' '; - if (isset($input['format'])) { - if ($input['format'] === 'paragraph') { - $summarySystemPrompt .= 'Return the summary as a paragraph. '; - } elseif ($input['format'] === 'bullet_points') { - $summarySystemPrompt .= 'Return the summary as a list of bullet points. '; - } elseif ($input['format'] === 'sentence') { - $summarySystemPrompt .= 'Return the summary as a single sentence. Do not include more than one sentence. '; + $customSystemPrompt = isset($input['system_prompt_summary']) && is_string($input['system_prompt_summary']) + ? trim($input['system_prompt_summary']) + : ''; + if ($customSystemPrompt !== '') { + // A user provided prompt overrides the others; format and complexity are ignored + $summarySystemPrompt = $customSystemPrompt . ' '; + } else { + // Fallback to the admin-configured prompt, then to default. Format and complexity appended + $summarySystemPrompt = $this->service->getSystemPromptSummary(); + if ($summarySystemPrompt === '') { + $summarySystemPrompt = self::DEFAULT_SYSTEM_PROMPT; + } else { + $summarySystemPrompt .= ' '; } - } - if (isset($input['complexity'])) { - if ($input['complexity'] === 'complex') { - $summarySystemPrompt .= 'Use complex language and vocabulary appropriate for an expert in the subject. '; - } elseif ($input['complexity'] === 'simple') { - $summarySystemPrompt .= 'Use simple language and vocabulary appropriate for a 5 year old. '; + + if (isset($input['format'])) { + if ($input['format'] === 'paragraph') { + $summarySystemPrompt .= 'Return the summary as a paragraph. '; + } elseif ($input['format'] === 'bullet_points') { + $summarySystemPrompt .= 'Return the summary as a list of bullet points. '; + } elseif ($input['format'] === 'sentence') { + $summarySystemPrompt .= 'Return the summary as a single sentence. Do not include more than one sentence. '; + } + } + if (isset($input['complexity'])) { + if ($input['complexity'] === 'complex') { + $summarySystemPrompt .= 'Use complex language and vocabulary appropriate for an expert in the subject. '; + } elseif ($input['complexity'] === 'simple') { + $summarySystemPrompt .= 'Use simple language and vocabulary appropriate for a 5 year old. '; + } } } if ($this->service->isUsingOpenAi() || $this->service->getChatEndpointEnabled()) { diff --git a/src/components/AdminSettings.vue b/src/components/AdminSettings.vue index 91f9c0b6..78803c7e 100644 --- a/src/components/AdminSettings.vue +++ b/src/components/AdminSettings.vue @@ -58,19 +58,6 @@ -

{{ t('integration_openai', 'Text generation') }}

-
- -
-

{{ t('integration_openai', 'Usage limits') }}

{{ t('integration_openai', 'Offer translation') }} +
{{ t('integration_openai', 'System prompts') }}
+
+ + + + +
+
+ + + + +
{{ t('integration_openai', 'Multimodal LLM Support') }}
{{ t('integration_openai', 'Which kinds of attachments the models of this service accept.') }} @@ -412,6 +443,7 @@ import NcInputField from '@nextcloud/vue/components/NcInputField' import NcLoadingIcon from '@nextcloud/vue/components/NcLoadingIcon' import NcNoteCard from '@nextcloud/vue/components/NcNoteCard' import NcSelect from '@nextcloud/vue/components/NcSelect' +import NcTextArea from '@nextcloud/vue/components/NcTextArea' import NcTextField from '@nextcloud/vue/components/NcTextField' import axios from '@nextcloud/axios' @@ -439,6 +471,7 @@ export default { NcLoadingIcon, NcNoteCard, NcSelect, + NcTextArea, NcTextField, }, diff --git a/tests/unit/Providers/OpenAiProviderTest.php b/tests/unit/Providers/OpenAiProviderTest.php index 81484cb8..e44faba4 100644 --- a/tests/unit/Providers/OpenAiProviderTest.php +++ b/tests/unit/Providers/OpenAiProviderTest.php @@ -539,12 +539,10 @@ public function testChangeToneProvider(): void { } public function testSummaryProvider(): void { - $this->openAiSettingsService->setSummarySystemPrompt('This is a custom summary system prompt'); $summaryProvider = new SummaryProvider( $this->openAiApiService, $this->createMock(\OCP\IL10N::class), $this->chunkService, - $this->openAiSettingsService, $this->service, self::TEXT_MODEL, ); @@ -578,11 +576,10 @@ public function testSummaryProvider(): void { $url = self::OPENAI_API_BASE . 'chat/completions'; $options = ['timeout' => Application::OPENAI_DEFAULT_REQUEST_TIMEOUT, 'headers' => ['User-Agent' => Application::USER_AGENT, 'Authorization' => self::AUTHORIZATION_HEADER, 'Content-Type' => 'application/json']]; - $systemPrompt = 'This is a custom summary system prompt '; $options['body'] = json_encode([ 'model' => Application::DEFAULT_COMPLETION_MODEL_ID, 'messages' => [ - ['role' => 'system', 'content' => $systemPrompt], + ['role' => 'system', 'content' => SummaryProvider::DEFAULT_SYSTEM_PROMPT], ['role' => 'user', 'content' => $prompt], ], 'n' => $n, @@ -608,6 +605,158 @@ public function testSummaryProvider(): void { $this->quotaUsageMapper->deleteUserQuotaUsages(self::TEST_USER1); } + public function testSummaryProviderWithAdminSystemPrompt(): void { + $adminSystemPrompt = 'You are a helpful assistant that summarizes text in Polish.'; + $service = $this->service->with(['system_prompt_summary' => $adminSystemPrompt]); + + $summaryProvider = new SummaryProvider( + $this->openAiApiService, + $this->createMock(\OCP\IL10N::class), + $this->chunkService, + $service, + self::TEXT_MODEL, + ); + + $prompt = 'This is a test prompt'; + $n = 1; + + $response = '{ + "id": "chatcmpl-123", + "object": "chat.completion", + "created": 1677652288, + "model": "gpt-3.5-turbo-0613", + "system_fingerprint": "fp_44709d6fcb", + "choices": [ + { + "index": 0, + "message": { + "role": "assistant", + "content": "This is a test response." + }, + "finish_reason": "stop" + } + ], + "usage": { + "prompt_tokens": 9, + "completion_tokens": 12, + "total_tokens": 21 + } + }'; + + $url = self::OPENAI_API_BASE . 'chat/completions'; + + $options = ['timeout' => Application::OPENAI_DEFAULT_REQUEST_TIMEOUT, 'headers' => ['User-Agent' => Application::USER_AGENT, 'Authorization' => self::AUTHORIZATION_HEADER, 'Content-Type' => 'application/json']]; + $options['body'] = json_encode([ + 'model' => Application::DEFAULT_COMPLETION_MODEL_ID, + 'messages' => [ + ['role' => 'system', 'content' => $adminSystemPrompt . ' '], + ['role' => 'user', 'content' => $prompt], + ], + 'n' => $n, + 'stream' => false, + 'max_completion_tokens' => Application::DEFAULT_MAX_NUM_OF_TOKENS, + 'user' => self::TEST_USER1, + ]); + + $iResponse = $this->createMock(\OCP\Http\Client\IResponse::class); + $iResponse->method('getBody')->willReturn($response); + $iResponse->method('getStatusCode')->willReturn(200); + $iResponse->method('getHeader')->with('Content-Type')->willReturn('application/json'); + + $this->iClient->expects($this->once())->method('post')->with($url, $options)->willReturn($iResponse); + + $result = $summaryProvider->process(self::TEST_USER1, ['input' => $prompt], fn () => true); + $this->assertEquals('This is a test response.', $result['output']); + + // Check that token usage is logged properly + $usage = $this->quotaUsageMapper->getQuotaUnitsOfUser(self::TEST_USER1, Application::QUOTA_TYPE_TEXT); + $this->assertEquals(21, $usage); + // Clear quota usage + $this->quotaUsageMapper->deleteUserQuotaUsages(self::TEST_USER1); + } + + public function testSummaryProviderWithUserSystemPrompt(): void { + // Define admin system prompt to ensure it is overwritten by user system prompt + $adminSystemPrompt = 'This is an admin system prompt for summarization.'; + $service = $this->service->with(['system_prompt_summary' => $adminSystemPrompt]); + + $summaryProvider = new SummaryProvider( + $this->openAiApiService, + $this->createMock(\OCP\IL10N::class), + $this->chunkService, + $service, + self::TEXT_MODEL, + ); + + // Ensure that complexity and format are ignored when user system prompt is set + $userSystemPrompt = 'This is a user system prompt for summarization.'; + $complexity = 'complex'; + $format = 'bullet_points'; + $prompt = 'This is a test prompt'; + $input = [ + 'input' => $prompt, + 'system_prompt_summary' => $userSystemPrompt, + 'format' => $format, + 'complexity' => $complexity, + ]; + + $n = 1; + + $response = '{ + "id": "chatcmpl-123", + "object": "chat.completion", + "created": 1677652288, + "model": "gpt-3.5-turbo-0613", + "system_fingerprint": "fp_44709d6fcb", + "choices": [ + { + "index": 0, + "message": { + "role": "assistant", + "content": "This is a test response." + }, + "finish_reason": "stop" + } + ], + "usage": { + "prompt_tokens": 9, + "completion_tokens": 12, + "total_tokens": 21 + } + }'; + + $url = self::OPENAI_API_BASE . 'chat/completions'; + + $options = ['timeout' => Application::OPENAI_DEFAULT_REQUEST_TIMEOUT, 'headers' => ['User-Agent' => Application::USER_AGENT, 'Authorization' => self::AUTHORIZATION_HEADER, 'Content-Type' => 'application/json']]; + $options['body'] = json_encode([ + 'model' => Application::DEFAULT_COMPLETION_MODEL_ID, + 'messages' => [ + ['role' => 'system', 'content' => $userSystemPrompt . ' '], + ['role' => 'user', 'content' => $prompt], + ], + 'n' => $n, + 'stream' => false, + 'max_completion_tokens' => Application::DEFAULT_MAX_NUM_OF_TOKENS, + 'user' => self::TEST_USER1, + ]); + + $iResponse = $this->createMock(\OCP\Http\Client\IResponse::class); + $iResponse->method('getBody')->willReturn($response); + $iResponse->method('getStatusCode')->willReturn(200); + $iResponse->method('getHeader')->with('Content-Type')->willReturn('application/json'); + + $this->iClient->expects($this->once())->method('post')->with($url, $options)->willReturn($iResponse); + + $result = $summaryProvider->process(self::TEST_USER1, $input, fn () => true); + $this->assertEquals('This is a test response.', $result['output']); + + // Check that token usage is logged properly + $usage = $this->quotaUsageMapper->getQuotaUnitsOfUser(self::TEST_USER1, Application::QUOTA_TYPE_TEXT); + $this->assertEquals(21, $usage); + // Clear quota usage + $this->quotaUsageMapper->deleteUserQuotaUsages(self::TEST_USER1); + } + public function testProofreadProvider(): void { $proofreadProvider = new ProofreadProvider( $this->openAiApiService, @@ -756,6 +905,89 @@ public function testTranslationProvider(): void { $this->quotaUsageMapper->deleteUserQuotaUsages(self::TEST_USER1); } + public function testTranslationProviderWithAdminSystemPrompt(): void { + $adminSystemPrompt = 'This is an admin system prompt for translation.'; + $service = $this->service->with(['system_prompt_translate' => $adminSystemPrompt]); + + $translationProvider = new TranslateProvider( + $this->openAiApiService, + $this->createMock(\OCP\IL10N::class), + $this->translateService, + $service, + self::TEXT_MODEL, + ); + + $inputText = 'This is a test prompt'; + $n = 1; + $fromLang = 'English'; + $toLang = 'Polish'; + $aiContent = ['translation' => 'This is a test response.']; + + $response = '{ + "id": "chatcmpl-123", + "object": "chat.completion", + "created": 1677652288, + "model": "gpt-4.1-mini", + "system_fingerprint": "fp_44709d6fcb", + "choices": [ + { + "index": 0, + "message": { + "role": "assistant", + "content": ' . json_encode(json_encode($aiContent)) . ' + }, + "finish_reason": "stop" + } + ], + "usage": { + "prompt_tokens": 9, + "completion_tokens": 12, + "total_tokens": 21 + } + }'; + + $url = self::OPENAI_API_BASE . 'chat/completions'; + $prompt = 'Translate the following text from ' . $fromLang . ' to ' . $toLang . ': ' . PHP_EOL . PHP_EOL . $inputText; + + $options = ['timeout' => Application::OPENAI_DEFAULT_REQUEST_TIMEOUT, 'headers' => ['User-Agent' => Application::USER_AGENT, 'Authorization' => self::AUTHORIZATION_HEADER, 'Content-Type' => 'application/json']]; + $options['body'] = json_encode([ + 'model' => Application::DEFAULT_COMPLETION_MODEL_ID, + 'messages' => [ + ['role' => 'system', 'content' => TranslateService::SYSTEM_PROMPT . PHP_EOL . $adminSystemPrompt], + ['role' => 'user', 'content' => $prompt], + ], + 'n' => $n, + 'stream' => false, + 'max_completion_tokens' => Application::DEFAULT_MAX_NUM_OF_TOKENS, + 'user' => self::TEST_USER1, + ...TranslateService::JSON_RESPONSE_FORMAT, + ]); + + $iResponse = $this->createMock(\OCP\Http\Client\IResponse::class); + $iResponse->method('getBody')->willReturn($response); + $iResponse->method('getStatusCode')->willReturn(200); + $iResponse->method('getHeader')->with('Content-Type')->willReturn('application/json'); + + $this->iClient->expects($this->once())->method('post')->with( + $this->equalTo($url), + $this->callback(function ($revdOptions) use ($options) { + $body = json_decode($revdOptions['body'], true); + $expectedBody = json_decode($options['body'], true); + $this->assertEquals($expectedBody, $body); + return true; + }), + )->willReturn($iResponse); + + $result = $translationProvider->process(self::TEST_USER1, ['input' => $inputText, 'origin_language' => $fromLang, 'target_language' => $toLang], fn () => true, new SynchronousProviderOptions(preferStreaming: false)); + $this->assertEquals(['output' => $aiContent['translation']], $result); + + // Check that token usage is logged properly + $usage = $this->quotaUsageMapper->getQuotaUnitsOfUser(self::TEST_USER1, Application::QUOTA_TYPE_TEXT); + $this->assertEquals(21, $usage); + // Clear quota usage + $this->quotaUsageMapper->deleteUserQuotaUsages(self::TEST_USER1); + } + public function testAudioToAudioTranslateProvider(): void { $l10n = $this->createMock(\OCP\IL10N::class); $l10n->method('t')->willReturnCallback(fn ($text) => $text);