Improved HTTP timeout configuration

This commit is contained in:
Thorsten Sommer 2026-05-21 16:21:39 +02:00
parent 5c4778d4c0
commit 6c72749658
Signed by untrusted user who does not match committer: tsommer
GPG Key ID: 371BBA77A02C0108
12 changed files with 123 additions and 97 deletions

View File

@ -89,8 +89,10 @@ public static class IImageSourceExtensions
case ContentImageSource.URL: case ContentImageSource.URL:
{ {
using var httpClient = new HttpClient(); using var httpClient = ExternalHttpClientTimeout.CreateHttpClient();
using var response = await httpClient.GetAsync(image.Source, HttpCompletionOption.ResponseHeadersRead, token); using var timeoutTokenSource = ExternalHttpClientTimeout.CreateTimeoutTokenSource(token);
var timeoutToken = timeoutTokenSource.Token;
using var response = await httpClient.GetAsync(image.Source, HttpCompletionOption.ResponseHeadersRead, timeoutToken);
if(response.IsSuccessStatusCode) if(response.IsSuccessStatusCode)
{ {
// Read the length of the content: // Read the length of the content:
@ -101,7 +103,7 @@ public static class IImageSourceExtensions
return (success: false, string.Empty); return (success: false, string.Empty);
} }
var bytes = await response.Content.ReadAsByteArrayAsync(token); var bytes = await response.Content.ReadAsByteArrayAsync(timeoutToken);
return (success: true, Convert.ToBase64String(bytes)); return (success: true, Convert.ToBase64String(bytes));
} }

View File

@ -260,9 +260,9 @@ CONFIG["SETTINGS"] = {}
-- Examples are: "CmdOrControl+Shift+D", "Alt+F9", "F8" -- Examples are: "CmdOrControl+Shift+D", "Alt+F9", "F8"
-- CONFIG["SETTINGS"]["DataApp.ShortcutVoiceRecording"] = "CmdOrControl+1" -- CONFIG["SETTINGS"]["DataApp.ShortcutVoiceRecording"] = "CmdOrControl+1"
-- Configure the HTTP timeout for requests to LLM providers, in seconds. -- Configure the HTTP timeout for external requests, in seconds.
-- The default is 3600 (1 hour). -- The default is 3600 (1 hour).
-- CONFIG["SETTINGS"]["DataApp.ProviderHttpTimeoutSeconds"] = 3600 -- CONFIG["SETTINGS"]["DataApp.HttpClientTimeoutSeconds"] = 3600
-- Example chat templates for this configuration: -- Example chat templates for this configuration:
CONFIG["CHAT_TEMPLATES"] = {} CONFIG["CHAT_TEMPLATES"] = {}

View File

@ -24,15 +24,12 @@ namespace AIStudio.Provider;
/// </summary> /// </summary>
public abstract class BaseProvider : IProvider, ISecretId public abstract class BaseProvider : IProvider, ISecretId
{ {
private const int DEFAULT_HTTP_TIMEOUT_SECONDS = 3600;
private static string TB(string fallbackEN) => I18N.I.T(fallbackEN, typeof(BaseProvider).Namespace, nameof(BaseProvider)); private static string TB(string fallbackEN) => I18N.I.T(fallbackEN, typeof(BaseProvider).Namespace, nameof(BaseProvider));
private static readonly SettingsManager SETTINGS_MANAGER = Program.SERVICE_PROVIDER.GetRequiredService<SettingsManager>();
/// <summary> /// <summary>
/// The HTTP client to use it for all requests. /// The HTTP client to use it for all requests.
/// </summary> /// </summary>
protected readonly HttpClient HttpClient = new(); protected readonly HttpClient HttpClient = ExternalHttpClientTimeout.CreateHttpClient();
/// <summary> /// <summary>
/// The logger to use. /// The logger to use.
@ -77,7 +74,6 @@ public abstract class BaseProvider : IProvider, ISecretId
// Set the base URL: // Set the base URL:
this.HttpClient.BaseAddress = new(url); this.HttpClient.BaseAddress = new(url);
this.HttpClient.Timeout = GetProviderHttpTimeout();
} }
#region Handling of IProvider, which all providers must implement #region Handling of IProvider, which all providers must implement
@ -145,13 +141,7 @@ public abstract class BaseProvider : IProvider, ISecretId
if (token.IsCancellationRequested) if (token.IsCancellationRequested)
return false; return false;
if (exception is TimeoutException) return ExternalHttpClientTimeout.IsTimeoutException(exception, token);
return true;
if (exception is OperationCanceledException)
return true;
return exception.InnerException is not null && this.IsTimeoutException(exception.InnerException, token);
} }
protected Task SendTimeoutError(string action) => MessageBus.INSTANCE.SendError(new( protected Task SendTimeoutError(string action) => MessageBus.INSTANCE.SendError(new(
@ -160,38 +150,9 @@ public abstract class BaseProvider : IProvider, ISecretId
TB("The request to the LLM provider '{0}' (type={1}) timed out after {2} while {3}. Please try again or check whether the provider is still responding."), TB("The request to the LLM provider '{0}' (type={1}) timed out after {2} while {3}. Please try again or check whether the provider is still responding."),
this.InstanceName, this.InstanceName,
this.Provider, this.Provider,
GetProviderHttpTimeoutDescription(), ExternalHttpClientTimeout.GetTimeoutDescription(),
action))); action)));
private static TimeSpan GetProviderHttpTimeout()
{
var seconds = SETTINGS_MANAGER.ConfigurationData.App.ProviderHttpTimeoutSeconds;
if (seconds <= 0)
seconds = DEFAULT_HTTP_TIMEOUT_SECONDS;
return TimeSpan.FromSeconds(seconds);
}
private static string GetProviderHttpTimeoutDescription()
{
var timeout = GetProviderHttpTimeout();
if (timeout.TotalHours >= 1 && timeout.TotalMinutes % 60 == 0)
{
var hours = (int)timeout.TotalHours;
return hours == 1 ? "1 hour" : $"{hours} hours";
}
if (timeout.TotalMinutes >= 1 && timeout.TotalSeconds % 60 == 0)
{
var minutes = (int)timeout.TotalMinutes;
return minutes == 1 ? "1 minute" : $"{minutes} minutes";
}
var seconds = (int)timeout.TotalSeconds;
return seconds == 1 ? "1 second" : $"{seconds} seconds";
}
protected async Task<string?> GetModelLoadingSecretKey(SecretStoreType storeType, string? apiKeyProvisional = null, bool isTryingSecret = false) => apiKeyProvisional switch protected async Task<string?> GetModelLoadingSecretKey(SecretStoreType storeType, string? apiKeyProvisional = null, bool isTryingSecret = false) => apiKeyProvisional switch
{ {
not null => apiKeyProvisional, not null => apiKeyProvisional,
@ -266,12 +227,14 @@ public abstract class BaseProvider : IProvider, ISecretId
/// Sends a request and handles rate limiting by exponential backoff. /// Sends a request and handles rate limiting by exponential backoff.
/// </summary> /// </summary>
/// <param name="requestBuilder">A function that builds the request.</param> /// <param name="requestBuilder">A function that builds the request.</param>
/// <param name="token">The cancellation token.</param> /// <param name="userCancellationToken">The user cancellation token.</param>
/// <param name="requestCancellationToken">The token to use for the HTTP request.</param>
/// <returns>The status object of the request.</returns> /// <returns>The status object of the request.</returns>
private async Task<HttpRateLimitedStreamResult> SendRequest(Func<Task<HttpRequestMessage>> requestBuilder, CancellationToken token = default) private async Task<HttpRateLimitedStreamResult> SendRequest(Func<Task<HttpRequestMessage>> requestBuilder, CancellationToken userCancellationToken = default, CancellationToken requestCancellationToken = default)
{ {
const int MAX_RETRIES = 6; const int MAX_RETRIES = 6;
const double RETRY_DELAY_SECONDS = 4; const double RETRY_DELAY_SECONDS = 4;
var effectiveCancellationToken = requestCancellationToken.CanBeCanceled ? requestCancellationToken : userCancellationToken;
var retry = 0; var retry = 0;
var response = default(HttpResponseMessage); var response = default(HttpResponseMessage);
@ -291,9 +254,9 @@ public abstract class BaseProvider : IProvider, ISecretId
HttpResponseMessage nextResponse; HttpResponseMessage nextResponse;
try try
{ {
nextResponse = await this.HttpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, token); nextResponse = await this.HttpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, effectiveCancellationToken);
} }
catch (Exception e) when (this.IsTimeoutException(e, token)) catch (Exception e) when (this.IsTimeoutException(e, userCancellationToken))
{ {
await this.SendTimeoutError("waiting for the chat response"); await this.SendTimeoutError("waiting for the chat response");
this.logger.LogError(e, "Timed out while sending a streaming request to provider '{ProviderInstanceName}' (provider={ProviderType}).", this.InstanceName, this.Provider); this.logger.LogError(e, "Timed out while sending a streaming request to provider '{ProviderInstanceName}' (provider={ProviderType}).", this.InstanceName, this.Provider);
@ -306,7 +269,7 @@ public abstract class BaseProvider : IProvider, ISecretId
break; break;
} }
var errorBody = await nextResponse.Content.ReadAsStringAsync(token); var errorBody = await nextResponse.Content.ReadAsStringAsync(effectiveCancellationToken);
if (nextResponse.StatusCode is HttpStatusCode.Forbidden) if (nextResponse.StatusCode is HttpStatusCode.Forbidden)
{ {
await MessageBus.INSTANCE.SendError(new(Icons.Material.Filled.Block, string.Format(TB("We tried to communicate with the LLM provider '{0}' (type={1}). You might not be able to use this provider from your location. The provider message is: '{2}'"), this.InstanceName, this.Provider, nextResponse.ReasonPhrase))); await MessageBus.INSTANCE.SendError(new(Icons.Material.Filled.Block, string.Format(TB("We tried to communicate with the LLM provider '{0}' (type={1}). You might not be able to use this provider from your location. The provider message is: '{2}'"), this.InstanceName, this.Provider, nextResponse.ReasonPhrase)));
@ -372,7 +335,7 @@ public abstract class BaseProvider : IProvider, ISecretId
timeSeconds = 90; timeSeconds = 90;
this.logger.LogDebug("Failed request with status code {ResponseStatusCode} (message = '{ErrorMessage}'). Retrying in {TimeSeconds:0.00} seconds.", nextResponse.StatusCode, errorMessage, timeSeconds); this.logger.LogDebug("Failed request with status code {ResponseStatusCode} (message = '{ErrorMessage}'). Retrying in {TimeSeconds:0.00} seconds.", nextResponse.StatusCode, errorMessage, timeSeconds);
await Task.Delay(TimeSpan.FromSeconds(timeSeconds), token); await Task.Delay(TimeSpan.FromSeconds(timeSeconds), effectiveCancellationToken);
} }
if(retry >= MAX_RETRIES || !string.IsNullOrWhiteSpace(errorMessage)) if(retry >= MAX_RETRIES || !string.IsNullOrWhiteSpace(errorMessage))
@ -399,10 +362,12 @@ public abstract class BaseProvider : IProvider, ISecretId
var annotationSupported = typeof(TAnnotation) != typeof(NoResponsesAnnotationStreamLine) && typeof(TAnnotation) != typeof(NoChatCompletionAnnotationStreamLine); var annotationSupported = typeof(TAnnotation) != typeof(NoResponsesAnnotationStreamLine) && typeof(TAnnotation) != typeof(NoChatCompletionAnnotationStreamLine);
StreamReader? streamReader = null; StreamReader? streamReader = null;
using var timeoutTokenSource = ExternalHttpClientTimeout.CreateTimeoutTokenSource(token);
var timeoutToken = timeoutTokenSource.Token;
try try
{ {
// Send the request using exponential backoff: // Send the request using exponential backoff:
var responseData = await this.SendRequest(requestBuilder, token); var responseData = await this.SendRequest(requestBuilder, token, timeoutToken);
if(responseData.IsFailedAfterAllRetries) if(responseData.IsFailedAfterAllRetries)
{ {
this.logger.LogError($"The {providerName} chat completion failed: {responseData.ErrorMessage}"); this.logger.LogError($"The {providerName} chat completion failed: {responseData.ErrorMessage}");
@ -410,7 +375,7 @@ public abstract class BaseProvider : IProvider, ISecretId
} }
// Open the response stream: // Open the response stream:
var providerStream = await responseData.Response!.Content.ReadAsStreamAsync(token); var providerStream = await responseData.Response!.Content.ReadAsStreamAsync(timeoutToken);
// Add a stream reader to read the stream, line by line: // Add a stream reader to read the stream, line by line:
streamReader = new StreamReader(providerStream); streamReader = new StreamReader(providerStream);
@ -441,18 +406,6 @@ public abstract class BaseProvider : IProvider, ISecretId
// //
while (true) while (true)
{ {
try
{
if(streamReader.EndOfStream)
break;
}
catch (Exception e)
{
await MessageBus.INSTANCE.SendError(new(Icons.Material.Filled.Stream, string.Format(TB("Tried to stream the LLM provider '{0}' answer. There were some problems with the stream. The message is: '{1}'"), this.InstanceName, e.Message)));
this.logger.LogWarning($"Failed to read the end-of-stream state from {providerName} '{this.InstanceName}': {e.Message}");
break;
}
// Check if the token is canceled: // Check if the token is canceled:
if (token.IsCancellationRequested) if (token.IsCancellationRequested)
{ {
@ -467,7 +420,7 @@ public abstract class BaseProvider : IProvider, ISecretId
string? line; string? line;
try try
{ {
line = await streamReader.ReadLineAsync(token); line = await streamReader.ReadLineAsync(timeoutToken);
} }
catch (Exception e) catch (Exception e)
{ {
@ -489,6 +442,9 @@ public abstract class BaseProvider : IProvider, ISecretId
break; break;
} }
if (line is null)
break;
// Skip empty lines: // Skip empty lines:
if (string.IsNullOrWhiteSpace(line)) if (string.IsNullOrWhiteSpace(line))
continue; continue;
@ -588,10 +544,12 @@ public abstract class BaseProvider : IProvider, ISecretId
var annotationSupported = typeof(TAnnotation) != typeof(NoResponsesAnnotationStreamLine) && typeof(TAnnotation) != typeof(NoChatCompletionAnnotationStreamLine); var annotationSupported = typeof(TAnnotation) != typeof(NoResponsesAnnotationStreamLine) && typeof(TAnnotation) != typeof(NoChatCompletionAnnotationStreamLine);
StreamReader? streamReader = null; StreamReader? streamReader = null;
using var timeoutTokenSource = ExternalHttpClientTimeout.CreateTimeoutTokenSource(token);
var timeoutToken = timeoutTokenSource.Token;
try try
{ {
// Send the request using exponential backoff: // Send the request using exponential backoff:
var responseData = await this.SendRequest(requestBuilder, token); var responseData = await this.SendRequest(requestBuilder, token, timeoutToken);
if(responseData.IsFailedAfterAllRetries) if(responseData.IsFailedAfterAllRetries)
{ {
this.logger.LogError($"The {providerName} responses call failed: {responseData.ErrorMessage}"); this.logger.LogError($"The {providerName} responses call failed: {responseData.ErrorMessage}");
@ -599,7 +557,7 @@ public abstract class BaseProvider : IProvider, ISecretId
} }
// Open the response stream: // Open the response stream:
var providerStream = await responseData.Response!.Content.ReadAsStreamAsync(token); var providerStream = await responseData.Response!.Content.ReadAsStreamAsync(timeoutToken);
// Add a stream reader to read the stream, line by line: // Add a stream reader to read the stream, line by line:
streamReader = new StreamReader(providerStream); streamReader = new StreamReader(providerStream);
@ -630,18 +588,6 @@ public abstract class BaseProvider : IProvider, ISecretId
// //
while (true) while (true)
{ {
try
{
if(streamReader.EndOfStream)
break;
}
catch (Exception e)
{
await MessageBus.INSTANCE.SendError(new(Icons.Material.Filled.Stream, string.Format(TB("Tried to stream the LLM provider '{0}' answer. There were some problems with the stream. The message is: '{1}'"), this.InstanceName, e.Message)));
this.logger.LogWarning($"Failed to read the end-of-stream state from {providerName} '{this.InstanceName}': {e.Message}");
break;
}
// Check if the token is canceled: // Check if the token is canceled:
if (token.IsCancellationRequested) if (token.IsCancellationRequested)
{ {
@ -656,7 +602,7 @@ public abstract class BaseProvider : IProvider, ISecretId
string? line; string? line;
try try
{ {
line = await streamReader.ReadLineAsync(token); line = await streamReader.ReadLineAsync(timeoutToken);
} }
catch (Exception e) catch (Exception e)
{ {
@ -678,6 +624,9 @@ public abstract class BaseProvider : IProvider, ISecretId
break; break;
} }
if (line is null)
break;
// Skip empty lines: // Skip empty lines:
if (string.IsNullOrWhiteSpace(line)) if (string.IsNullOrWhiteSpace(line))
continue; continue;

View File

@ -95,9 +95,9 @@ public sealed class DataApp(Expression<Func<Data, DataApp>>? configSelection = n
public string ShortcutVoiceRecording { get; set; } = ManagedConfiguration.Register(configSelection, n => n.ShortcutVoiceRecording, string.Empty); public string ShortcutVoiceRecording { get; set; } = ManagedConfiguration.Register(configSelection, n => n.ShortcutVoiceRecording, string.Empty);
/// <summary> /// <summary>
/// The HTTP timeout in seconds for requests to LLM providers. /// The HTTP timeout in seconds for external HTTP clients.
/// </summary> /// </summary>
public int ProviderHttpTimeoutSeconds { get; set; } = ManagedConfiguration.Register(configSelection, n => n.ProviderHttpTimeoutSeconds, 3600); public int HttpClientTimeoutSeconds { get; set; } = ManagedConfiguration.Register(configSelection, n => n.HttpClientTimeoutSeconds, ExternalHttpClientTimeout.DEFAULT_HTTP_CLIENT_TIMEOUT_SECONDS);
/// <summary> /// <summary>
/// Should the user be allowed to add providers? /// Should the user be allowed to add providers?

View File

@ -23,10 +23,7 @@ public abstract class ERIClientBase(IERIDataSource dataSource) : IDisposable
} }
}; };
protected readonly HttpClient HttpClient = new() protected readonly HttpClient HttpClient = ExternalHttpClientTimeout.CreateHttpClient(new Uri($"{dataSource.Hostname}:{dataSource.Port}"));
{
BaseAddress = new Uri($"{dataSource.Hostname}:{dataSource.Port}"),
};
protected string SecurityToken = string.Empty; protected string SecurityToken = string.Empty;

View File

@ -0,0 +1,77 @@
using AIStudio.Settings;
namespace AIStudio.Tools;
/// <summary>
/// Provides utility methods to standardize the management of HTTP client timeouts
/// across various components in the application.
/// </summary>
public static class ExternalHttpClientTimeout
{
public const int DEFAULT_HTTP_CLIENT_TIMEOUT_SECONDS = 3600;
public static HttpClient CreateHttpClient(Uri? baseAddress = null)
{
var httpClient = new HttpClient();
Configure(httpClient, baseAddress);
return httpClient;
}
public static string GetTimeoutDescription()
{
var timeout = GetTimeout();
if (timeout.TotalHours >= 1 && timeout.TotalMinutes % 60 == 0)
{
var hours = (int)timeout.TotalHours;
return hours == 1 ? "1 hour" : $"{hours} hours";
}
if (timeout.TotalMinutes >= 1 && timeout.TotalSeconds % 60 == 0)
{
var minutes = (int)timeout.TotalMinutes;
return minutes == 1 ? "1 minute" : $"{minutes} minutes";
}
var seconds = (int)timeout.TotalSeconds;
return seconds == 1 ? "1 second" : $"{seconds} seconds";
}
public static CancellationTokenSource CreateTimeoutTokenSource(CancellationToken cancellationToken)
{
var timeoutTokenSource = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
timeoutTokenSource.CancelAfter(GetTimeout());
return timeoutTokenSource;
}
public static bool IsTimeoutException(Exception exception, CancellationToken userCancellationToken = default)
{
if (userCancellationToken.IsCancellationRequested)
return false;
if (exception is TimeoutException)
return true;
if (exception is OperationCanceledException)
return true;
return exception.InnerException is not null && IsTimeoutException(exception.InnerException, userCancellationToken);
}
private static TimeSpan GetTimeout()
{
var settingsManager = Program.SERVICE_PROVIDER.GetRequiredService<SettingsManager>();
var seconds = settingsManager.ConfigurationData.App.HttpClientTimeoutSeconds;
if (seconds <= 0)
seconds = DEFAULT_HTTP_CLIENT_TIMEOUT_SECONDS;
return TimeSpan.FromSeconds(seconds);
}
private static void Configure(HttpClient httpClient, Uri? baseAddress = null)
{
httpClient.Timeout = GetTimeout();
if (baseAddress is not null)
httpClient.BaseAddress = baseAddress;
}
}

View File

@ -172,8 +172,8 @@ public sealed class PluginConfiguration(bool isInternal, LuaState state, PluginT
// Config: global voice recording shortcut // Config: global voice recording shortcut
ManagedConfiguration.TryProcessConfiguration(x => x.App, x => x.ShortcutVoiceRecording, this.Id, settingsTable, dryRun); ManagedConfiguration.TryProcessConfiguration(x => x.App, x => x.ShortcutVoiceRecording, this.Id, settingsTable, dryRun);
// Config: timeout for HTTP requests to providers // Config: timeout for external HTTP requests
ManagedConfiguration.TryProcessConfiguration(x => x.App, x => x.ProviderHttpTimeoutSeconds, this.Id, settingsTable, dryRun); ManagedConfiguration.TryProcessConfiguration(x => x.App, x => x.HttpClientTimeoutSeconds, this.Id, settingsTable, dryRun);
// Handle configured LLM providers: // Handle configured LLM providers:
PluginConfigurationObject.TryParse(PluginConfigurationObjectType.LLM_PROVIDER, x => x.Providers, x => x.NextProviderNum, mainTable, this.Id, ref this.configObjects, dryRun); PluginConfigurationObject.TryParse(PluginConfigurationObjectType.LLM_PROVIDER, x => x.Providers, x => x.NextProviderNum, mainTable, this.Id, ref this.configObjects, dryRun);

View File

@ -15,7 +15,7 @@ public static partial class PluginFactory
var serverUrl = configServerUrl.EndsWith('/') ? configServerUrl[..^1] : configServerUrl; var serverUrl = configServerUrl.EndsWith('/') ? configServerUrl[..^1] : configServerUrl;
var downloadUrl = $"{serverUrl}/{configPlugId}.zip"; var downloadUrl = $"{serverUrl}/{configPlugId}.zip";
using var http = new HttpClient(); using var http = ExternalHttpClientTimeout.CreateHttpClient();
using var request = new HttpRequestMessage(HttpMethod.Get, downloadUrl); using var request = new HttpRequestMessage(HttpMethod.Get, downloadUrl);
var response = await http.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken); var response = await http.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken);
if (!response.IsSuccessStatusCode) if (!response.IsSuccessStatusCode)
@ -52,7 +52,7 @@ public static partial class PluginFactory
try try
{ {
await LockHotReloadAsync(); await LockHotReloadAsync();
using var httpClient = new HttpClient(); using var httpClient = ExternalHttpClientTimeout.CreateHttpClient();
var response = await httpClient.GetAsync(downloadUrl, cancellationToken); var response = await httpClient.GetAsync(downloadUrl, cancellationToken);
if (!response.IsSuccessStatusCode) if (!response.IsSuccessStatusCode)
{ {

View File

@ -245,8 +245,8 @@ public static partial class PluginFactory
if(ManagedConfiguration.IsConfigurationLeftOver(x => x.App, x => x.ShortcutVoiceRecording, AVAILABLE_PLUGINS)) if(ManagedConfiguration.IsConfigurationLeftOver(x => x.App, x => x.ShortcutVoiceRecording, AVAILABLE_PLUGINS))
wasConfigurationChanged = true; wasConfigurationChanged = true;
// Check for the provider HTTP timeout: // Check for the external HTTP client timeout:
if(ManagedConfiguration.IsConfigurationLeftOver(x => x.App, x => x.ProviderHttpTimeoutSeconds, AVAILABLE_PLUGINS)) if(ManagedConfiguration.IsConfigurationLeftOver(x => x.App, x => x.HttpClientTimeoutSeconds, AVAILABLE_PLUGINS))
wasConfigurationChanged = true; wasConfigurationChanged = true;
// Check if audit is required before it can be activated // Check if audit is required before it can be activated

View File

@ -2,6 +2,7 @@
- Released the voice recording and transcription for all users. You no longer need to enable a preview feature to configure transcription providers, select a transcription provider, or use dictation. - Released the voice recording and transcription for all users. You no longer need to enable a preview feature to configure transcription providers, select a transcription provider, or use dictation.
- Added support for organization-managed ERI servers in configuration plugins, so admins can preconfigure external data sources for users. - Added support for organization-managed ERI servers in configuration plugins, so admins can preconfigure external data sources for users.
- Added an export option for ERI server data sources, so admins can create configuration plugin snippets without writing the Lua code manually. - Added an export option for ERI server data sources, so admins can create configuration plugin snippets without writing the Lua code manually.
- Added an option to configure the timeout setting for all requests. This is useful when you have a slow network connection, or you have to work with slow AI servers. It is also possible to configure this timeout for an entire organization using configuration plugins.
- Added the username to the information page to make organization support easier when users share their screen. - Added the username to the information page to make organization support easier when users share their screen.
- Improved the app's security foundation with major modernization of the native runtime and its internal communication layer. This work is mostly invisible during everyday use, but it replaces older components that no longer received the security updates we require. We also continued updating security-sensitive dependencies so AI Studio stays on a healthier, better maintained base. - Improved the app's security foundation with major modernization of the native runtime and its internal communication layer. This work is mostly invisible during everyday use, but it replaces older components that no longer received the security updates we require. We also continued updating security-sensitive dependencies so AI Studio stays on a healthier, better maintained base.
- Improved the Pandoc management and detection process to make it more reliable. - Improved the Pandoc management and detection process to make it more reliable.