mirror of
https://github.com/MindWorkAI/AI-Studio.git
synced 2026-10-13 00:53:48 +00:00
Rebuilt how AI Studio knows what a model can do (#960)
Build and Release / Determine run mode (push) Waiting to run
Build and Release / Sync Flatpak repo (push) Blocked by required conditions
Build and Release / Collect Flatpak artifacts (push) Blocked by required conditions
Build and Release / Verify (push) Waiting to run
Build and Release / Build app (${{ matrix.dotnet_runtime }}) (-aarch64-apple-darwin, osx-arm64, macos-latest, aarch64-apple-darwin, dmg,app,updater, dmg) (push) Blocked by required conditions
Build and Release / Build app (${{ matrix.dotnet_runtime }}) (-aarch64-pc-windows-msvc.exe, win-arm64, windows-latest, aarch64-pc-windows-msvc, nsis,updater, nsis) (push) Blocked by required conditions
Build and Release / Build app (${{ matrix.dotnet_runtime }}) (-aarch64-unknown-linux-gnu, linux-arm64, ubuntu-22.04-arm, aarch64-unknown-linux-gnu, appimage,updater, appimage) (push) Blocked by required conditions
Build and Release / Build app (${{ matrix.dotnet_runtime }}) (-x86_64-apple-darwin, osx-x64, macos-latest, x86_64-apple-darwin, dmg,app,updater, dmg) (push) Blocked by required conditions
Build and Release / Build app (${{ matrix.dotnet_runtime }}) (-x86_64-pc-windows-msvc.exe, win-x64, windows-latest, x86_64-pc-windows-msvc, nsis,updater, nsis) (push) Blocked by required conditions
Build and Release / Build app (${{ matrix.dotnet_runtime }}) (-x86_64-unknown-linux-gnu, linux-x64, ubuntu-22.04, x86_64-unknown-linux-gnu, appimage,updater, appimage) (push) Blocked by required conditions
Build and Release / Prepare & create release (push) Blocked by required conditions
Build and Release / Publish release (push) Blocked by required conditions
Build and Release / Determine run mode (push) Waiting to run
Build and Release / Sync Flatpak repo (push) Blocked by required conditions
Build and Release / Collect Flatpak artifacts (push) Blocked by required conditions
Build and Release / Verify (push) Waiting to run
Build and Release / Build app (${{ matrix.dotnet_runtime }}) (-aarch64-apple-darwin, osx-arm64, macos-latest, aarch64-apple-darwin, dmg,app,updater, dmg) (push) Blocked by required conditions
Build and Release / Build app (${{ matrix.dotnet_runtime }}) (-aarch64-pc-windows-msvc.exe, win-arm64, windows-latest, aarch64-pc-windows-msvc, nsis,updater, nsis) (push) Blocked by required conditions
Build and Release / Build app (${{ matrix.dotnet_runtime }}) (-aarch64-unknown-linux-gnu, linux-arm64, ubuntu-22.04-arm, aarch64-unknown-linux-gnu, appimage,updater, appimage) (push) Blocked by required conditions
Build and Release / Build app (${{ matrix.dotnet_runtime }}) (-x86_64-apple-darwin, osx-x64, macos-latest, x86_64-apple-darwin, dmg,app,updater, dmg) (push) Blocked by required conditions
Build and Release / Build app (${{ matrix.dotnet_runtime }}) (-x86_64-pc-windows-msvc.exe, win-x64, windows-latest, x86_64-pc-windows-msvc, nsis,updater, nsis) (push) Blocked by required conditions
Build and Release / Build app (${{ matrix.dotnet_runtime }}) (-x86_64-unknown-linux-gnu, linux-x64, ubuntu-22.04, x86_64-unknown-linux-gnu, appimage,updater, appimage) (push) Blocked by required conditions
Build and Release / Prepare & create release (push) Blocked by required conditions
Build and Release / Publish release (push) Blocked by required conditions
This commit is contained in:
1 parent
d21e09dd1e
commit
d85b4e71b6
287 files changed
+18341
-3677
No files matched your search
@@ -0,0 +1,242 @@
|
||||
using AIStudio.Chat;
|
||||
|
||||
namespace AIStudio.Tests.Chat;
|
||||
|
||||
/// <summary>
|
||||
/// Checks what a conversation is counted as costing before it is sent.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The number under the input field used to count the sentence being typed and nothing else, which
|
||||
/// answers a question nobody asks: what decides whether the next message fits is everything that
|
||||
/// travels with it. So what is collected here has to be what the message builder actually sends --
|
||||
/// no more, because a number which counts something that stays behind is wrong in the direction
|
||||
/// that makes a person stop writing.
|
||||
/// </remarks>
|
||||
[TestFixture]
|
||||
public sealed class ConversationPartsTests
|
||||
{
|
||||
private string directory = string.Empty;
|
||||
|
||||
[SetUp]
|
||||
public void CreateFiles()
|
||||
{
|
||||
this.directory = Path.Combine(Path.GetTempPath(), $"ai-studio-tests-{Guid.NewGuid():N}");
|
||||
Directory.CreateDirectory(this.directory);
|
||||
}
|
||||
|
||||
[TearDown]
|
||||
public void RemoveFiles()
|
||||
{
|
||||
if (Directory.Exists(this.directory))
|
||||
Directory.Delete(this.directory, true);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void TheWholeConversationCountsAndNotOnlyWhatIsBeingTyped()
|
||||
{
|
||||
var thread = new ChatThread
|
||||
{
|
||||
SystemPrompt = "You are helpful.",
|
||||
Blocks =
|
||||
[
|
||||
Block("What is the capital of France?"),
|
||||
Block("Paris."),
|
||||
],
|
||||
};
|
||||
|
||||
var parts = ConversationParts.Of(thread, "You are helpful.", "And of Italy?", null, imagesAreSent: true);
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(parts.Texts, Is.EqualTo(new[] { "You are helpful.", "What is the capital of France?", "Paris." }));
|
||||
Assert.That(parts.GrowingTexts, Is.EqualTo(new[] { "And of Italy?" }));
|
||||
});
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void WhatIsStillBeingWrittenIsKeptApartFromWhatStands()
|
||||
{
|
||||
//
|
||||
// Both cost the same and both are counted. They are kept apart because of what happens
|
||||
// afterwards: a message which stands says the same thing forever and its count is worth
|
||||
// remembering, while the answer being streamed is a different text three seconds later.
|
||||
//
|
||||
var streaming = Block("The answer so far");
|
||||
((ContentText)streaming.Content!).IsStreaming = true;
|
||||
var thread = new ChatThread { Blocks = [Block("A question."), streaming] };
|
||||
|
||||
var parts = ConversationParts.Of(thread, string.Empty, "a draft", null, imagesAreSent: true);
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(parts.Texts, Is.EqualTo(new[] { "A question." }));
|
||||
Assert.That(parts.GrowingTexts, Is.EqualTo(new[] { "The answer so far", "a draft" }));
|
||||
});
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void AnAnswerWhichIsFinishedStandsLikeAnyOtherMessage()
|
||||
{
|
||||
var finished = Block("The whole answer.");
|
||||
((ContentText)finished.Content!).IsStreaming = false;
|
||||
|
||||
var parts = ConversationParts.Of(new() { Blocks = [finished] }, string.Empty, string.Empty, null, imagesAreSent: true);
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(parts.Texts, Is.EqualTo(new[] { "The whole answer." }));
|
||||
Assert.That(parts.GrowingTexts, Is.Empty);
|
||||
});
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void TheSystemPromptCountedIsTheOneWhichWouldBeSent()
|
||||
{
|
||||
//
|
||||
// Not the one standing in the thread. A chat template may replace it, retrieved data is
|
||||
// appended to it, a profile adds a paragraph and the tool policy adds another -- and
|
||||
// switching a profile while writing has to move the number, which it cannot do if the
|
||||
// thread's own field is what gets counted.
|
||||
//
|
||||
var thread = new ChatThread { SystemPrompt = "What the person typed." };
|
||||
|
||||
var parts = ConversationParts.Of(thread, "What the request carries.", string.Empty, null, imagesAreSent: true);
|
||||
|
||||
Assert.That(parts.Texts, Is.EqualTo(new[] { "What the request carries." }));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void ABlockHiddenFromTheUserStillCosts()
|
||||
{
|
||||
//
|
||||
// Hidden on the screen, not in the request: the message builder sends it like any other
|
||||
// block, so its tokens are gone whether or not anybody can see where they went.
|
||||
//
|
||||
var hidden = Block("An instruction the user does not see.");
|
||||
var thread = new ChatThread { Blocks = [new() { ContentType = hidden.ContentType, Role = hidden.Role, Content = hidden.Content, HideFromUser = true }] };
|
||||
|
||||
var parts = ConversationParts.Of(thread, string.Empty, string.Empty, null, imagesAreSent: true);
|
||||
|
||||
Assert.That(parts.Texts, Is.EqualTo(new[] { "An instruction the user does not see." }));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void WithoutAConversationOnlyTheDraftCounts()
|
||||
{
|
||||
var parts = ConversationParts.Of(null, string.Empty, "Hello", null, imagesAreSent: true);
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(parts.Texts, Is.Empty);
|
||||
Assert.That(parts.GrowingTexts, Is.EqualTo(new[] { "Hello" }));
|
||||
});
|
||||
}
|
||||
|
||||
[TestCase("")]
|
||||
[TestCase(" ")]
|
||||
public void NothingWrittenIsNothingToCount(string draft)
|
||||
{
|
||||
var parts = ConversationParts.Of(null, string.Empty, draft, null, imagesAreSent: true);
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(parts.Texts, Is.Empty);
|
||||
Assert.That(parts.GrowingTexts, Is.Empty);
|
||||
});
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void ABlockWithoutTextIsSkippedBecauseItIsNeverSent()
|
||||
{
|
||||
//
|
||||
// The message builder drops a block whose text is empty, whatever else hangs off it. A
|
||||
// count which added that block's attachments would report tokens for a message which is
|
||||
// never built.
|
||||
//
|
||||
var document = this.WriteFile("notes.txt", "some content");
|
||||
var empty = Block(string.Empty);
|
||||
((ContentText)empty.Content!).FileAttachments.Add(FileAttachment.FromPath(document));
|
||||
|
||||
var parts = ConversationParts.Of(new() { Blocks = [empty] }, string.Empty, string.Empty, null, imagesAreSent: true);
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(parts.Texts, Is.Empty);
|
||||
Assert.That(parts.Documents, Is.Empty);
|
||||
});
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void AttachmentsOfTheConversationAndOfTheComposerBothCount()
|
||||
{
|
||||
//
|
||||
// A document attached three messages ago is sent again with every further message, so it
|
||||
// costs its tokens again every time. That is exactly the thing a person cannot see and
|
||||
// which this number is for.
|
||||
//
|
||||
var older = this.WriteFile("older.txt", "older content");
|
||||
var draft = this.WriteFile("draft.txt", "draft content");
|
||||
var block = Block("Please read this.");
|
||||
((ContentText)block.Content!).FileAttachments.Add(FileAttachment.FromPath(older));
|
||||
|
||||
var parts = ConversationParts.Of(new() { Blocks = [block] }, string.Empty, "And this one.", [FileAttachment.FromPath(draft)], imagesAreSent: true);
|
||||
|
||||
Assert.That(parts.Documents.Select(document => document.FileName), Is.EqualTo(new[] { "older.txt", "draft.txt" }));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void AnAttachmentWhoseFileIsGoneCountsForNothing()
|
||||
{
|
||||
//
|
||||
// It is not sent either: the message builder reports it as unavailable and leaves it out.
|
||||
//
|
||||
var attachment = FileAttachment.FromPath(Path.Combine(this.directory, "never-existed.txt"));
|
||||
|
||||
var parts = ConversationParts.Of(null, string.Empty, "Here", [attachment], imagesAreSent: true);
|
||||
|
||||
Assert.That(parts.Documents, Is.Empty);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void ImagesAreCountedSeparatelyFromDocuments()
|
||||
{
|
||||
var document = this.WriteFile("notes.txt", "content");
|
||||
var image = this.WriteFile("photo.png", "not really a png");
|
||||
|
||||
var parts = ConversationParts.Of(null, string.Empty, "Look", [FileAttachment.FromPath(document), FileAttachment.FromPath(image)], imagesAreSent: true);
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(parts.Documents.Select(entry => entry.FileName), Is.EqualTo(new[] { "notes.txt" }));
|
||||
Assert.That(parts.Images, Is.EqualTo(1));
|
||||
});
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void AModelWhichTakesNoImagesIsSentNoneAndIsToldAboutNone()
|
||||
{
|
||||
//
|
||||
// The message builder leaves the pictures out entirely for such a model, so reporting them
|
||||
// as uncounted would tell a person about a cost which is not there.
|
||||
//
|
||||
var image = this.WriteFile("photo.png", "not really a png");
|
||||
|
||||
var parts = ConversationParts.Of(null, string.Empty, "Look", [FileAttachment.FromPath(image)], imagesAreSent: false);
|
||||
|
||||
Assert.That(parts.Images, Is.Zero);
|
||||
}
|
||||
|
||||
private static ContentBlock Block(string text) => new()
|
||||
{
|
||||
ContentType = ContentType.TEXT,
|
||||
Role = ChatRole.USER,
|
||||
Content = new ContentText { Text = text },
|
||||
};
|
||||
|
||||
private string WriteFile(string name, string content)
|
||||
{
|
||||
var path = Path.Combine(this.directory, name);
|
||||
File.WriteAllText(path, content);
|
||||
return path;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
using AIStudio.Chat;
|
||||
|
||||
namespace AIStudio.Tests.Chat;
|
||||
|
||||
/// <summary>
|
||||
/// Checks when the token count is recomputed and when it is not.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This is the part which kept going wrong. The number used to be wired to the places which change
|
||||
/// the conversation -- fifteen of them in the end -- and four review rounds each found another place
|
||||
/// which had been forgotten. So it is no longer wired to anything: whoever suspects a change nudges,
|
||||
/// and what is checked here is that the tracker turns those nudges into the right amount of work.
|
||||
///
|
||||
/// The waits are generous on purpose. What is asserted is the behaviour, not the clock, so every
|
||||
/// interval here is far enough apart that a busy build machine cannot turn one into the other.
|
||||
/// </remarks>
|
||||
[TestFixture]
|
||||
public sealed class ConversationTokenTrackerTests
|
||||
{
|
||||
/// <summary>
|
||||
/// A heartbeat which never fires, for the tests which are about nudges alone.
|
||||
/// </summary>
|
||||
private static readonly TimeSpan NO_HEARTBEAT = Timeout.InfiniteTimeSpan;
|
||||
|
||||
[Test]
|
||||
public async Task ManyNudgesInARowCostOneCount()
|
||||
{
|
||||
//
|
||||
// Loading a chat touches several things one after the other, and every one of them renders.
|
||||
// Counting once per render would measure the same conversation half a dozen times.
|
||||
//
|
||||
var runs = 0;
|
||||
var firstRun = new TaskCompletionSource();
|
||||
|
||||
await using var tracker = new ConversationTokenTracker(_ =>
|
||||
{
|
||||
Interlocked.Increment(ref runs);
|
||||
firstRun.TrySetResult();
|
||||
return Task.CompletedTask;
|
||||
}, () => TimeSpan.FromSeconds(2), NO_HEARTBEAT);
|
||||
|
||||
tracker.Start();
|
||||
for (var i = 0; i < 50; i++)
|
||||
tracker.Nudge();
|
||||
|
||||
await firstRun.Task.WaitAsync(TimeSpan.FromSeconds(5));
|
||||
await Task.Delay(200);
|
||||
|
||||
Assert.That(runs, Is.EqualTo(1));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task ANudgeArrivingDuringACountLeadsToExactlyOneMore()
|
||||
{
|
||||
//
|
||||
// Something changed while we were reading, so the answer we just worked out may already be
|
||||
// out of date -- but only one further count can be needed, however many nudges arrived.
|
||||
//
|
||||
var runs = 0;
|
||||
var firstRunStarted = new TaskCompletionSource();
|
||||
var releaseFirstRun = new TaskCompletionSource();
|
||||
|
||||
await using var tracker = new ConversationTokenTracker(async _ =>
|
||||
{
|
||||
if (Interlocked.Increment(ref runs) is not 1)
|
||||
return;
|
||||
|
||||
firstRunStarted.TrySetResult();
|
||||
await releaseFirstRun.Task;
|
||||
}, () => TimeSpan.FromMilliseconds(100), NO_HEARTBEAT);
|
||||
|
||||
tracker.Start();
|
||||
tracker.Nudge();
|
||||
await firstRunStarted.Task.WaitAsync(TimeSpan.FromSeconds(5));
|
||||
|
||||
//
|
||||
// Nudged while the first run is held up, five times over, because a render storm is what
|
||||
// this has to survive.
|
||||
//
|
||||
for (var i = 0; i < 5; i++)
|
||||
tracker.Nudge();
|
||||
|
||||
releaseFirstRun.SetResult();
|
||||
await Task.Delay(1_000);
|
||||
|
||||
Assert.That(runs, Is.EqualTo(2));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task WithoutAnyNudgeTheHeartbeatStillCounts()
|
||||
{
|
||||
//
|
||||
// For what happens outside AI Studio: an attached file somebody edits in another program
|
||||
// changes what the next message costs, and nothing here renders because of it.
|
||||
//
|
||||
var runs = 0;
|
||||
|
||||
await using var tracker = new ConversationTokenTracker(_ =>
|
||||
{
|
||||
Interlocked.Increment(ref runs);
|
||||
return Task.CompletedTask;
|
||||
}, () => TimeSpan.FromMilliseconds(50), TimeSpan.FromMilliseconds(200));
|
||||
|
||||
tracker.Start();
|
||||
await Task.Delay(1_000);
|
||||
|
||||
Assert.That(runs, Is.GreaterThanOrEqualTo(2));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task TheQuietTimeIsAskedAnewAfterEveryRun()
|
||||
{
|
||||
//
|
||||
// Because the right answer changes with what is going on. Showing a new number renders, and
|
||||
// a render nudges, so while something moves continuously this delay is the entire cadence
|
||||
// -- and a chat which is waiting for a model wants a slower one than a chat which is not.
|
||||
//
|
||||
var runs = 0;
|
||||
var asked = 0;
|
||||
|
||||
await using var tracker = new ConversationTokenTracker(_ =>
|
||||
{
|
||||
Interlocked.Increment(ref runs);
|
||||
return Task.CompletedTask;
|
||||
}, () =>
|
||||
{
|
||||
Interlocked.Increment(ref asked);
|
||||
return TimeSpan.FromMilliseconds(50);
|
||||
}, TimeSpan.FromMilliseconds(100));
|
||||
|
||||
tracker.Start();
|
||||
await Task.Delay(1_000);
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(runs, Is.GreaterThanOrEqualTo(2));
|
||||
Assert.That(asked, Is.EqualTo(runs));
|
||||
});
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task NothingIsCountedAfterTheTrackerIsGone()
|
||||
{
|
||||
var runs = 0;
|
||||
var tracker = new ConversationTokenTracker(_ =>
|
||||
{
|
||||
Interlocked.Increment(ref runs);
|
||||
return Task.CompletedTask;
|
||||
}, () => TimeSpan.FromMilliseconds(50), TimeSpan.FromMilliseconds(100));
|
||||
|
||||
tracker.Start();
|
||||
await Task.Delay(400);
|
||||
await tracker.DisposeAsync();
|
||||
|
||||
var afterDisposal = runs;
|
||||
await Task.Delay(400);
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(afterDisposal, Is.GreaterThan(0), "The tracker never ran, so this proves nothing about stopping it.");
|
||||
Assert.That(runs, Is.EqualTo(afterDisposal));
|
||||
});
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task ACountWhichHangsDoesNotHoldUpDisposal()
|
||||
{
|
||||
//
|
||||
// Counting ends in an IPC call to the runtime, and a component going away must not wait for
|
||||
// one which is not coming back. The token handed to the work is the way out, and this is
|
||||
// the test that it really is one.
|
||||
//
|
||||
var running = new TaskCompletionSource();
|
||||
var tracker = new ConversationTokenTracker(async token =>
|
||||
{
|
||||
running.TrySetResult();
|
||||
await Task.Delay(Timeout.InfiniteTimeSpan, token);
|
||||
}, () => TimeSpan.FromMilliseconds(50), NO_HEARTBEAT);
|
||||
|
||||
tracker.Start();
|
||||
tracker.Nudge();
|
||||
await running.Task.WaitAsync(TimeSpan.FromSeconds(5));
|
||||
|
||||
var disposal = tracker.DisposeAsync().AsTask();
|
||||
var finishedInTime = await Task.WhenAny(disposal, Task.Delay(TimeSpan.FromSeconds(2))) == disposal;
|
||||
|
||||
Assert.That(finishedInTime, Is.True);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task AFailedCountDoesNotEndTheTracker()
|
||||
{
|
||||
//
|
||||
// A tracker which died on one bad answer would leave a stale number standing forever, which
|
||||
// is the one failure this whole mechanism exists to rule out.
|
||||
//
|
||||
var runs = 0;
|
||||
|
||||
await using var tracker = new ConversationTokenTracker(_ =>
|
||||
{
|
||||
Interlocked.Increment(ref runs);
|
||||
throw new InvalidOperationException("The tokenizer did not answer.");
|
||||
}, () => TimeSpan.FromMilliseconds(50), TimeSpan.FromMilliseconds(100));
|
||||
|
||||
tracker.Start();
|
||||
await Task.Delay(1_000);
|
||||
|
||||
Assert.That(runs, Is.GreaterThanOrEqualTo(2));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
using AIStudio.Chat;
|
||||
using AIStudio.Models;
|
||||
|
||||
namespace AIStudio.Tests.Chat;
|
||||
|
||||
/// <summary>
|
||||
/// Checks what the chat says about the images a conversation carries.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The visual briefing refuses a build with too many pictures, because that build is expensive and
|
||||
/// fails late. A chat cannot refuse anything: the pictures are already in the conversation, and
|
||||
/// taking them back out means deleting messages. So the chat says so instead, and what is checked
|
||||
/// here is that it says so at the right moment -- and, more importantly, that it stays quiet when
|
||||
/// nobody wrote a limit down.
|
||||
/// </remarks>
|
||||
[TestFixture]
|
||||
public sealed class ConversationTokensTests
|
||||
{
|
||||
[TestCase(1, 100, false)]
|
||||
[TestCase(100, 100, false, Description = "Exactly the limit still fits. It is a maximum, not a threshold.")]
|
||||
[TestCase(101, 100, true)]
|
||||
[TestCase(3_601, 3_600, true)]
|
||||
public void TooManyPicturesIsAQuestionOfTheNumberTheVendorStated(int images, int allowed, bool tooMany)
|
||||
{
|
||||
var counted = new ConversationTokens
|
||||
{
|
||||
IsKnown = true,
|
||||
UncountedImages = images,
|
||||
ImageLimits = new ImageLimits(null, allowed),
|
||||
};
|
||||
|
||||
Assert.That(counted.TooManyImages, Is.EqualTo(tooMany));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void WithoutAStatedLimitThereIsNoSuchThingAsTooMany()
|
||||
{
|
||||
//
|
||||
// The common case. Most models are served at whatever their operator configured, and an app
|
||||
// which warned about the seventh picture would be inventing a ceiling nobody wrote.
|
||||
//
|
||||
var counted = new ConversationTokens
|
||||
{
|
||||
IsKnown = true,
|
||||
UncountedImages = 500,
|
||||
ImageLimits = ImageLimits.UNKNOWN,
|
||||
};
|
||||
|
||||
Assert.That(counted.TooManyImages, Is.False);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void TheSmallerOfTwoStatedLimitsIsTheOneWhichDecides()
|
||||
{
|
||||
//
|
||||
// A message is part of a request, so a conversation which fits the request limit can still
|
||||
// be too much for one message. Both are compared against the same number of pictures,
|
||||
// because a chat sends all of them in one message.
|
||||
//
|
||||
var counted = new ConversationTokens
|
||||
{
|
||||
IsKnown = true,
|
||||
UncountedImages = 20,
|
||||
ImageLimits = new ImageLimits(8, 100),
|
||||
};
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(counted.TooManyImages, Is.True);
|
||||
Assert.That(counted.ImageLimits.MaxInOneMessage, Is.EqualTo(8));
|
||||
});
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void AConversationWithoutPicturesNeverComplainsAboutThem()
|
||||
{
|
||||
var counted = new ConversationTokens
|
||||
{
|
||||
IsKnown = true,
|
||||
UncountedImages = 0,
|
||||
ImageLimits = new ImageLimits(null, 0),
|
||||
};
|
||||
|
||||
Assert.That(counted.TooManyImages, Is.False, "Not even against a model which takes none at all.");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void AnUnavailableCountClaimsNothingAboutPictures()
|
||||
{
|
||||
//
|
||||
// Nothing could be counted, so nothing is known -- including how many pictures travel. A
|
||||
// warning built on that would be made up.
|
||||
//
|
||||
Assert.That(ConversationTokens.UNAVAILABLE.TooManyImages, Is.False);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
using AIStudio.Chat;
|
||||
using AIStudio.Provider;
|
||||
using AIStudio.Provider.OpenAI;
|
||||
|
||||
namespace AIStudio.Tests.Chat;
|
||||
|
||||
/// <summary>
|
||||
/// Checks that writing a message asks the same question as attaching the picture did.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// These were two questions until now. Attaching a file asked the configured provider, so a person's
|
||||
/// expert settings counted; building the message asked the automatic answer alone, so they did not.
|
||||
/// Somebody who switched image input on for their own installation watched the picture attach and
|
||||
/// then watched it disappear on the way to the model -- every chat round and every tool round, with
|
||||
/// nothing anywhere saying why.
|
||||
/// </remarks>
|
||||
[TestFixture]
|
||||
public sealed class ListContentBlockExtensionsTests
|
||||
{
|
||||
[Test]
|
||||
public async Task ImageInputSwitchedOnByHandReachesTheMessageAsWell()
|
||||
{
|
||||
var provider = new AIStudio.Settings.Provider(0, "test", "Test", LLMProviders.SELF_HOSTED, new Model("llama3.3:70b", null))
|
||||
{
|
||||
// The rules say this model reads text only, which is what makes it the right model here:
|
||||
CapabilityOverrides = new() { MultipleImageInput = true },
|
||||
};
|
||||
|
||||
var messages = await BlocksWithAPicture().BuildMessagesAsync(provider, _ => "user", Text, Picture);
|
||||
|
||||
Assert.That(messages.Single(), Is.InstanceOf<MultimodalMessage>(), "The picture is part of the message because the person said this model can read one.");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task WithoutThatSwitchThePictureStaysOut()
|
||||
{
|
||||
var provider = new AIStudio.Settings.Provider(0, "test", "Test", LLMProviders.SELF_HOSTED, new Model("llama3.3:70b", null));
|
||||
|
||||
var messages = await BlocksWithAPicture().BuildMessagesAsync(provider, _ => "user", Text, Picture);
|
||||
|
||||
Assert.That(messages.Single(), Is.InstanceOf<TextMessage>(), "Nothing says this model reads pictures, so the text goes on its own.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// One block of text with a picture hanging on it.
|
||||
/// </summary>
|
||||
/// <returns>The blocks.</returns>
|
||||
private static List<ContentBlock> BlocksWithAPicture() =>
|
||||
[
|
||||
new()
|
||||
{
|
||||
Role = ChatRole.USER,
|
||||
ContentType = ContentType.TEXT,
|
||||
Content = new ContentText
|
||||
{
|
||||
Text = "What is in this picture?",
|
||||
FileAttachments = [new FileAttachmentImage("picture.png", "/tmp/picture.png", 1_024)],
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
private static ISubContent Text(string text) => new SubContentText { Text = text };
|
||||
|
||||
private static Task<ISubContent> Picture(FileAttachmentImage image) => Task.FromResult<ISubContent>(new SubContentText { Text = image.FileName });
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
using System.Globalization;
|
||||
|
||||
using AIStudio.Chat;
|
||||
|
||||
namespace AIStudio.Tests.Chat;
|
||||
|
||||
/// <summary>
|
||||
/// Checks how a number of tokens is written under the input field.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The culture is an argument rather than something taken from the machine, and that is the point
|
||||
/// being checked as much as the digits are: AI Studio's language is chosen in its own settings, so
|
||||
/// the thread's culture says nothing about which separators a person expects to read.
|
||||
/// </remarks>
|
||||
[TestFixture]
|
||||
public sealed class TokenAmountTests
|
||||
{
|
||||
private static readonly CultureInfo AMERICAN = CultureInfo.GetCultureInfo("en-US");
|
||||
|
||||
private static readonly CultureInfo GERMAN = CultureInfo.GetCultureInfo("de-DE");
|
||||
|
||||
[TestCase(0, "0")]
|
||||
[TestCase(7, "7")]
|
||||
[TestCase(847, "847")]
|
||||
[TestCase(999, "999", Description = "The last number written out in full.")]
|
||||
[TestCase(1_000, "1.00k")]
|
||||
[TestCase(1_234, "1.23k")]
|
||||
[TestCase(12_347, "12.35k")]
|
||||
[TestCase(128_000, "128.00k")]
|
||||
[TestCase(400_000, "400.00k")]
|
||||
[TestCase(999_499, "999.50k")]
|
||||
[TestCase(999_999, "1.00M", Description = "Rounded before the unit is chosen, so it does not read as 1,000.00k.")]
|
||||
[TestCase(1_000_000, "1.00M")]
|
||||
[TestCase(1_048_576, "1.05M")]
|
||||
[TestCase(1_050_000, "1.05M", Description = "Which is how OpenAI writes it themselves.")]
|
||||
[TestCase(2_000_000, "2.00M")]
|
||||
public void ANumberOfTokensIsWrittenTheWayItIsRead(int tokens, string wanted)
|
||||
{
|
||||
Assert.That(TokenAmount.Format(tokens, AMERICAN), Is.EqualTo(wanted));
|
||||
}
|
||||
|
||||
[TestCase(999, "999")]
|
||||
[TestCase(1_234, "1,23k")]
|
||||
[TestCase(400_000, "400,00k")]
|
||||
[TestCase(1_048_576, "1,05M")]
|
||||
public void TheSeparatorsAreTheOnesTheUserKnows(int tokens, string wanted)
|
||||
{
|
||||
//
|
||||
// A German reads 1,23k where an American reads 1.23k. Writing either of them the other way
|
||||
// around reads as a number a thousand times off.
|
||||
//
|
||||
Assert.That(TokenAmount.Format(tokens, GERMAN), Is.EqualTo(wanted));
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user