Skip to content
Open
6 changes: 3 additions & 3 deletions lib/Controller/ServiceController.php
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@
*/
class ServiceController extends Controller {
/** Properties that may only be changed with a confirmed password */
private const SENSITIVE_PROPERTIES = ['url', 'api_key', 'basic_user', 'basic_password'];
private const SENSITIVE_PROPERTIES = ['url', 'api_key', 'basic_user', 'basic_password', 'extra_headers'];

public function __construct(
string $appName,
Expand Down Expand Up @@ -59,7 +59,7 @@ public function create(): DataResponse {
/**
* Update the given properties of a service
*
* The URL and the credentials can only be set through
* The URL, the credentials and the extra headers can only be set through
* {@see self::updateSensitive()}.
*
* @param string $id ID of the service
Expand All @@ -76,7 +76,7 @@ public function update(string $id, array $values): DataResponse {
}

/**
* Update the URL and the credentials of a service
* Update the URL, the credentials and the extra headers of a service
*
* Secrets that are sent back unchanged (as the placeholder the frontend
* received) are kept.
Expand Down
62 changes: 59 additions & 3 deletions lib/Service/OpenAiAPIService.php
Original file line number Diff line number Diff line change
Expand Up @@ -451,6 +451,7 @@ public function createStreamedChatCompletion(
?string $toolMessage = null,
?array $tools = null,
?array $files = null,
?string $conversationId = null,
): \Generator {
if ($this->isQuotaExceeded($userId, Application::QUOTA_TYPE_TEXT, $service)) {
throw new Exception($this->l10n->t('Text generation quota exceeded'), Http::STATUS_TOO_MANY_REQUESTS);
Expand Down Expand Up @@ -482,6 +483,7 @@ public function createStreamedChatCompletion(
true,
0,
true,
$this->resolveConversationId($conversationId),
);

$streamResult = yield from $this->streamingService->parseStreamChatResponse($response);
Expand Down Expand Up @@ -511,11 +513,12 @@ public function createChatCompletion(
?string $toolMessage = null,
?array $tools = null,
?array $files = null,
?string $conversationId = null,
): array {
$response = $this->requestChatCompletion(
$userId, $service, $model, $userPrompt, $systemPrompt, $history,
$n, $maxTokens, $extraParams, $toolMessage, $tools, $files,
false,
false, $conversationId,
);

if (isset($response['usage'], $response['usage']['total_tokens'])) {
Expand Down Expand Up @@ -560,6 +563,7 @@ public function requestChatCompletion(
?array $tools = null,
?array $files = null,
bool $stream = false,
?string $conversationId = null,
): array {
if ($this->isQuotaExceeded($userId, Application::QUOTA_TYPE_TEXT, $service)) {
throw new Exception($this->l10n->t('Text generation quota exceeded'), Http::STATUS_TOO_MANY_REQUESTS);
Expand All @@ -581,7 +585,7 @@ public function requestChatCompletion(
$stream,
);

return $this->request($userId, $service, 'chat/completions', $params, 'POST');
return $this->request($userId, $service, 'chat/completions', $params, 'POST', conversationId: $this->resolveConversationId($conversationId));
}

/**
Expand Down Expand Up @@ -1214,6 +1218,44 @@ private function requestLocalAiImageEdit(
return $this->request($userId, $service, 'images/generations', $params, 'POST');
}

/**
* The conversation ID a chat completion request is sent with.
*
* Chat completion requests always carry a conversation ID: tasks which do
* not belong to a conversation get a throwaway random one, regenerated per
* request so unrelated requests never share state on services which key
* conversations on that ID.
*/
private function resolveConversationId(?string $conversationId): string {
return $conversationId !== null && $conversationId !== ''
? $conversationId
: bin2hex(random_bytes(16));
}

/**
* Merge the extra headers configured on the service into the request
* options. They are applied before the authentication and content-type
* headers, so the service's own credentials always win over a configured
* Authorization header.
*
* Headers using the {$conversation_id} variable are dropped when the
* request has no conversation ID, see {@see ServiceConfig::expandHeaderValue()}.
*
* @param string|null $conversationId
* @param array<mixed> $options
* @return array<mixed>
*/
private function addExtraHeaders(ServiceConfig $service, array $options, ?string $conversationId = null): array {
foreach ($service->getExtraHeaders() as $header) {
$value = ServiceConfig::expandHeaderValue($header['value'], $conversationId);
if ($value === null) {
continue;
}
$options['headers'][$header['name']] = $value;
}
return $options;
}

/**
* @param string|null $userId
* @return array
Expand All @@ -1226,6 +1268,7 @@ public function getImageRequestOptions(?string $userId, ServiceConfig $service):
'User-Agent' => Application::USER_AGENT,
],
];
$requestOptions = $this->addExtraHeaders($service, $requestOptions);

if ($service->getImageRequestAuth()) {
if ($service->usesBasicAuth()) {
Expand Down Expand Up @@ -1360,6 +1403,7 @@ private function updateExpProcessingTime(ServiceConfig $service, string $key, in
* @param string|null $contentType
* @param bool $logErrors if set to false error logs will be suppressed
* @param int $retryCount number of retries that have been attempted so far
* @param string|null $conversationId the conversation ID of the request, used to expand the {$conversation_id} token of the extra headers
* @return array decoded request result or error
* @throws Exception|UserFacingProcessingException
*/
Expand All @@ -1368,6 +1412,7 @@ public function request(
?string $contentType = null, bool $logErrors = true,
int $retryCount = 0,
bool $stream = false,
?string $conversationId = null,
): array {
try {
// the user's own credentials take precedence over the admin ones
Expand All @@ -1385,6 +1430,7 @@ public function request(
'User-Agent' => Application::USER_AGENT,
],
];
$options = $this->addExtraHeaders($service, $options, $conversationId);

if ($serviceUrl === Application::OPENAI_API_BASE_URL && $apiKey === '') {
return ['error' => 'An API key is required for api.openai.com'];
Expand Down Expand Up @@ -1517,7 +1563,7 @@ public function request(
}
$this->logger->warning("Rate limit exceeded, retrying in $sleep seconds", ['retry_count' => $retryCount]);
sleep($sleep);
return $this->request($userId, $service, $endPoint, $params, $method, $contentType, $logErrors, $retryCount + 1, $stream);
return $this->request($userId, $service, $endPoint, $params, $method, $contentType, $logErrors, $retryCount + 1, $stream, $conversationId);
} else {
$this->logger->warning('Rate limit exceeded, maximum retries reached', ['retry_count' => $retryCount]);
}
Expand Down Expand Up @@ -1567,6 +1613,16 @@ public function request(
intval($e->getCode()),
userFacingMessage: $this->l10n->t('%s API error: AI backend is currently not reachable. Contact your system administrator.', [$service->getDisplayName()]),
);
} catch (\InvalidArgumentException $e) {
// the HTTP client validates the headers while building the request and
// its message quotes the rejected value, which may hold credentials,
// so it only goes to the log and not into the error
$this->logger->warning('API request rejected with invalid headers on service ' . $service->getId(), ['exception' => $e]);
throw new Exception(
$this->l10n->t('The request headers configured for this service are invalid'),
Http::STATUS_BAD_REQUEST,
$e,
);
}
}

Expand Down
77 changes: 77 additions & 0 deletions lib/Service/ServiceConfig.php
Original file line number Diff line number Diff line change
Expand Up @@ -56,18 +56,32 @@ class ServiceConfig implements JsonSerializable {
'stt_models' => 'array',
'tts_models' => 'array',
'quotas' => 'array',
'extra_headers' => 'array',
];

/** Properties that are stored encrypted and never sent to the frontend */
public const SECRET_PROPERTIES = ['api_key', 'basic_password'];

/** Characters an HTTP header name is made of (the RFC 7230 token, as checked by Guzzle) */
public const HEADER_NAME_PATTERN = '/^[a-zA-Z0-9!#$%&\'*+.^_`|~-]+$/D';

/** Characters an HTTP header value may be made of: HTAB, printable and ISO-8859-1 bytes (the RFC 9110 field-value, as accepted by Guzzle) */
public const HEADER_VALUE_PATTERN = '/^[\x09\x20-\x7E\x80-\xFF]*$/D';

/** The extra header value variable holding the conversation ID of a chat completion request */
public const CONVERSATION_ID_VARIABLE = '{$conversation_id}';

/** The variables extra header values may use */
public const SUPPORTED_HEADER_VARIABLES = [self::CONVERSATION_ID_VARIABLE];

/**
* @param array<int, int> $quotas
* @param list<string> $ttsVoices
* @param list<string> $textModels
* @param list<string> $imageModels
* @param list<string> $sttModels
* @param list<string> $ttsModels
* @param list<array{name: string, value: string}> $extraHeaders
*/
public function __construct(
private string $id,
Expand Down Expand Up @@ -104,6 +118,7 @@ public function __construct(
private array $sttModels = [],
private array $ttsModels = [],
private array $quotas = Application::DEFAULT_QUOTAS,
private array $extraHeaders = [],
) {
}

Expand Down Expand Up @@ -186,6 +201,8 @@ public function with(array $values): self {
break;
case 'quotas': $new->quotas = self::normalizeQuotas($value, $new->quotas);
break;
case 'extra_headers': $new->extraHeaders = self::normalizeHeaders($value);
break;
}
}
return $new;
Expand Down Expand Up @@ -226,6 +243,32 @@ private static function normalizeQuotas(mixed $quotas, array $base): array {
return $normalized;
}

/**
* Keep the name/value pairs that have a name, trim the rest. A row
* without a name is one the admin has not filled in yet, and sending it
* along in requests would only be confusing.
*
* @param mixed $headers
* @return list<array{name: string, value: string}>
*/
private static function normalizeHeaders(mixed $headers): array {
if (!is_array($headers)) {
return [];
}
$normalized = [];
foreach ($headers as $header) {
if (!is_array($header)) {
continue;
}
$name = trim((string)($header['name'] ?? ''));
if ($name === '') {
continue;
}
$normalized[] = ['name' => $name, 'value' => trim((string)($header['value'] ?? ''))];
}
return $normalized;
}

public function getId(): string {
return $this->id;
}
Expand Down Expand Up @@ -456,6 +499,39 @@ public function getQuota(int $quotaType): int {
return $this->quotas[$quotaType] ?? 0;
}

/**
* The extra headers to send with every request to this service
*
* @return list<array{name: string, value: string}>
*/
public function getExtraHeaders(): array {
return $this->extraHeaders;
}

/**
* Replace the {$conversation_id} variable of an extra header value with
* the ID of the conversation of the request being sent.
*
* A value using the variable of a request that has no conversation is not
* meant to be sent at all: chat completion requests always have a
* conversation ID (the one of their task, or a generated one), so the
* header is dropped for the other requests, which is signalled by
* returning null. Anything else in the value, including text that merely
* looks like a variable, travels literally.
*/
public static function expandHeaderValue(string $value, ?string $conversationId): ?string {
if (!str_contains($value, self::CONVERSATION_ID_VARIABLE)) {
return $value;
}
if ($conversationId === null || $conversationId === '') {
return null;
}
$expanded = str_replace(self::CONVERSATION_ID_VARIABLE, $conversationId, $value);
// the ID comes from the task input of another app, so what it expands to
// must stay within the characters an HTTP client is willing to send
return preg_match(self::HEADER_VALUE_PATTERN, $expanded) === 1 ? $expanded : null;
}

/**
* Full representation, including secrets, as stored in app config
*
Expand Down Expand Up @@ -494,6 +570,7 @@ public function jsonSerialize(): array {
'stt_models' => $this->sttModels,
'tts_models' => $this->ttsModels,
'quotas' => $this->quotas,
'extra_headers' => $this->extraHeaders,
];
}

Expand Down
25 changes: 25 additions & 0 deletions lib/Service/ServicesService.php
Original file line number Diff line number Diff line change
Expand Up @@ -429,6 +429,31 @@ private function validate(array $values): void {
&& preg_match('/^\d+x\d+$/', (string)$values['default_image_size']) !== 1) {
throw new Exception('Invalid image size value. Expected the format <width>x<height>');
}
if (isset($values['extra_headers'])) {
foreach ($values['extra_headers'] as $header) {
if (!is_array($header) || !isset($header['name'], $header['value'])
|| !is_string($header['name']) || !is_string($header['value'])) {
throw new Exception('Invalid extra header. Expected a name and a value');
}
$name = trim($header['name']);
if ($name === '') {
throw new Exception('Invalid extra header name. It cannot be empty');
}
if (preg_match(ServiceConfig::HEADER_NAME_PATTERN, $name) !== 1) {
throw new Exception('Invalid extra header name. Only the characters allowed in an HTTP header name are accepted: ' . $name);
}
if (preg_match(ServiceConfig::HEADER_VALUE_PATTERN, $header['value']) !== 1) {
throw new Exception('Invalid extra header value. Only the characters allowed in an HTTP header value are accepted: ' . $name);
}
if (preg_match_all('/\{\$[^}]*\}/', $header['value'], $variables) > 0) {
foreach ($variables[0] as $variable) {
if (!in_array($variable, ServiceConfig::SUPPORTED_HEADER_VARIABLES, true)) {
throw new Exception('Unknown variable in an extra header value: ' . $variable . '. Supported variables: ' . implode(', ', ServiceConfig::SUPPORTED_HEADER_VARIABLES));
}
}
}
}
}
}

/**
Expand Down
4 changes: 3 additions & 1 deletion lib/Service/TranslateService.php
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ public static function getCoreLanguagesByCode(): array {
public function translate(
ServiceConfig $service, string $inputText, string $sourceLanguageCode, string $targetLanguageCode, string $model, ?int $maxTokens,
?string $userId, ?callable $reportProgress = null, bool $preferStreaming = false, ?callable $reportOutput = null,
?string $conversationId = null,
): string {
$chunks = $this->chunkService->chunkSplitPrompt($service, $inputText, true, $maxTokens);
$translation = '';
Expand Down Expand Up @@ -105,7 +106,8 @@ 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, TranslateService::SYSTEM_PROMPT, null, 1, $maxTokens, TranslateService::JSON_RESPONSE_FORMAT,
conversationId: $conversationId,
);
$completions = $completionsObj['messages'];
} else {
Expand Down
13 changes: 11 additions & 2 deletions lib/TaskProcessing/AnalyzeImagesProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,11 @@ public function getOptionalInputShape(): array {
$this->l->t('The maximum number of words/tokens that can be generated in the output.'),
EShapeType::Number
),
'conversation_id' => new ShapeDescriptor(
$this->l->t('Conversation ID'),
$this->l->t('The ID of the conversation this request belongs to.'),
EShapeType::Text
),
];
}

Expand Down Expand Up @@ -126,6 +131,10 @@ public function process(
if (isset($input['max_tokens']) && is_int($input['max_tokens'])) {
$maxTokens = $input['max_tokens'];
}

$conversationId = isset($input['conversation_id']) && is_string($input['conversation_id'])
? $input['conversation_id']
: null;
$fileSizeTotal = array_reduce(
$images,
function ($carry, $file) {
Expand All @@ -145,7 +154,7 @@ function ($carry, $file) {
try {
$systemPrompt = 'Take the user\'s question and answer it based on the provided images. Ensure that the answer matches the language of the user\'s question.';
if ($preferStreaming) {
$chunks = $this->openAiAPIService->createStreamedChatCompletion($userId, $this->service, $model, $prompt, $systemPrompt, $history, 1, $maxTokens, null, null, null, $images);
$chunks = $this->openAiAPIService->createStreamedChatCompletion($userId, $this->service, $model, $prompt, $systemPrompt, $history, 1, $maxTokens, null, null, null, $images, conversationId: $conversationId);
$time = microtime(true);
$streamedOutput = '';
$streamedReasoning = '';
Expand Down Expand Up @@ -183,7 +192,7 @@ function ($carry, $file) {
$completion = $returnValue['messages'];
$reasoning = $returnValue['reasoning_messages'];
} else {
$returnValue = $this->openAiAPIService->createChatCompletion($userId, $this->service, $model, $prompt, $systemPrompt, $history, 1, $maxTokens, null, null, null, $images);
$returnValue = $this->openAiAPIService->createChatCompletion($userId, $this->service, $model, $prompt, $systemPrompt, $history, 1, $maxTokens, null, null, null, $images, conversationId: $conversationId);
$completion = $returnValue['messages'];
$reasoning = $returnValue['reasoning_messages'];
}
Expand Down
Loading
Loading