using System.Net.Http.Headers;
using AIStudio.Chat;
using AIStudio.Provider;
using AIStudio.Settings;
using AIStudio.Tools;
using Microsoft.Extensions.Logging.Abstractions;
namespace AIStudio.Tests.Tools;
///
/// Checks the User-Agent which AI Studio sends to self-hosted servers.
///
///
/// Operators of a gateway such as LiteLLM read it in their logs to see which assistant or agent sent a
/// request. It has to stay a valid header value whatever a plugin calls its assistant, because an
/// invalid one would either break the request or silently vanish from the logs. Who receives it at all
/// is a privacy question: nobody without the consent of the user, and a cloud provider never.
///
[TestFixture]
public sealed class AppUserAgentTests
{
[TestCase(LLMProviders.SELF_HOSTED)]
[TestCase(LLMProviders.LITE_LLM)]
public void ASelfHostedServerReceivesItOnlyWhenSharingIsOn(LLMProviders provider)
{
var settingsManager = CreateSettingsManager();
Assert.That(AppUserAgent.IsAllowedFor(provider, settingsManager), Is.False, "Sharing is off by default, so not even a self-hosted server may learn anything.");
settingsManager.ConfigurationData.App.ShareFeatureUsageWithSelfHostedServerOperators = true;
Assert.That(AppUserAgent.IsAllowedFor(provider, settingsManager), Is.True);
}
[Test]
public void NoOtherProviderEverReceivesIt()
{
var settingsManager = CreateSettingsManager();
settingsManager.ConfigurationData.App.ShareFeatureUsageWithSelfHostedServerOperators = true;
//
// Listed by exclusion on purpose: a provider added later falls on the safe side, and whoever
// wants it to receive the User-Agent has to change this test and say why.
//
foreach (var provider in Enum.GetValues().Except([LLMProviders.SELF_HOSTED, LLMProviders.LITE_LLM]))
Assert.That(AppUserAgent.IsAllowedFor(provider, settingsManager), Is.False, $"The provider {provider} must never learn which feature sent a request.");
}
[Test]
public void TheBaseNamesTheApp()
{
Assert.That(AppUserAgent.BASE, Does.StartWith("MindWorkAIStudio/"));
Assert.That(ProductInfoHeaderValue.TryParse(AppUserAgent.BASE.Split(' ')[0], out _), Is.True);
}
[Test]
public void ABuiltInComponentIsNamedByItsEnumValue()
{
var userAgent = AppUserAgent.ForComponent(AIStudio.Tools.Components.TRANSLATION_ASSISTANT);
Assert.That(userAgent, Is.EqualTo($"{AppUserAgent.BASE} Component/TRANSLATION_ASSISTANT"));
}
[Test]
public void NoComponentKeepsTheBase()
{
Assert.That(AppUserAgent.ForComponent(AIStudio.Tools.Components.NONE), Is.EqualTo(AppUserAgent.BASE));
}
[TestCase("Übersetzung für Straßen", "Ubersetzung-fur-Strassen")]
[TestCase(" My (cool) Assistant/v2 ", "My-cool-Assistant-v2")]
[TestCase("already_valid-name.1", "already_valid-name.1")]
[TestCase("日本語", "")]
public void APluginNameBecomesAValidProductToken(string pluginName, string expectedToken)
{
var userAgent = AppUserAgent.ForComponent(AIStudio.Tools.Components.DYNAMIC_ASSISTANT, pluginName);
var expected = string.IsNullOrEmpty(expectedToken)
? $"{AppUserAgent.BASE} Component/DYNAMIC_ASSISTANT"
: $"{AppUserAgent.BASE} Component/DYNAMIC_ASSISTANT Assistant/{expectedToken}";
Assert.That(userAgent, Is.EqualTo(expected));
}
[Test]
public void ARequestHeaderReplacesTheDefault()
{
using var request = new HttpRequestMessage();
request.Headers.UserAgent.ParseAdd(AppUserAgent.BASE);
AppUserAgent.ApplyComponent(request.Headers, new ChatThread { RuntimeComponent = AIStudio.Tools.Components.EMAIL_ASSISTANT });
Assert.That(request.Headers.UserAgent.ToString(), Is.EqualTo($"{AppUserAgent.BASE} Component/EMAIL_ASSISTANT"));
Assert.That(request.Headers.UserAgent.Count, Is.EqualTo(3), "The header must still parse as product, platform comment, and component.");
}
[Test]
public void AThreadWithoutAComponentDoesNotCountAsChat()
{
using var request = new HttpRequestMessage();
AppUserAgent.ApplyComponent(request.Headers, new ChatThread());
Assert.That(request.Headers.UserAgent.ToString(), Is.EqualTo(AppUserAgent.BASE), "A thread whose sender forgot to name its component must not be counted as chat, nor as anything else.");
}
private static SettingsManager CreateSettingsManager() => new(NullLogger.Instance, null!);
}