Scaled down and converted images before sending them to AI models (#1101)

This commit is contained in:
Thorsten Sommer authored and GitHub committed 2026-10-22 10:57:04 +02:00
1 parent 1c8632bbe0
commit ba99bf6fae
33 files changed
+1550 -264

No files matched your search

+60
View File
@@ -0,0 +1,60 @@
using System.Collections.Concurrent;
using System.Net;
using System.Text;
using System.Text.Json;
namespace AIStudio.Tests.Tools;
/// <summary>
/// Plays the image route of the runtime for a test, and notes what the app asked for.
/// </summary>
/// <remarks>
/// It prepares nothing: every image comes back as the same three bytes, with the code and type the
/// test sets. The answer is spelled out in the wire format of the runtime, so a test fails as soon
/// as the app stops understanding what the runtime actually sends.
/// </remarks>
internal sealed class FakeImageRuntime : HttpMessageHandler
{
/// <summary>
/// The three bytes every prepared image consists of, as base64.
/// </summary>
public const string IMAGE_BASE64 = "AQID";
private readonly ConcurrentQueue<string> requestedPaths = new();
private readonly ConcurrentQueue<long> requestedBudgets = new();
/// <summary>
/// The code the runtime answers with, as the runtime spells it.
/// </summary>
public string ErrorCode { get; set; } = "NONE";
/// <summary>
/// The MIME type the runtime reports for every prepared image.
/// </summary>
public string MimeType { get; set; } = "image/jpeg";
/// <summary>
/// The paths of the images the app asked to prepare, in order.
/// </summary>
public IReadOnlyList<string> RequestedPaths => [..this.requestedPaths];
/// <summary>
/// The byte budgets the app asked for, in order.
/// </summary>
public IReadOnlyList<long> RequestedBudgets => [..this.requestedBudgets];
protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
if (request.RequestUri!.AbsolutePath is not "/image/prepare-for-model")
return new HttpResponseMessage(HttpStatusCode.NotFound);
using (var body = JsonDocument.Parse(await request.Content!.ReadAsStringAsync(cancellationToken)))
{
this.requestedPaths.Enqueue(body.RootElement.GetProperty("path").GetString() ?? string.Empty);
this.requestedBudgets.Enqueue(body.RootElement.GetProperty("max_bytes").GetInt64());
}
var answer = JsonSerializer.Serialize(new { error_code = this.ErrorCode, base64 = IMAGE_BASE64, mime_type = this.MimeType, width = 1, height = 1, was_resized = false });
return new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent(answer, Encoding.UTF8, "application/json") };
}
}
+46
View File
@@ -0,0 +1,46 @@
using AIStudio.Chat;
using AIStudio.Tools.Rust;
namespace AIStudio.Tests.Tools;
/// <summary>
/// Checks which image formats may be attached for an AI model.
/// </summary>
/// <remarks>
/// The runtime decides which formats it can prepare for a model, see prepare_image_for_model in
/// runtime/src/image.rs. An image the app lets through but the runtime cannot prepare would only
/// fail once the message is sent; one the app refuses although the runtime could prepare it would
/// be refused for nothing.
/// </remarks>
[TestFixture]
public sealed class ModelImageFormatTests
{
/// <summary>
/// The extensions model_input_format in runtime/src/image.rs accepts.
/// </summary>
private static readonly string[] RUNTIME_MODEL_FORMATS = ["jpg", "jpeg", "png", "webp", "gif", "bmp", "tif", "tiff"];
[Test]
public void EveryFormatTheRuntimePreparesMayBeAttached()
{
Assert.That(FileTypes.MODEL_IMAGE.FlattenExtensions(), Is.EquivalentTo(RUNTIME_MODEL_FORMATS));
}
[Test]
public void OnlyFormatsWithoutADecoderAreRefused()
{
var refused = FileTypes.IMAGE.FlattenExtensions().Except(FileTypes.MODEL_IMAGE.FlattenExtensions());
Assert.That(refused, Is.EquivalentTo(new[] { "svg", "heic", "avif" }));
}
[Test]
public void ATifFileIsAnImage()
{
Assert.Multiple(() =>
{
Assert.That(FileAttachment.FromPath("/tmp/scan.tif").IsImage, Is.True);
Assert.That(FileTypes.IsAllowedPath("/tmp/scan.tif", FileTypes.MODEL_IMAGE), Is.True);
});
}
}
+132
View File
@@ -0,0 +1,132 @@
using AIStudio.Chat;
using AIStudio.Provider;
using AIStudio.Tools.Rust;
using AIStudio.Tools.Services;
using Microsoft.Extensions.Logging.Abstractions;
namespace AIStudio.Tests.Tools;
/// <summary>
/// Checks when the image preparation asks the runtime again, and when it answers from its cache.
/// </summary>
/// <remarks>
/// Every message prepares all images of the conversation anew. Asking the runtime each time would
/// decode every photo again; answering from the cache after the file changed would send the old
/// picture. The runtime is played in its wire format, so these tests also fail as soon as the app
/// stops understanding what the runtime sends.
/// </remarks>
[TestFixture]
public sealed class ModelImageServiceTests
{
private string imagePath = string.Empty;
private FakeImageRuntime runtime = null!;
private RustService rustService = null!;
private ModelImageService service = null!;
[SetUp]
public void PrepareService()
{
this.imagePath = Path.Combine(Path.GetTempPath(), $"model-image-{Guid.NewGuid():N}.png");
File.WriteAllBytes(this.imagePath, [1, 2, 3]);
this.runtime = new FakeImageRuntime();
this.rustService = new RustService(this.runtime);
this.service = new ModelImageService(this.rustService, NullLogger<ModelImageService>.Instance);
}
[TearDown]
public void CleanUp()
{
this.service.Dispose();
this.rustService.Dispose();
this.runtime.Dispose();
File.Delete(this.imagePath);
}
[Test]
public async Task TheSameFileForTheSameProviderIsPreparedOnce()
{
var first = await this.service.PrepareAsync(this.Image, ProviderOf(LLMProviders.ANTHROPIC));
var second = await this.service.PrepareAsync(this.Image, ProviderOf(LLMProviders.ANTHROPIC));
Assert.Multiple(() =>
{
Assert.That(first.IsPrepared, Is.True);
Assert.That(first.MimeType, Is.EqualTo("image/jpeg"));
Assert.That(second, Is.SameAs(first));
Assert.That(this.runtime.RequestedPaths, Has.Count.EqualTo(1), "The second message decoded the photo again.");
});
}
[Test]
public async Task AChangedFileIsPreparedAgain()
{
await this.service.PrepareAsync(this.Image, ProviderOf(LLMProviders.ANTHROPIC));
await File.WriteAllBytesAsync(this.imagePath, [1, 2, 3, 4]);
await this.service.PrepareAsync(this.Image, ProviderOf(LLMProviders.ANTHROPIC));
Assert.That(this.runtime.RequestedPaths, Has.Count.EqualTo(2), "The model would get the picture as it was before.");
}
[Test]
public async Task AnotherBudgetIsPreparedAgainWithThatBudget()
{
await this.service.PrepareAsync(this.Image, ProviderOf(LLMProviders.ANTHROPIC));
await this.service.PrepareAsync(this.Image, ProviderOf(LLMProviders.OPEN_AI));
Assert.Multiple(() =>
{
Assert.That(this.runtime.RequestedBudgets, Is.EqualTo(new[] { 3_932_160, IImageSourceExtensions.MAX_IMAGE_SIZE_BYTES }));
Assert.That(this.runtime.RequestedPaths, Is.All.EqualTo(new FileInfo(this.imagePath).FullName));
});
}
[Test]
public async Task AFailureIsTriedAgainWithTheNextMessage()
{
this.runtime.ErrorCode = "NOT_DECODABLE";
var failed = await this.service.PrepareAsync(this.Image, ProviderOf(LLMProviders.ANTHROPIC));
this.runtime.ErrorCode = "NONE";
var prepared = await this.service.PrepareAsync(this.Image, ProviderOf(LLMProviders.ANTHROPIC));
Assert.Multiple(() =>
{
Assert.That(failed.ErrorCode, Is.EqualTo(ModelImageErrorCode.NOT_DECODABLE));
Assert.That(failed.IsPrepared, Is.False);
Assert.That(prepared.IsPrepared, Is.True);
Assert.That(this.runtime.RequestedPaths, Has.Count.EqualTo(2), "A failure must not stick to the image.");
});
}
[Test]
public async Task ACodeFromANewerRuntimeNeverCountsAsAPreparedImage()
{
this.runtime.ErrorCode = "SOMETHING_NEW";
var image = await this.service.PrepareAsync(this.Image, ProviderOf(LLMProviders.ANTHROPIC));
Assert.Multiple(() =>
{
Assert.That(image.ErrorCode, Is.EqualTo(ModelImageErrorCode.UNKNOWN));
Assert.That(image.IsPrepared, Is.False, "The base64 of the answer must not go to the model when the code says nothing we know.");
});
}
[Test]
public async Task AMissingFileDoesNotReachTheRuntime()
{
File.Delete(this.imagePath);
var image = await this.service.PrepareAsync(this.Image, ProviderOf(LLMProviders.ANTHROPIC));
Assert.Multiple(() =>
{
Assert.That(image.ErrorCode, Is.EqualTo(ModelImageErrorCode.FILE_NOT_FOUND));
Assert.That(this.runtime.RequestedPaths, Is.Empty);
});
}
private FileAttachmentImage Image => new("picture.png", this.imagePath, 3);
private static AIStudio.Settings.Provider ProviderOf(LLMProviders llmProvider) => new(0, "test", "Test", llmProvider, new Model("model", null));
}