Files
nc-talk-ai/tests/unit/Service/LLMClientTest.php
pascalkienast 82328b843d Retry LLM payloads with reasoning token params after 400/422
Some providers reject temperature/max_tokens. Remap once to
max_completion_tokens instead of matching model name strings.
2026-08-18 17:27:02 +00:00

765 lines
30 KiB
PHP

<?php
declare(strict_types=1);
namespace OCA\EducAI\Tests\Unit\Service;
use OCA\EducAI\Db\Settings;
use OCA\EducAI\AppInfo\Application;
use OCA\EducAI\Service\LLMClient;
use OCA\EducAI\Service\SettingsService;
use OCP\Http\Client\IClient;
use OCP\Http\Client\IClientService;
use OCP\Http\Client\IResponse;
use OCP\IConfig;
use PHPUnit\Framework\TestCase;
use Psr\Log\LoggerInterface;
class LLMClientTest extends TestCase {
public function testListModelOptionsCombinesPrimaryAndSecondaryEndpoints(): void {
$settings = new Settings();
$settings->setApiProvider('custom');
$settings->setApiEndpoint('https://primary.example.invalid/v1/chat/completions');
$settings->setSecondaryApiEndpoint('https://secondary.example.invalid/v1/chat/completions');
$settings->setLlmModelsTimeout(25);
$settingsService = $this->createMock(SettingsService::class);
$settingsService->method('getSettings')->willReturn($settings);
$settingsService->method('getApiKey')->willReturn('primary-key');
$settingsService->method('getSecondaryApiKey')->willReturn('secondary-key');
$settingsService->method('normalizePositiveInteger')
->willReturnCallback(static fn (?int $value, int $fallback): int => $value !== null && $value > 0 ? $value : $fallback);
$primaryResponse = $this->jsonResponse([
'data' => [
['id' => 'model-a'],
['id' => 'model-b'],
],
]);
$secondaryResponse = $this->jsonResponse([
'models' => [
['id' => 'model-a'],
'model-c',
],
]);
$client = $this->createMock(IClient::class);
$client->expects($this->exactly(2))
->method('get')
->willReturnCallback(function (string $uri, array $options) use ($primaryResponse, $secondaryResponse): IResponse {
$this->assertSame(25, $options['timeout']);
if ($uri === 'https://primary.example.invalid/v1/models') {
$this->assertSame('Bearer primary-key', $options['headers']['Authorization']);
return $primaryResponse;
}
if ($uri === 'https://secondary.example.invalid/v1/models') {
$this->assertSame('Bearer secondary-key', $options['headers']['Authorization']);
return $secondaryResponse;
}
$this->fail('Unexpected model endpoint: ' . $uri);
});
$llmClient = new LLMClient(
$this->clientService($client),
$settingsService,
$this->logger()
);
$options = $llmClient->listModelOptions();
$this->assertSame([
'primary:model-a',
'primary:model-b',
'secondary:model-a',
'secondary:model-c',
], array_column($options, 'id'));
$this->assertSame('Secondary · model-a', $options[2]['label']);
}
public function testSendChatCompletionRetriesFallbackOnTimeout(): void {
$settings = new Settings();
$settings->setApiProvider('custom');
$settings->setApiEndpoint('https://primary.example.invalid/v1/chat/completions');
$settings->setSecondaryApiEndpoint('https://secondary.example.invalid/v1/chat/completions');
$settings->setDefaultModel('primary:model-a');
$settings->setFallbackModel('secondary:model-b');
$settings->setLlmChatTimeout(90);
$settingsService = $this->createMock(SettingsService::class);
$settingsService->method('getSettings')->willReturn($settings);
$settingsService->method('getApiKey')->willReturn('primary-key');
$settingsService->method('getSecondaryApiKey')->willReturn('secondary-key');
$settingsService->method('normalizePositiveInteger')
->willReturnCallback(static fn (?int $value, int $fallback): int => $value !== null && $value > 0 ? $value : $fallback);
$fallbackResponse = $this->jsonResponse([
'model' => 'model-b',
'choices' => [
[
'message' => ['content' => 'fallback answer'],
'finish_reason' => 'stop',
],
],
]);
$calls = [];
$client = $this->createMock(IClient::class);
$client->expects($this->exactly(2))
->method('post')
->willReturnCallback(function (string $uri, array $options) use (&$calls, $fallbackResponse): IResponse {
$calls[] = [$uri, $options['json']['model'] ?? null, $options['headers']['Authorization'] ?? null];
if (count($calls) === 1) {
throw new \Exception('cURL error 28: Operation timed out');
}
return $fallbackResponse;
});
$llmClient = new LLMClient(
$this->clientService($client),
$settingsService,
$this->logger()
);
$result = $llmClient->sendChatCompletion('system', [['role' => 'user', 'content' => 'hi']]);
$this->assertSame('fallback answer', $result['content']);
$this->assertSame('secondary:model-b', $result['model_reference']);
$this->assertSame([
['https://primary.example.invalid/v1/chat/completions', 'model-a', 'Bearer primary-key'],
['https://secondary.example.invalid/v1/chat/completions', 'model-b', 'Bearer secondary-key'],
], $calls);
}
public function testUnprefixedModelRoutesToSecondaryWhenOnlySecondaryHasModelInCache(): void {
$settings = new Settings();
$settings->setApiProvider('custom');
$settings->setApiEndpoint('https://primary.example.invalid/v1/chat/completions');
$settings->setSecondaryApiEndpoint('https://secondary.example.invalid/v1/chat/completions');
$settings->setLlmChatTimeout(90);
$settingsService = $this->createMock(SettingsService::class);
$settingsService->method('getSettings')->willReturn($settings);
$settingsService->method('getApiKey')->willReturn('primary-key');
$settingsService->method('getSecondaryApiKey')->willReturn('secondary-key');
$settingsService->method('normalizePositiveInteger')
->willReturnCallback(static fn (?int $value, int $fallback): int => $value !== null && $value > 0 ? $value : $fallback);
$client = $this->createMock(IClient::class);
$client->expects($this->never())->method('get');
$client->expects($this->once())
->method('post')
->with(
'https://secondary.example.invalid/v1/chat/completions',
$this->callback(function (array $options): bool {
$this->assertSame('model-c', $options['json']['model'] ?? null);
$this->assertSame('Bearer secondary-key', $options['headers']['Authorization'] ?? null);
return true;
})
)
->willReturn($this->jsonResponse([
'model' => 'model-c',
'choices' => [
['message' => ['content' => 'secondary answer']],
],
]));
$llmClient = new LLMClient(
$this->clientService($client),
$settingsService,
$this->logger(),
$this->cachedModelOptionsConfig($settings, [
['id' => 'primary:model-a', 'label' => 'Primary · model-a', 'model' => 'model-a', 'endpoint' => 'primary'],
['id' => 'secondary:model-c', 'label' => 'Secondary · model-c', 'model' => 'model-c', 'endpoint' => 'secondary'],
])
);
$result = $llmClient->sendChatCompletion('system', [['role' => 'user', 'content' => 'hi']], 'model-c');
$this->assertSame('secondary answer', $result['content']);
$this->assertSame('secondary:model-c', $result['model_reference']);
}
public function testUnprefixedModelPrefersPrimaryWhenBothEndpointsHaveModel(): void {
$settings = new Settings();
$settings->setApiProvider('custom');
$settings->setApiEndpoint('https://primary.example.invalid/v1/chat/completions');
$settings->setSecondaryApiEndpoint('https://secondary.example.invalid/v1/chat/completions');
$settings->setLlmChatTimeout(90);
$settingsService = $this->createMock(SettingsService::class);
$settingsService->method('getSettings')->willReturn($settings);
$settingsService->method('getApiKey')->willReturn('primary-key');
$settingsService->method('getSecondaryApiKey')->willReturn('secondary-key');
$settingsService->method('normalizePositiveInteger')
->willReturnCallback(static fn (?int $value, int $fallback): int => $value !== null && $value > 0 ? $value : $fallback);
$client = $this->createMock(IClient::class);
$client->expects($this->never())->method('get');
$client->expects($this->once())
->method('post')
->with(
'https://primary.example.invalid/v1/chat/completions',
$this->callback(function (array $options): bool {
$this->assertSame('model-a', $options['json']['model'] ?? null);
$this->assertSame('Bearer primary-key', $options['headers']['Authorization'] ?? null);
return true;
})
)
->willReturn($this->jsonResponse([
'model' => 'model-a',
'choices' => [
['message' => ['content' => 'primary answer']],
],
]));
$llmClient = new LLMClient(
$this->clientService($client),
$settingsService,
$this->logger(),
$this->cachedModelOptionsConfig($settings, [
['id' => 'primary:model-a', 'label' => 'Primary · model-a', 'model' => 'model-a', 'endpoint' => 'primary'],
['id' => 'secondary:model-a', 'label' => 'Secondary · model-a', 'model' => 'model-a', 'endpoint' => 'secondary'],
])
);
$result = $llmClient->sendChatCompletion('system', [['role' => 'user', 'content' => 'hi']], 'model-a');
$this->assertSame('primary answer', $result['content']);
$this->assertSame('primary:model-a', $result['model_reference']);
}
public function testSendChatCompletionDoesNotFallbackOnFourHundredErrors(): void {
$settings = new Settings();
$settings->setApiProvider('custom');
$settings->setApiEndpoint('https://primary.example.invalid/v1/chat/completions');
$settings->setSecondaryApiEndpoint('https://secondary.example.invalid/v1/chat/completions');
$settings->setDefaultModel('primary:model-a');
$settings->setFallbackModel('secondary:model-b');
$settingsService = $this->createMock(SettingsService::class);
$settingsService->method('getSettings')->willReturn($settings);
$settingsService->method('getApiKey')->willReturn('primary-key');
$settingsService->method('getSecondaryApiKey')->willReturn('secondary-key');
$settingsService->method('normalizePositiveInteger')
->willReturnCallback(static fn (?int $value, int $fallback): int => $value !== null && $value > 0 ? $value : $fallback);
$client = $this->createMock(IClient::class);
$client->expects($this->exactly(2))
->method('post')
->willThrowException(new \Exception('Bad Request', 400));
$llmClient = new LLMClient(
$this->clientService($client),
$settingsService,
$this->logger()
);
try {
$llmClient->sendChatCompletion('system', [['role' => 'user', 'content' => 'hi']]);
$this->fail('Expected LLM request failure');
} catch (\Exception $e) {
$this->assertSame('Failed to get response from AI', $e->getMessage());
$this->assertSame('Bad Request', $e->getPrevious()?->getMessage());
}
}
public function testSendChatCompletionPublicExceptionDoesNotExposeEndpointUrl(): void {
$settings = new Settings();
$settings->setApiProvider('custom');
$settings->setApiEndpoint('https://secret.example.invalid/v1/chat/completions');
$settings->setDefaultModel('primary:model-a');
$settingsService = $this->createMock(SettingsService::class);
$settingsService->method('getSettings')->willReturn($settings);
$settingsService->method('getApiKey')->willReturn('primary-key');
$settingsService->method('normalizePositiveInteger')
->willReturnCallback(static fn (?int $value, int $fallback): int => $value !== null && $value > 0 ? $value : $fallback);
$client = $this->createMock(IClient::class);
$client->expects($this->once())
->method('post')
->willThrowException(new \Exception('cURL error 7: Failed to connect to https://secret.example.invalid/v1/chat/completions'));
$llmClient = new LLMClient(
$this->clientService($client),
$settingsService,
$this->logger()
);
try {
$llmClient->sendChatCompletion('system', [['role' => 'user', 'content' => 'hi']]);
$this->fail('Expected LLM request failure');
} catch (\Exception $e) {
$this->assertSame('Failed to get response from AI', $e->getMessage());
$this->assertStringNotContainsString('https://secret.example.invalid', $e->getMessage());
$this->assertStringContainsString('https://secret.example.invalid', $e->getPrevious()?->getMessage() ?? '');
}
}
public function testSendChatCompletionSanitizesInvalidUtf8BeforeJsonRequest(): void {
$settings = new Settings();
$settings->setApiProvider('custom');
$settings->setApiEndpoint('https://primary.example.invalid/v1/chat/completions');
$settings->setDefaultModel('primary:model-a');
$settings->setLlmChatTimeout(90);
$settingsService = $this->createMock(SettingsService::class);
$settingsService->method('getSettings')->willReturn($settings);
$settingsService->method('getApiKey')->willReturn('primary-key');
$settingsService->method('normalizePositiveInteger')
->willReturnCallback(static fn (?int $value, int $fallback): int => $value !== null && $value > 0 ? $value : $fallback);
$invalidUtf8 = substr(str_repeat('a', 3999) . '💡', 0, 4000);
$this->assertFalse(mb_check_encoding($invalidUtf8, 'UTF-8'));
$response = $this->jsonResponse([
'model' => 'model-a',
'choices' => [
['message' => ['content' => 'sanitized answer']],
],
]);
$client = $this->createMock(IClient::class);
$client->expects($this->once())
->method('post')
->willReturnCallback(function (string $uri, array $options) use ($response): IResponse {
$this->assertSame('https://primary.example.invalid/v1/chat/completions', $uri);
$this->assertNotFalse(json_encode($options['json']));
$this->assertTrue(mb_check_encoding($options['json']['messages'][1]['content'] ?? '', 'UTF-8'));
$this->assertTrue(mb_check_encoding($options['json']['tools'][0]['function']['description'] ?? '', 'UTF-8'));
return $response;
});
$llmClient = new LLMClient(
$this->clientService($client),
$settingsService,
$this->logger()
);
$result = $llmClient->sendChatCompletion(
'system',
[['role' => 'user', 'content' => $invalidUtf8]],
null,
[
'tools' => [[
'type' => 'function',
'function' => [
'name' => 'search_test',
'description' => $invalidUtf8,
'parameters' => ['type' => 'object'],
],
]],
]
);
$this->assertSame('sanitized answer', $result['content']);
}
public function testStreamChatCompletionRequestsAndReturnsUsage(): void {
$settings = new Settings();
$settings->setApiProvider('custom');
$settings->setApiEndpoint('https://primary.example.invalid/v1/chat/completions');
$settings->setDefaultModel('primary:model-a');
$settings->setLlmStreamTimeout(240);
$settingsService = $this->createMock(SettingsService::class);
$settingsService->method('getSettings')->willReturn($settings);
$settingsService->method('getApiKey')->willReturn('primary-key');
$settingsService->method('normalizePositiveInteger')
->willReturnCallback(static fn (?int $value, int $fallback): int => $value !== null && $value > 0 ? $value : $fallback);
$client = $this->createMock(IClient::class);
$client->expects($this->once())
->method('post')
->with(
'https://primary.example.invalid/v1/chat/completions',
$this->callback(function (array $options): bool {
$this->assertTrue($options['json']['stream'] ?? false);
$this->assertSame(['include_usage' => true], $options['json']['stream_options'] ?? null);
return true;
})
)
->willReturn($this->rawResponse(
"data: {\"choices\":[{\"delta\":{\"content\":\"hel\"}}]}\n\n"
. "data: {\"choices\":[{\"delta\":{\"content\":\"lo\"}}]}\n\n"
. "data: {\"choices\":[],\"usage\":{\"prompt_tokens\":4,\"completion_tokens\":2,\"total_tokens\":6}}\n\n"
. "data: [DONE]\n\n"
));
$llmClient = new LLMClient(
$this->clientService($client),
$settingsService,
$this->logger()
);
$result = $llmClient->streamChatCompletion('system', [['role' => 'user', 'content' => 'hi']], static function (): void {});
$this->assertSame('hello', $result['content']);
$this->assertSame(['prompt_tokens' => 4, 'completion_tokens' => 2, 'total_tokens' => 6], $result['usage']);
}
public function testSendChatCompletionKeepsTemperatureForOtherModels(): void {
$settings = new Settings();
$settings->setApiProvider('custom');
$settings->setApiEndpoint('https://primary.example.invalid/v1/chat/completions');
$settings->setDefaultModel('primary:up/minimax-m2-5');
$settings->setLlmChatTimeout(90);
$settingsService = $this->createMock(SettingsService::class);
$settingsService->method('getSettings')->willReturn($settings);
$settingsService->method('getApiKey')->willReturn('primary-key');
$settingsService->method('normalizePositiveInteger')
->willReturnCallback(static fn (?int $value, int $fallback): int => $value !== null && $value > 0 ? $value : $fallback);
$client = $this->createMock(IClient::class);
$client->expects($this->once())
->method('post')
->with(
'https://primary.example.invalid/v1/chat/completions',
$this->callback(function (array $options): bool {
$this->assertSame('up/minimax-m2-5', $options['json']['model'] ?? null);
$this->assertSame(0.2, $options['json']['temperature'] ?? null);
$this->assertSame(1000, $options['json']['max_tokens'] ?? null);
$this->assertArrayNotHasKey('max_completion_tokens', $options['json']);
return true;
})
)
->willReturn($this->jsonResponse([
'model' => 'up/minimax-m2-5',
'choices' => [
['message' => ['content' => 'ok']],
],
]));
$llmClient = new LLMClient(
$this->clientService($client),
$settingsService,
$this->logger()
);
$result = $llmClient->sendChatCompletion(
'system',
[['role' => 'user', 'content' => 'hi']],
null,
['temperature' => 0.2]
);
$this->assertSame('ok', $result['content']);
}
public function testSendChatCompletionRetriesWithReasoningParametersWhenClassicRejected(): void {
$settings = new Settings();
$settings->setApiProvider('custom');
$settings->setApiEndpoint('https://primary.example.invalid/v1/chat/completions');
$settings->setDefaultModel('primary:model-a');
$settings->setLlmChatTimeout(90);
$settingsService = $this->createMock(SettingsService::class);
$settingsService->method('getSettings')->willReturn($settings);
$settingsService->method('getApiKey')->willReturn('primary-key');
$settingsService->method('normalizePositiveInteger')
->willReturnCallback(static fn (?int $value, int $fallback): int => $value !== null && $value > 0 ? $value : $fallback);
$calls = [];
$client = $this->createMock(IClient::class);
$client->expects($this->exactly(2))
->method('post')
->willReturnCallback(function (string $uri, array $options) use (&$calls): IResponse {
$this->assertSame('https://primary.example.invalid/v1/chat/completions', $uri);
$calls[] = $options['json'];
if (count($calls) === 1) {
return $this->jsonResponse(['error' => 'unsupported parameter'], 400);
}
return $this->jsonResponse([
'model' => 'model-a',
'choices' => [
['message' => ['content' => 'ok']],
],
]);
});
$llmClient = new LLMClient(
$this->clientService($client),
$settingsService,
$this->logger()
);
$result = $llmClient->sendChatCompletion(
'system',
[['role' => 'user', 'content' => 'hi']],
null,
['temperature' => 0.2, 'max_tokens' => 800]
);
$this->assertSame('ok', $result['content']);
$this->assertSame(0.2, $calls[0]['temperature'] ?? null);
$this->assertSame(800, $calls[0]['max_tokens'] ?? null);
$this->assertArrayNotHasKey('max_completion_tokens', $calls[0]);
$this->assertSame(800, $calls[1]['max_completion_tokens'] ?? null);
$this->assertArrayNotHasKey('max_tokens', $calls[1]);
$this->assertArrayNotHasKey('temperature', $calls[1]);
}
public function testStreamChatCompletionRetriesWithReasoningParametersWhenClassicRejected(): void {
$settings = new Settings();
$settings->setApiProvider('custom');
$settings->setApiEndpoint('https://primary.example.invalid/v1/chat/completions');
$settings->setDefaultModel('primary:model-a');
$settings->setLlmStreamTimeout(240);
$settingsService = $this->createMock(SettingsService::class);
$settingsService->method('getSettings')->willReturn($settings);
$settingsService->method('getApiKey')->willReturn('primary-key');
$settingsService->method('normalizePositiveInteger')
->willReturnCallback(static fn (?int $value, int $fallback): int => $value !== null && $value > 0 ? $value : $fallback);
$calls = [];
$client = $this->createMock(IClient::class);
$client->expects($this->exactly(2))
->method('post')
->willReturnCallback(function (string $uri, array $options) use (&$calls): IResponse {
$this->assertSame('https://primary.example.invalid/v1/chat/completions', $uri);
$calls[] = $options['json'];
if (count($calls) === 1) {
return $this->rawResponse('{"error":"unsupported parameter"}', 400);
}
return $this->rawResponse("data: {\"choices\":[{\"delta\":{\"content\":\"ok\"}}]}\n\ndata: [DONE]\n\n");
});
$llmClient = new LLMClient(
$this->clientService($client),
$settingsService,
$this->logger()
);
// Caller-supplied stream_options makes the usage-option retry ineligible,
// so this isolates the reasoning-parameter remap as the second POST.
$result = $llmClient->streamChatCompletion(
'system',
[['role' => 'user', 'content' => 'hi']],
static function (): void {},
null,
[
'temperature' => 0.2,
'max_tokens' => 800,
'stream_options' => ['include_usage' => true],
]
);
$this->assertSame('ok', $result['content']);
$this->assertSame(0.2, $calls[0]['temperature'] ?? null);
$this->assertSame(800, $calls[0]['max_tokens'] ?? null);
$this->assertArrayNotHasKey('max_completion_tokens', $calls[0]);
$this->assertSame(800, $calls[1]['max_completion_tokens'] ?? null);
$this->assertArrayNotHasKey('max_tokens', $calls[1]);
$this->assertArrayNotHasKey('temperature', $calls[1]);
}
public function testStreamChatCompletionRetriesWithoutUsageOptionsWhenRejected(): void {
$settings = new Settings();
$settings->setApiProvider('custom');
$settings->setApiEndpoint('https://primary.example.invalid/v1/chat/completions');
$settings->setDefaultModel('primary:model-a');
$settings->setLlmStreamTimeout(240);
$settingsService = $this->createMock(SettingsService::class);
$settingsService->method('getSettings')->willReturn($settings);
$settingsService->method('getApiKey')->willReturn('primary-key');
$settingsService->method('normalizePositiveInteger')
->willReturnCallback(static fn (?int $value, int $fallback): int => $value !== null && $value > 0 ? $value : $fallback);
$calls = [];
$client = $this->createMock(IClient::class);
$client->expects($this->exactly(2))
->method('post')
->willReturnCallback(function (string $uri, array $options) use (&$calls): IResponse {
$this->assertSame('https://primary.example.invalid/v1/chat/completions', $uri);
$calls[] = $options['json'];
if (count($calls) === 1) {
return $this->rawResponse('{"error":"unknown stream_options"}', 400);
}
return $this->rawResponse("data: {\"choices\":[{\"delta\":{\"content\":\"fallback\"}}]}\n\ndata: [DONE]\n\n");
});
$llmClient = new LLMClient(
$this->clientService($client),
$settingsService,
$this->logger()
);
$result = $llmClient->streamChatCompletion('system', [['role' => 'user', 'content' => 'hi']], static function (): void {});
$this->assertSame('fallback', $result['content']);
$this->assertSame(['include_usage' => true], $calls[0]['stream_options'] ?? null);
$this->assertArrayNotHasKey('stream_options', $calls[1]);
$this->assertSame(0.7, $calls[0]['temperature'] ?? null);
$this->assertSame(1000, $calls[0]['max_tokens'] ?? null);
$this->assertArrayNotHasKey('max_completion_tokens', $calls[0]);
$this->assertSame(0.7, $calls[1]['temperature'] ?? null);
$this->assertSame(1000, $calls[1]['max_tokens'] ?? null);
$this->assertArrayNotHasKey('max_completion_tokens', $calls[1]);
}
public function testStreamChatCompletionRetriesUsageThenReasoningParametersWhenBothRejected(): void {
$settings = new Settings();
$settings->setApiProvider('custom');
$settings->setApiEndpoint('https://primary.example.invalid/v1/chat/completions');
$settings->setDefaultModel('primary:model-a');
$settings->setLlmStreamTimeout(240);
$settingsService = $this->createMock(SettingsService::class);
$settingsService->method('getSettings')->willReturn($settings);
$settingsService->method('getApiKey')->willReturn('primary-key');
$settingsService->method('normalizePositiveInteger')
->willReturnCallback(static fn (?int $value, int $fallback): int => $value !== null && $value > 0 ? $value : $fallback);
$calls = [];
$client = $this->createMock(IClient::class);
$client->expects($this->exactly(3))
->method('post')
->willReturnCallback(function (string $uri, array $options) use (&$calls): IResponse {
$this->assertSame('https://primary.example.invalid/v1/chat/completions', $uri);
$calls[] = $options['json'];
if (count($calls) < 3) {
return $this->rawResponse('{"error":"unsupported parameter"}', 400);
}
return $this->rawResponse("data: {\"choices\":[{\"delta\":{\"content\":\"ok\"}}]}\n\ndata: [DONE]\n\n");
});
$llmClient = new LLMClient(
$this->clientService($client),
$settingsService,
$this->logger()
);
$result = $llmClient->streamChatCompletion(
'system',
[['role' => 'user', 'content' => 'hi']],
static function (): void {},
null,
['temperature' => 0.2, 'max_tokens' => 800]
);
$this->assertSame('ok', $result['content']);
$this->assertSame(['include_usage' => true], $calls[0]['stream_options'] ?? null);
$this->assertSame(0.2, $calls[0]['temperature'] ?? null);
$this->assertSame(800, $calls[0]['max_tokens'] ?? null);
$this->assertArrayNotHasKey('max_completion_tokens', $calls[0]);
$this->assertArrayNotHasKey('stream_options', $calls[1]);
$this->assertSame(0.2, $calls[1]['temperature'] ?? null);
$this->assertSame(800, $calls[1]['max_tokens'] ?? null);
$this->assertArrayNotHasKey('max_completion_tokens', $calls[1]);
$this->assertArrayNotHasKey('stream_options', $calls[2]);
$this->assertSame(800, $calls[2]['max_completion_tokens'] ?? null);
$this->assertArrayNotHasKey('max_tokens', $calls[2]);
$this->assertArrayNotHasKey('temperature', $calls[2]);
}
public function testStreamChatCompletionRetriesFallbackOnServerErrorBeforeFirstChunk(): void {
$settings = new Settings();
$settings->setApiProvider('custom');
$settings->setApiEndpoint('https://primary.example.invalid/v1/chat/completions');
$settings->setSecondaryApiEndpoint('https://secondary.example.invalid/v1/chat/completions');
$settings->setDefaultModel('primary:model-a');
$settings->setFallbackModel('secondary:model-b');
$settings->setLlmStreamTimeout(240);
$settingsService = $this->createMock(SettingsService::class);
$settingsService->method('getSettings')->willReturn($settings);
$settingsService->method('getApiKey')->willReturn('primary-key');
$settingsService->method('getSecondaryApiKey')->willReturn('secondary-key');
$settingsService->method('normalizePositiveInteger')
->willReturnCallback(static fn (?int $value, int $fallback): int => $value !== null && $value > 0 ? $value : $fallback);
$calls = [];
$client = $this->createMock(IClient::class);
$client->expects($this->exactly(2))
->method('post')
->willReturnCallback(function (string $uri, array $options) use (&$calls): IResponse {
$calls[] = [$uri, $options['json']['model'] ?? null, $options['headers']['Authorization'] ?? null];
if (count($calls) === 1) {
return $this->rawResponse('{"error":"temporary provider failure"}', 500);
}
return $this->rawResponse("data: {\"choices\":[{\"delta\":{\"content\":\"fallback answer\"}}]}\n\ndata: [DONE]\n\n");
});
$llmClient = new LLMClient(
$this->clientService($client),
$settingsService,
$this->logger()
);
$result = $llmClient->streamChatCompletion('system', [['role' => 'user', 'content' => 'hi']], static function (): void {});
$this->assertSame('fallback answer', $result['content']);
$this->assertSame('secondary:model-b', $result['model_reference']);
$this->assertSame([
['https://primary.example.invalid/v1/chat/completions', 'model-a', 'Bearer primary-key'],
['https://secondary.example.invalid/v1/chat/completions', 'model-b', 'Bearer secondary-key'],
], $calls);
}
private function jsonResponse(array $body, int $status = 200): IResponse {
$response = $this->createMock(IResponse::class);
$response->method('getBody')->willReturn(json_encode($body) ?: '');
$response->method('getHeader')->willReturn('');
$response->method('getStatusCode')->willReturn($status);
return $response;
}
private function rawResponse(string $body, int $status = 200): IResponse {
$response = $this->createMock(IResponse::class);
$response->method('getBody')->willReturn($body);
$response->method('getHeader')->willReturn('');
$response->method('getStatusCode')->willReturn($status);
return $response;
}
/**
* @param array<int,array{id:string,label:string,model:string,endpoint:string}> $options
*/
private function cachedModelOptionsConfig(Settings $settings, array $options): IConfig {
$fingerprint = sha1(implode('|', [
trim((string)$settings->getApiProvider()),
rtrim(trim((string)$settings->getApiEndpoint()), '/'),
rtrim(trim((string)$settings->getSecondaryApiEndpoint()), '/'),
]));
$config = $this->createMock(IConfig::class);
$config->expects($this->once())
->method('getAppValue')
->with(Application::APP_ID, 'llm_model_options_cache', '')
->willReturn(json_encode([
'fingerprint' => $fingerprint,
'expires_at' => time() + 60,
'options' => $options,
]) ?: '');
$config->expects($this->never())->method('setAppValue');
return $config;
}
private function clientService(IClient $client): IClientService {
$clientService = $this->createMock(IClientService::class);
$clientService->method('newClient')->willReturn($client);
return $clientService;
}
private function logger(): LoggerInterface {
return new class implements LoggerInterface {
public function emergency($message, array $context = []): void {}
public function alert($message, array $context = []): void {}
public function critical($message, array $context = []): void {}
public function error($message, array $context = []): void {}
public function warning($message, array $context = []): void {}
public function notice($message, array $context = []): void {}
public function info($message, array $context = []): void {}
public function debug($message, array $context = []): void {}
public function log($level, $message, array $context = []): void {}
};
}
}