Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
38 changes: 35 additions & 3 deletions lib/Service/OpenAiAPIService.php
Original file line number Diff line number Diff line change
Expand Up @@ -408,6 +408,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 @@ -439,6 +440,7 @@ public function createStreamedChatCompletion(
true,
0,
true,
$conversationId,
);

$streamResult = yield from $this->streamingService->parseStreamChatResponse($response);
Expand Down Expand Up @@ -468,11 +470,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 @@ -517,6 +520,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 @@ -538,7 +542,7 @@ public function requestChatCompletion(
$stream,
);

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

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

/**
* 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 @@ -1183,6 +1211,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 @@ -1317,6 +1346,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 assistant conversation ID used to expand the {$conversation_id} token of the extra headers
* @return array decoded request result or error
* @throws Exception|UserFacingProcessingException
*/
Expand All @@ -1325,6 +1355,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 @@ -1342,6 +1373,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 @@ -1474,7 +1506,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
73 changes: 73 additions & 0 deletions lib/Service/ServiceConfig.php
Original file line number Diff line number Diff line change
Expand Up @@ -56,18 +56,29 @@ 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) */
public const HEADER_NAME_PATTERN = '/^[a-zA-Z0-9!#$%&\'*+.^_`|~-]+$/';

/** The extra header value variable holding the assistant conversation ID of a chat 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 +115,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 +198,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 +240,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 @@ -446,6 +486,38 @@ 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: only chat requests know the conversation, 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 line breaks in
// it must not be able to forge headers
return preg_match('/[\r\n]/', $expanded) === 1 ? null : $expanded;
}

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

Expand Down
22 changes: 22 additions & 0 deletions lib/Service/ServicesService.php
Original file line number Diff line number Diff line change
Expand Up @@ -429,6 +429,28 @@ 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 !== '' && 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('/[\r\n]/', $header['value']) === 1) {
throw new Exception('Invalid extra header value. Line breaks are not allowed: ' . $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
11 changes: 10 additions & 1 deletion lib/TaskProcessing/AudioToAudioChatProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,11 @@ public function getOptionalInputShape(): array {
: $this->l->t('Speech speed modifier'),
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 @@ -175,9 +180,13 @@ public function process(?string $userId, array $input, callable $reportProgress)
'audio' => ['voice' => $outputVoice, 'format' => 'mp3'],
];
$systemPrompt .= ' Producing text responses will break the user interface. Important: You have multimodal voice capability, and you use voice exclusively to respond.';
$conversationId = isset($input['conversation_id']) && is_string($input['conversation_id'])
? $input['conversation_id']
: null;
$completion = $this->openAiAPIService->createChatCompletion(
$userId, $this->service, $this->model, null, $systemPrompt, $history, 1, 1000,
$extraParams, null, null, [$inputFile]
$extraParams, null, null, [$inputFile],
conversationId: $conversationId,
);
$message = array_pop($completion['audio_messages']);
// TODO find a way to force the model to answer with audio when there is only text in the history
Expand Down
16 changes: 14 additions & 2 deletions lib/TaskProcessing/MultimodalChatWithToolsProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,11 @@ public function getOptionalInputShape(): array {
$this->l->t('The maximum number of words/tokens that can be generated in the completion.'),
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 @@ -156,10 +161,16 @@ 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;

try {
if ($preferStreaming) {
$chunks = $this->openAiAPIService->createStreamedChatCompletion(
$userId, $this->service, $this->model, $userPrompt, $systemPrompt, $history, 1, $maxTokens, null, $toolMessage, $tools, $inputAttachments
$userId, $this->service, $this->model, $userPrompt, $systemPrompt, $history, 1, $maxTokens, null, $toolMessage, $tools, $inputAttachments,
conversationId: $conversationId,
);
$time = microtime(true);
$streamedOutput = '';
Expand Down Expand Up @@ -197,7 +208,8 @@ public function process(
$returnValue = $chunks->getReturn();
} else {
$returnValue = $this->openAiAPIService->createChatCompletion(
$userId, $this->service, $this->model, $userPrompt, $systemPrompt, $history, 1, $maxTokens, null, $toolMessage, $tools, $inputAttachments
$userId, $this->service, $this->model, $userPrompt, $systemPrompt, $history, 1, $maxTokens, null, $toolMessage, $tools, $inputAttachments,
conversationId: $conversationId,
);
}
} catch (UserFacingProcessingException $e) {
Expand Down
Loading
Loading