Rebuilt how AI Studio knows what a model can do (#960)
Some checks are pending
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:
Thorsten Sommer 2026-09-13 14:17:25 +02:00 committed by GitHub
parent d21e09dd1e
commit d85b4e71b6
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
287 changed files with 18341 additions and 3677 deletions

View File

@ -724,9 +724,94 @@ jobs:
overwrite: true overwrite: true
retention-days: ${{ env.RETENTION_INTERMEDIATE_ASSETS }} retention-days: ${{ env.RETENTION_INTERMEDIATE_ASSETS }}
#
# The quality gate. Deliberately without an `if` on build_enabled: a pull request only builds
# when somebody sets the run-pipeline label, and a gate which is closed exactly while nobody is
# looking is not a gate. It is cheap for the same reason it is unconditional -- one platform, no
# Tauri bundle, no signing, no artifacts.
#
verify:
name: Verify
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Read the toolchain versions from the metadata
run: |
# The .NET SDK version. The format is '9.0.205 (commit 3e1383b780)',
# so we extract the version number alone:
dotnet_sdk_version=$(sed -n '4p' metadata.txt | sed 's/[^0-9.]*\([0-9.]*\).*/\1/')
# The Rust version, written the same way:
rust_version=$(sed -n '6p' metadata.txt | sed 's/[^0-9.]*\([0-9.]*\).*/\1/')
echo "DOTNET_SDK_VERSION=${dotnet_sdk_version}" >> $GITHUB_ENV
echo "RUST_VERSION=${rust_version}" >> $GITHUB_ENV
echo ".NET SDK version: '${dotnet_sdk_version}'"
echo "Rust version: '${rust_version}'"
- name: Setup .NET
uses: actions/setup-dotnet@v4
with:
dotnet-version: ${{ env.DOTNET_SDK_VERSION }}
cache: true
cache-dependency-path: 'app/MindWork AI Studio/packages.lock.json'
- name: Cache Rust
uses: actions/cache@v4
with:
path: |
~/.cargo/git/db/
~/.cargo/registry/index/
~/.cargo/registry/cache/
runtime/target
key: verify-linux-x64-rust-${{ env.RUST_VERSION }}
- name: Setup Rust (stable)
uses: dtolnay/rust-toolchain@master
with:
toolchain: ${{ env.RUST_VERSION }}
components: clippy
- name: Setup dependencies (Ubuntu-specific)
run: |
sudo apt-get update
sudo apt-get install -y libwebkit2gtk-4.1-dev libayatana-appindicator3-dev librsvg2-dev patchelf libfuse2 xdg-utils gstreamer1.0-plugins-base gstreamer1.0-plugins-good
- name: Place stand-ins for what Tauri's build script expects
run: |
# Tauri's build script insists that everything the configuration lists is already there,
# and refuses to run otherwise -- so nothing Rust compiles without it. Two of those
# things are products of a build which has not run here: the .NET app as a sidecar, and
# the PDF library which the build downloads into the resources. The other two resource
# directories, notices and tokenizers, are in the repository and need nothing.
#
# This job never bundles anything and never starts the app; it compiles, tests and lints
# the Rust code, and no test opens either file. Empty stand-ins are therefore enough,
# while publishing the sidecar and downloading the library would cost minutes for files
# nobody here reads. Should a Rust test ever need the real library, this job has to
# deploy it the way build_main does instead of placing a stand-in.
mkdir -p "app/MindWork AI Studio/bin/dist"
touch "app/MindWork AI Studio/bin/dist/mindworkAIStudioServer-x86_64-unknown-linux-gnu"
chmod +x "app/MindWork AI Studio/bin/dist/mindworkAIStudioServer-x86_64-unknown-linux-gnu"
mkdir -p runtime/resources/libraries
touch runtime/resources/libraries/stand-in-for-verify.txt
- name: Run the quality gate
run: |
cd "app/Build"
dotnet run verify
build_main: build_main:
name: Build app (${{ matrix.dotnet_runtime }}) name: Build app (${{ matrix.dotnet_runtime }})
needs: [determine_run_mode, read_metadata] needs: [determine_run_mode, read_metadata, verify]
if: needs.determine_run_mode.outputs.build_enabled == 'true' if: needs.determine_run_mode.outputs.build_enabled == 'true'
permissions: permissions:
contents: read contents: read

3
.gitignore vendored
View File

@ -172,3 +172,6 @@ orleans.codegen.cs
# Tauri generated schemas/manifests # Tauri generated schemas/manifests
/runtime/gen/ /runtime/gen/
# Ignore what a failing snapshot test leaves behind for comparison:
/app/Tests/Models/Corpus/CapabilitySnapshot.actual.txt

View File

@ -80,7 +80,21 @@ Notes:
troubleshooting, no matter whether it came from the MCP server or from the user. troubleshooting, no matter whether it came from the MCP server or from the user.
### Running Tests ### Running Tests
Currently, no automated test suite exists in the repository. The .NET tests live in `app/Tests`, a single NUnit project that holds the tests of every area; each
area gets its own folder and namespace below it rather than a project of its own. Agents run them
through the IDE for the same reason they build there:
```
mcp__rider__execute_terminal_command command: "cd app/Tests && dotnet test"
```
An assembly-wide `[SetUpFixture]` in `app/Tests/TestHost.cs` fills the static application state that
the app itself only fills while starting up, `Program.LOGGER_FACTORY` above all. Types that
initialize a static logger from it — `Settings.Provider` among them — otherwise die in their type
initializer before the first assertion. Prefer writing new code so that it does not reach for such
statics at all.
The Rust tests run with `cargo test` in `runtime/`, through the `rustrover` MCP server.
## Architecture Details ## Architecture Details
@ -141,7 +155,8 @@ Key structure:
Plugins are written in Lua and provide: Plugins are written in Lua and provide:
- **Language plugins** - I18N translations (e.g., German language pack) - **Language plugins** - I18N translations (e.g., German language pack)
- **Configuration plugins** - Enterprise IT configurations for centrally managed providers, settings - **Configuration plugins** - Enterprise IT configurations for centrally managed providers, settings
- **Future:** Assistant plugins for custom assistants - **Assistant plugins** - custom assistants and direct-chat launchers, subject to approval or a local security audit
- **Model plugins** - what an organization's own models can do, see `documentation/Models.md`
**Example configuration plugin:** `app/MindWork AI Studio/Plugins/configuration/plugin.lua` **Example configuration plugin:** `app/MindWork AI Studio/Plugins/configuration/plugin.lua`
@ -177,6 +192,21 @@ When adding, changing, or removing model-driven tools, keep these parts in sync:
Tool implementations must treat model-provided arguments as untrusted input. Validate settings and arguments, protect secrets with `SensitiveTraceArgumentNames`, use `ToolExecutionBlockedException` for intentional policy blocks, and check provider confidence before returning sensitive data to the model. Tool implementations must treat model-provided arguments as untrusted input. Validate settings and arguments, protect secrets with `SensitiveTraceArgumentNames`, use `ToolExecutionBlockedException` for intentional policy blocks, and check provider confidence before returning sensitive data to the model.
## Model Capabilities
**Documentation:** `documentation/Models.md`
What a model can do is answered in `app/MindWork AI Studio/Models/`, through `provider.GetModelProfile()`. Never ask `ModelRegistry` directly from a component: the extension method is what adds the expert settings and what a provider's model list reported, and the registry alone answers neither.
When adding, changing, or removing model knowledge, keep these parts in sync:
- `app/MindWork AI Studio/Models/<Vendor>/<Family>.cs` for the family itself. Creating the class is enough — the source generator in `app/SourceGeneratedMappings/` collects every non-abstract `ModelFamily` and `IModelHost` at compile time, so there is no registration list. Do not add reflection here; `PublishTrimmed` is on.
- `app/Tests/Models/Corpus/` for the model IDs the family covers, marked as either unchanged or expected to change. A porting difference which nobody declared is what the corpus exists to catch.
- `app/MindWork AI Studio/Models/Kinds/` when the change is about what kind of model something is, rather than what it can do. These are ordinary rules of the same engine.
- `app/MindWork AI Studio/Models/Hosting/Hosts/` when a provider wraps model names or cannot pass an API through. A host unwraps and trims the transport; it states nothing about the model itself.
- `app/MindWork AI Studio/Plugins/models/plugin.lua` when a new field can be declared by an organization, and `app/MindWork AI Studio/Plugins/configuration/plugin.lua` when it can be overridden per provider instance.
Rules are never tried in order: specificity is computed from the rule, and two rules of equal specificity on one name fail the test suite. State how a model reasons with `Reasoning(...)` — the three reasoning capabilities are override vocabulary and must never appear in a profile. Every family and every host has to name the page it was read from and the day somebody read it; `dotnet run verify-models` reports the ones which have gone stale.
## RAG (Retrieval-Augmented Generation) ## RAG (Retrieval-Augmented Generation)
RAG integration is currently in development (preview feature). Architecture: RAG integration is currently in development (preview feature). Architecture:

View File

@ -189,6 +189,8 @@ You want to know how to build MindWork AI Studio from source? [Check out the ins
Do you want to add or maintain model-driven tools? [Read the tool development guide here](documentation/Tools.md). Do you want to add or maintain model-driven tools? [Read the tool development guide here](documentation/Tools.md).
Do you want to teach AI Studio what a model can do? [Read the model capabilities guide here](documentation/Models.md).
</details> </details>
<details> <details>

View File

@ -87,8 +87,10 @@ public sealed partial class UpdateMetadataCommands
await new CollectI18NKeysCommand().CollectI18NKeys(); await new CollectI18NKeysCommand().CollectI18NKeys();
// Build the final release, where Rust knows the updated metadata, the .NET // Build the final release, where Rust knows the updated metadata, the .NET
// artifacts are already in place, and .NET knows the updated web assets, etc.: // artifacts are already in place, and .NET knows the updated web assets, etc.
await this.Build(offline); // The gate already ran in the first build; running it a second time on the same
// sources would only add minutes:
await this.Build(offline, skipVerify: true);
} }
[Command("update-metainfo", Description = "Update the AppStream metainfo entry of one release from its changelog")] [Command("update-metainfo", Description = "Update the AppStream metainfo entry of one release from its changelog")]
@ -221,11 +223,21 @@ public sealed partial class UpdateMetadataCommands
[Command("build", Description = "Build MindWork AI Studio")] [Command("build", Description = "Build MindWork AI Studio")]
public async Task Build( public async Task Build(
[Option("offline", Description = "Skip downloads and use locally available build dependencies")] bool offline = false) [Option("offline", Description = "Skip downloads and use locally available build dependencies")] bool offline = false,
[Option("skip-verify", Description = "Skip the quality gate which otherwise runs before anything is built")] bool skipVerify = false)
{ {
if(!Environment.IsWorkingDirectoryValid()) if(!Environment.IsWorkingDirectoryValid())
return; return;
//
// The gate runs before anything is built, and the build stops when it does not pass. That
// way the same command answers both questions a person has -- is it sound, and does it
// build -- and answers them in that order, because building something the tests reject
// takes minutes to produce an artifact nobody should use.
//
if (!skipVerify && await new VerifyCommand().Verify() is not 0)
throw new CommandExitedException(1);
// //
// Build the .NET project: // Build the .NET project:
// //
@ -596,7 +608,7 @@ public sealed partial class UpdateMetadataCommands
// Drop any earlier entry of this version, so that the version stays unique and moves to the top. // Drop any earlier entry of this version, so that the version stays unique and moves to the top.
// We remove from the back, so that the index of the remaining matches stays valid: // We remove from the back, so that the index of the remaining matches stays valid:
foreach (var previousRelease in ReleaseBlockRegex().Matches(metainfo).Cast<Match>().Where(match => ReleaseTagHasVersion(match.Value, appVersion)).Reverse()) foreach (var previousRelease in ReleaseBlockRegex().Matches(metainfo).Where(match => ReleaseTagHasVersion(match.Value, appVersion)).Reverse())
metainfo = metainfo.Remove(previousRelease.Index, previousRelease.Length); metainfo = metainfo.Remove(previousRelease.Index, previousRelease.Length);
var lineEnding = metainfo.Contains("\r\n", StringComparison.Ordinal) ? "\r\n" : "\n"; var lineEnding = metainfo.Contains("\r\n", StringComparison.Ordinal) ? "\r\n" : "\n";

View File

@ -0,0 +1,92 @@
using Build.Tools;
// ReSharper disable ClassNeverInstantiated.Global
// ReSharper disable UnusedType.Global
// ReSharper disable UnusedMember.Global
namespace Build.Commands;
/// <summary>
/// The quality gate: one command, the same one locally and in the pipeline.
/// </summary>
/// <remarks>
/// Every check runs, even after one of them has failed. A gate which stops at the first failure
/// tells you one thing per run, and the next run costs the same minutes again -- while the point of
/// running the whole thing is to learn everything which is wrong in one go.
/// </remarks>
public sealed class VerifyCommand
{
/// <summary>
/// How the .NET app is named once it lies where Tauri expects it.
/// </summary>
private const string SIDECAR_PREFIX = "mindworkAIStudioServer-";
[Command("verify", Description = "Run the quality gate: .NET tests, Rust tests, Clippy, and the model sources")]
public async Task<int> Verify()
{
if(!Environment.IsWorkingDirectoryValid())
return 1;
Console.WriteLine("==============================");
Console.WriteLine("- Quality gate: every check runs, so that the first failure does not hide the next ...");
var results = new List<(string What, int ExitCode)>
{
(".NET tests", await CommandRunner.RunAsync(Environment.GetTestsDirectory(), "dotnet", "test --nologo")),
};
var runtimeDirectory = Environment.GetRustRuntimeDirectory();
if (WhatTauriExpectsIsThere())
{
results.Add(("Rust tests", await CommandRunner.RunAsync(runtimeDirectory, "cargo", "test")));
results.Add(("Clippy", await CommandRunner.RunAsync(runtimeDirectory, "cargo", "clippy --all-targets -- -D warnings")));
}
else
{
//
// Tauri's build script insists that everything the configuration lists is already
// there and refuses to run otherwise, so nothing Rust compiles until a build has
// produced those files once. Failing here would be a trap rather than a gate: the way
// to produce them is `dotnet run build`, and that command runs this gate first -- a
// fresh clone would never get past it.
//
Console.WriteLine("- Skipping the Rust tests and Clippy: the .NET sidecar or the downloaded libraries are missing, and Tauri's build script needs both before anything Rust compiles.");
Console.WriteLine(" Run 'dotnet run build --skip-verify' once. From then on, this part of the gate runs with the rest.");
}
results.Add(("Model sources", new VerifyModelsCommand().VerifyModels()));
Console.WriteLine("==============================");
Console.WriteLine("- Quality gate:");
foreach (var (what, exitCode) in results)
Console.WriteLine($" - {what}: {(exitCode is 0 ? "passed" : $"failed, exit code {exitCode}")}");
var failed = results.Count(result => result.ExitCode is not 0);
if (failed is 0)
{
Console.WriteLine($"- All {results.Count} checks passed.");
return 0;
}
Console.WriteLine($"- {failed} of {results.Count} checks failed.");
return 1;
}
/// <summary>
/// Whether a build has already produced the files Tauri's build script reads.
/// </summary>
/// <remarks>
/// Both are products of a build rather than of the repository: the .NET app arrives as a
/// sidecar, and the PDF library is downloaded into the resources. The other resource
/// directories the configuration names are in the repository and are always there.
/// </remarks>
/// <returns>True, when cargo can get past the build script.</returns>
private static bool WhatTauriExpectsIsThere()
{
var distributionDirectory = Path.Combine(Environment.GetAIStudioDirectory(), "bin", "dist");
if (!Directory.Exists(distributionDirectory) || !Directory.EnumerateFiles(distributionDirectory, $"{SIDECAR_PREFIX}*").Any())
return false;
var librariesDirectory = Path.Combine(Environment.GetRustRuntimeDirectory(), "resources", "libraries");
return Directory.Exists(librariesDirectory) && Directory.EnumerateFiles(librariesDirectory).Any();
}
}

View File

@ -0,0 +1,178 @@
using System.Text.RegularExpressions;
// ReSharper disable ClassNeverInstantiated.Global
// ReSharper disable UnusedType.Global
// ReSharper disable UnusedMember.Global
namespace Build.Commands;
/// <summary>
/// Reports how long ago somebody last read the pages the model rules were written from.
/// </summary>
/// <remarks>
/// Everything a rule set can be asked about itself is asked by the test project, against the
/// registry as it is really built: whether two rules claim the same names with the same right,
/// whether every family and every host names a page and a day, whether every pattern is written the
/// way model names arrive, whether a family reaches for one of the three reasoning words, and
/// whether a rank was set without saying what it moves past. Those belong there and not here --
/// asking them a second time in this command would be a second implementation of the same
/// judgement, and two implementations of one judgement drift apart.
///
/// The one question a test cannot ask is this one, because its answer changes with the calendar
/// rather than with the code: a family nobody touched would turn red on some Tuesday six months
/// after it was written. That is why it reports instead of failing, and why it is a command of its
/// own rather than a test or an analyzer.
///
/// It fails on exactly one thing: when it can no longer read the sources at all. A check which
/// quietly reads nothing reports that everything is fine.
/// </remarks>
public sealed partial class VerifyModelsCommand
{
/// <summary>
/// How long a page may go unread before it is worth mentioning.
/// </summary>
private const int DEFAULT_MONTHS = 6;
/// <summary>
/// The part of a source statement which is there in every spelling of it.
/// </summary>
/// <remarks>
/// Counting these and comparing the count with what the pattern below actually read is how this
/// command notices that it has gone blind, rather than reporting an empty list of old sources.
/// </remarks>
private const string DAY_MARKER = "new DateOnly(";
[Command("verify-models", Description = "Report how long ago the pages behind the model rules were read")]
public int VerifyModels(
[Option("months", Description = "How long a page may go unread before it is reported")] int months = DEFAULT_MONTHS)
{
if(!Environment.IsWorkingDirectoryValid())
return 1;
if (months < 1)
{
Console.WriteLine("- Error: The number of months has to be at least 1.");
return 1;
}
var modelsDirectory = Path.Combine(Environment.GetAIStudioDirectory(), "Models");
if (!Directory.Exists(modelsDirectory))
{
Console.WriteLine($"- Error: The models directory '{modelsDirectory}' does not exist. Either it moved, or this command looks in the wrong place.");
return 1;
}
Console.WriteLine("==============================");
Console.WriteLine("- Reading the sources behind the model rules ...");
var repository = Environment.GetRepositoryDirectory();
var files = Directory.EnumerateFiles(modelsDirectory, "*.cs", SearchOption.AllDirectories).Order(StringComparer.Ordinal).ToArray();
var sources = new List<ReadSource>();
var unreadable = new List<string>();
foreach (var file in files)
{
var place = RelativeTo(repository, file);
var lines = File.ReadAllLines(file);
for (var index = 0; index < lines.Length; index++)
{
var line = lines[index];
var stated = CountOccurrences(line, DAY_MARKER);
if (stated is 0)
continue;
var read = SourceStatement().Matches(line);
foreach (Match statement in read)
sources.Add(new(place, index + 1, statement.Groups["url"].Value, new(int.Parse(statement.Groups["year"].ValueSpan), int.Parse(statement.Groups["month"].ValueSpan), int.Parse(statement.Groups["day"].ValueSpan))));
for (var missed = read.Count; missed < stated; missed++)
unreadable.Add($"{place}:{index + 1}");
}
}
if (sources.Count is 0)
{
Console.WriteLine($"- Error: Not one source was found in the {files.Length} files under '{RelativeTo(repository, modelsDirectory)}'.");
Console.WriteLine(" Every family and every host states one, so finding none means this command can no longer read them.");
Console.WriteLine(" A check which reads nothing reports that everything is fine, which is why this is an error rather than an empty report.");
return 1;
}
if (unreadable.Count > 0)
{
Console.WriteLine($"- Error: {unreadable.Count} source(s) are written in a shape this command cannot read:");
foreach (var place in unreadable)
Console.WriteLine($" - {place}");
Console.WriteLine(" A source whose day cannot be read never grows old, and would stay out of the report below without anybody noticing.");
Console.WriteLine(""" Write it as new("<url>", new DateOnly(<year>, <month>, <day>), "<note>") on one line, or teach this command the new shape.""");
return 1;
}
var oldest = sources.MinBy(source => source.CheckedOn);
var newest = sources.MaxBy(source => source.CheckedOn);
Console.WriteLine($"- Read {sources.Count} sources in {files.Length} files under '{RelativeTo(repository, modelsDirectory)}'.");
Console.WriteLine($" - Oldest: {oldest.CheckedOn:yyyy-MM-dd}, in {oldest.Place}:{oldest.Line}");
Console.WriteLine($" - Newest: {newest.CheckedOn:yyyy-MM-dd}, in {newest.Place}:{newest.Line}");
var lastAcceptableDay = DateOnly.FromDateTime(DateTime.Today).AddMonths(-months);
var stale = sources.Where(source => source.CheckedOn < lastAcceptableDay).OrderBy(source => source.CheckedOn).ToArray();
if (stale.Length is 0)
{
Console.WriteLine($"- Every source was read on {lastAcceptableDay:yyyy-MM-dd} or later, so none of them is older than {months} months.");
return 0;
}
var insideActions = string.Equals(global::System.Environment.GetEnvironmentVariable("GITHUB_ACTIONS"), "true", StringComparison.OrdinalIgnoreCase);
Console.WriteLine($"- {stale.Length} source(s) have not been read since {lastAcceptableDay:yyyy-MM-dd}:");
foreach (var source in stale)
{
Console.WriteLine($" - {source.Place}:{source.Line}, last read on {source.CheckedOn:yyyy-MM-dd}: {source.Url}");
if (insideActions)
Console.WriteLine($"::warning file={source.Place},line={source.Line}::This model source was last read on {source.CheckedOn:yyyy-MM-dd}: {source.Url}");
}
Console.WriteLine("- This is a report and never a failure: a page nobody has looked at for a while is not a page which changed.");
return 0;
}
/// <summary>
/// How a source statement is written, in the one spelling the whole model namespace uses.
/// </summary>
/// <remarks>
/// Both the family and the host state it as a target-typed new, so the type name itself is
/// nowhere in the line. What is always there is the page, then the day.
/// </remarks>
[GeneratedRegex("""new\("(?<url>[^"]*)",\s*new DateOnly\((?<year>\d{4}),\s*(?<month>\d{1,2}),\s*(?<day>\d{1,2})\)""")]
private static partial Regex SourceStatement();
private static int CountOccurrences(string line, string marker)
{
var found = 0;
var at = line.IndexOf(marker, StringComparison.Ordinal);
while (at >= 0)
{
found++;
at = line.IndexOf(marker, at + marker.Length, StringComparison.Ordinal);
}
return found;
}
/// <summary>
/// A path as GitHub reads it: relative to the checkout, with forward slashes.
/// </summary>
/// <remarks>
/// An annotation carrying an absolute path of somebody's machine lands nowhere, and it does so
/// without saying that it did.
/// </remarks>
private static string RelativeTo(string repository, string path) => Path.GetRelativePath(repository, path).Replace('\\', '/');
/// <summary>
/// One page a rule was written from, and the day somebody last read it.
/// </summary>
/// <param name="Place">The file it is stated in, relative to the repository.</param>
/// <param name="Line">The line it is stated on.</param>
/// <param name="Url">The page.</param>
/// <param name="CheckedOn">The day somebody last read it.</param>
private readonly record struct ReadSource(string Place, int Line, string Url, DateOnly CheckedOn);
}

View File

@ -7,4 +7,6 @@ app.AddCommands<UpdateMetadataCommands>();
app.AddCommands<UpdateWebAssetsCommand>(); app.AddCommands<UpdateWebAssetsCommand>();
app.AddCommands<CollectI18NKeysCommand>(); app.AddCommands<CollectI18NKeysCommand>();
app.AddCommands<AssistantPluginHashCommand>(); app.AddCommands<AssistantPluginHashCommand>();
app.AddCommands<VerifyModelsCommand>();
app.AddCommands<VerifyCommand>();
app.Run(); app.Run();

View File

@ -0,0 +1,62 @@
using System.ComponentModel;
using System.Diagnostics;
namespace Build.Tools;
/// <summary>
/// Runs one external tool and lets it write straight to the terminal.
/// </summary>
/// <remarks>
/// The output is deliberately not captured. A gate which swallows the output of a failing test run
/// and then prints "failed" leaves the person who has to fix it with nothing to go on, while the
/// tools it runs already say everything worth saying -- which test, which line, which lint.
/// </remarks>
public static class CommandRunner
{
/// <summary>
/// What a tool which could not be started at all reports.
/// </summary>
/// <remarks>
/// Anything but zero counts as a failure, so the exact number matters only in that it is not
/// one a tool would plausibly return itself.
/// </remarks>
public const int COULD_NOT_START = 127;
/// <summary>
/// Runs a tool and waits for it.
/// </summary>
/// <param name="workingDirectory">Where the tool should run.</param>
/// <param name="fileName">The tool, as it is called on the PATH.</param>
/// <param name="arguments">What to pass it.</param>
/// <returns>The exit code of the tool, or COULD_NOT_START when it never ran.</returns>
public static async Task<int> RunAsync(string workingDirectory, string fileName, string arguments)
{
Console.WriteLine($"- Running '{fileName} {arguments}' in '{workingDirectory}' ...");
var startInfo = new ProcessStartInfo
{
FileName = fileName,
Arguments = arguments,
WorkingDirectory = workingDirectory,
UseShellExecute = false,
};
try
{
using var process = Process.Start(startInfo);
if (process is null)
{
Console.WriteLine($"- Error: '{fileName}' did not start, and the system did not say why.");
return COULD_NOT_START;
}
await process.WaitForExitAsync();
return process.ExitCode;
}
catch (Win32Exception exception)
{
Console.WriteLine($"- Error: '{fileName}' could not be started: {exception.Message}");
Console.WriteLine($" Is '{fileName}' installed and on the PATH?");
return COULD_NOT_START;
}
}
}

View File

@ -34,6 +34,28 @@ public static class Environment
return Path.GetFullPath(directory); return Path.GetFullPath(directory);
} }
public static string GetTestsDirectory()
{
var currentDirectory = Directory.GetCurrentDirectory();
var directory = Path.Combine(currentDirectory, "..", "Tests");
return Path.GetFullPath(directory);
}
/// <summary>
/// The root of the git repository, which is what a path in a report is written relative to.
/// </summary>
/// <remarks>
/// GitHub resolves the file of an annotation against the checkout, not against wherever a tool
/// happened to run. An absolute path of somebody's machine would therefore land the annotation
/// nowhere, without saying so.
/// </remarks>
public static string GetRepositoryDirectory()
{
var currentDirectory = Directory.GetCurrentDirectory();
var directory = Path.Combine(currentDirectory, "..", "..");
return Path.GetFullPath(directory);
}
public static string GetRustRuntimeDirectory() public static string GetRustRuntimeDirectory()
{ {
var currentDirectory = Directory.GetCurrentDirectory(); var currentDirectory = Directory.GetCurrentDirectory();

View File

@ -10,6 +10,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SharedTools", "SharedTools\
EndProject EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SourceGeneratedMappings", "SourceGeneratedMappings\SourceGeneratedMappings.csproj", "{4D7141D5-9C22-4D85-B748-290D15FF484C}" Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SourceGeneratedMappings", "SourceGeneratedMappings\SourceGeneratedMappings.csproj", "{4D7141D5-9C22-4D85-B748-290D15FF484C}"
EndProject EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Tests", "Tests\Tests.csproj", "{CD46329B-D135-4594-9A70-55D3480F8FEE}"
EndProject
Global Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU Debug|Any CPU = Debug|Any CPU
@ -36,6 +38,10 @@ Global
{4D7141D5-9C22-4D85-B748-290D15FF484C}.Debug|Any CPU.Build.0 = Debug|Any CPU {4D7141D5-9C22-4D85-B748-290D15FF484C}.Debug|Any CPU.Build.0 = Debug|Any CPU
{4D7141D5-9C22-4D85-B748-290D15FF484C}.Release|Any CPU.ActiveCfg = Release|Any CPU {4D7141D5-9C22-4D85-B748-290D15FF484C}.Release|Any CPU.ActiveCfg = Release|Any CPU
{4D7141D5-9C22-4D85-B748-290D15FF484C}.Release|Any CPU.Build.0 = Release|Any CPU {4D7141D5-9C22-4D85-B748-290D15FF484C}.Release|Any CPU.Build.0 = Release|Any CPU
{CD46329B-D135-4594-9A70-55D3480F8FEE}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{CD46329B-D135-4594-9A70-55D3480F8FEE}.Debug|Any CPU.Build.0 = Debug|Any CPU
{CD46329B-D135-4594-9A70-55D3480F8FEE}.Release|Any CPU.ActiveCfg = Release|Any CPU
{CD46329B-D135-4594-9A70-55D3480F8FEE}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection EndGlobalSection
GlobalSection(NestedProjects) = preSolution GlobalSection(NestedProjects) = preSolution
EndGlobalSection EndGlobalSection

View File

@ -3571,9 +3571,15 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::CHATCOMPONENT::T1849313532"] = "Type your
-- Your Prompt (use selected instance '{0}', provider '{1}') -- Your Prompt (use selected instance '{0}', provider '{1}')
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::CHATCOMPONENT::T1967611328"] = "Your Prompt (use selected instance '{0}', provider '{1}')" UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::CHATCOMPONENT::T1967611328"] = "Your Prompt (use selected instance '{0}', provider '{1}')"
-- approx. {0} of {1} tokens
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::CHATCOMPONENT::T1992478915"] = "approx. {0} of {1} tokens"
-- Code -- Code
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::CHATCOMPONENT::T2036185364"] = "Code" UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::CHATCOMPONENT::T2036185364"] = "Code"
-- plus {0} image(s), which is more than the {1} this model accepts
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::CHATCOMPONENT::T2059172343"] = "plus {0} image(s), which is more than the {1} this model accepts"
-- Italic -- Italic
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::CHATCOMPONENT::T2377171085"] = "Italic" UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::CHATCOMPONENT::T2377171085"] = "Italic"
@ -3595,15 +3601,21 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::CHATCOMPONENT::T2991985411"] = "Delete th
-- Move Chat to Workspace -- Move Chat to Workspace
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::CHATCOMPONENT::T3045856778"] = "Move Chat to Workspace" UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::CHATCOMPONENT::T3045856778"] = "Move Chat to Workspace"
-- {0} tokens
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::CHATCOMPONENT::T3244065777"] = "{0} tokens"
-- plus {0} image(s), which cannot be counted
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::CHATCOMPONENT::T3619858297"] = "plus {0} image(s), which cannot be counted"
-- Select a provider first -- Select a provider first
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::CHATCOMPONENT::T3654197869"] = "Select a provider first" UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::CHATCOMPONENT::T3654197869"] = "Select a provider first"
-- Estimated amount of tokens:
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::CHATCOMPONENT::T377990776"] = "Estimated amount of tokens:"
-- Start new chat in workspace '{0}' -- Start new chat in workspace '{0}'
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::CHATCOMPONENT::T3928697643"] = "Start new chat in workspace '{0}'" UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::CHATCOMPONENT::T3928697643"] = "Start new chat in workspace '{0}'"
-- {0} of {1} tokens
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::CHATCOMPONENT::T3996190985"] = "{0} of {1} tokens"
-- Start temporary chat -- Start temporary chat
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::CHATCOMPONENT::T4113970938"] = "Start temporary chat" UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::CHATCOMPONENT::T4113970938"] = "Start temporary chat"
@ -3619,6 +3631,9 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::CHATCOMPONENT::T636393754"] = "Move the c
-- Show your workspaces -- Show your workspaces
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::CHATCOMPONENT::T733672375"] = "Show your workspaces" UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::CHATCOMPONENT::T733672375"] = "Show your workspaces"
-- approx. {0} tokens
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::CHATCOMPONENT::T857715435"] = "approx. {0} tokens"
-- Create template from current chat -- Create template from current chat
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::CHATTEMPLATESELECTION::T1112722156"] = "Create template from current chat" UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::CHATTEMPLATESELECTION::T1112722156"] = "Create template from current chat"
@ -5014,6 +5029,15 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::THIRDPARTYCOMPONENT::T1392042694"] = "Ope
-- License: -- License:
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::THIRDPARTYCOMPONENT::T1908172666"] = "License:" UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::THIRDPARTYCOMPONENT::T1908172666"] = "License:"
-- The vendor of this model publishes no tokenizer file and counts through their API instead ({0}). AI Studio therefore estimates the token count with its built-in tokenizer.
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::TOKENIZERHINT::T3965340739"] = "The vendor of this model publishes no tokenizer file and counts through their API instead ({0}). AI Studio therefore estimates the token count with its built-in tokenizer."
-- This model uses OpenAI's {0} encoding, which does not come as a tokenizer.json file. AI Studio therefore estimates the token count with its built-in tokenizer.
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::TOKENIZERHINT::T466506475"] = "This model uses OpenAI's {0} encoding, which does not come as a tokenizer.json file. AI Studio therefore estimates the token count with its built-in tokenizer."
-- This model uses the tokenizer of {0}. Download its tokenizer.json file and select it below to count exactly instead of estimating.
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::TOKENIZERHINT::T924854143"] = "This model uses the tokenizer of {0}. Download its tokenizer.json file and select it below to count exactly instead of estimating."
-- Tool selection is hidden -- Tool selection is hidden
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::TOOLDEFAULTSCONFIGURATION::T2096103917"] = "Tool selection is hidden" UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::TOOLDEFAULTSCONFIGURATION::T2096103917"] = "Tool selection is hidden"
@ -6901,15 +6925,27 @@ UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PROVIDERDIALOG::T1108876344"] = "Hide Expert
-- Failed to store the API key in the operating system. The message was: {0}. Please try again. -- Failed to store the API key in the operating system. The message was: {0}. Please try again.
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PROVIDERDIALOG::T1122745046"] = "Failed to store the API key in the operating system. The message was: {0}. Please try again." UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PROVIDERDIALOG::T1122745046"] = "Failed to store the API key in the operating system. The message was: {0}. Please try again."
-- Where the stored numbers do not match your installation, state yours here. This matters most for self-hosted models: they run with whatever their operator configured, which the model card cannot know.
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PROVIDERDIALOG::T115770087"] = "Where the stored numbers do not match your installation, state yours here. This matters most for self-hosted models: they run with whatever their operator configured, which the model card cannot know."
-- Per message
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PROVIDERDIALOG::T1316004715"] = "Per message"
-- API Key -- API Key
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PROVIDERDIALOG::T1324664716"] = "API Key" UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PROVIDERDIALOG::T1324664716"] = "API Key"
-- Create account -- Create account
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PROVIDERDIALOG::T1356621346"] = "Create account" UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PROVIDERDIALOG::T1356621346"] = "Create account"
-- Per request
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PROVIDERDIALOG::T1363121973"] = "Per request"
-- Failed to validate the selected tokenizer. Please try again. -- Failed to validate the selected tokenizer. Please try again.
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PROVIDERDIALOG::T1384494471"] = "Failed to validate the selected tokenizer. Please try again." UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PROVIDERDIALOG::T1384494471"] = "Failed to validate the selected tokenizer. Please try again."
-- Override Model Limits
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PROVIDERDIALOG::T1518445332"] = "Override Model Limits"
-- Load models -- Load models
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PROVIDERDIALOG::T15352225"] = "Load models" UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PROVIDERDIALOG::T15352225"] = "Load models"
@ -6955,6 +6991,9 @@ UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PROVIDERDIALOG::T2029870721"] = "The current
-- Additional API parameters must form a JSON object. -- Additional API parameters must form a JSON object.
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PROVIDERDIALOG::T2051143391"] = "Additional API parameters must form a JSON object." UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PROVIDERDIALOG::T2051143391"] = "Additional API parameters must form a JSON object."
-- Nobody has stated a window for this model. Left empty, the chat counts the tokens of a conversation without saying what they may grow to.
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PROVIDERDIALOG::T2138841031"] = "Nobody has stated a window for this model. Left empty, the chat counts the tokens of a conversation without saying what they may grow to."
-- Use detected model behavior: {0}. -- Use detected model behavior: {0}.
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PROVIDERDIALOG::T2141072961"] = "Use detected model behavior: {0}." UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PROVIDERDIALOG::T2141072961"] = "Use detected model behavior: {0}."
@ -6976,6 +7015,9 @@ UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PROVIDERDIALOG::T2439094236"] = "Failed to r
-- Invalid tokenizer: -- Invalid tokenizer:
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PROVIDERDIALOG::T2448302543"] = "Invalid tokenizer:" UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PROVIDERDIALOG::T2448302543"] = "Invalid tokenizer:"
-- Vendors state one of the two, the other, or neither. Whichever is smaller decides how many images one message may carry; an empty field states nothing.
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PROVIDERDIALOG::T2519267200"] = "Vendors state one of the two, the other, or neither. Whichever is smaller decides how many images one message may carry; an empty field states nothing."
-- Enabled -- Enabled
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PROVIDERDIALOG::T2626085950"] = "Enabled" UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PROVIDERDIALOG::T2626085950"] = "Enabled"
@ -7003,6 +7045,9 @@ UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PROVIDERDIALOG::T2842060373"] = "Instance Na
-- On by default -- On by default
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PROVIDERDIALOG::T2843289040"] = "On by default" UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PROVIDERDIALOG::T2843289040"] = "On by default"
-- No limit known, so AI Studio does not stop anybody from attaching more.
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PROVIDERDIALOG::T2986951856"] = "No limit known, so AI Studio does not stop anybody from attaching more."
-- No reasoning (thinking) capability. -- No reasoning (thinking) capability.
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PROVIDERDIALOG::T301695429"] = "No reasoning (thinking) capability." UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PROVIDERDIALOG::T301695429"] = "No reasoning (thinking) capability."
@ -7012,6 +7057,12 @@ UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PROVIDERDIALOG::T3079061205"] = "Please be c
-- Reasoning (thinking) is available and on unless additional API parameters disable it. -- Reasoning (thinking) is available and on unless additional API parameters disable it.
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PROVIDERDIALOG::T310420667"] = "Reasoning (thinking) is available and on unless additional API parameters disable it." UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PROVIDERDIALOG::T310420667"] = "Reasoning (thinking) is available and on unless additional API parameters disable it."
-- Detected: {0} tokens. Leave the field empty to use that.
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PROVIDERDIALOG::T311903903"] = "Detected: {0} tokens. Leave the field empty to use that."
-- At most {0} images at once.
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PROVIDERDIALOG::T3187806707"] = "At most {0} images at once."
-- Disabled -- Disabled
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PROVIDERDIALOG::T3217987877"] = "Disabled" UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PROVIDERDIALOG::T3217987877"] = "Disabled"
@ -7048,6 +7099,12 @@ UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PROVIDERDIALOG::T3804472591"] = "Duplicate k
-- Override Model Capabilities -- Override Model Capabilities
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PROVIDERDIALOG::T3904244586"] = "Override Model Capabilities" UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PROVIDERDIALOG::T3904244586"] = "Override Model Capabilities"
-- Images
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PROVIDERDIALOG::T401363915"] = "Images"
-- Context window in tokens
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PROVIDERDIALOG::T4083607555"] = "Context window in tokens"
-- Currently, we cannot query the models for the selected provider and/or host. Therefore, please enter the model name manually. -- Currently, we cannot query the models for the selected provider and/or host. Therefore, please enter the model name manually.
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PROVIDERDIALOG::T4116737656"] = "Currently, we cannot query the models for the selected provider and/or host. Therefore, please enter the model name manually." UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PROVIDERDIALOG::T4116737656"] = "Currently, we cannot query the models for the selected provider and/or host. Therefore, please enter the model name manually."
@ -9967,6 +10024,9 @@ UI_TEXT_CONTENT["AISTUDIO::PROVIDER::BASEPROVIDER::T4049517041"] = "We tried to
-- The provider '{0}' does not know the selected model. Please select another model. -- The provider '{0}' does not know the selected model. Please select another model.
UI_TEXT_CONTENT["AISTUDIO::PROVIDER::BASEPROVIDER::T411393889"] = "The provider '{0}' does not know the selected model. Please select another model." UI_TEXT_CONTENT["AISTUDIO::PROVIDER::BASEPROVIDER::T411393889"] = "The provider '{0}' does not know the selected model. Please select another model."
-- The text was longer than the selected model accepts, which is {0} tokens. Please select a model which takes longer texts, or reduce the chunk size of the data source.
UI_TEXT_CONTENT["AISTUDIO::PROVIDER::BASEPROVIDER::T479223640"] = "The text was longer than the selected model accepts, which is {0} tokens. Please select a model which takes longer texts, or reduce the chunk size of the data source."
-- The provider '{0}' reported an error: {1} -- The provider '{0}' reported an error: {1}
UI_TEXT_CONTENT["AISTUDIO::PROVIDER::BASEPROVIDER::T700894460"] = "The provider '{0}' reported an error: {1}" UI_TEXT_CONTENT["AISTUDIO::PROVIDER::BASEPROVIDER::T700894460"] = "The provider '{0}' reported an error: {1}"
@ -11422,6 +11482,9 @@ UI_TEXT_CONTENT["AISTUDIO::TOOLS::PLUGINSYSTEM::PLUGINLANGUAGE::T4112586014"] =
-- The field LANG_NAME does not exist or is not a valid string. -- The field LANG_NAME does not exist or is not a valid string.
UI_TEXT_CONTENT["AISTUDIO::TOOLS::PLUGINSYSTEM::PLUGINLANGUAGE::T4204700759"] = "The field LANG_NAME does not exist or is not a valid string." UI_TEXT_CONTENT["AISTUDIO::TOOLS::PLUGINSYSTEM::PLUGINLANGUAGE::T4204700759"] = "The field LANG_NAME does not exist or is not a valid string."
-- The table MODELS does not exist or is using an invalid syntax.
UI_TEXT_CONTENT["AISTUDIO::TOOLS::PLUGINSYSTEM::PLUGINMODELS::T976664425"] = "The table MODELS does not exist or is using an invalid syntax."
-- Artists -- Artists
UI_TEXT_CONTENT["AISTUDIO::TOOLS::PLUGINSYSTEM::PLUGINTARGETGROUPEXTENSIONS::T1142248183"] = "Artists" UI_TEXT_CONTENT["AISTUDIO::TOOLS::PLUGINSYSTEM::PLUGINTARGETGROUPEXTENSIONS::T1142248183"] = "Artists"
@ -11464,6 +11527,9 @@ UI_TEXT_CONTENT["AISTUDIO::TOOLS::PLUGINSYSTEM::PLUGINTARGETGROUPEXTENSIONS::T62
-- Software developers -- Software developers
UI_TEXT_CONTENT["AISTUDIO::TOOLS::PLUGINSYSTEM::PLUGINTARGETGROUPEXTENSIONS::T831424531"] = "Software developers" UI_TEXT_CONTENT["AISTUDIO::TOOLS::PLUGINSYSTEM::PLUGINTARGETGROUPEXTENSIONS::T831424531"] = "Software developers"
-- Model plugin
UI_TEXT_CONTENT["AISTUDIO::TOOLS::PLUGINSYSTEM::PLUGINTYPEEXTENSIONS::T1507522553"] = "Model plugin"
-- Theme plugin -- Theme plugin
UI_TEXT_CONTENT["AISTUDIO::TOOLS::PLUGINSYSTEM::PLUGINTYPEEXTENSIONS::T1682350097"] = "Theme plugin" UI_TEXT_CONTENT["AISTUDIO::TOOLS::PLUGINSYSTEM::PLUGINTYPEEXTENSIONS::T1682350097"] = "Theme plugin"

View File

@ -267,17 +267,31 @@ internal sealed partial class VisualBriefingBuildOrchestrator
FileTypes.IsAllowedPath(source.Path, FileTypes.IMAGE)).ToArray(); FileTypes.IsAllowedPath(source.Path, FileTypes.IMAGE)).ToArray();
if (imageSources.Length == 0) if (imageSources.Length == 0)
return; return;
var capabilities = provider.GetModelCapabilities(); var profile = provider.GetModelProfile();
var acceptsImages = imageSources.Length == 1 var acceptsImages = imageSources.Length == 1
? capabilities.Contains(Capability.SINGLE_IMAGE_INPUT) || ? profile.HasAny(Capability.SINGLE_IMAGE_INPUT | Capability.MULTIPLE_IMAGE_INPUT)
capabilities.Contains(Capability.MULTIPLE_IMAGE_INPUT) : profile.Has(Capability.MULTIPLE_IMAGE_INPUT);
: capabilities.Contains(Capability.MULTIPLE_IMAGE_INPUT);
if (!acceptsImages) if (!acceptsImages)
throw new VisualBriefingBuildException( throw new VisualBriefingBuildException(
VisualBriefingFailureCode.MODEL_CAPABILITY_MISSING, VisualBriefingFailureCode.MODEL_CAPABILITY_MISSING,
VisualBriefingBuildStage.SOURCE_PREPARATION, VisualBriefingBuildStage.SOURCE_PREPARATION,
"The selected model cannot process the number of source images and visual assets.", "The selected model cannot process the number of source images and visual assets.",
$"ImageCount={imageSources.Length}; SingleImage={capabilities.Contains(Capability.SINGLE_IMAGE_INPUT)}; MultipleImages={capabilities.Contains(Capability.MULTIPLE_IMAGE_INPUT)}."); $"ImageCount={imageSources.Length}; SingleImage={profile.Has(Capability.SINGLE_IMAGE_INPUT)}; MultipleImages={profile.Has(Capability.MULTIPLE_IMAGE_INPUT)}.");
//
// And then the number, where a vendor has stated one. Without it, "takes several images"
// is all the check above can ask, and a briefing of two hundred pictures passes it only to
// be refused by the provider after everything has been read, uploaded and paid for.
//
// No limit is invented where none is documented. A model whose vendor says nothing keeps
// the answer it has always had, which is that several means several.
//
if (profile.Images.MaxInOneMessage is { } allowed && imageSources.Length > allowed)
throw new VisualBriefingBuildException(
VisualBriefingFailureCode.MODEL_CAPABILITY_MISSING,
VisualBriefingBuildStage.SOURCE_PREPARATION,
$"The selected model accepts at most {allowed} images at once, and this briefing uses {imageSources.Length}.",
$"ImageCount={imageSources.Length}; MaxPerMessage={profile.Images.MaxPerMessage}; MaxPerRequest={profile.Images.MaxPerRequest}.");
} }
/// <summary> /// <summary>

View File

@ -146,10 +146,8 @@ public sealed record ChatThread
/// </summary> /// </summary>
public bool MayRunTools(SettingsManager settingsManager) => this.RuntimeToolsAreAssistantManaged || settingsManager.IsToolSelectionVisible(this.RuntimeComponent); public bool MayRunTools(SettingsManager settingsManager) => this.RuntimeToolsAreAssistantManaged || settingsManager.IsToolSelectionVisible(this.RuntimeComponent);
private bool allowProfile = true;
/// <summary> /// <summary>
/// Prepares the system prompt for the chat thread. /// Prepares the system prompt for the chat thread, and remembers what it was built from.
/// </summary> /// </summary>
/// <remarks> /// <remarks>
/// The actual system prompt depends on the selected profile. If no profile is selected, /// The actual system prompt depends on the selected profile. If no profile is selected,
@ -161,7 +159,35 @@ public sealed record ChatThread
/// <returns>The prepared system prompt.</returns> /// <returns>The prepared system prompt.</returns>
public string PrepareSystemPrompt(SettingsManager settingsManager, IEnumerable<ToolDefinition>? runnableToolDefinitions = null) public string PrepareSystemPrompt(SettingsManager settingsManager, IEnumerable<ToolDefinition>? runnableToolDefinitions = null)
{ {
this.allowProfile = true; var prepared = this.BuildSystemPrompt(settingsManager, runnableToolDefinitions);
// We need a way to save the changed system prompt in our chat thread.
// Otherwise, the chat thread will always tell us that it is using the
// default system prompt:
this.SystemPrompt = prepared.BasePrompt;
LOGGER.LogInformation(prepared.Explanation);
return prepared.Text;
}
/// <summary>
/// Works out the system prompt without changing anything about the thread.
/// </summary>
/// <remarks>
/// Split off from the preparation above so that somebody can ask how long the next request
/// would be. Counting the tokens of a conversation has to ask the same question the request
/// asks -- a count against the prompt a person typed, rather than against the one a chat
/// template, a data source, a profile and the tool policy make of it, is a number about a
/// request which is never sent.
///
/// Nothing here writes to the thread and nothing logs, because this runs while somebody types.
/// </remarks>
/// <param name="settingsManager">The settings manager instance to use.</param>
/// <param name="runnableToolDefinitions">The tools which may run in this thread. Null when the thread runs without tools.</param>
/// <returns>The system prompt and what building it decided.</returns>
public PreparedSystemPrompt BuildSystemPrompt(SettingsManager settingsManager, IEnumerable<ToolDefinition>? runnableToolDefinitions = null)
{
var allowProfile = true;
// //
// Use the information from the chat template, if provided. Otherwise, use the default system prompt // Use the information from the chat template, if provided. Otherwise, use the default system prompt
@ -186,18 +212,12 @@ public sealed record ChatThread
else else
{ {
logMessage = $"Using chat template '{chatTemplate.Name}' for chat thread '{this.Name}'."; logMessage = $"Using chat template '{chatTemplate.Name}' for chat thread '{this.Name}'.";
this.allowProfile = chatTemplate.AllowProfileUsage; allowProfile = chatTemplate.AllowProfileUsage;
systemPromptTextWithChatTemplate = chatTemplate.ToSystemPrompt(); systemPromptTextWithChatTemplate = chatTemplate.ToSystemPrompt();
} }
} }
} }
} }
// We need a way to save the changed system prompt in our chat thread.
// Otherwise, the chat thread will always tell us that it is using the
// default system prompt:
this.SystemPrompt = systemPromptTextWithChatTemplate;
LOGGER.LogInformation(logMessage);
// //
// Add augmented data, if available: // Add augmented data, if available:
@ -214,18 +234,16 @@ public sealed record ChatThread
false => systemPromptTextWithChatTemplate, false => systemPromptTextWithChatTemplate,
}; };
if(isAugmentedDataAvailable) logMessage = isAugmentedDataAvailable
LOGGER.LogInformation("Augmented data is available for the chat thread."); ? $"{logMessage} Augmented data is available for the chat thread."
else : $"{logMessage} No augmented data is available for the chat thread.";
LOGGER.LogInformation("No augmented data is available for the chat thread.");
// //
// Add information from the profile if available and allowed: // Add information from the profile if available and allowed:
// //
string systemPromptText; string systemPromptText;
logMessage = $"Using no profile for chat thread '{this.Name}'."; var profileNote = $"Using no profile for chat thread '{this.Name}'.";
if (string.IsNullOrWhiteSpace(this.SelectedProfile) || !this.allowProfile) if (string.IsNullOrWhiteSpace(this.SelectedProfile) || !allowProfile)
systemPromptText = systemPromptWithAugmentedData; systemPromptText = systemPromptWithAugmentedData;
else else
{ {
@ -242,7 +260,7 @@ public sealed record ChatThread
systemPromptText = systemPromptWithAugmentedData; systemPromptText = systemPromptWithAugmentedData;
else else
{ {
logMessage = $"Using profile '{profile.Name}' for chat thread '{this.Name}'."; profileNote = $"Using profile '{profile.Name}' for chat thread '{this.Name}'.";
systemPromptText = $""" systemPromptText = $"""
{systemPromptWithAugmentedData} {systemPromptWithAugmentedData}
@ -252,8 +270,6 @@ public sealed record ChatThread
} }
} }
} }
LOGGER.LogInformation(logMessage);
var toolPolicy = ToolSelectionRules.BuildToolPolicyPrompt(runnableToolDefinitions ?? []); var toolPolicy = ToolSelectionRules.BuildToolPolicyPrompt(runnableToolDefinitions ?? []);
if (!string.IsNullOrWhiteSpace(toolPolicy)) if (!string.IsNullOrWhiteSpace(toolPolicy))
@ -265,9 +281,10 @@ public sealed record ChatThread
"""; """;
} }
var explanation = $"{logMessage} {profileNote}";
if(!this.IncludeDateTime) if(!this.IncludeDateTime)
return systemPromptText; return new(systemPromptText, systemPromptTextWithChatTemplate, allowProfile, explanation);
// //
// Prepend the current date and time to the system prompt: // Prepend the current date and time to the system prompt:
// //
@ -278,11 +295,13 @@ public sealed record ChatThread
$"Today is {nowUtc:dddd, MMMM d, yyyy h:mm tt} (UTC) and {nowLocal:dddd, MMMM d, yyyy h:mm tt} (local time)." $"Today is {nowUtc:dddd, MMMM d, yyyy h:mm tt} (UTC) and {nowLocal:dddd, MMMM d, yyyy h:mm tt} (local time)."
); );
return $""" var withDateTime = $"""
{currentDateTime} {currentDateTime}
{systemPromptText} {systemPromptText}
"""; """;
return new(withDateTime, systemPromptTextWithChatTemplate, allowProfile, explanation);
} }
/// <summary> /// <summary>

View File

@ -0,0 +1,136 @@
namespace AIStudio.Chat;
/// <summary>
/// Everything a conversation would put into the next request, sorted by how it can be counted.
/// </summary>
/// <remarks>
/// Collected here rather than while counting, so that what counts towards a token budget is one
/// question with one answer which a test can ask. It follows what the message builder actually
/// sends: the system prompt, the text of every block, and the attachments hanging off those
/// blocks -- plus whatever is standing in the composer but has not been sent yet, because that is
/// the part a person is deciding about while they look at the number.
/// </remarks>
public sealed record ConversationParts
{
/// <summary>
/// A conversation with nothing in it.
/// </summary>
public static readonly ConversationParts NOTHING = new();
/// <summary>
/// The texts which go into the request as they are.
/// </summary>
public IReadOnlyList<string> Texts { get; init; } = [];
/// <summary>
/// The texts which are still being written.
/// </summary>
/// <remarks>
/// They cost exactly what the others cost; what sets them apart is that they will never be seen
/// again in this shape. The sentence somebody is typing changes with the next pause, and an
/// answer being streamed is a different text three seconds later -- so remembering what they
/// cost fills memory with answers nobody will ask for again.
/// </remarks>
public IReadOnlyList<string> GrowingTexts { get; init; } = [];
/// <summary>
/// The documents whose content is put into the request.
/// </summary>
public IReadOnlyList<FileAttachment> Documents { get; init; } = [];
/// <summary>
/// How many images travel along.
/// </summary>
public int Images { get; init; }
/// <summary>
/// Collects what a conversation would send.
/// </summary>
/// <remarks>
/// Blocks without text are skipped, because the message builder skips them too: a block whose
/// text is empty never becomes a message, whatever else hangs off it.
/// </remarks>
/// <param name="thread">The conversation so far, or null when there is none yet.</param>
/// <param name="systemPrompt">
/// The system prompt as it would be sent, which is not the one a person typed: a chat template
/// may replace it, the retrieved data of a data source is appended to it, a profile adds a
/// paragraph, and the tool policy adds another.
/// </param>
/// <param name="draft">What stands in the composer.</param>
/// <param name="draftAttachments">What is attached to the composer.</param>
/// <param name="imagesAreSent">Whether the model takes images at all. When it does not, none are sent.</param>
/// <returns>The parts of the conversation.</returns>
public static ConversationParts Of(ChatThread? thread, string systemPrompt, string draft, IEnumerable<FileAttachment>? draftAttachments, bool imagesAreSent)
{
var texts = new List<string>();
var growing = new List<string>();
var documents = new List<FileAttachment>();
var images = 0;
if (!string.IsNullOrWhiteSpace(systemPrompt))
texts.Add(systemPrompt);
if (thread is not null)
{
//
// Blocks hidden from the user are counted like any other. They are hidden on the screen,
// not in the request: the message builder sends them, so they take their tokens whether
// or not anybody can see them.
//
foreach (var block in thread.Blocks)
{
if (block.ContentType is not ContentType.TEXT || block.Content is not ContentText text || string.IsNullOrWhiteSpace(text.Text))
continue;
if (text.IsStreaming)
growing.Add(text.Text);
else
texts.Add(text.Text);
Sort(text.FileAttachments, documents, ref images);
}
}
if (!string.IsNullOrWhiteSpace(draft))
growing.Add(draft);
if (draftAttachments is not null)
Sort(draftAttachments, documents, ref images);
return new()
{
Texts = texts,
GrowingTexts = growing,
Documents = documents,
Images = imagesAreSent ? images : 0,
};
}
/// <summary>
/// Puts attachments into the two groups they are counted in.
/// </summary>
/// <remarks>
/// An attachment whose file is gone is left out of both. It is not sent either: the message
/// builder drops it and tells the person about it, so counting it would promise a request which
/// is never made.
/// </remarks>
private static void Sort(IEnumerable<FileAttachment> attachments, List<FileAttachment> documents, ref int images)
{
foreach (var attachment in attachments)
{
if (!attachment.Exists)
continue;
switch (attachment.Type)
{
case FileAttachmentType.DOCUMENT:
documents.Add(attachment);
break;
case FileAttachmentType.IMAGE:
images++;
break;
}
}
}
}

View File

@ -0,0 +1,172 @@
namespace AIStudio.Chat;
/// <summary>
/// Keeps a number up to date which nothing announces.
/// </summary>
/// <remarks>
/// A conversation is a plain list of plain objects. Nothing raises an event when a block is added,
/// when a document is attached, or when an answer grows by another sentence -- so a number derived
/// from all of that cannot be wired to the places which change it. It was tried: fifteen call sites,
/// and four review rounds each found another one which was missing.
///
/// So the number is recomputed instead of notified. Whoever thinks something may have changed nudges
/// this tracker, and the tracker decides when to do the work: many nudges in a row become one run, a
/// nudge arriving during a run becomes exactly one further run, and a minimum distance keeps a burst
/// of them from turning into a burst of counting.
///
/// The heartbeat is not distrust of the nudges. Attachments are read from disk every time they are
/// sent, so a file somebody edits in another program changes what the next message costs without
/// anything happening in AI Studio which anyone could nudge from.
/// </remarks>
/// <param name="recount">Does the actual work. Gets a token which ends it when the tracker goes away.</param>
/// <param name="quietTime">
/// How long to stay quiet after a run before honouring the next nudge. Asked again each time,
/// because what is reasonable depends on what is going on: a person who just switched a profile is
/// waiting for the number, while an answer being written moves it with every word and wants a
/// slower pace than the words arrive at.
/// </param>
/// <param name="heartbeat">How long to wait for a nudge before running anyway.</param>
public sealed class ConversationTokenTracker(Func<CancellationToken, Task> recount, Func<TimeSpan> quietTime, TimeSpan heartbeat) : IAsyncDisposable
{
/// <summary>
/// How long a tracker which is going away waits for its own loop.
/// </summary>
/// <remarks>
/// The loop ends on cancellation, so this is only ever reached when something it called does
/// not. Whoever is leaving the screen must not be the one who waits for that.
/// </remarks>
private static readonly TimeSpan SHUTDOWN_PATIENCE = TimeSpan.FromSeconds(2);
private readonly SemaphoreSlim wakeUp = new(0, 1);
private readonly CancellationTokenSource stopping = new();
private Task? loop;
/// <summary>
/// Starts the loop. Calling this twice does nothing the second time.
/// </summary>
public void Start() => this.loop ??= Task.Run(this.RunAsync);
/// <summary>
/// Says that something may have changed.
/// </summary>
/// <remarks>
/// Cheap on purpose, because it is called from the render path. It says "maybe", never "yes":
/// asking for a run which turns out to change nothing costs a few lookups, while missing one is
/// the bug this whole class exists to make impossible.
/// </remarks>
public void Nudge()
{
//
// One pending wake-up is all a loop can act on. A second one would only make it run again
// with the same answer.
//
if (this.wakeUp.CurrentCount > 0)
return;
try
{
this.wakeUp.Release();
}
catch (SemaphoreFullException)
{
//
// Two threads got past the check above at the same time. The one which won left the
// wake-up we wanted, so there is nothing left to do here.
//
}
catch (ObjectDisposedException)
{
// The tracker is going away, and a number nobody will look at needs no update.
}
}
private async Task RunAsync()
{
var token = this.stopping.Token;
while (!token.IsCancellationRequested)
{
try
{
//
// Sleeps until somebody nudges -- or until the heartbeat is due, which is what the
// timeout returning false means. Both lead to the same run, so the result is not
// even looked at.
//
await this.wakeUp.WaitAsync(heartbeat, token);
if (token.IsCancellationRequested)
return;
//
// Deliberately without draining further wake-ups first. A nudge which arrives while
// this run reads the conversation may well be about a change this run is already
// seeing -- and then the extra run costs a few lookups. Draining would risk the
// other case, where the change comes after the read and nobody asks again.
//
try
{
await recount(token);
}
catch (Exception) when (!token.IsCancellationRequested)
{
//
// One failed run must not end the loop: a tracker which died on a single bad
// answer would leave a stale number standing forever, which is the failure this
// class was built to rule out. Saying what went wrong is the job of the work
// itself, which is the only side that has a logger.
//
}
//
// The quiet time is kept after the work, not before it: the first nudge of a burst
// is answered at once, and the rest of the burst collapses into the single run which
// follows this delay.
//
// It is also what paces a run which feeds itself. Showing a new number renders, and
// a render nudges -- so while something changes continuously, this delay is the
// whole cadence.
//
await Task.Delay(quietTime(), token);
}
catch (OperationCanceledException)
{
return;
}
catch (ObjectDisposedException)
{
// The tracker was disposed underneath this loop, which is another way of stopping.
return;
}
}
}
#region Implementation of IAsyncDisposable
public async ValueTask DisposeAsync()
{
await this.stopping.CancelAsync();
if (this.loop is not null)
{
try
{
//
// Awaited rather than abandoned, so that nothing is still counting into a component
// which is already gone. The counting itself takes the same token, so a run which
// sits in an IPC call ends with it -- and the patience is there for the case where
// it does not, because a chat being closed is not worth hanging on to.
//
await this.loop.WaitAsync(SHUTDOWN_PATIENCE);
}
catch (Exception)
{
// The loop ends on cancellation; whatever else it carries out is of no use here.
}
}
this.stopping.Dispose();
this.wakeUp.Dispose();
}
#endregion
}

View File

@ -0,0 +1,83 @@
using AIStudio.Models;
namespace AIStudio.Chat;
/// <summary>
/// What a conversation costs, as far as the app can count it.
/// </summary>
/// <remarks>
/// Three separate statements, and keeping them apart is the point. How many tokens were counted is
/// one; what the model's window is, if anybody has written it down, is the second; and how much of
/// the conversation could not be counted at all is the third. Folding any of them into the others
/// would turn a gap into a number somebody reads as a fact.
/// </remarks>
public readonly record struct ConversationTokens
{
/// <summary>
/// The answer when nothing could be counted, which is what a broken tokenizer leaves behind.
/// </summary>
/// <remarks>
/// Deliberately not a zero. A conversation of no tokens and a conversation nobody could measure
/// look the same as a number and are not the same thing, so the display shows nothing at all
/// rather than claiming an empty chat.
/// </remarks>
public static readonly ConversationTokens UNAVAILABLE = new();
/// <summary>
/// Whether anything could be counted.
/// </summary>
public bool IsKnown { get; init; }
/// <summary>
/// How many tokens the counted parts of the conversation take.
/// </summary>
public int Tokens { get; init; }
/// <summary>
/// Whether the number is an estimate rather than the model's own count.
/// </summary>
/// <remarks>
/// True whenever the built-in tokenizer did the counting, which is the normal case: a model's
/// own tokenizer is only used where somebody configured one for their provider. Two tokenizers
/// disagree by a few percent on ordinary prose and by a lot more on code or a language they were
/// not trained on, so the number is shown as an approximation unless we counted with the
/// tokenizer the model itself uses.
/// </remarks>
public bool IsEstimate { get; init; }
/// <summary>
/// How much the model reads, where anybody has stated it.
/// </summary>
public ContextWindow Window { get; init; }
/// <summary>
/// How many images travel along which nobody can count.
/// </summary>
/// <remarks>
/// Every vendor charges images differently -- OpenAI by tiles of the scaled image, Anthropic by
/// its area, Google by tiles of another size -- and none of those numbers can be had from the
/// file without decoding it first. So they are reported as a number of images instead of being
/// guessed at, or worse, counted as the base64 text they are sent as: that text is two to three
/// orders of magnitude longer than what any vendor charges for the picture.
/// </remarks>
public int UncountedImages { get; init; }
/// <summary>
/// How many images the model takes, where its vendor stated a number.
/// </summary>
public ImageLimits ImageLimits { get; init; }
/// <summary>
/// Whether more images travel than the model is documented to accept.
/// </summary>
/// <remarks>
/// Counted over the whole conversation rather than over the message being written, because that
/// is what a request carries: every picture anybody attached is sent again with every further
/// message, so a chat crosses this line long after the message which added the picture -- and
/// the person who crosses it has usually forgotten that the pictures are still there.
///
/// False whenever nobody stated a limit, which is most models. An invented ceiling would refuse
/// something that works.
/// </remarks>
public bool TooManyImages => this.ImageLimits.MaxInOneMessage is { } allowed && this.UncountedImages > allowed;
}

View File

@ -11,23 +11,25 @@ public static class ListContentBlockExtensions
/// </summary> /// </summary>
/// <param name="blocks">The list of content blocks to process.</param> /// <param name="blocks">The list of content blocks to process.</param>
/// <param name="roleTransformer">A function that transforms each content block into a message result asynchronously.</param> /// <param name="roleTransformer">A function that transforms each content block into a message result asynchronously.</param>
/// <param name="selectedProvider">The selected LLM provider.</param> /// <param name="provider">The configured provider, whose model is being written to.</param>
/// <param name="selectedModel">The selected model.</param>
/// <param name="textSubContentFactory">A factory function to create text sub-content.</param> /// <param name="textSubContentFactory">A factory function to create text sub-content.</param>
/// <param name="imageSubContentFactory">A factory function to create image sub-content.</param> /// <param name="imageSubContentFactory">A factory function to create image sub-content.</param>
/// <returns>An asynchronous task that resolves to a list of transformed results.</returns> /// <returns>An asynchronous task that resolves to a list of transformed results.</returns>
public static async Task<IList<IMessageBase>> BuildMessagesAsync( public static async Task<IList<IMessageBase>> BuildMessagesAsync(
this List<ContentBlock> blocks, this List<ContentBlock> blocks,
LLMProviders selectedProvider, AIStudio.Settings.Provider provider,
Model selectedModel,
Func<ChatRole, string> roleTransformer, Func<ChatRole, string> roleTransformer,
Func<string, ISubContent> textSubContentFactory, Func<string, ISubContent> textSubContentFactory,
Func<FileAttachmentImage, Task<ISubContent>> imageSubContentFactory) Func<FileAttachmentImage, Task<ISubContent>> imageSubContentFactory)
{ {
var capabilities = selectedProvider.GetModelCapabilities(selectedModel); //
var canProcessImages = capabilities.Contains(Capability.MULTIPLE_IMAGE_INPUT) || // Asked through the configured provider, so that what a person set in their expert settings
capabilities.Contains(Capability.SINGLE_IMAGE_INPUT); // counts here too. It did not: this path read the automatic answer alone, so somebody who
// switched image input on saw it work while attaching the picture and saw it ignored while
// the message was built -- every chat round and every tool round.
//
var canProcessImages = provider.SupportsImageInput();
var messageTaskList = new List<Task<IMessageBase>>(blocks.Count); var messageTaskList = new List<Task<IMessageBase>>(blocks.Count);
foreach (var block in blocks) foreach (var block in blocks)
{ {
@ -102,8 +104,7 @@ public static class ListContentBlockExtensions
/// Processes a list of content blocks using direct image URL format to create message results asynchronously. /// Processes a list of content blocks using direct image URL format to create message results asynchronously.
/// </summary> /// </summary>
/// <param name="blocks">The list of content blocks to process.</param> /// <param name="blocks">The list of content blocks to process.</param>
/// <param name="selectedProvider">The selected LLM provider.</param> /// <param name="provider">The configured provider, whose model is being written to.</param>
/// <param name="selectedModel">The selected model.</param>
/// <returns>An asynchronous task that resolves to a list of transformed message results.</returns> /// <returns>An asynchronous task that resolves to a list of transformed message results.</returns>
/// <remarks> /// <remarks>
/// Uses direct image URL format where the image data is placed directly in the image_url field: /// Uses direct image URL format where the image data is placed directly in the image_url field:
@ -114,10 +115,8 @@ public static class ListContentBlockExtensions
/// </remarks> /// </remarks>
public static async Task<IList<IMessageBase>> BuildMessagesUsingDirectImageUrlAsync( public static async Task<IList<IMessageBase>> BuildMessagesUsingDirectImageUrlAsync(
this List<ContentBlock> blocks, this List<ContentBlock> blocks,
LLMProviders selectedProvider, AIStudio.Settings.Provider provider) => await blocks.BuildMessagesAsync(
Model selectedModel) => await blocks.BuildMessagesAsync( provider,
selectedProvider,
selectedModel,
StandardRoleTransformer, StandardRoleTransformer,
StandardTextSubContentFactory, StandardTextSubContentFactory,
DirectImageSubContentFactory); DirectImageSubContentFactory);
@ -126,8 +125,7 @@ public static class ListContentBlockExtensions
/// Processes a list of content blocks using nested image URL format to create message results asynchronously. /// Processes a list of content blocks using nested image URL format to create message results asynchronously.
/// </summary> /// </summary>
/// <param name="blocks">The list of content blocks to process.</param> /// <param name="blocks">The list of content blocks to process.</param>
/// <param name="selectedProvider">The selected LLM provider.</param> /// <param name="provider">The configured provider, whose model is being written to.</param>
/// <param name="selectedModel">The selected model.</param>
/// <returns>An asynchronous task that resolves to a list of transformed message results.</returns> /// <returns>An asynchronous task that resolves to a list of transformed message results.</returns>
/// <remarks> /// <remarks>
/// Uses nested image URL format where the image data is wrapped in an object: /// Uses nested image URL format where the image data is wrapped in an object:
@ -138,10 +136,8 @@ public static class ListContentBlockExtensions
/// </remarks> /// </remarks>
public static async Task<IList<IMessageBase>> BuildMessagesUsingNestedImageUrlAsync( public static async Task<IList<IMessageBase>> BuildMessagesUsingNestedImageUrlAsync(
this List<ContentBlock> blocks, this List<ContentBlock> blocks,
LLMProviders selectedProvider, AIStudio.Settings.Provider provider) => await blocks.BuildMessagesAsync(
Model selectedModel) => await blocks.BuildMessagesAsync( provider,
selectedProvider,
selectedModel,
StandardRoleTransformer, StandardRoleTransformer,
StandardTextSubContentFactory, StandardTextSubContentFactory,
NestedImageSubContentFactory); NestedImageSubContentFactory);

View File

@ -0,0 +1,19 @@
namespace AIStudio.Chat;
/// <summary>
/// The system prompt of a chat thread as it would be sent, together with what building it decided.
/// </summary>
/// <remarks>
/// The system prompt is not the text a person typed into it. A chat template may replace it, the
/// retrieved data of a data source is appended to it, a profile adds its own paragraph, the tool
/// policy adds another, and the current date goes in front of everything. Whoever wants to know how
/// long the next request is has to ask the same question the request does.
/// </remarks>
/// <param name="Text">The whole system prompt, as the provider receives it.</param>
/// <param name="BasePrompt">
/// The prompt without any of the parts added around it. The thread keeps this one, so that it can
/// still say which prompt it was configured with rather than the assembled result.
/// </param>
/// <param name="ProfileIsAllowed">Whether the chat template let a profile take part.</param>
/// <param name="Explanation">What was used, in one sentence, for the log.</param>
public sealed record PreparedSystemPrompt(string Text, string BasePrompt, bool ProfileIsAllowed, string Explanation);

View File

@ -0,0 +1,47 @@
using System.Globalization;
namespace AIStudio.Chat;
/// <summary>
/// Writes a number of tokens the way a person reads it next to their input field.
/// </summary>
/// <remarks>
/// A context window of a million tokens written out in full is eight characters of noise under a
/// text field, and nobody reads the last five of them. So everything from a thousand on is
/// shortened, and two decimals keep the resolution a person acts on: the difference between 1.20k
/// and 1.80k is one they can see, while the last three digits of 1,234 are not.
///
/// The culture is passed in rather than taken from the thread. AI Studio's language is chosen in
/// its settings and does not move the thread's culture along with it, so a German who picked German
/// would otherwise read English separators inside a German sentence.
/// </remarks>
public static class TokenAmount
{
/// <summary>
/// Below this, the exact number is shown.
/// </summary>
private const int EXACT_BELOW = 1_000;
/// <summary>
/// Writes a number of tokens.
/// </summary>
/// <param name="tokens">The number of tokens.</param>
/// <param name="culture">The culture whose separators the number is written with.</param>
/// <returns>The number, shortened from a thousand on.</returns>
public static string Format(int tokens, CultureInfo culture)
{
if (tokens < EXACT_BELOW)
return tokens.ToString("N0", culture);
//
// Rounded before the unit is chosen, not after. Otherwise the few hundred tokens just below
// a million round up inside their own unit and read as "1,000.00k", which is a number
// nobody writes.
//
var thousands = tokens / 1_000d;
if (Math.Round(thousands, 2) < 1_000d)
return $"{thousands.ToString("N2", culture)}k";
return $"{(tokens / 1_000_000d).ToString("N2", culture)}M";
}
}

View File

@ -258,8 +258,16 @@ public partial class AttachDocuments : MSGComponentBase
this.DocumentPaths = await ReviewAttachmentsDialog.OpenDialogAsync(this.DialogService, this.DocumentPaths); this.DocumentPaths = await ReviewAttachmentsDialog.OpenDialogAsync(this.DialogService, this.DocumentPaths);
foreach (var removedAttachment in previousAttachments.Except(this.DocumentPaths)) foreach (var removedAttachment in previousAttachments.Except(this.DocumentPaths))
ManagedTranscriptAttachment.TryDeleteOwnedFile(removedAttachment); ManagedTranscriptAttachment.TryDeleteOwnedFile(removedAttachment);
this.ReconcileOwnerPendingTranscripts(); this.ReconcileOwnerPendingTranscripts();
//
// Said out loud, like every other path in this file. Removing a file in the dialog changed
// the attachments while whoever owns them heard nothing about it -- the chat then kept
// showing what the message no longer carries.
//
await this.DocumentPathsChanged.InvokeAsync(this.DocumentPaths);
await this.OnChange(this.DocumentPaths);
} }
private async Task ClearAllFiles() private async Task ClearAllFiles()

View File

@ -51,7 +51,6 @@
Disabled="@this.IsInputForbidden()" Disabled="@this.IsInputForbidden()"
Immediate="@true" Immediate="@true"
OnKeyUp="@this.InputKeyEvent" OnKeyUp="@this.InputKeyEvent"
WhenTextChangedAsync="@(_ =>this.CalculateTokenCount())"
UserAttributes="@USER_INPUT_ATTRIBUTES" UserAttributes="@USER_INPUT_ATTRIBUTES"
Class="@this.UserInputClass" Class="@this.UserInputClass"
DebounceTime="TimeSpan.FromSeconds(1)" DebounceTime="TimeSpan.FromSeconds(1)"

View File

@ -1,3 +1,5 @@
using System.Globalization;
using AIStudio.Chat; using AIStudio.Chat;
using AIStudio.Dialogs; using AIStudio.Dialogs;
using AIStudio.Provider; using AIStudio.Provider;
@ -54,9 +56,10 @@ public partial class ChatComponent : MSGComponentBase
[Inject] [Inject]
private IDialogService DialogService { get; init; } = null!; private IDialogService DialogService { get; init; } = null!;
[Inject]
private ConversationTokenCounter ConversationTokenCounter { get; init; } = null!;
[Inject]
private RustService RustService { get; init; } = null!;
[Inject] [Inject]
private IJSRuntime JsRuntime { get; init; } = null!; private IJSRuntime JsRuntime { get; init; } = null!;
@ -93,11 +96,112 @@ public partial class ChatComponent : MSGComponentBase
private Guid loadedParameterWorkspaceId = Guid.Empty; private Guid loadedParameterWorkspaceId = Guid.Empty;
private Guid foregroundChatId = Guid.Empty; private Guid foregroundChatId = Guid.Empty;
private int workspaceHeaderSyncVersion; private int workspaceHeaderSyncVersion;
private string tokenCount = "0"; private ConversationTokens conversationTokens = ConversationTokens.UNAVAILABLE;
private bool HasCustomTokenizer => !string.IsNullOrWhiteSpace(this.Provider.TokenizerPath);
private string TokenCountMessage => this.HasCustomTokenizer /// <summary>
? $"{this.T("Estimated amount of tokens:")} {this.tokenCount}" /// How much of the window must be used before the number starts saying so.
: string.Empty; /// </summary>
private const double WINDOW_NEARLY_FULL = 0.8d;
/// <summary>
/// How long the token count stays quiet after it ran, while nothing is being written.
/// </summary>
/// <remarks>
/// A render is cheap to ask about and a count is not. This is what keeps a burst of renders --
/// loading a chat touches several things in a row -- from turning into a burst of counting,
/// while staying short enough that switching a profile moves the number right away.
/// </remarks>
private static readonly TimeSpan TOKEN_COUNT_QUIET_TIME = TimeSpan.FromMilliseconds(500);
/// <summary>
/// How long the token count stays quiet while an answer is being written.
/// </summary>
/// <remarks>
/// An answer grows with every word, so each count finds a new number, shows it, and thereby
/// renders -- which asks for the next count. That makes this the whole cadence while a model
/// writes, and three seconds is the pace the chat itself keeps: the job service hands its
/// progress to the screen no more often than that.
/// </remarks>
private static readonly TimeSpan TOKEN_COUNT_STREAMING_QUIET_TIME = TimeSpan.FromSeconds(3);
/// <summary>
/// How long the token count waits for a reason before counting anyway.
/// </summary>
/// <remarks>
/// For what happens outside AI Studio: an attached document is read from disk every time it is
/// sent, so somebody editing it in another program changes what the next message costs without
/// anything here rendering.
/// </remarks>
private static readonly TimeSpan TOKEN_COUNT_HEARTBEAT = TimeSpan.FromSeconds(10);
/// <summary>
/// Recomputes the token count whenever something might have changed.
/// </summary>
private ConversationTokenTracker? tokenTracker;
/// <summary>
/// How long to leave the token count alone after it ran.
/// </summary>
private TimeSpan TokenCountQuietTime() => this.IsCurrentChatStreaming ? TOKEN_COUNT_STREAMING_QUIET_TIME : TOKEN_COUNT_QUIET_TIME;
/// <summary>
/// The culture the token numbers are written in.
/// </summary>
/// <remarks>
/// Taken from the language plugin the user chose, not from the machine. AI Studio's language is
/// a setting of its own, and a German who set German would otherwise read English separators
/// inside a German sentence -- where "1,234" means something a thousand times smaller.
/// </remarks>
private CultureInfo currentCulture = CultureInfo.InvariantCulture;
/// <summary>
/// What the helper text under the input field says about the token budget.
/// </summary>
/// <remarks>
/// Four sentences rather than one built from pieces, because a translator needs to see the
/// whole thing: which of the two numbers is the limit, and where the word for "about" belongs,
/// are decisions no language makes the same way.
///
/// The images are named rather than counted. Every vendor charges a picture differently, and
/// none of those rules can be applied without decoding the file, so the honest answer is to say
/// how many of them the number does not include.
/// </remarks>
private string TokenCountMessage
{
get
{
if (!this.conversationTokens.IsKnown)
return string.Empty;
var used = TokenAmount.Format(this.conversationTokens.Tokens, this.currentCulture);
var budget = this.conversationTokens.Window.IsKnown
? string.Format(this.conversationTokens.IsEstimate ? this.T("approx. {0} of {1} tokens") : this.T("{0} of {1} tokens"), used, TokenAmount.Format(this.conversationTokens.Window.DefaultTokens, this.currentCulture))
: string.Format(this.conversationTokens.IsEstimate ? this.T("approx. {0} tokens") : this.T("{0} tokens"), used);
if (this.conversationTokens.UncountedImages is 0)
return budget;
//
// The pictures of the whole conversation, not of the message being written: every one
// of them is sent again with every further message, so a chat runs past the model's
// limit long after anybody last thought about images.
//
var images = this.conversationTokens.TooManyImages
? string.Format(this.T("plus {0} image(s), which is more than the {1} this model accepts"), this.conversationTokens.UncountedImages, this.conversationTokens.ImageLimits.MaxInOneMessage)
: string.Format(this.T("plus {0} image(s), which cannot be counted"), this.conversationTokens.UncountedImages);
return $"{budget} {images}";
}
}
/// <summary>
/// Takes over the culture of the language the user chose for AI Studio.
/// </summary>
private async Task RefreshCulture()
{
var activeLanguagePlugin = await this.SettingsManager.GetActiveLanguagePlugin();
this.currentCulture = CommonTools.DeriveActiveCultureOrInvariant(activeLanguagePlugin.IETFTag);
}
private MediaImportOwner CurrentMediaImportOwner => MediaImportOwner.ForChat(this.ChatThread?.ChatId ?? this.draftMediaOwnerId); private MediaImportOwner CurrentMediaImportOwner => MediaImportOwner.ForChat(this.ChatThread?.ChatId ?? this.draftMediaOwnerId);
@ -125,6 +229,15 @@ public partial class ChatComponent : MSGComponentBase
protected override async Task OnInitializedAsync() protected override async Task OnInitializedAsync()
{ {
this.MediaTranscriptionService.StateChanged += this.OnMediaImportStateChanged; this.MediaTranscriptionService.StateChanged += this.OnMediaImportStateChanged;
await this.RefreshCulture();
//
// The number under the input field follows from the conversation, and nothing in a
// conversation announces that it changed: blocks, attachments and the answer being written
// are plain objects somebody mutates. So it is recomputed rather than notified.
//
this.tokenTracker = new(this.RecountTokensAsync, this.TokenCountQuietTime, TOKEN_COUNT_HEARTBEAT);
this.tokenTracker.Start();
// Apply the filters for the message bus: // Apply the filters for the message bus:
this.ApplyFilters([], [ Event.HAS_CHAT_UNSAVED_CHANGES, Event.RESET_CHAT_STATE, Event.CHAT_STREAMING_DONE, Event.AI_JOB_CHANGED, Event.AI_JOB_FINISHED, Event.CHAT_GENERATION_CHANGED, Event.WORKSPACE_RENAMED, Event.CONFIGURATION_CHANGED ]); this.ApplyFilters([], [ Event.HAS_CHAT_UNSAVED_CHANGES, Event.RESET_CHAT_STATE, Event.CHAT_STREAMING_DONE, Event.AI_JOB_CHANGED, Event.AI_JOB_FINISHED, Event.CHAT_GENERATION_CHANGED, Event.WORKSPACE_RENAMED, Event.CONFIGURATION_CHANGED ]);
@ -374,28 +487,30 @@ public partial class ChatComponent : MSGComponentBase
await this.inputField.FocusAsync(); await this.inputField.FocusAsync();
this.previousInputForbidden = inputForbidden; this.previousInputForbidden = inputForbidden;
//
// Everything which can move the token count also renders this component: the selections in
// the toolbar, the attachments and the composer all travel through an event callback whose
// receiver is this component, and the streamed answer arrives as a message which already
// asks for a render. So this one line stands in for the fifteen call sites which used to be
// spread over this file -- and which kept missing one.
//
this.tokenTracker?.Nudge();
await base.OnAfterRenderAsync(firstRender); await base.OnAfterRenderAsync(firstRender);
} }
protected override async Task OnParametersSetAsync() protected override async Task OnParametersSetAsync()
{ {
var incomingChatId = this.ChatThread?.ChatId ?? Guid.Empty; var incomingChatId = this.ChatThread?.ChatId ?? Guid.Empty;
var providerChanged = this.Provider != this.lastSeenProvider;
if (incomingChatId != this.lastSeenChatId || this.Provider != this.lastSeenProvider) if (incomingChatId != this.lastSeenChatId || this.Provider != this.lastSeenProvider)
{ {
this.lastSeenChatId = incomingChatId; this.lastSeenChatId = incomingChatId;
this.lastSeenProvider = this.Provider; this.lastSeenProvider = this.Provider;
if (providerChanged)
this.tokenCount = "0";
this.previousInputForbidden = true; this.previousInputForbidden = true;
} }
await this.ApplyLoadedChatParameterAsync(); await this.ApplyLoadedChatParameterAsync();
await this.SyncForegroundChatAsync(); await this.SyncForegroundChatAsync();
if (providerChanged && this.HasCustomTokenizer)
await this.CalculateTokenCount();
await this.ConsumeMediaOutcomeAsync(); await this.ConsumeMediaOutcomeAsync();
await base.OnParametersSetAsync(); await base.OnParametersSetAsync();
} }
@ -539,7 +654,45 @@ public partial class ChatComponent : MSGComponentBase
private string UserInputStyle => this.SettingsManager.ConfigurationData.Confidence.ShowProviderConfidence ? this.Provider.UsedLLMProvider.GetConfidence(this.SettingsManager).SetColorStyle(this.SettingsManager) : string.Empty; private string UserInputStyle => this.SettingsManager.ConfigurationData.Confidence.ShowProviderConfidence ? this.Provider.UsedLLMProvider.GetConfidence(this.SettingsManager).SetColorStyle(this.SettingsManager) : string.Empty;
private string UserInputClass => this.SettingsManager.ConfigurationData.Confidence.ShowProviderConfidence ? "confidence-border" : string.Empty; private string UserInputClass => $"{(this.SettingsManager.ConfigurationData.Confidence.ShowProviderConfidence ? "confidence-border" : string.Empty)} {this.TokenBudgetClass}".Trim();
/// <summary>
/// How much of the model's context window the conversation already takes.
/// </summary>
/// <remarks>
/// Zero whenever nobody wrote the window down. There is then nothing to be full of, and a share
/// of an unknown total would be a number made up on the spot.
/// </remarks>
private double TokenBudgetFill => this.conversationTokens is { IsKnown: true, Window.IsKnown: true }
? (double) this.conversationTokens.Tokens / this.conversationTokens.Window.DefaultTokens
: 0d;
/// <summary>
/// What the number under the input field is coloured with, if anything.
/// </summary>
/// <remarks>
/// Two steps rather than a gradient: below four fifths there is nothing to do about it, above
/// it there is -- shorten the chat, start a new one, or pick a model which reads more -- and
/// past the window the request will be refused or trimmed by the provider.
///
/// Images share the second step and have no first one. There is no "nearly too many pictures":
/// either they fit or the request comes back as an error, and no number of them is worth a
/// warning as long as it fits.
/// </remarks>
private string TokenBudgetClass
{
get
{
//
// Too many pictures is the same kind of news as a full window: the request will be
// refused, and for the same reason -- more was put in than the model takes.
//
if (this.conversationTokens.TooManyImages || this.TokenBudgetFill >= 1d)
return "token-budget-exceeded";
return this.TokenBudgetFill >= WINDOW_NEARLY_FULL ? "token-budget-nearly-full" : string.Empty;
}
}
private void ApplyStandardDataSourceOptions() private void ApplyStandardDataSourceOptions()
{ {
@ -603,15 +756,20 @@ public partial class ChatComponent : MSGComponentBase
private async Task ProfileWasChanged(Profile profile) private async Task ProfileWasChanged(Profile profile)
{ {
this.currentProfile = this.SettingsManager.GetProfileById(profile.Id); this.currentProfile = this.SettingsManager.GetProfileById(profile.Id);
if(this.ChatThread is null)
return;
this.ChatThread = this.ChatThread with //
// A thread which already exists has to carry the choice. Before the first message there is
// none, and the choice then travels in the thread a new chat is started with.
//
if (this.ChatThread is not null)
{ {
SelectedProfile = this.currentProfile.Id, this.ChatThread = this.ChatThread with
}; {
SelectedProfile = this.currentProfile.Id,
await this.ChatThreadChanged.InvokeAsync(this.ChatThread); };
await this.ChatThreadChanged.InvokeAsync(this.ChatThread);
}
} }
private async Task ChatTemplateWasChanged(ChatTemplate chatTemplate) private async Task ChatTemplateWasChanged(ChatTemplate chatTemplate)
@ -623,10 +781,8 @@ public partial class ChatComponent : MSGComponentBase
// Apply template's file attachments (replaces existing): // Apply template's file attachments (replaces existing):
this.ComposerState.ReplaceFileAttachments(this.currentChatTemplate.FileAttachments); this.ComposerState.ReplaceFileAttachments(this.currentChatTemplate.FileAttachments);
if(this.ChatThread is null) if (this.ChatThread is not null)
return; await this.StartNewChat(true);
await this.StartNewChat(true);
} }
private void RefreshCurrentProfileAndChatTemplate() private void RefreshCurrentProfileAndChatTemplate()
@ -726,10 +882,7 @@ public partial class ChatComponent : MSGComponentBase
// Was a modifier key pressed as well? // Was a modifier key pressed as well?
var isModifier = keyEvent.AltKey || keyEvent.CtrlKey || keyEvent.MetaKey || keyEvent.ShiftKey; var isModifier = keyEvent.AltKey || keyEvent.CtrlKey || keyEvent.MetaKey || keyEvent.ShiftKey;
if (isEnter)
await this.CalculateTokenCount();
// Depending on the user's settings, might react to shortcuts: // Depending on the user's settings, might react to shortcuts:
switch (this.SettingsManager.ConfigurationData.Chat.ShortcutSendBehavior) switch (this.SettingsManager.ConfigurationData.Chat.ShortcutSendBehavior)
{ {
@ -774,21 +927,13 @@ public partial class ChatComponent : MSGComponentBase
this.RefreshCurrentProfileAndChatTemplate(); this.RefreshCurrentProfileAndChatTemplate();
var promptName = this.ExtractThreadName(this.ComposerState.UserInput); var promptName = this.ExtractThreadName(this.ComposerState.UserInput);
this.ChatThread = new() var threadName = string.IsNullOrWhiteSpace(this.ComposerState.UserInput)
? $"Transkription: {Path.GetFileName(firstMediaPath)}"
: promptName;
this.ChatThread = this.NewChatThread(threadName) with
{ {
IncludeDateTime = true,
SelectedProvider = this.Provider.Id,
SelectedProfile = this.currentProfile.Id,
SelectedChatTemplate = this.currentChatTemplate.Id,
SelectedToolIds = [..this.selectedToolIds],
SystemPrompt = SystemPrompts.DEFAULT,
WorkspaceId = this.currentWorkspaceId,
ChatId = Guid.NewGuid(),
DataSourceOptions = this.earlyDataSourceOptions, DataSourceOptions = this.earlyDataSourceOptions,
Name = string.IsNullOrWhiteSpace(this.ComposerState.UserInput)
? $"Transkription: {Path.GetFileName(firstMediaPath)}"
: promptName,
Blocks = this.currentChatTemplate == ChatTemplate.NO_CHAT_TEMPLATE ? [] : this.currentChatTemplate.ExampleConversation.Select(block => block.DeepClone()).ToList(),
}; };
await WorkspaceBehaviour.StoreChatAsync(this.ChatThread); await WorkspaceBehaviour.StoreChatAsync(this.ChatThread);
@ -819,21 +964,11 @@ public partial class ChatComponent : MSGComponentBase
// Create a new chat thread if necessary: // Create a new chat thread if necessary:
if (this.ChatThread is null) if (this.ChatThread is null)
{ {
this.ChatThread = new() this.ChatThread = this.NewChatThread(this.ExtractThreadName(this.ComposerState.UserInput)) with
{ {
IncludeDateTime = true,
SelectedProvider = this.Provider.Id,
SelectedProfile = this.currentProfile.Id,
SelectedChatTemplate = this.currentChatTemplate.Id,
SelectedToolIds = [..this.selectedToolIds],
SystemPrompt = SystemPrompts.DEFAULT,
WorkspaceId = this.currentWorkspaceId,
ChatId = Guid.NewGuid(),
DataSourceOptions = this.earlyDataSourceOptions, DataSourceOptions = this.earlyDataSourceOptions,
Name = this.ExtractThreadName(this.ComposerState.UserInput),
Blocks = this.currentChatTemplate == ChatTemplate.NO_CHAT_TEMPLATE ? [] : this.currentChatTemplate.ExampleConversation.Select(x => x.DeepClone()).ToList(),
}; };
this.MarkCurrentChatAsLoadedParameter(); this.MarkCurrentChatAsLoadedParameter();
await this.ChatThreadChanged.InvokeAsync(this.ChatThread); await this.ChatThreadChanged.InvokeAsync(this.ChatThread);
} }
@ -916,8 +1051,7 @@ public partial class ChatComponent : MSGComponentBase
this.ComposerState.Clear(); this.ComposerState.Clear();
await this.inputField.BlurAsync(); await this.inputField.BlurAsync();
this.tokenCount = "0";
// Enable the stream state for the chat component: // Enable the stream state for the chat component:
this.hasUnsavedChanges = true; this.hasUnsavedChanges = true;
@ -962,7 +1096,7 @@ public partial class ChatComponent : MSGComponentBase
private void ApplyToolSelectionOfLoadedChat() => private void ApplyToolSelectionOfLoadedChat() =>
this.selectedToolIds = ToolSelectionRules.NormalizeSelection(this.ChatThread?.SelectedToolIds ?? this.SettingsManager.GetDefaultToolIds(Tools.Components.CHAT)); this.selectedToolIds = ToolSelectionRules.NormalizeSelection(this.ChatThread?.SelectedToolIds ?? this.SettingsManager.GetDefaultToolIds(Tools.Components.CHAT));
private Task SelectedToolIdsChanged(HashSet<string> updatedToolIds) private void SelectedToolIdsChanged(HashSet<string> updatedToolIds)
{ {
this.selectedToolIds = ToolSelectionRules.NormalizeSelection(updatedToolIds); this.selectedToolIds = ToolSelectionRules.NormalizeSelection(updatedToolIds);
@ -977,8 +1111,6 @@ public partial class ChatComponent : MSGComponentBase
this.ChatThread.SelectedToolIds = [..this.selectedToolIds]; this.ChatThread.SelectedToolIds = [..this.selectedToolIds];
this.hasUnsavedChanges = true; this.hasUnsavedChanges = true;
} }
return Task.CompletedTask;
} }
private async Task SaveThread() private async Task SaveThread()
@ -1087,19 +1219,7 @@ public partial class ChatComponent : MSGComponentBase
// reset the chat thread only. The workspace id and the workspace name remain // reset the chat thread only. The workspace id and the workspace name remain
// the same: // the same:
// //
this.ChatThread = new() this.ChatThread = this.NewChatThread(string.Empty);
{
IncludeDateTime = true,
SelectedProvider = this.Provider.Id,
SelectedProfile = this.currentProfile.Id,
SelectedChatTemplate = this.currentChatTemplate.Id,
SelectedToolIds = [..this.selectedToolIds],
SystemPrompt = SystemPrompts.DEFAULT,
WorkspaceId = this.currentWorkspaceId,
ChatId = Guid.NewGuid(),
Name = string.Empty,
Blocks = this.currentChatTemplate == ChatTemplate.NO_CHAT_TEMPLATE ? [] : this.currentChatTemplate.ExampleConversation.Select(x => x.DeepClone()).ToList(),
};
} }
this.ComposerState.ApplyTemplate(this.currentChatTemplate); this.ComposerState.ApplyTemplate(this.currentChatTemplate);
@ -1112,7 +1232,7 @@ public partial class ChatComponent : MSGComponentBase
this.MarkCurrentChatAsLoadedParameter(); this.MarkCurrentChatAsLoadedParameter();
await this.ChatThreadChanged.InvokeAsync(this.ChatThread); await this.ChatThreadChanged.InvokeAsync(this.ChatThread);
} }
private async Task MoveChatToWorkspace() private async Task MoveChatToWorkspace()
{ {
if(this.ChatThread is null) if(this.ChatThread is null)
@ -1215,7 +1335,7 @@ public partial class ChatComponent : MSGComponentBase
this.ApplyStandardDataSourceOptions(); this.ApplyStandardDataSourceOptions();
await this.ChatThreadChanged.InvokeAsync(this.ChatThread); await this.ChatThreadChanged.InvokeAsync(this.ChatThread);
} }
private async Task SelectProviderWhenLoadingChat() private async Task SelectProviderWhenLoadingChat()
{ {
var chatProvider = this.ChatThread?.SelectedProvider; var chatProvider = this.ChatThread?.SelectedProvider;
@ -1270,37 +1390,35 @@ public partial class ChatComponent : MSGComponentBase
{ {
if(this.ChatThread is null) if(this.ChatThread is null)
return Task.CompletedTask; return Task.CompletedTask;
if (block is not ContentText textBlock) if (block is not ContentText textBlock)
return Task.CompletedTask; return Task.CompletedTask;
var lastBlock = this.ChatThread.Blocks.Last(); var lastBlock = this.ChatThread.Blocks.Last();
var lastBlockContent = lastBlock.Content; var lastBlockContent = lastBlock.Content;
if(lastBlockContent is null) if(lastBlockContent is null)
return Task.CompletedTask; return Task.CompletedTask;
this.RestoreComposerFromTextBlock(textBlock); this.RestoreComposerFromTextBlock(textBlock);
this.ChatThread.Remove(block); this.ChatThread.Remove(block);
this.ChatThread.Remove(lastBlockContent); this.ChatThread.Remove(lastBlockContent);
this.hasUnsavedChanges = true; this.hasUnsavedChanges = true;
this.StateHasChanged(); this.StateHasChanged();
return Task.CompletedTask; return Task.CompletedTask;
} }
private Task EditLastBlock(IContent block) private Task EditLastBlock(IContent block)
{ {
if(this.ChatThread is null) if(this.ChatThread is null)
return Task.CompletedTask; return Task.CompletedTask;
if (block is not ContentText textBlock) if (block is not ContentText textBlock)
return Task.CompletedTask; return Task.CompletedTask;
this.RestoreComposerFromTextBlock(textBlock); this.RestoreComposerFromTextBlock(textBlock);
this.ChatThread.Remove(block); this.ChatThread.Remove(block);
this.hasUnsavedChanges = true; this.hasUnsavedChanges = true;
this.StateHasChanged(); this.StateHasChanged();
return Task.CompletedTask; return Task.CompletedTask;
} }
@ -1309,42 +1427,115 @@ public partial class ChatComponent : MSGComponentBase
this.ComposerState.RestoreFromTextBlock(textBlock); this.ComposerState.RestoreFromTextBlock(textBlock);
} }
private async Task CalculateTokenCount() /// <summary>
/// Works out what the next request would take out of the model's context window.
/// </summary>
/// <remarks>
/// The whole conversation, not only what is being typed. A number counting the draft alone
/// answers a question nobody asks: what decides whether the next message fits is everything
/// which travels with it, and in a chat of any age the draft is the smallest part of that.
///
/// This used to run only for providers with a tokenizer of their own, which is almost nobody,
/// so almost nobody ever saw a number. The runtime falls back to the tokenizer shipped with AI
/// Studio when a provider names none, so the count is available everywhere -- it is then an
/// estimate, and it says so.
///
/// Read the text from the bound property rather than from the input field: the field is a
/// component reference, which is only set once the component has rendered.
///
/// Called by the tracker, never directly. Whoever thinks something changed nudges it instead,
/// and it decides when the work is worth doing.
/// </remarks>
/// <param name="token">Ends the count when the component goes away.</param>
private async Task RecountTokensAsync(CancellationToken token)
{ {
if (!this.HasCustomTokenizer) var provider = AIStudio.Settings.Provider.NONE;
{ var parts = ConversationParts.NOTHING;
if (this.tokenCount != "0")
{
this.tokenCount = "0";
this.StateHasChanged();
}
return;
}
// //
// Read the text from the bound property rather than from the input field: the field is a // Collected on the render thread, counted off it. Counting may take an IPC call per text,
// component reference, which is only set once the component has rendered. Counting is also // and while it runs, the background job which writes the answer appends to the very list
// triggered while parameters are set, which happens before that. // which is walked here.
// //
var currentInput = this.UserInput; await this.InvokeAsync(() =>
if (string.IsNullOrEmpty(currentInput))
{ {
this.tokenCount = "0"; //
return; // Before the first message there is no thread yet, so what is measured is the one a new
} // chat would start with. A preselected profile or a chat template is already part of
// that, and it may even bring an example conversation along -- reporting nothing for all
// of it would tell a person their window is empty while their first message is not.
//
var thread = this.ChatThread ?? this.NewChatThread(string.Empty);
provider = this.Provider;
parts = ConversationParts.Of(thread, this.BuildSystemPromptFor(thread), this.UserInput, this.ComposerState.FileAttachments, provider.SupportsImageInput());
});
var response = await this.RustService.GetTokenCount(this.Provider, currentInput); var counted = await this.ConversationTokenCounter.CountAsync(provider, parts, token);
if (response is null) if (token.IsCancellationRequested)
return; return;
if (!response.Value.Success)
await this.InvokeAsync(() =>
{ {
this.Logger.LogWarning("Failed to calculate token count: reason='{Reason}'", response.Value.Message); if (counted == this.conversationTokens)
return; return;
}
this.tokenCount = response.Value.TokenCount.ToString(); this.conversationTokens = counted;
this.StateHasChanged(); this.StateHasChanged();
});
} }
/// <summary>
/// Works out the system prompt a thread would send.
/// </summary>
/// <remarks>
/// Not the prompt a person typed: a chat template may replace it, the retrieved data of a data
/// source is appended to it, the selected profile adds a paragraph, and the policy of the
/// selected tools adds another. Switching a profile while writing therefore moves the number,
/// which is the whole reason this is asked rather than read off the thread.
///
/// The tools are filtered for the provider the same way they are before sending, so that a tool
/// the provider is not trusted enough to receive does not count either.
/// </remarks>
/// <param name="thread">The thread to build the prompt for.</param>
/// <returns>The system prompt as it would be sent.</returns>
private string BuildSystemPromptFor(ChatThread thread)
{
var toolDefinitions = this.ToolRegistry.FilterToolIdsForProvider(this.Provider, this.selectedToolIds)
.Select(this.ToolRegistry.GetDefinition)
.Where(definition => definition is not null)
.Select(definition => definition!)
.ToList();
return thread.BuildSystemPrompt(this.SettingsManager, toolDefinitions).Text;
}
/// <summary>
/// The thread a new chat starts with, as the selections made so far decide it.
/// </summary>
/// <remarks>
/// In one place because three code paths used to write it out, and because the token count has
/// to measure the same thing they build. A count against a thread assembled differently from
/// the one which is then sent would be wrong in exactly the moment a person looks at it: before
/// they send their first message.
///
/// The data source options are left out on purpose: two of the three callers set them and the
/// third replaces them right afterwards, so this stays the part they agree on.
/// </remarks>
/// <param name="name">The name of the thread.</param>
/// <returns>The new thread.</returns>
private ChatThread NewChatThread(string name) => new()
{
IncludeDateTime = true,
SelectedProvider = this.Provider.Id,
SelectedProfile = this.currentProfile.Id,
SelectedChatTemplate = this.currentChatTemplate.Id,
SelectedToolIds = [..this.selectedToolIds],
SystemPrompt = SystemPrompts.DEFAULT,
WorkspaceId = this.currentWorkspaceId,
ChatId = Guid.NewGuid(),
Name = name,
Blocks = this.currentChatTemplate == ChatTemplate.NO_CHAT_TEMPLATE ? [] : this.currentChatTemplate.ExampleConversation.Select(block => block.DeepClone()).ToList(),
};
#region Overrides of MSGComponentBase #region Overrides of MSGComponentBase
@ -1372,6 +1563,7 @@ public partial class ChatComponent : MSGComponentBase
case Event.CONFIGURATION_CHANGED: case Event.CONFIGURATION_CHANGED:
case Event.PLUGINS_RELOADED: case Event.PLUGINS_RELOADED:
await this.RefreshCulture();
await this.RefreshChatSelectionsAfterConfigurationChange(); await this.RefreshChatSelectionsAfterConfigurationChange();
this.StateHasChanged(); this.StateHasChanged();
break; break;
@ -1419,6 +1611,10 @@ public partial class ChatComponent : MSGComponentBase
protected override async ValueTask DisposeResourcesAsync() protected override async ValueTask DisposeResourcesAsync()
{ {
this.MediaTranscriptionService.StateChanged -= this.OnMediaImportStateChanged; this.MediaTranscriptionService.StateChanged -= this.OnMediaImportStateChanged;
if (this.tokenTracker is not null)
await this.tokenTracker.DisposeAsync();
if(this.SettingsManager.ConfigurationData.Workspace.StorageBehavior is WorkspaceStorageBehavior.STORE_CHATS_AUTOMATICALLY) if(this.SettingsManager.ConfigurationData.Workspace.StorageBehavior is WorkspaceStorageBehavior.STORE_CHATS_AUTOMATICALLY)
{ {
await this.SaveThread(); await this.SaveThread();

View File

@ -55,16 +55,16 @@ public partial class ProviderSelection : MSGComponentBase
private IReadOnlyList<CapabilityIcon> GetCapabilityIcons(AIStudio.Settings.Provider provider) private IReadOnlyList<CapabilityIcon> GetCapabilityIcons(AIStudio.Settings.Provider provider)
{ {
var capabilities = provider.GetModelCapabilities(); var profile = provider.GetModelProfile();
List<CapabilityIcon> capabilityIcons = []; List<CapabilityIcon> capabilityIcons = [];
if (capabilities.Contains(Capability.AUDIO_INPUT)) if (profile.Has(Capability.AUDIO_INPUT))
capabilityIcons.Add(new(Icons.Material.Filled.GraphicEq, this.T("Audio input possible"))); capabilityIcons.Add(new(Icons.Material.Filled.GraphicEq, this.T("Audio input possible")));
if (capabilities.Contains(Capability.SINGLE_IMAGE_INPUT) || capabilities.Contains(Capability.MULTIPLE_IMAGE_INPUT)) if (profile.HasAny(Capability.SINGLE_IMAGE_INPUT | Capability.MULTIPLE_IMAGE_INPUT))
capabilityIcons.Add(new(Icons.Material.Filled.Image, this.T("Image input possible"))); capabilityIcons.Add(new(Icons.Material.Filled.Image, this.T("Image input possible")));
if (capabilities.Contains(Capability.SPEECH_INPUT)) if (profile.Has(Capability.SPEECH_INPUT))
capabilityIcons.Add(new(Icons.Material.Filled.Mic, this.T("Speech input possible"))); capabilityIcons.Add(new(Icons.Material.Filled.Mic, this.T("Speech input possible")));
var reasoningIndicatorState = provider.GetReasoningIndicatorState(); var reasoningIndicatorState = provider.GetReasoningIndicatorState();

View File

@ -0,0 +1,6 @@
@if (!string.IsNullOrWhiteSpace(this.Text))
{
<MudJustifiedText Typo="Typo.body2" Class="@this.Class">
@this.Text
</MudJustifiedText>
}

View File

@ -0,0 +1,70 @@
using AIStudio.Models;
using AIStudio.Provider;
using AIStudio.Settings;
using AIStudio.Tools.PluginSystem;
using Microsoft.AspNetCore.Components;
namespace AIStudio.Components;
/// <summary>
/// Says which tokenizer a model uses, next to the field which asks for one.
/// </summary>
/// <remarks>
/// The field takes a tokenizer.json file and nothing else, and for a long time it said nothing about
/// which file. That leaves two kinds of people stuck: the ones who could download the right one and
/// do not know its name, and the ones who go looking for Anthropic's tokenizer file, which was never
/// published.
///
/// One component rather than a sentence in each dialog, because both the LLM provider dialog and the
/// embedding provider dialog ask the same question and deserve the same answer. Two copies would be
/// two sets of translations of the same three sentences, and the second copy is the one which gets
/// forgotten when the wording changes.
/// </remarks>
public partial class TokenizerHint : ComponentBase
{
private static string TB(string fallbackEN) => I18N.I.T(fallbackEN, typeof(TokenizerHint).Namespace, nameof(TokenizerHint));
/// <summary>
/// Which provider the model is served by.
/// </summary>
[Parameter]
public LLMProviders LLMProvider { get; set; } = LLMProviders.NONE;
/// <summary>
/// The model whose tokenizer is in question.
/// </summary>
[Parameter]
public Model Model { get; set; }
/// <summary>
/// The classes of the text, so a dialog can keep its own spacing.
/// </summary>
[Parameter]
public string Class { get; set; } = "mb-3";
/// <summary>
/// What there is to say, or nothing at all.
/// </summary>
/// <remarks>
/// Empty for a model nobody named a tokenizer for, which is most of them. Saying "unknown"
/// would fill the dialog with a line which helps nobody; saying nothing leaves it as it was.
/// </remarks>
private string Text
{
get
{
var tokenizer = this.LLMProvider.GetModelProfile(this.Model).Tokenizer;
return tokenizer.IsKnown ? Describe(tokenizer) : string.Empty;
}
}
private static string Describe(TokenizerRef tokenizer) => tokenizer.Kind switch
{
TokenizerKind.HUGGING_FACE => string.Format(TB("This model uses the tokenizer of {0}. Download its tokenizer.json file and select it below to count exactly instead of estimating."), tokenizer.Id),
TokenizerKind.TIKTOKEN => string.Format(TB("This model uses OpenAI's {0} encoding, which does not come as a tokenizer.json file. AI Studio therefore estimates the token count with its built-in tokenizer."), tokenizer.Id),
TokenizerKind.PROVIDER_API => string.Format(TB("The vendor of this model publishes no tokenizer file and counts through their API instead ({0}). AI Studio therefore estimates the token count with its built-in tokenizer."), tokenizer.Id),
_ => string.Empty,
};
}

View File

@ -187,6 +187,7 @@
Disabled="@this.IsEnterpriseConfiguration" Disabled="@this.IsEnterpriseConfiguration"
Validation="@this.ValidateEmbeddingBatchSize" Validation="@this.ValidateEmbeddingBatchSize"
HelperText="@T("How many chunks are sent to the embedding provider at once. The default is 1.")"/> HelperText="@T("How many chunks are sent to the embedding provider at once. The default is 1.")"/>
<TokenizerHint LLMProvider="@this.DataLLMProvider" Model="@this.DataModel"/>
<PathDropZone IdPrefix="tokenizer" Disabled="@(() => this.IsEnterpriseConfiguration)" OnPathsDropped="@this.OnTokenizerPathsDropped"> <PathDropZone IdPrefix="tokenizer" Disabled="@(() => this.IsEnterpriseConfiguration)" OnPathsDropped="@this.OnTokenizerPathsDropped">
<MudStack Row="@true" Spacing="3" Class="mb-3" StretchItems="StretchItems.None" AlignItems="AlignItems.Center"> <MudStack Row="@true" Spacing="3" Class="mb-3" StretchItems="StretchItems.None" AlignItems="AlignItems.Center">
<MudTextField <MudTextField

View File

@ -242,6 +242,64 @@
</MudSelect> </MudSelect>
</MudPaper> </MudPaper>
</MudStack> </MudStack>
<MudText Typo="Typo.subtitle2" Class="mb-2">
@T("Override Model Limits")
</MudText>
<MudJustifiedText Typo="Typo.body2" Class="mb-4">
@T("Where the stored numbers do not match your installation, state yours here. This matters most for self-hosted models: they run with whatever their operator configured, which the model card cannot know.")
</MudJustifiedText>
<MudStack Class="mb-4" Spacing="2">
<MudPaper Outlined="@true" Class="pa-3">
<MudNumericField T="int?"
Value="@this.capabilityOverrides.ContextWindowTokens"
ValueChanged="@this.SetContextWindowOverride"
Label="@T("Context window in tokens")"
HelperText="@this.ContextWindowHelperText"
Placeholder="@this.AutomaticContextWindowPlaceholder"
Variant="Variant.Outlined"
Margin="Margin.Dense"
Min="1"
HideSpinButtons="@true"
Clearable="@true"
Disabled="@this.IsEnterpriseConfiguration"
Adornment="Adornment.Start"
AdornmentIcon="@Icons.Material.Filled.Straighten"
AdornmentColor="Color.Info" />
</MudPaper>
<MudPaper Outlined="@true" Class="pa-3">
<MudText Typo="Typo.body2">
@T("Images")
</MudText>
<MudText Typo="Typo.caption" Color="Color.Secondary">
@this.ImageLimitsEffectiveLabel
</MudText>
<MudStack Row="@true" Spacing="3" Wrap="Wrap.Wrap" Class="border-dashed border rounded-lg pa-3 mt-3">
<MudNumericField T="int?"
Value="@this.capabilityOverrides.MaxImagesPerMessage"
ValueChanged="@this.SetMaxImagesPerMessageOverride"
Label="@T("Per message")"
Placeholder="@this.AutomaticImageLimitPlaceholder(this.GetAutomaticModelProfile().Images.MaxPerMessage)"
Variant="Variant.Outlined"
Margin="Margin.Dense"
Min="0"
Clearable="@true"
Disabled="@this.IsEnterpriseConfiguration" />
<MudNumericField T="int?"
Value="@this.capabilityOverrides.MaxImagesPerRequest"
ValueChanged="@this.SetMaxImagesPerRequestOverride"
Label="@T("Per request")"
Placeholder="@this.AutomaticImageLimitPlaceholder(this.GetAutomaticModelProfile().Images.MaxPerRequest)"
Variant="Variant.Outlined"
Margin="Margin.Dense"
Min="0"
Clearable="@true"
Disabled="@this.IsEnterpriseConfiguration" />
</MudStack>
<MudText Typo="Typo.caption" Color="Color.Secondary" Class="mt-2">
@T("Vendors state one of the two, the other, or neither. Whichever is smaller decides how many images one message may carry; an empty field states nothing.")
</MudText>
</MudPaper>
</MudStack>
<MudJustifiedText Typo="Typo.body2" Class="mb-4"> <MudJustifiedText Typo="Typo.body2" Class="mb-4">
@string.Format(T("The current model uses the {0}."), this.GetCurrentModelApiLabel()) @string.Format(T("The current model uses the {0}."), this.GetCurrentModelApiLabel())
</MudJustifiedText> </MudJustifiedText>
@ -251,6 +309,7 @@
<MudJustifiedText Typo="Typo.body1" Class="mt-4 mb-3"> <MudJustifiedText Typo="Typo.body1" Class="mt-4 mb-3">
@T("For better token estimates, you can configure a custom tokenizer for this provider.") @T("For better token estimates, you can configure a custom tokenizer for this provider.")
</MudJustifiedText> </MudJustifiedText>
<TokenizerHint LLMProvider="@this.DataLLMProvider" Model="@this.DataModel"/>
<PathDropZone IdPrefix="tokenizer" Disabled="@(() => this.IsEnterpriseConfiguration)" OnPathsDropped="@this.OnTokenizerPathsDropped"> <PathDropZone IdPrefix="tokenizer" Disabled="@(() => this.IsEnterpriseConfiguration)" OnPathsDropped="@this.OnTokenizerPathsDropped">
<MudStack Row="@true" Spacing="3" Class="mb-3" StretchItems="StretchItems.None" AlignItems="AlignItems.Center"> <MudStack Row="@true" Spacing="3" Class="mb-3" StretchItems="StretchItems.None" AlignItems="AlignItems.Center">
<MudTextField <MudTextField

View File

@ -1,7 +1,9 @@
using System.Globalization;
using System.Text; using System.Text;
using System.Text.Json; using System.Text.Json;
using AIStudio.Components; using AIStudio.Components;
using AIStudio.Models;
using AIStudio.Provider; using AIStudio.Provider;
using AIStudio.Provider.HuggingFace; using AIStudio.Provider.HuggingFace;
using AIStudio.Tools.Rust; using AIStudio.Tools.Rust;
@ -163,6 +165,16 @@ public partial class ProviderDialog : MSGComponentBase, ISecretId
private bool usesLegacySystemModelFallback; private bool usesLegacySystemModelFallback;
private bool showExpertSettings; private bool showExpertSettings;
private ProviderCapabilityOverrides capabilityOverrides = new(); private ProviderCapabilityOverrides capabilityOverrides = new();
/// <summary>
/// The culture the numbers of this dialog are written in.
/// </summary>
/// <remarks>
/// AI Studio's language is a setting of its own and does not move the thread's culture along
/// with it. Without this, a German who chose German would read a context window of 131,072
/// tokens as a number a thousand times smaller.
/// </remarks>
private CultureInfo currentCulture = CultureInfo.InvariantCulture;
// We get the form reference from Blazor code to validate it manually: // We get the form reference from Blazor code to validate it manually:
private MudForm form = null!; private MudForm form = null!;
@ -226,7 +238,11 @@ public partial class ProviderDialog : MSGComponentBase, ISecretId
{ {
// Call the base initialization first so that the I18N is ready: // Call the base initialization first so that the I18N is ready:
await base.OnInitializedAsync(); await base.OnInitializedAsync();
// The numbers of the expert settings are written the way the chosen language writes them:
var activeLanguagePlugin = await this.SettingsManager.GetActiveLanguagePlugin();
this.currentCulture = CommonTools.DeriveActiveCultureOrInvariant(activeLanguagePlugin.IETFTag);
// Configure the spellchecking for the instance name input: // Configure the spellchecking for the instance name input:
this.SettingsManager.InjectSpellchecking(SPELLCHECK_ATTRIBUTES); this.SettingsManager.InjectSpellchecking(SPELLCHECK_ATTRIBUTES);
@ -663,33 +679,29 @@ public partial class ProviderDialog : MSGComponentBase, ISecretId
if (alwaysReasoning is null && optionalReasoning is null && reasoningByDefault is null) if (alwaysReasoning is null && optionalReasoning is null && reasoningByDefault is null)
return ReasoningOverrideMode.AUTOMATIC; return ReasoningOverrideMode.AUTOMATIC;
var capabilities = this.GetCurrentModelCapabilities(); return ModeOf(this.GetCurrentModelProfile().Reasoning);
if (capabilities.Contains(Capability.ALWAYS_REASONING))
return ReasoningOverrideMode.ALWAYS_ON;
if (capabilities.Contains(Capability.REASONING_BY_DEFAULT))
return ReasoningOverrideMode.ON_BY_DEFAULT;
if (capabilities.Contains(Capability.OPTIONAL_REASONING))
return ReasoningOverrideMode.CAN_BE_ENABLED;
return ReasoningOverrideMode.NO_REASONING;
} }
private ReasoningOverrideMode GetAutomaticReasoningOverrideMode() private ReasoningOverrideMode GetAutomaticReasoningOverrideMode() => ModeOf(this.GetAutomaticModelProfile().Reasoning);
/// <summary>
/// Which of the choices in this dialog a reasoning state is.
/// </summary>
/// <remarks>
/// The five entries of the list were always this one answer, only written as three flags and
/// read back by asking for them in the right order. Now they are the same four words plus
/// "automatic", which is the absence of a statement rather than a state a model can be in.
/// </remarks>
/// <param name="reasoning">How the model reasons.</param>
/// <returns>The choice standing for it.</returns>
private static ReasoningOverrideMode ModeOf(ReasoningSupport reasoning) => reasoning switch
{ {
var capabilities = this.GetAutomaticModelCapabilities(); ReasoningSupport.ALWAYS => ReasoningOverrideMode.ALWAYS_ON,
if (capabilities.Contains(Capability.ALWAYS_REASONING)) ReasoningSupport.ON_BY_DEFAULT => ReasoningOverrideMode.ON_BY_DEFAULT,
return ReasoningOverrideMode.ALWAYS_ON; ReasoningSupport.OPTIONAL => ReasoningOverrideMode.CAN_BE_ENABLED,
if (capabilities.Contains(Capability.REASONING_BY_DEFAULT)) _ => ReasoningOverrideMode.NO_REASONING,
return ReasoningOverrideMode.ON_BY_DEFAULT; };
if (capabilities.Contains(Capability.OPTIONAL_REASONING))
return ReasoningOverrideMode.CAN_BE_ENABLED;
return ReasoningOverrideMode.NO_REASONING;
}
private void SetReasoningOverrideMode(ReasoningOverrideMode mode) private void SetReasoningOverrideMode(ReasoningOverrideMode mode)
{ {
@ -746,11 +758,7 @@ public partial class ProviderDialog : MSGComponentBase, ISecretId
private bool HasCapabilityOverride(Capability capability) => this.capabilityOverrides.GetOverride(capability) is not null; private bool HasCapabilityOverride(Capability capability) => this.capabilityOverrides.GetOverride(capability) is not null;
private bool IsCapabilityEnabled(Capability capability) private bool IsCapabilityEnabled(Capability capability) => this.GetCurrentModelProfile().Has(capability);
{
var capabilities = this.GetCurrentModelCapabilities();
return capabilities.Contains(capability);
}
private string GetCapabilityEffectiveLabel(Capability capability) private string GetCapabilityEffectiveLabel(Capability capability)
{ {
@ -761,21 +769,107 @@ public partial class ProviderDialog : MSGComponentBase, ISecretId
return isEnabled ? T("Enabled (Auto)") : T("Disabled (Auto)"); return isEnabled ? T("Enabled (Auto)") : T("Disabled (Auto)");
} }
private List<Capability> GetCurrentModelCapabilities() /// <summary>
/// States how many tokens this installation reads and writes.
/// </summary>
/// <param name="tokens">The number of tokens, or null to go back to the automatic answer.</param>
private void SetContextWindowOverride(int? tokens) => this.capabilityOverrides = this.capabilityOverrides with { ContextWindowTokens = tokens };
/// <summary>
/// States how many images one message may carry here.
/// </summary>
/// <param name="images">The number of images, or null to go back to the automatic answer.</param>
private void SetMaxImagesPerMessageOverride(int? images) => this.capabilityOverrides = this.capabilityOverrides with { MaxImagesPerMessage = images };
/// <summary>
/// States how many images one request may carry here.
/// </summary>
/// <param name="images">The number of images, or null to go back to the automatic answer.</param>
private void SetMaxImagesPerRequestOverride(int? images) => this.capabilityOverrides = this.capabilityOverrides with { MaxImagesPerRequest = images };
/// <summary>
/// What an empty window field shows.
/// </summary>
/// <remarks>
/// Written without separators, unlike the number in the helper text next to it: this one stands
/// inside the field a person types into, and what they see there has to be what they may type.
/// </remarks>
private string AutomaticContextWindowPlaceholder
{ {
var currentProviderSettings = this.CreateProviderSettings(); get
return currentProviderSettings.GetModelCapabilities(); {
var context = this.GetAutomaticModelProfile().Context;
return context.IsKnown ? context.DefaultTokens.ToString(CultureInfo.InvariantCulture) : string.Empty;
}
} }
private List<Capability> GetAutomaticModelCapabilities() => this.DataLLMProvider.GetModelCapabilities(this.GetSelectedModel()); /// <summary>
/// What an empty image field shows.
/// </summary>
/// <param name="limit">The limit the rules worked out, if any.</param>
/// <returns>The number, or nothing where nobody stated one.</returns>
private string AutomaticImageLimitPlaceholder(int? limit) => limit?.ToString(CultureInfo.InvariantCulture) ?? string.Empty;
/// <summary>
/// What the window field says below itself.
/// </summary>
/// <remarks>
/// It names the automatic answer rather than the one in effect, because the number in effect is
/// already in the field. What a person cannot otherwise see is what they would go back to.
/// </remarks>
private string ContextWindowHelperText
{
get
{
var context = this.GetAutomaticModelProfile().Context;
return context.IsKnown
? string.Format(T("Detected: {0} tokens. Leave the field empty to use that."), context.DefaultTokens.ToString("N0", this.currentCulture))
: T("Nobody has stated a window for this model. Left empty, the chat counts the tokens of a conversation without saying what they may grow to.");
}
}
/// <summary>
/// What the two image fields say above themselves.
/// </summary>
/// <remarks>
/// The number in effect, not the two the person typed: which of them decides is the one thing
/// two fields cannot show on their own, and it is the one the chat and the Visual Briefing go by.
/// </remarks>
private string ImageLimitsEffectiveLabel
{
get
{
var allowed = this.GetCurrentModelProfile().Images.MaxInOneMessage;
return allowed is { } count
? string.Format(T("At most {0} images at once."), count.ToString("N0", this.currentCulture))
: T("No limit known, so AI Studio does not stop anybody from attaching more.");
}
}
/// <summary>
/// What the model can do as this provider instance is configured, the person's own settings included.
/// </summary>
/// <returns>The profile.</returns>
private ModelProfile GetCurrentModelProfile() => this.CreateProviderSettings().GetModelProfile();
/// <summary>
/// What holds without anybody switching anything, which is what each field shows as its automatic answer.
/// </summary>
/// <remarks>
/// The rules, plus whatever the provider itself stated when the model list was loaded a moment
/// ago. A self-hosted engine is the case this matters for: it reports the window it was started
/// with, and that is the number a person gets by leaving the field below empty.
/// </remarks>
/// <returns>The profile.</returns>
private ModelProfile GetAutomaticModelProfile() => this.CreateProviderSettings().GetAutomaticModelProfile();
private string GetCurrentModelApiLabel() private string GetCurrentModelApiLabel()
{ {
var capabilities = this.GetCurrentModelCapabilities(); var profile = this.GetCurrentModelProfile();
if (capabilities.Contains(Capability.RESPONSES_API)) if (profile.Has(Capability.RESPONSES_API))
return "Responses API"; return "Responses API";
if (capabilities.Contains(Capability.CHAT_COMPLETION_API)) if (profile.Has(Capability.CHAT_COMPLETION_API))
return "Chat Completions API"; return "Chat Completions API";
return "Unknown"; return "Unknown";

View File

@ -82,6 +82,15 @@
<Folder Include="Plugins\assistants\assets\" /> <Folder Include="Plugins\assistants\assets\" />
</ItemGroup> </ItemGroup>
<!--
The test project has to reach Program.LOGGER_FACTORY. Program is internal, and it stays that
way: it is the entry point, not API. Several types initialize a static logger field from that
factory, so a test process must assign it before it touches any of them.
-->
<ItemGroup>
<InternalsVisibleTo Include="Tests" />
</ItemGroup>
<!-- Read the meta data file --> <!-- Read the meta data file -->
<Target Name="ReadMetaData" BeforeTargets="BeforeBuild"> <Target Name="ReadMetaData" BeforeTargets="BeforeBuild">
<Error Text="The ../../metadata.txt file was not found!" Condition="!Exists('../../metadata.txt')" /> <Error Text="The ../../metadata.txt file was not found!" Condition="!Exists('../../metadata.txt')" />

View File

@ -0,0 +1,29 @@
using AIStudio.Provider;
using static AIStudio.Provider.Capability;
namespace AIStudio.Models.Alibaba;
/// <summary>
/// Alibaba's embedding models, which turn text into a vector and answer nothing.
/// </summary>
/// <remarks>
/// The previous rules answered for these with the Model Studio default and told them they call
/// functions. The prefix is Alibaba's own: the app filters the catalog by "text-embedding-" to find
/// them, which is also why the rule may be written that broadly -- bound to this provider, it can
/// only ever meet the models Alibaba names that way.
/// </remarks>
public sealed class ModelStudioEmbeddingFamily : ModelFamily
{
/// <inheritdoc />
public override ModelVendor Vendor => ModelVendor.ALIBABA;
/// <inheritdoc />
public override ModelSource Source => new("https://www.alibabacloud.com/help/en/model-studio/embedding", new DateOnly(2026, 9, 11), "Provider/AlibabaCloud/ProviderAlibabaCloud.cs adds these in GetEmbeddingModels and filters the catalog by the prefix \"text-embedding-\".");
/// <inheritdoc />
protected override void Declare(ModelFamilyBuilder builder) =>
builder.Rule("text-embedding").AsPrefix().OnlyOn(LLMProviders.ALIBABA_CLOUD)
.Capabilities(TEXT_INPUT | EMBEDDING)
.Kind(ModelKind.EMBEDDING);
}

View File

@ -0,0 +1,24 @@
using AIStudio.Provider;
using static AIStudio.Provider.Capability;
namespace AIStudio.Models.Alibaba;
/// <summary>
/// QVQ, the thinking-only model which also looks at pictures.
/// </summary>
public sealed class ModelStudioQvqFamily : ModelFamily
{
/// <inheritdoc />
public override ModelVendor Vendor => ModelVendor.ALIBABA;
/// <inheritdoc />
public override ModelSource Source => new("https://www.alibabacloud.com/help/en/model-studio/models", new DateOnly(2026, 9, 11), "Ported unchanged from the rules in ProviderExtensions.Alibaba.cs: images in, thinking which cannot be switched off, and no tools.");
/// <inheritdoc />
protected override void Declare(ModelFamilyBuilder builder) =>
builder.Rule("qvq").AsSegment().OnlyOn(LLMProviders.ALIBABA_CLOUD)
.Capabilities(TEXT_INPUT | MULTIPLE_IMAGE_INPUT | TEXT_OUTPUT)
.Apis(CHAT_COMPLETION_API)
.Reasoning(ReasoningSupport.ALWAYS);
}

View File

@ -0,0 +1,83 @@
using AIStudio.Provider;
using static AIStudio.Provider.Capability;
namespace AIStudio.Models.Alibaba;
/// <summary>
/// The Qwen models Alibaba Cloud Model Studio serves.
/// </summary>
/// <remarks>
/// Everything called Model Studio here is bound to Alibaba Cloud, and that is the point of it.
/// Model Studio sells commercial models -- qwen-max, qwen3.7-max, qwq-plus -- which carry the
/// family names of the open weights without being them, and it answers differently for several
/// names the open weights share with it. The old rules kept the two apart by having two functions;
/// here they are kept apart by saying which provider a rule speaks for. The unbound families next
/// to these are the open weights, which answer everywhere else.
///
/// The first rule is the catalog's own fallback, and it is written as a plain substring on purpose:
/// a substring is the weakest thing a rule can be, so every other rule here beats it without anyone
/// arranging that. It also has to be one, because "qwen2.5-72b-instruct" does not contain "qwen" as
/// a whole name part -- the version grows straight out of the family name.
/// </remarks>
public sealed class ModelStudioQwenFamily : ModelFamily
{
/// <inheritdoc />
public override ModelVendor Vendor => ModelVendor.ALIBABA;
/// <inheritdoc />
public override ModelSource Source => new("https://www.alibabacloud.com/help/en/model-studio/models", new DateOnly(2026, 9, 12), "Capabilities ported unchanged from ProviderExtensions.Alibaba.cs, which follow Alibaba's own list of models that call functions. Alibaba's announcement of Qwen3.8-Max states a window of up to one million tokens; the other Qwen models are served at sizes their list does not state per model.");
/// <inheritdoc />
protected override void Declare(ModelFamilyBuilder builder)
{
builder.Rule("qwen").AsSubstring().OnlyOn(LLMProviders.ALIBABA_CLOUD)
.Capabilities(TEXT_INPUT | TEXT_OUTPUT | FUNCTION_CALLING)
.Apis(CHAT_COMPLETION_API);
// Qwen 3 thinks when asked to:
builder.Rule("qwen3").AsPrefix().OnlyOn(LLMProviders.ALIBABA_CLOUD).Inherits()
.Reasoning(ReasoningSupport.OPTIONAL);
builder.Rule("qwen3.5").AsPrefix().OnlyOn(LLMProviders.ALIBABA_CLOUD).InheritsFrom("qwen3")
.Capabilities(MULTIPLE_IMAGE_INPUT);
builder.Rule("qwen3.6").AsPrefix().OnlyOn(LLMProviders.ALIBABA_CLOUD).InheritsFrom("qwen3")
.Capabilities(MULTIPLE_IMAGE_INPUT | VIDEO_INPUT)
.Reasoning(ReasoningSupport.ALWAYS);
//
// Qwen 3.7 thinks unless it is told not to, and it started out reading nothing but text.
// Vision arrived in the middle of the series, so only the June snapshot may be told that
// it sees: the rolling max alias still answers as the May one.
//
builder.Rule("qwen3.7").AsPrefix().OnlyOn(LLMProviders.ALIBABA_CLOUD).InheritsFrom("qwen3")
.Reasoning(ReasoningSupport.ON_BY_DEFAULT);
builder.Rule("qwen3.7").AsPrefix().AlsoContains("preview").OnlyOn(LLMProviders.ALIBABA_CLOUD).Inherits()
.Reasoning(ReasoningSupport.ALWAYS);
builder.Rule("qwen3.7").AsPrefix().AlsoContains("2026-05-17").OnlyOn(LLMProviders.ALIBABA_CLOUD).Inherits();
builder.Rule("qwen3.7").AsPrefix().AlsoContains("2026-06-08").OnlyOn(LLMProviders.ALIBABA_CLOUD)
.Capabilities(TEXT_INPUT | MULTIPLE_IMAGE_INPUT | VIDEO_INPUT | TEXT_OUTPUT | FUNCTION_CALLING)
.Apis(CHAT_COMPLETION_API)
.Reasoning(ReasoningSupport.ON_BY_DEFAULT);
// Qwen 3.8, whose 27B checkpoint is what the tier without a size resolves to:
builder.Rule("qwen3.8").AsPrefix().OnlyOn(LLMProviders.ALIBABA_CLOUD).InheritsFrom("qwen3")
.Capabilities(MULTIPLE_IMAGE_INPUT)
.Reasoning(ReasoningSupport.ON_BY_DEFAULT);
builder.Rule("qwen3.8-flash").AsPrefix().OnlyOn(LLMProviders.ALIBABA_CLOUD).Inherits()
.Capabilities(VIDEO_INPUT);
//
// Unlike the open-weight checkpoint of the same name, the Max model keeps its vision when
// it is reached through Model Studio:
//
builder.Rule("qwen3.8-max").AsPrefix().OnlyOn(LLMProviders.ALIBABA_CLOUD).InheritsFrom("qwen3.8-flash")
.Reasoning(ReasoningSupport.ALWAYS)
.ContextWindow(1_000_000);
}
}

View File

@ -0,0 +1,32 @@
using AIStudio.Provider;
using static AIStudio.Provider.Capability;
namespace AIStudio.Models.Alibaba;
/// <summary>
/// The Qwen Omni models, which take everything in and answer in text or in speech.
/// </summary>
/// <remarks>
/// Alibaba lists the Qwen3 Omni series among the models which call functions and leaves the older
/// ones off that list, which is the whole difference between the two rules below.
/// </remarks>
public sealed class ModelStudioQwenOmniFamily : ModelFamily
{
/// <inheritdoc />
public override ModelVendor Vendor => ModelVendor.ALIBABA;
/// <inheritdoc />
public override ModelSource Source => new("https://www.alibabacloud.com/help/en/model-studio/models", new DateOnly(2026, 9, 11), "Ported unchanged from the rules in ProviderExtensions.Alibaba.cs: every modality in, text and speech out, tool calling from Qwen3 on.");
/// <inheritdoc />
protected override void Declare(ModelFamilyBuilder builder)
{
builder.Rule("qwen").AsSubstring().AlsoContains("omni").OnlyOn(LLMProviders.ALIBABA_CLOUD)
.Capabilities(TEXT_INPUT | MULTIPLE_IMAGE_INPUT | AUDIO_INPUT | SPEECH_INPUT | VIDEO_INPUT | TEXT_OUTPUT | SPEECH_OUTPUT)
.Apis(CHAT_COMPLETION_API);
builder.Rule("qwen3").AsPrefix().AlsoContains("omni").OnlyOn(LLMProviders.ALIBABA_CLOUD).Inherits()
.Capabilities(FUNCTION_CALLING);
}
}

View File

@ -0,0 +1,32 @@
using AIStudio.Provider;
using static AIStudio.Provider.Capability;
namespace AIStudio.Models.Alibaba;
/// <summary>
/// The Qwen VL models, the ones built to look at pictures.
/// </summary>
/// <remarks>
/// As with the Omni series, Alibaba names only the Qwen3 VL models as function callers and the
/// older ones not at all.
/// </remarks>
public sealed class ModelStudioQwenVisionFamily : ModelFamily
{
/// <inheritdoc />
public override ModelVendor Vendor => ModelVendor.ALIBABA;
/// <inheritdoc />
public override ModelSource Source => new("https://www.alibabacloud.com/help/en/model-studio/models", new DateOnly(2026, 9, 11), "Ported unchanged from the rules in ProviderExtensions.Alibaba.cs: images in, and tool calling from Qwen3 on.");
/// <inheritdoc />
protected override void Declare(ModelFamilyBuilder builder)
{
builder.Rule("qwen").AsSubstring().AlsoContains("vl").OnlyOn(LLMProviders.ALIBABA_CLOUD)
.Capabilities(TEXT_INPUT | MULTIPLE_IMAGE_INPUT | TEXT_OUTPUT)
.Apis(CHAT_COMPLETION_API);
builder.Rule("qwen3").AsPrefix().AlsoContains("vl").OnlyOn(LLMProviders.ALIBABA_CLOUD).Inherits()
.Capabilities(FUNCTION_CALLING);
}
}

View File

@ -0,0 +1,34 @@
using AIStudio.Provider;
using static AIStudio.Provider.Capability;
namespace AIStudio.Models.Alibaba;
/// <summary>
/// QwQ as Model Studio serves it, which is qwq-plus.
/// </summary>
/// <remarks>
/// This is the contradiction the provider-bound rules were built for. What Model Studio sells under
/// this name is a commercial thinking-only model built on Qwen 2.5; QwQ-32B, which everybody else
/// serves, is the open-weight model. They share a family name and nothing else, and the old rules
/// could only keep them apart by living in two different functions.
///
/// Neither of them appears in Alibaba's list of models which call functions, and the model card of
/// the open weights does not mention tools at all, which is why no such ability is stated here.
/// Anybody who knows better turns it on in the expert settings.
/// </remarks>
public sealed class ModelStudioQwqFamily : ModelFamily
{
/// <inheritdoc />
public override ModelVendor Vendor => ModelVendor.ALIBABA;
/// <inheritdoc />
public override ModelSource Source => new("https://www.alibabacloud.com/help/en/model-studio/models", new DateOnly(2026, 9, 11), "Ported unchanged from the rules in ProviderExtensions.Alibaba.cs: text in, text out, thinking which cannot be switched off, and no tools.");
/// <inheritdoc />
protected override void Declare(ModelFamilyBuilder builder) =>
builder.Rule("qwq").AsSegment().OnlyOn(LLMProviders.ALIBABA_CLOUD)
.Capabilities(TEXT_INPUT | TEXT_OUTPUT)
.Apis(CHAT_COMPLETION_API)
.Reasoning(ReasoningSupport.ALWAYS);
}

View File

@ -0,0 +1,77 @@
using static AIStudio.Provider.Capability;
namespace AIStudio.Models.Alibaba;
/// <summary>
/// Qwen as everybody except Alibaba Cloud serves it: the open weights.
/// </summary>
/// <remarks>
/// The counterpart to the Model Studio families next door, and the reason those are bound to their
/// provider. Alibaba sells commercial models under the same family names, and for several of them
/// it promises something else than the published checkpoint does. Nothing here is bound: these
/// rules answer wherever the weights are run, which is every gateway and every engine somebody
/// starts on their own machine.
///
/// The whole line calls functions, from Qwen 2.5 on, and the Coder checkpoints are built for
/// exactly that. Thinking is not promised by the fallback: the older generations cannot do it, and
/// which of the newer ones think by default differs per checkpoint, so those say it one by one.
/// </remarks>
public sealed class QwenFamily : ModelFamily
{
/// <inheritdoc />
public override ModelVendor Vendor => ModelVendor.ALIBABA;
/// <inheritdoc />
public override ModelSource Source => new("https://huggingface.co/Qwen", new DateOnly(2026, 9, 11), "Ported unchanged from the Qwen block of ProviderExtensions.OpenSource.cs, which is the one answering everywhere but Alibaba Cloud.");
/// <inheritdoc />
protected override void Declare(ModelFamilyBuilder builder)
{
//
// A substring, because the version grows straight out of the family name: there is no name
// part "qwen" in "qwen2.5-72b-instruct". It is also the weakest thing a rule can be, which
// is what lets every rule below beat it without anybody arranging an order.
//
builder.Rule("qwen").AsSubstring()
.Capabilities(TEXT_INPUT | TEXT_OUTPUT | FUNCTION_CALLING)
.Apis(CHAT_COMPLETION_API);
// The VL checkpoints are the ones built to look at pictures:
builder.Rule("qwen").AsSubstring().AlsoContains("vl").Inherits()
.Capabilities(MULTIPLE_IMAGE_INPUT);
// Qwen 3.5 sees, and thinks when the request asks it to:
builder.Rule("qwen3.5").AsPrefix()
.Capabilities(TEXT_INPUT | MULTIPLE_IMAGE_INPUT | TEXT_OUTPUT | FUNCTION_CALLING)
.Apis(CHAT_COMPLETION_API)
.Reasoning(ReasoningSupport.OPTIONAL);
builder.Rule("qwen3.6").AsPrefix().Inherits()
.Reasoning(ReasoningSupport.ON_BY_DEFAULT);
//
// The 3.8 tier without a size is the 27B checkpoint: that is what a rolling tag such as
// "qwen3.8:latest" resolves to, so it is what the tier may promise.
//
builder.Rule("qwen3.8").AsPrefix().InheritsFrom("qwen3.6");
// Flash-Next is the published checkpoint and Flash the production model; both watch videos:
builder.Rule("qwen3.8-flash").AsPrefix().InheritsFrom("qwen3.8")
.Capabilities(VIDEO_INPUT);
//
// Blablador writes the 27B checkpoint in two further ways, and no normalization turns
// either into the canonical name: it separates the family from the version ("Qwen 3.8-27B
// with DFlash on haicluster"), and its short alias drops the dot ("alias-qwen38-27b").
//
builder.Rule("qwen-3.8-27b").AsSegment().InheritsFrom("qwen3.8");
builder.Rule("qwen38-27b").AsSegment().InheritsFrom("qwen3.8");
// The big 3.8 checkpoint reads nothing but text, and it thinks whatever it is asked:
builder.Rule("qwen3.8-2.4t-a95b").AsPrefix()
.Capabilities(TEXT_INPUT | TEXT_OUTPUT | FUNCTION_CALLING)
.Apis(CHAT_COMPLETION_API)
.Reasoning(ReasoningSupport.ALWAYS);
}
}

View File

@ -0,0 +1,31 @@
using static AIStudio.Provider.Capability;
namespace AIStudio.Models.Alibaba;
/// <summary>
/// QwQ as everybody except Alibaba Cloud serves it: the open weights built on Qwen 2.5.
/// </summary>
/// <remarks>
/// The other half of the contradiction the provider-bound rules exist for. What Model Studio sells
/// as "qwq-plus" is a commercial model; QwQ-32B, which the gateways and the local engines serve, is
/// the published checkpoint. The two share a family name and nothing else.
///
/// Both answer the same here, and for the same reason: neither the model card nor Alibaba's list of
/// models which call functions mentions tools at all. Anybody who knows better says so in the
/// expert settings.
/// </remarks>
public sealed class QwqFamily : ModelFamily
{
/// <inheritdoc />
public override ModelVendor Vendor => ModelVendor.ALIBABA;
/// <inheritdoc />
public override ModelSource Source => new("https://huggingface.co/Qwen/QwQ-32B", new DateOnly(2026, 9, 11), "Ported unchanged from the QwQ check of ProviderExtensions.OpenSource.cs: text in, text out, thinking which cannot be switched off, and no tools.");
/// <inheritdoc />
protected override void Declare(ModelFamilyBuilder builder) =>
builder.Rule("qwq").AsSegment()
.Capabilities(TEXT_INPUT | TEXT_OUTPUT)
.Apis(CHAT_COMPLETION_API)
.Reasoning(ReasoningSupport.ALWAYS);
}

View File

@ -0,0 +1,130 @@
using AIStudio.Models.Matching;
using static AIStudio.Provider.Capability;
namespace AIStudio.Models.Anthropic;
/// <summary>
/// Claude, all of it: the 3.x models, the 4.x models, and the 5 line.
/// </summary>
/// <remarks>
/// One family, because every Claude is the same shape and always has been -- text and images in,
/// text out, tool calling, one API. What each generation adds to that is a single sentence about
/// thinking, and the rules below are almost nothing but those sentences.
///
/// The first rule is the family's own fallback, and it is a statement rather than an accident: a
/// Claude nobody has written a rule for yet is still a Claude, and every one of them so far reads
/// images and calls tools. It answers for whole name parts, so every rule bound to the start of a
/// name beats it, whatever their lengths -- which is what lets it sit first and mean "unless".
///
/// The one thing no rule below states is how many images a Claude takes. Anthropic ties that number
/// to the context window instead of to the model, so it is worked out afterwards rather than written
/// on every line which sets a window.
/// </remarks>
public sealed class ClaudeFamily : ModelFamily
{
/// <summary>
/// The window every Claude has unless its own rule states the larger one.
/// </summary>
private const int STANDARD_WINDOW = 200_000;
/// <summary>
/// The window of the Claude models which read a million tokens.
/// </summary>
private const int LARGE_WINDOW = 1_000_000;
/// <summary>
/// How many images one request may carry when the model has the standard window.
/// </summary>
private const int IMAGES_PER_REQUEST_STANDARD_WINDOW = 100;
/// <summary>
/// How many images one request may carry for every other Claude.
/// </summary>
private const int IMAGES_PER_REQUEST_OTHERWISE = 600;
/// <inheritdoc />
public override ModelVendor Vendor => ModelVendor.ANTHROPIC;
/// <inheritdoc />
public override ModelSource Source => new("https://platform.claude.com/docs/en/build-with-claude/context-windows", new DateOnly(2026, 9, 12), "Capabilities ported unchanged from ProviderExtensions.Anthropic.cs: one shape for all of Claude, and one sentence per generation about how it thinks. The context window page names the models with a 1M window and says every other Claude has 200k.");
/// <inheritdoc />
public override IReadOnlyList<ModelSource> FurtherSources =>
[
new("https://platform.claude.com/docs/en/build-with-claude/vision", new DateOnly(2026, 9, 12), "The vision page gives the image limit as a rule rather than as a number per model: 100 images per request on the API for models with a 200k-token context window, 600 per request for all other models. The 20 it also names belongs to claude.ai, not to the API."),
new("https://platform.claude.com/docs/en/build-with-claude/token-counting", new DateOnly(2026, 9, 12), "Anthropic publishes no tokenizer file at all; they count through the /v1/messages/count_tokens endpoint instead. The same page warns that Claude 4.7 and later use a newer tokenizer, on which the same text counts roughly 30 percent higher -- so AI Studio's built-in estimate is further off for those models than for the older ones.")
];
/// <inheritdoc />
/// <remarks>
/// Anthropic states no image limit per model. They state a rule which reads off the context
/// window, and this is that rule -- written once rather than repeated as a number on every line
/// which sets a window. Two statements of one fact drift apart, and the way they drift here is
/// silent: the next Claude with the larger window would quietly keep the smaller limit because
/// somebody wrote one number and not the other.
/// </remarks>
public override ModelProfile Refine(in ModelId id, in ModelProfile selected)
{
if (!selected.Context.IsKnown)
return selected;
var perRequest = selected.Context.DefaultTokens is STANDARD_WINDOW ? IMAGES_PER_REQUEST_STANDARD_WINDOW : IMAGES_PER_REQUEST_OTHERWISE;
return selected with { Images = new ImageLimits(null, perRequest) };
}
/// <inheritdoc />
protected override void Declare(ModelFamilyBuilder builder)
{
//
// 200k is the window of every Claude which is not named on the list of the 1M ones, which is
// how Anthropic states it themselves: the page names the exceptions and says "other Claude
// models" for the rest. So the fallback carries it, and the generations which got the larger
// window say so one by one below.
//
builder.Rule("claude").AsSegment()
.Capabilities(TEXT_INPUT | MULTIPLE_IMAGE_INPUT | TEXT_OUTPUT | FUNCTION_CALLING)
.Apis(CHAT_COMPLETION_API)
.ContextWindow(STANDARD_WINDOW)
.Tokenizer(TokenizerKind.PROVIDER_API, "/v1/messages/count_tokens");
//
// The 3.x models say nothing beyond the shape above, so nothing is written for them: the
// previous rules had a branch for "claude-3-" which returned exactly what its fallback
// returned. Only 3.7 differs, by being the first Claude which could be asked to think.
//
builder.Rule("claude-3-7").AsPrefix().InheritsFrom("claude")
.Reasoning(ReasoningSupport.OPTIONAL);
// The 4.x models think when a thinking budget is given, and not otherwise:
builder.Rule("claude-opus-4").AsPrefix().InheritsFrom("claude")
.Reasoning(ReasoningSupport.OPTIONAL);
builder.Rule("claude-sonnet-4").AsPrefix().InheritsFrom("claude-opus-4");
builder.Rule("claude-haiku-4-5").AsPrefix().InheritsFrom("claude-opus-4");
//
// Where the window grew inside the 4 line. These rules say nothing but the number: Opus 4.6
// through 4.8 and Sonnet 4.6 have the 1M window, while the 4.0, 4.1 and 4.5 models of the
// same prefixes keep the 200k they were released with.
//
builder.Rule("claude-opus-4-6").AsPrefix().InheritsFrom("claude-opus-4").ContextWindow(LARGE_WINDOW);
builder.Rule("claude-opus-4-7").AsPrefix().InheritsFrom("claude-opus-4").ContextWindow(LARGE_WINDOW);
builder.Rule("claude-opus-4-8").AsPrefix().InheritsFrom("claude-opus-4").ContextWindow(LARGE_WINDOW);
builder.Rule("claude-sonnet-4-6").AsPrefix().InheritsFrom("claude-opus-4").ContextWindow(LARGE_WINDOW);
// Opus 5 and Sonnet 5 think adaptively unless thinking is turned off:
builder.Rule("claude-opus-5").AsPrefix().InheritsFrom("claude")
.Reasoning(ReasoningSupport.ON_BY_DEFAULT)
.ContextWindow(LARGE_WINDOW);
builder.Rule("claude-sonnet-5").AsPrefix().InheritsFrom("claude-opus-5");
// Fable 5 and Mythos 5 always think, and there is no switch for it:
builder.Rule("claude-fable-5").AsPrefix().InheritsFrom("claude")
.Reasoning(ReasoningSupport.ALWAYS)
.ContextWindow(LARGE_WINDOW);
builder.Rule("claude-mythos-5").AsPrefix().InheritsFrom("claude-fable-5");
}
}

View File

@ -0,0 +1,41 @@
using static AIStudio.Provider.Capability;
namespace AIStudio.Models.Baidu;
/// <summary>
/// ERNIE, from Baidu.
/// </summary>
/// <remarks>
/// The line calls functions and the thinking checkpoints keep the channel open whatever the request
/// says. The vision checkpoints are the exception, and the reason this family is written down at
/// all: they run in a thinking and a non-thinking mode, and tool calling is not documented for them.
/// Left to the assumption they would be offered tools nobody has said they can use.
///
/// The vision rule wins over the thinking rule by the latter stepping aside rather than by being
/// less specific: ERNIE ships a checkpoint which is both, and two rules claiming it with the same
/// right would be a coin toss.
/// </remarks>
public sealed class ErnieFamily : ModelFamily
{
/// <inheritdoc />
public override ModelVendor Vendor => ModelVendor.BAIDU;
/// <inheritdoc />
public override ModelSource Source => new("https://ernie.baidu.com/blog/", new DateOnly(2026, 9, 12), "Ported unchanged from the ERNIE block of ProviderExtensions.OpenSource.cs.");
/// <inheritdoc />
protected override void Declare(ModelFamilyBuilder builder)
{
builder.Rule("ernie").AsSubstring()
.Capabilities(TEXT_INPUT | TEXT_OUTPUT | FUNCTION_CALLING)
.Apis(CHAT_COMPLETION_API);
builder.Rule("ernie").AsSubstring().AlsoContains("thinking").NotContains("vl").Inherits()
.Reasoning(ReasoningSupport.ALWAYS);
builder.Rule("ernie").AsSubstring().AlsoContains("vl")
.Capabilities(TEXT_INPUT | MULTIPLE_IMAGE_INPUT | TEXT_OUTPUT)
.Apis(CHAT_COMPLETION_API)
.Reasoning(ReasoningSupport.OPTIONAL);
}
}

View File

@ -0,0 +1,30 @@
using static AIStudio.Provider.Capability;
namespace AIStudio.Models.Cohere;
/// <summary>
/// Aya, which comes from Cohere as well and was not built for tools.
/// </summary>
/// <remarks>
/// Their documentation says it in as many words, which is why these are a family of their own
/// rather than a variant of Command: everything the Command rules state would be wrong here.
/// </remarks>
public sealed class AyaFamily : ModelFamily
{
/// <inheritdoc />
public override ModelVendor Vendor => ModelVendor.COHERE;
/// <inheritdoc />
public override ModelSource Source => new("https://docs.cohere.com/docs/aya", new DateOnly(2026, 9, 11), "Ported unchanged from the Aya block of ProviderExtensions.OpenSource.cs.");
/// <inheritdoc />
protected override void Declare(ModelFamilyBuilder builder)
{
builder.Rule("aya-expanse").AsSubstring()
.Capabilities(TEXT_INPUT | TEXT_OUTPUT)
.Apis(CHAT_COMPLETION_API);
builder.Rule("aya-vision").AsSubstring().Inherits()
.Capabilities(MULTIPLE_IMAGE_INPUT);
}
}

View File

@ -0,0 +1,41 @@
using static AIStudio.Provider.Capability;
namespace AIStudio.Models.Cohere;
/// <summary>
/// Command, the Cohere line built for tool use.
/// </summary>
/// <remarks>
/// Most of the line calls functions, in one step and in several, so the family states it. Command A
/// Vision is the exception Cohere names outright: tool use is not supported with it.
/// </remarks>
public sealed class CommandFamily : ModelFamily
{
/// <inheritdoc />
public override ModelVendor Vendor => ModelVendor.COHERE;
/// <inheritdoc />
public override ModelSource Source => new("https://docs.cohere.com/docs/models", new DateOnly(2026, 9, 11), "Ported unchanged from the Command block of ProviderExtensions.OpenSource.cs.");
/// <inheritdoc />
protected override void Declare(ModelFamilyBuilder builder)
{
builder.Rule("command-a").AsSubstring()
.Capabilities(TEXT_INPUT | TEXT_OUTPUT | FUNCTION_CALLING)
.Apis(CHAT_COMPLETION_API);
builder.Rule("command-r").AsSubstring().Inherits();
// Command A+ sees, and thinks unless the request turns the thinking off:
builder.Rule("command-a-plus").AsSubstring().Inherits()
.Capabilities(MULTIPLE_IMAGE_INPUT)
.Reasoning(ReasoningSupport.ON_BY_DEFAULT);
builder.Rule("command-a-reasoning").AsSubstring().InheritsFrom("command-r")
.Reasoning(ReasoningSupport.ON_BY_DEFAULT);
builder.Rule("command-a-vision").AsSubstring()
.Capabilities(TEXT_INPUT | MULTIPLE_IMAGE_INPUT | TEXT_OUTPUT)
.Apis(CHAT_COMPLETION_API);
}
}

View File

@ -0,0 +1,57 @@
namespace AIStudio.Models;
/// <summary>
/// How much a model can read and write in one conversation, in tokens.
/// </summary>
/// <remarks>
/// Two numbers, because the model cards name two. There is what the model does as it ships, and
/// there is what an operator can raise it to by configuring the engine, usually through one of the
/// rope-scaling settings. A self-hosted model runs at whatever its operator chose, so the second
/// number is a ceiling, not a promise.
///
/// Nothing here says "unknown" with a zero. The default value of this type is unknown, which is the
/// right answer for a model nobody has written anything about yet, and a known window can never be
/// zero tokens wide because the factory below refuses to build one.
/// </remarks>
public readonly record struct ContextWindow
{
/// <summary>
/// The window of a model we have no statement about.
/// </summary>
public static readonly ContextWindow UNKNOWN = new();
/// <summary>
/// Whether anything is known about this window at all. When false, both numbers are meaningless.
/// </summary>
public bool IsKnown { get; private init; }
/// <summary>
/// What the model reads and writes without anyone configuring it.
/// </summary>
public int DefaultTokens { get; private init; }
/// <summary>
/// What an operator can raise the window to, or null when it cannot be raised or nobody knows.
/// </summary>
public int? RaisableToTokens { get; private init; }
/// <summary>
/// States a known context window.
/// </summary>
/// <param name="defaultTokens">What the model does as it ships. Has to be greater than zero.</param>
/// <param name="raisableTo">What an operator can raise it to. Has to be at least the default.</param>
/// <returns>The window.</returns>
public static ContextWindow Of(int defaultTokens, int? raisableTo = null)
{
ArgumentOutOfRangeException.ThrowIfNegativeOrZero(defaultTokens);
if (raisableTo is not null)
ArgumentOutOfRangeException.ThrowIfLessThan(raisableTo.Value, defaultTokens);
return new()
{
IsKnown = true,
DefaultTokens = defaultTokens,
RaisableToTokens = raisableTo,
};
}
}

View File

@ -0,0 +1,78 @@
using static AIStudio.Provider.Capability;
namespace AIStudio.Models.DeepSeek;
/// <summary>
/// DeepSeek, from V3 to V4, including R1 and the checkpoints distilled from it.
/// </summary>
/// <remarks>
/// One family for all of it, and bound to no provider: DeepSeek publishes its models as open
/// weights and offers them on its own platform under the very same names, so a rule written once
/// answers wherever the model turns up. The old code arrived at that by having its DeepSeek
/// function call the open weights function, which is one of the loops this rebuild is undoing.
///
/// The distills are the case the whole priority question came from. They are Llama and Qwen
/// checkpoints fine-tuned on R1 answers, so they carry "r1" in their name and would be read as R1
/// itself -- which would promise the tool calling they lost together with R1's chat template. Here
/// the rule for them is the R1 rule with one condition more, and that alone decides it.
///
/// Point releases behind a dot need a line of their own, as everywhere: "deepseek-v4" does not
/// answer for "deepseek-v4.1", because a dot separates versions rather than name parts.
/// </remarks>
public sealed class DeepSeekFamily : ModelFamily
{
/// <inheritdoc />
public override ModelVendor Vendor => ModelVendor.DEEP_SEEK;
/// <inheritdoc />
public override ModelSource Source => new("https://api-docs.deepseek.com/quick_start/pricing", new DateOnly(2026, 9, 12), "Capabilities ported unchanged from ProviderExtensions.DeepSeek.cs and the DeepSeek block of ProviderExtensions.OpenSource.cs. The pricing page states a 1M window for the V4 models; the older lines are served at different sizes depending on who serves them, so no window is stated for them.");
/// <inheritdoc />
protected override void Declare(ModelFamilyBuilder builder)
{
builder.Rule("deepseek").AsSegment()
.Capabilities(TEXT_INPUT | TEXT_OUTPUT)
.Apis(CHAT_COMPLETION_API);
// The V3 line answers directly and calls functions:
builder.Rule("deepseek-v3").AsPrefix().Inherits()
.Capabilities(FUNCTION_CALLING);
//
// From V3.1 on there is a thinking mode which the request turns on, and V3.2 added tool
// calling inside it. The gateways write these either as "deepseek-v3.1" or as
// "deepseek-chat-v3.1", so the version alone is what is looked for.
//
builder.Rule("deepseek").AsSegment().AlsoContains("v3.1").InheritsFrom("deepseek-v3")
.Reasoning(ReasoningSupport.OPTIONAL);
builder.Rule("deepseek").AsSegment().AlsoContains("v3.2").InheritsFrom("deepseek-v3")
.Reasoning(ReasoningSupport.OPTIONAL);
builder.Rule("deepseek-r1").AsPrefix().InheritsFrom("deepseek-v3")
.Reasoning(ReasoningSupport.ALWAYS);
// The distills kept the chat template of the model they were built from, so none of the
// tool calling R1 itself was trained for survived:
builder.Rule("deepseek-r1").AsPrefix().AlsoContains("distill").Inherits()
.Removes(FUNCTION_CALLING);
builder.Rule("deepseek-v4").AsPrefix().InheritsFrom("deepseek-v3")
.Reasoning(ReasoningSupport.ON_BY_DEFAULT)
.ContextWindow(1_000_000);
builder.Rule("deepseek-v4").AsPrefix().AlsoContains("vision").Inherits()
.Capabilities(MULTIPLE_IMAGE_INPUT);
//
// The two aliases of DeepSeek's own platform. They name a mode rather than a model: both
// point at the current flash model, one with thinking and one without. Exactly these
// names and no others -- "deepseek-chat-v3.1" is a gateway's name for a version, not this
// alias.
//
builder.Rule("deepseek-chat").AsExact().InheritsFrom("deepseek-v3");
builder.Rule("deepseek-reasoner").AsExact().InheritsFrom("deepseek-v3")
.Reasoning(ReasoningSupport.ALWAYS);
}
}

View File

@ -0,0 +1,91 @@
using static AIStudio.Provider.Capability;
namespace AIStudio.Models.Google;
/// <summary>
/// The Gemini chat models.
/// </summary>
/// <remarks>
/// A Gemini reads everything -- text, images, audio, speech, video -- writes text, and calls tools.
/// That is the first rule, and it is the family's own fallback for a Gemini nobody has written a
/// rule for yet. What the generations add to it is how they think, and the older exceptions take
/// something away instead.
///
/// Every generation gets a line of its own, including the dotted ones. The dot is a version
/// boundary rather than a name part boundary, deliberately -- it is what keeps llama3 and llama3.1
/// apart -- so a rule for "gemini-3" does not answer for "gemini-3.1", and each has to say so
/// itself. The previous rules searched for "gemini-3" anywhere in the name and covered unreleased
/// versions by accident; the price of not doing that is a line per generation, and the verification
/// run names any model of the corpus which finds no rule.
/// </remarks>
public sealed class GeminiFamily : ModelFamily
{
/// <inheritdoc />
public override ModelVendor Vendor => ModelVendor.GOOGLE;
/// <inheritdoc />
public override ModelSource Source => new("https://ai.google.dev/gemini-api/docs/gemini-3", new DateOnly(2026, 9, 12), "Capabilities ported unchanged from ProviderExtensions.Google.cs: one shape for all of Gemini, one sentence per generation about thinking. The Gemini 3 guide states a one million token input window; the 2.5 model pages state their input limit as 1,048,576, and both numbers are written here as their page gives them.");
/// <inheritdoc />
public override IReadOnlyList<ModelSource> FurtherSources =>
[
new("https://ai.google.dev/gemini-api/docs/image-understanding", new DateOnly(2026, 9, 12), "States one number for the whole family: \"Gemini models support a maximum of 3,600 image files per request.\" The 20 MB it also names is a limit on the request body rather than on the number of images."),
new("https://ai.google.dev/gemini-api/docs/tokens", new DateOnly(2026, 9, 12), "Google publishes no tokenizer file for Gemini. Counting happens through the countTokens method of the API, which returns the number of tokens of the input alone.")
];
/// <inheritdoc />
protected override void Declare(ModelFamilyBuilder builder)
{
//
// Google states the image limit once, for all of Gemini, so it sits on the fallback and
// every generation inherits it. The two rules below which do not inherit from here say
// nothing about it: the live model looks at no still images at all, and for the 1.0 vision
// model Google's current pages state no number any more.
//
builder.Rule("gemini").AsSegment()
.Capabilities(TEXT_INPUT | MULTIPLE_IMAGE_INPUT | AUDIO_INPUT | SPEECH_INPUT | VIDEO_INPUT | TEXT_OUTPUT | FUNCTION_CALLING)
.Apis(CHAT_COMPLETION_API)
.Images(maxPerRequest: 3_600)
.Tokenizer(TokenizerKind.PROVIDER_API, "countTokens");
// The one Gemini which only ever read text and images:
builder.Rule("gemini-1.0-pro-vision").AsPrefix()
.Capabilities(TEXT_INPUT | MULTIPLE_IMAGE_INPUT | TEXT_OUTPUT)
.Apis(CHAT_COMPLETION_API);
//
// The live model, which belongs to a different API: it speaks back, and it is the one
// Gemini that does not look at still images.
//
builder.Rule("gemini-2.0-flash-live").AsPrefix()
.Capabilities(TEXT_INPUT | AUDIO_INPUT | SPEECH_INPUT | VIDEO_INPUT | TEXT_OUTPUT | SPEECH_OUTPUT | FUNCTION_CALLING)
.Apis(CHAT_COMPLETION_API);
//
// Google states an input limit and an output limit rather than one window. The input limit
// is the one a conversation is measured against, because that is where the conversation
// accumulates, so that is the number written here.
//
builder.Rule("gemini-2.5").AsPrefix().InheritsFrom("gemini")
.Reasoning(ReasoningSupport.ALWAYS)
.ContextWindow(1_048_576);
//
// The one exception of the 2.5 line: it can think, but only when asked. From the 3.x line
// on, even the Flash Lite models think at their lowest level.
//
builder.Rule("gemini-2.5-flash-lite").AsPrefix().InheritsFrom("gemini-2.5")
.Reasoning(ReasoningSupport.OPTIONAL);
builder.Rule("gemini-3").AsPrefix().InheritsFrom("gemini")
.Reasoning(ReasoningSupport.ALWAYS)
.ContextWindow(1_000_000);
builder.Rule("gemini-3.1").AsPrefix().InheritsFrom("gemini-3");
builder.Rule("gemini-3.7").AsPrefix().InheritsFrom("gemini-3");
// The two rolling aliases, which carry no version number and point at the current line:
builder.Rule("gemini-flash-latest").AsExact().InheritsFrom("gemini-3");
builder.Rule("gemini-pro-latest").AsExact().InheritsFrom("gemini-3");
}
}

View File

@ -0,0 +1,42 @@
using static AIStudio.Provider.Capability;
namespace AIStudio.Models.Google;
/// <summary>
/// The Gemini models which draw as well as write.
/// </summary>
/// <remarks>
/// They are named like every other Gemini, with a version and a size, and the only thing setting
/// them apart is the name part "image". So the rules here are the generation rules of the chat
/// family with that one part required on top, and requiring it is exactly what makes them win: two
/// rules reaching equally far into a name are separated by how many conditions they carry.
///
/// What they can do is nearly the opposite of what their generation can. They write images, which
/// no chat Gemini does, and they call no tools, which every chat Gemini does. Reading them as chat
/// models of their line -- which is what happens when nobody asks about the image part first --
/// promises tool calling that is not there.
/// </remarks>
public sealed class GeminiImageFamily : ModelFamily
{
/// <inheritdoc />
public override ModelVendor Vendor => ModelVendor.GOOGLE;
/// <inheritdoc />
public override ModelSource Source => new("https://ai.google.dev/gemini-api/docs/image-generation", new DateOnly(2026, 9, 11), "Ported unchanged from the rules in ProviderExtensions.Google.cs: images out, no tool calling, and thinking from the 3 line on.");
/// <inheritdoc />
protected override void Declare(ModelFamilyBuilder builder)
{
builder.Rule("gemini-2.5").AsPrefix().AlsoContains("image")
.Capabilities(TEXT_INPUT | MULTIPLE_IMAGE_INPUT | TEXT_OUTPUT | IMAGE_OUTPUT)
.Apis(CHAT_COMPLETION_API);
// From the 3 line on they think about a complicated prompt, and it cannot be switched off:
builder.Rule("gemini-3").AsPrefix().AlsoContains("image").Inherits()
.Reasoning(ReasoningSupport.ALWAYS);
// Only the 3.1 Flash image models watch video:
builder.Rule("gemini-3.1").AsPrefix().AlsoContains("image").Inherits()
.Capabilities(VIDEO_INPUT);
}
}

View File

@ -0,0 +1,78 @@
using static AIStudio.Provider.Capability;
namespace AIStudio.Models.Google;
/// <summary>
/// Gemma, the open weights Google publishes next to Gemini.
/// </summary>
/// <remarks>
/// Two generations and one spelling problem. Ollama writes "gemma3:27b" and the hub writes
/// "gemma-3-27b-it", and no normalization turns one into the other, so each statement stands twice.
/// What it buys is that the rules never have to ask who served the model.
///
/// Tool calling is the line between the generations. What Google documents for Gemma 3 is writing
/// the tool descriptions into the prompt by hand, which is a different thing from what the tools
/// field of an OpenAI-compatible request does: the chat template has neither a tool role nor tool
/// tokens, and Ollama refuses a request carrying tools for these models. Gemma 4 is the first with
/// tokens of its own, and the first that thinks -- when the request opens the thinking channel.
/// </remarks>
public sealed class GemmaFamily : ModelFamily
{
/// <inheritdoc />
public override ModelVendor Vendor => ModelVendor.GOOGLE;
/// <inheritdoc />
public override ModelSource Source => new("https://ai.google.dev/gemma/docs/core", new DateOnly(2026, 9, 11), "Ported unchanged from the Gemma block of ProviderExtensions.OpenSource.cs.");
/// <inheritdoc />
protected override void Declare(ModelFamilyBuilder builder)
{
// The early generations take text only and were not built for tools:
builder.Rule("gemma").AsSubstring()
.Capabilities(TEXT_INPUT | TEXT_OUTPUT)
.Apis(CHAT_COMPLETION_API);
// Gemma 3 reads pictures from the 4B checkpoint upwards:
builder.Rule("gemma3").AsSubstring()
.Capabilities(TEXT_INPUT | MULTIPLE_IMAGE_INPUT | TEXT_OUTPUT)
.Apis(CHAT_COMPLETION_API);
builder.Rule("gemma-3").AsSubstring().Inherits();
// The 1B checkpoint is the one that does not:
builder.Rule("gemma3").AsSubstring().AlsoContains("1b")
.Capabilities(TEXT_INPUT | TEXT_OUTPUT)
.Apis(CHAT_COMPLETION_API);
builder.Rule("gemma-3").AsSubstring().AlsoContains("1b").Inherits();
//
// The 3n checkpoints listen as well. Video is not a modality of any Gemma: the model cards
// list text, image, and audio, and mention video only as frames somebody else cut it into.
//
builder.Rule("gemma3n").AsSubstring()
.Capabilities(TEXT_INPUT | MULTIPLE_IMAGE_INPUT | AUDIO_INPUT | TEXT_OUTPUT)
.Apis(CHAT_COMPLETION_API);
builder.Rule("gemma-3n").AsSubstring().Inherits();
// Every checkpoint of Gemma 4 is multimodal; there is no text-only variant of it:
builder.Rule("gemma4").AsSubstring()
.Capabilities(TEXT_INPUT | MULTIPLE_IMAGE_INPUT | TEXT_OUTPUT | FUNCTION_CALLING)
.Apis(CHAT_COMPLETION_API)
.Reasoning(ReasoningSupport.OPTIONAL);
builder.Rule("gemma-4").AsSubstring().Inherits();
// Three of its checkpoints hear, and they are named one by one because that is all they
// have in common:
builder.Rule("gemma4").AsSubstring().AlsoContains("e2b").Inherits().Capabilities(AUDIO_INPUT);
builder.Rule("gemma-4").AsSubstring().AlsoContains("e2b").Inherits();
builder.Rule("gemma4").AsSubstring().AlsoContains("e4b").Inherits();
builder.Rule("gemma-4").AsSubstring().AlsoContains("e4b").Inherits();
builder.Rule("gemma4").AsSubstring().AlsoContains("12b").Inherits();
builder.Rule("gemma-4").AsSubstring().AlsoContains("12b").Inherits();
}
}

View File

@ -0,0 +1,35 @@
using AIStudio.Provider;
using static AIStudio.Provider.Capability;
namespace AIStudio.Models.Google;
/// <summary>
/// Google's embedding models, which turn text into a vector and answer nothing.
/// </summary>
/// <remarks>
/// The previous rules answered for these with the Google default: images in, text out, tool
/// calling. None of it is true, and the app already knows better -- it asks every provider for its
/// embedding models through a method of its own.
///
/// The Gemini one needs a rule of its own for another reason: its name begins with "gemini", so
/// without one it would be read as a chat model of the family.
/// </remarks>
public sealed class GoogleEmbeddingFamily : ModelFamily
{
/// <inheritdoc />
public override ModelVendor Vendor => ModelVendor.GOOGLE;
/// <inheritdoc />
public override ModelSource Source => new("https://ai.google.dev/gemini-api/docs/embeddings", new DateOnly(2026, 9, 11), "The app lists these under IProvider.GetEmbeddingModels, which is where the statement that they embed comes from.");
/// <inheritdoc />
protected override void Declare(ModelFamilyBuilder builder)
{
builder.Rule("text-embedding-004").AsExact()
.Capabilities(TEXT_INPUT | EMBEDDING)
.Kind(ModelKind.EMBEDDING);
builder.Rule("gemini-embedding").AsPrefix().Inherits();
}
}

View File

@ -0,0 +1,32 @@
using AIStudio.Provider;
using static AIStudio.Provider.Capability;
namespace AIStudio.Models.Google;
/// <summary>
/// Imagen, which draws a picture from a description and does nothing else.
/// </summary>
/// <remarks>
/// The previous rules had no branch for it. Its name does not contain "gemini", so it fell to the
/// last line of the Google function and was answered as a chat model: reads images, writes text,
/// calls functions. Not one of the three is true, and the one thing it does -- writing an image --
/// was not said at all.
///
/// Whole name parts, not a substring: "imagen" also sits inside "imagenet" and "reimagined", and a
/// chat model carrying such a word would be turned into an image generator by a careless match.
/// </remarks>
public sealed class ImagenFamily : ModelFamily
{
/// <inheritdoc />
public override ModelVendor Vendor => ModelVendor.GOOGLE;
/// <inheritdoc />
public override ModelSource Source => new("https://ai.google.dev/gemini-api/docs/imagen", new DateOnly(2026, 9, 11), "A description goes in and an image comes out; there is no conversation and no tool calling.");
/// <inheritdoc />
protected override void Declare(ModelFamilyBuilder builder) =>
builder.Rule("imagen").AsSegment()
.Capabilities(TEXT_INPUT | IMAGE_OUTPUT)
.Kind(ModelKind.IMAGE_GENERATION);
}

View File

@ -0,0 +1,183 @@
using AIStudio.Models.Matching;
namespace AIStudio.Models.Hosting;
/// <summary>
/// The ways a host wraps a model name, and how to take one wrapping off again.
/// </summary>
/// <remarks>
/// A wrapping is worked on the name as the provider reported it, never on the normalized one. That
/// is not a detail: normalizing writes every separator as a hyphen, so "meta-llama/Llama-3.3-70B"
/// and "meta-llama-llama-3.3-70b" are the same text afterwards and nobody can say where the
/// organization ended. The slash, the colon, and the spaces are the whole evidence, and they only
/// exist in the original.
/// </remarks>
public static class HostNaming
{
/// <summary>
/// What separates the organization from the model on a hub.
/// </summary>
private const char ORGANIZATION_SEPARATOR = '/';
/// <summary>
/// What separates the model from the inference provider it should be routed to.
/// </summary>
private const char ROUTING_SEPARATOR = ':';
/// <summary>
/// Takes the organization off a hub style name.
/// </summary>
/// <remarks>
/// Hubs and gateways write "organization/model", and a few hosts put a whole path in front:
/// Fireworks answers with "accounts/fireworks/models/llama-v3p1-405b-instruct". Taking one
/// segment at a time is what covers both without a second rule -- the caller keeps asking until
/// nothing is left to take.
/// </remarks>
/// <param name="id">The name as it arrived.</param>
/// <param name="inner">The name without its first path segment.</param>
/// <param name="declaredVendor">Who the organization says built the model, when we recognize it.</param>
/// <returns>True, when there was an organization to take off.</returns>
public static bool TrySplitOrganization(in ModelId id, out ModelId inner, out ModelVendor? declaredVendor)
{
inner = id;
declaredVendor = null;
var separatorIndex = id.Original.IndexOf(ORGANIZATION_SEPARATOR);
if (separatorIndex is -1)
return false;
var model = id.Original[(separatorIndex + 1)..];
if (string.IsNullOrWhiteSpace(model))
return false;
inner = new(model);
//
// An organization nobody recognizes says nothing rather than saying "unknown": the rules
// may still work out who built the model from its name, and a stated vendor would stop
// them from trying.
//
var vendor = VendorOfOrganization(id.Original[..separatorIndex]);
declaredVendor = vendor is ModelVendor.UNKNOWN ? null : vendor;
return true;
}
/// <summary>
/// Takes the routing suffix off a name.
/// </summary>
/// <remarks>
/// The suffix says where a request goes, not what the model is: "google/gemma-4-31B-it:novita"
/// is the same model as "google/gemma-4-31B-it". Names on the hub carry no colon of their own,
/// so the last one always starts the suffix. This is not true everywhere -- Ollama writes the
/// variant after a colon, as in "qwen3.8:27b-mlx", and taking that off would throw away which
/// model it is. That is why only the host which has a router asks for this.
/// </remarks>
/// <param name="id">The name as it arrived.</param>
/// <param name="inner">The name without its routing suffix.</param>
/// <returns>True, when there was a suffix to take off.</returns>
public static bool TryStripRoutingSuffix(in ModelId id, out ModelId inner)
{
inner = id;
var separatorIndex = id.Original.LastIndexOf(ROUTING_SEPARATOR);
if (separatorIndex is -1)
return false;
var model = id.Original[..separatorIndex];
if (string.IsNullOrWhiteSpace(model))
return false;
inner = new(model);
return true;
}
/// <summary>
/// Takes the position in a menu off a name.
/// </summary>
/// <remarks>
/// Blablador answers with the line a person would read in a list: "1 - Llama3 405 the best
/// general model". The leading number is where the model sits in that list, and it changes
/// whenever the operator adds one.
///
/// The spaces around the hyphen are what makes this safe to ask. A number followed directly by
/// a hyphen is an ordinary part of a name -- "70b-instruct" would lose the size it is named
/// after -- so only the spaced form counts as a menu position.
/// </remarks>
/// <param name="id">The name as it arrived.</param>
/// <param name="inner">The name without its leading number.</param>
/// <returns>True, when there was a menu position to take off.</returns>
public static bool TryStripMenuPosition(in ModelId id, out ModelId inner)
{
inner = id;
var text = id.Original.AsSpan();
var digits = 0;
while (digits < text.Length && char.IsAsciiDigit(text[digits]))
digits++;
if (digits is 0)
return false;
var afterDigits = text[digits..];
if (afterDigits.IsEmpty || afterDigits[0] is not ' ')
return false;
var afterSpace = afterDigits.TrimStart();
if (afterSpace.IsEmpty || afterSpace[0] is not '-')
return false;
var afterHyphen = afterSpace[1..];
if (afterHyphen.IsEmpty || afterHyphen[0] is not ' ')
return false;
var model = afterHyphen.TrimStart();
if (model.IsEmpty)
return false;
inner = new(model.ToString());
return true;
}
/// <summary>
/// Who an organization on a hub stands for.
/// </summary>
/// <remarks>
/// Hubs name the organization which published the weights, which is who built the model. The
/// spellings are theirs, not ours, which is why several of them appear twice: the same vendor
/// publishes under one name on one hub and another name on the next. Anything not listed is
/// somebody we have no rules for yet, and saying so is the honest answer.
/// </remarks>
/// <param name="organization">The organization as the host wrote it, in any casing.</param>
/// <returns>The vendor, or unknown.</returns>
public static ModelVendor VendorOfOrganization(string organization) => organization.ToLowerInvariant() switch
{
"openai" => ModelVendor.OPEN_AI,
"anthropic" => ModelVendor.ANTHROPIC,
"google" => ModelVendor.GOOGLE,
"mistral" or "mistralai" => ModelVendor.MISTRAL_AI,
"meta" or "meta-llama" => ModelVendor.META,
"alibaba" or "qwen" => ModelVendor.ALIBABA,
"deepseek" or "deepseek-ai" => ModelVendor.DEEP_SEEK,
"perplexity" => ModelVendor.PERPLEXITY,
"x-ai" or "xai" => ModelVendor.XAI,
"microsoft" => ModelVendor.MICROSOFT,
"nvidia" => ModelVendor.NVIDIA,
"ibm-granite" => ModelVendor.IBM,
"cohere" or "coherelabs" or "cohereforai" => ModelVendor.COHERE,
"moonshot" or "moonshotai" => ModelVendor.MOONSHOT_AI,
"tencent" or "tencent-hunyuan" => ModelVendor.TENCENT,
"z-ai" or "zai-org" => ModelVendor.Z_AI,
"minimax" or "minimaxai" => ModelVendor.MINIMAX,
"ai2" or "allenai" => ModelVendor.AI2,
"bytedance" or "bytedance-seed" => ModelVendor.BYTE_DANCE,
"tii" or "tiiuae" => ModelVendor.TII,
"inclusionai" => ModelVendor.INCLUSION_AI,
"baidu" or "baidu-ernie" => ModelVendor.BAIDU,
"huggingfacetb" => ModelVendor.HUGGING_FACE,
"servicenow" or "servicenow-ai" => ModelVendor.SERVICE_NOW,
"internlm" or "opengvlab" or "shanghai-ai-laboratory" => ModelVendor.SHANGHAI_AI_LAB,
"swiss-ai" => ModelVendor.SWISS_AI,
_ => ModelVendor.UNKNOWN,
};
}

View File

@ -0,0 +1,21 @@
using AIStudio.Provider;
namespace AIStudio.Models.Hosting.Hosts;
/// <summary>
/// Alibaba Cloud Model Studio.
/// </summary>
/// <remarks>
/// Worth knowing about this one: several names mean a different model here than they do anywhere
/// else. "qwq" is the commercial qwq-plus on Model Studio and the open weights everywhere else.
/// That is not settled here but in the rules, which can bind themselves to a provider -- this host
/// exists so that they have a provider to bind to.
/// </remarks>
public sealed class HostAlibabaCloud : ModelHost
{
/// <inheritdoc />
public override LLMProviders Provider => LLMProviders.ALIBABA_CLOUD;
/// <inheritdoc />
public override ModelSource Source => new("https://www.alibabacloud.com/help/en/model-studio/compatibility-of-openai-with-dashscope", new DateOnly(2026, 9, 11), "Models are named plainly, and the app reaches them through the OpenAI-compatible endpoint.");
}

View File

@ -0,0 +1,15 @@
using AIStudio.Provider;
namespace AIStudio.Models.Hosting.Hosts;
/// <summary>
/// Anthropic's own cloud.
/// </summary>
public sealed class HostAnthropic : ModelHost
{
/// <inheritdoc />
public override LLMProviders Provider => LLMProviders.ANTHROPIC;
/// <inheritdoc />
public override ModelSource Source => new("https://docs.anthropic.com/en/api/messages", new DateOnly(2026, 9, 11), "Models are named plainly, and there is one API to reach them through.");
}

View File

@ -0,0 +1,20 @@
using AIStudio.Provider;
namespace AIStudio.Models.Hosting.Hosts;
/// <summary>
/// DeepSeek's own platform.
/// </summary>
/// <remarks>
/// It names its models by what they are for rather than by which checkpoint answers: "deepseek-chat"
/// and "deepseek-reasoner" both point at whatever is current. Those are aliases, not wrappings, so
/// there is nothing to take off -- the rules answer for the alias itself.
/// </remarks>
public sealed class HostDeepSeek : ModelHost
{
/// <inheritdoc />
public override LLMProviders Provider => LLMProviders.DEEP_SEEK;
/// <inheritdoc />
public override ModelSource Source => new("https://api-docs.deepseek.com/", new DateOnly(2026, 9, 11), "Models are named plainly, and the app reaches them through the OpenAI-compatible endpoint.");
}

View File

@ -0,0 +1,25 @@
using AIStudio.Models.Matching;
using AIStudio.Provider;
namespace AIStudio.Models.Hosting.Hosts;
/// <summary>
/// Fireworks AI, which puts a whole account path in front of every model.
/// </summary>
/// <remarks>
/// "accounts/fireworks/models/llama-v3p1-405b-instruct" is three segments of path and then the
/// model. Nothing here counts them: the same taking-off-one-segment the gateways use is asked
/// again until there is no path left. None of the three segments names a vendor we know, so none
/// of them claims to.
/// </remarks>
public sealed class HostFireworks : ModelHost
{
/// <inheritdoc />
public override LLMProviders Provider => LLMProviders.FIREWORKS;
/// <inheritdoc />
public override ModelSource Source => new("https://fireworks.ai/models?show=Serverless", new DateOnly(2026, 9, 11), "Models are named \"accounts/<account>/models/<model>\", served through the OpenAI-compatible chat completion API.");
/// <inheritdoc />
public override bool TryUnwrap(in ModelId id, out ModelId inner, out ModelVendor? declaredVendor) => HostNaming.TrySplitOrganization(id, out inner, out declaredVendor);
}

View File

@ -0,0 +1,26 @@
using AIStudio.Provider;
namespace AIStudio.Models.Hosting.Hosts;
/// <summary>
/// The GWDG's academic cloud, which resells commercial models next to the open weights it runs.
/// </summary>
/// <remarks>
/// This is the host the transport rule was written for. It offers Claude and GPT under the very
/// names their vendors use -- "claude-sonnet-5", "gpt-5.5" -- so the rules recognize them and
/// answer with everything those models can do at their vendor. Everything except the API: a request
/// goes to Göttingen, not to San Francisco, and the Responses API is not served there.
///
/// The old code arrived at the same answer by having the open weights rules notice a Claude name
/// and call the Anthropic rules, then correct the result. Here the recognizing and the correcting
/// are two different things in two different places, which is why neither has to know about the
/// other.
/// </remarks>
public sealed class HostGWDG : ModelHost
{
/// <inheritdoc />
public override LLMProviders Provider => LLMProviders.GWDG;
/// <inheritdoc />
public override ModelSource Source => new("https://docs.hpc.gwdg.de/services/saia/index.html", new DateOnly(2026, 9, 11), "Open weights and resold commercial models alike are named plainly, and all of them are served through the OpenAI-compatible chat completion API.");
}

View File

@ -0,0 +1,15 @@
using AIStudio.Provider;
namespace AIStudio.Models.Hosting.Hosts;
/// <summary>
/// Google's own cloud.
/// </summary>
public sealed class HostGoogle : ModelHost
{
/// <inheritdoc />
public override LLMProviders Provider => LLMProviders.GOOGLE;
/// <inheritdoc />
public override ModelSource Source => new("https://ai.google.dev/gemini-api/docs/openai", new DateOnly(2026, 9, 11), "Models are named plainly, and the app reaches them through the OpenAI-compatible endpoint.");
}

View File

@ -0,0 +1,24 @@
using AIStudio.Models.Matching;
using AIStudio.Provider;
namespace AIStudio.Models.Hosting.Hosts;
/// <summary>
/// Groq, which serves open weights and writes some of their names the way the hub does.
/// </summary>
/// <remarks>
/// Both spellings appear side by side in its catalog: "llama-3.3-70b-versatile" carries no
/// organization, "moonshotai/kimi-k2-instruct" and "openai/gpt-oss-120b" do. Taking one off when
/// there is one settles both without a rule per spelling.
/// </remarks>
public sealed class HostGroq : ModelHost
{
/// <inheritdoc />
public override LLMProviders Provider => LLMProviders.GROQ;
/// <inheritdoc />
public override ModelSource Source => new("https://console.groq.com/docs/api-reference", new DateOnly(2026, 9, 11), "Models are named either plainly or as the hub names them, and served through the OpenAI-compatible chat completion API.");
/// <inheritdoc />
public override bool TryUnwrap(in ModelId id, out ModelId inner, out ModelVendor? declaredVendor) => HostNaming.TrySplitOrganization(id, out inner, out declaredVendor);
}

View File

@ -0,0 +1,29 @@
using AIStudio.Models.Matching;
using AIStudio.Provider;
namespace AIStudio.Models.Hosting.Hosts;
/// <summary>
/// Helmholtz Blablador, which answers with the line a person would read in a menu.
/// </summary>
/// <remarks>
/// "1 - Llama3 405 the best general model" is a whole sentence, and the number in front is where
/// the entry sits in the list -- it moves whenever the operator adds a model. Taking it off is the
/// one thing this host does; the prose after the model name stays because there is no telling
/// where the name ends and the recommendation begins.
/// </remarks>
public sealed class HostHelmholtz : ModelHost
{
/// <inheritdoc />
public override LLMProviders Provider => LLMProviders.HELMHOLTZ;
/// <inheritdoc />
public override ModelSource Source => new("https://sdlaml.pages.jsc.fz-juelich.de/ai/guides/blablador_api_access/", new DateOnly(2026, 9, 11), "Models are named as menu entries, \"<position> - <description>\", and served through the OpenAI-compatible chat completion API.");
/// <inheritdoc />
public override bool TryUnwrap(in ModelId id, out ModelId inner, out ModelVendor? declaredVendor)
{
declaredVendor = null;
return HostNaming.TryStripMenuPosition(id, out inner);
}
}

View File

@ -0,0 +1,15 @@
using AIStudio.Provider;
namespace AIStudio.Models.Hosting.Hosts;
/// <summary>
/// Hetzner's inference offering, which serves open weights under their plain names.
/// </summary>
public sealed class HostHetzner : ModelHost
{
/// <inheritdoc />
public override LLMProviders Provider => LLMProviders.HETZNER;
/// <inheritdoc />
public override ModelSource Source => new("https://experiments.hetzner.com/docs/inference", new DateOnly(2026, 9, 11), "Open weights named plainly, served through the OpenAI-compatible chat completion API.");
}

View File

@ -0,0 +1,36 @@
using AIStudio.Models.Matching;
using AIStudio.Provider;
namespace AIStudio.Models.Hosting.Hosts;
/// <summary>
/// The Hugging Face router, whose names carry two wrappings rather than one.
/// </summary>
/// <remarks>
/// "google/gemma-4-31B-it:novita" says three things at once: who published the weights, which model
/// it is, and which inference provider should answer. The suffix goes first, because it is the
/// outermost and because it says nothing about the model -- a request routed to Novita and one
/// routed to Together AI reach the same weights.
///
/// This is the case the whole walk was written for. A host which took both off at once would work
/// here and nowhere else; taking one off at a time is what also covers the account path Fireworks
/// puts in front, without either host knowing about the other.
/// </remarks>
public sealed class HostHuggingFace : ModelHost
{
/// <inheritdoc />
public override LLMProviders Provider => LLMProviders.HUGGINGFACE;
/// <inheritdoc />
public override ModelSource Source => new("https://huggingface.co/docs/inference-providers/index", new DateOnly(2026, 9, 11), "Models are named as the hub names them, \"organization/model\", optionally followed by a colon and the inference provider to route to.");
/// <inheritdoc />
public override bool TryUnwrap(in ModelId id, out ModelId inner, out ModelVendor? declaredVendor)
{
declaredVendor = null;
if (HostNaming.TryStripRoutingSuffix(id, out inner))
return true;
return HostNaming.TrySplitOrganization(id, out inner, out declaredVendor);
}
}

View File

@ -0,0 +1,24 @@
using AIStudio.Models.Matching;
using AIStudio.Provider;
namespace AIStudio.Models.Hosting.Hosts;
/// <summary>
/// The IONOS AI Model Hub, which keeps the hub spelling of the models it serves.
/// </summary>
/// <remarks>
/// Its catalog reads like the hub's: "meta-llama/Llama-3.3-70B-Instruct",
/// "mistralai/Mistral-Small-24B-Instruct". So the organization comes off, and with it comes the
/// vendor -- stated rather than guessed from the name.
/// </remarks>
public sealed class HostIONOS : ModelHost
{
/// <inheritdoc />
public override LLMProviders Provider => LLMProviders.IONOS;
/// <inheritdoc />
public override ModelSource Source => new("https://docs.ionos.com/cloud/ai/ai-model-hub", new DateOnly(2026, 9, 11), "Open weights named as the hub names them, served through the OpenAI-compatible chat completion API.");
/// <inheritdoc />
public override bool TryUnwrap(in ModelId id, out ModelId inner, out ModelVendor? declaredVendor) => HostNaming.TrySplitOrganization(id, out inner, out declaredVendor);
}

View File

@ -0,0 +1,30 @@
using AIStudio.Models.Matching;
using AIStudio.Provider;
namespace AIStudio.Models.Hosting.Hosts;
/// <summary>
/// A LiteLLM proxy, which somebody operates themselves and names as they please.
/// </summary>
/// <remarks>
/// Aliases here are whatever the operator wrote in their configuration. Many of them keep the
/// "vendor/model" shape, some name the cloud instead of the vendor ("azure/gpt-5.6"), and some are
/// a word ("the-fast-one"). Taking off a prefix costs nothing in the last case and helps in the
/// first two, and a prefix nobody recognizes states no vendor -- so a name the operator invented
/// is left for the rules to make what they can of.
///
/// This is also the host where a person is most likely to correct us by hand, which is what the
/// expert settings are for: an alias only its operator can decipher is not something rules will
/// ever get right.
/// </remarks>
public sealed class HostLiteLLM : ModelHost
{
/// <inheritdoc />
public override LLMProviders Provider => LLMProviders.LITE_LLM;
/// <inheritdoc />
public override ModelSource Source => new("https://docs.litellm.ai/docs/proxy/user_keys", new DateOnly(2026, 9, 11), "Models are whatever the operator named them, served through the OpenAI-compatible chat completion API.");
/// <inheritdoc />
public override bool TryUnwrap(in ModelId id, out ModelId inner, out ModelVendor? declaredVendor) => HostNaming.TrySplitOrganization(id, out inner, out declaredVendor);
}

View File

@ -0,0 +1,20 @@
using AIStudio.Provider;
namespace AIStudio.Models.Hosting.Hosts;
/// <summary>
/// Mistral's own platform, which by now also serves models Mistral did not build.
/// </summary>
/// <remarks>
/// It names those under their plain names rather than prefixing them, so there is nothing to
/// unwrap here. Which model it is remains a question for the rules; what this host settles is that
/// whatever answers, it answers through Mistral's own API.
/// </remarks>
public sealed class HostMistral : ModelHost
{
/// <inheritdoc />
public override LLMProviders Provider => LLMProviders.MISTRAL;
/// <inheritdoc />
public override ModelSource Source => new("https://docs.mistral.ai/api/", new DateOnly(2026, 9, 11), "Models are named plainly, its own and the open weights it hosts alike.");
}

View File

@ -0,0 +1,28 @@
using AIStudio.Provider;
namespace AIStudio.Models.Hosting.Hosts;
/// <summary>
/// OpenAI's own cloud, the one place where the Responses API is actually spoken.
/// </summary>
/// <remarks>
/// This is the single host that does not put its models on the ordinary chat completion API,
/// because it is the single place the app sends a Responses API request from. Everywhere else a
/// GPT model is reached -- a gateway, a reseller, somebody's own proxy -- it is reached through the
/// ordinary API, and the host there says so.
/// </remarks>
public sealed class HostOpenAI : ModelHost
{
/// <inheritdoc />
public override LLMProviders Provider => LLMProviders.OPEN_AI;
/// <inheritdoc />
public override ModelSource Source => new("https://platform.openai.com/docs/api-reference/responses", new DateOnly(2026, 9, 11), "Models are named plainly, and both the Responses API and the chat completion API are served here.");
/// <inheritdoc />
/// <remarks>
/// Nothing is taken away: whichever of the two APIs a model states, it can be reached through
/// it here.
/// </remarks>
public override ModelProfile ApplyTransport(in ModelProfile profile) => profile;
}

View File

@ -0,0 +1,25 @@
using AIStudio.Models.Matching;
using AIStudio.Provider;
namespace AIStudio.Models.Hosting.Hosts;
/// <summary>
/// OpenRouter, which serves other people's models and says whose they are.
/// </summary>
/// <remarks>
/// The vendor prefix is the reason the old rules delegated between vendors in circles: a name such
/// as "anthropic/claude-opus-5" had to be handed to whoever knew Claude, and the same for every
/// other vendor. Here the prefix is simply taken off, and the vendor stated, and one set of rules
/// answers the bare name -- no matter which provider it arrived from.
/// </remarks>
public sealed class HostOpenRouter : ModelHost
{
/// <inheritdoc />
public override LLMProviders Provider => LLMProviders.OPEN_ROUTER;
/// <inheritdoc />
public override ModelSource Source => new("https://openrouter.ai/docs/api-reference/overview", new DateOnly(2026, 9, 11), "Models are named \"vendor/model\", and every one of them is served through the OpenAI-compatible chat completion API.");
/// <inheritdoc />
public override bool TryUnwrap(in ModelId id, out ModelId inner, out ModelVendor? declaredVendor) => HostNaming.TrySplitOrganization(id, out inner, out declaredVendor);
}

View File

@ -0,0 +1,15 @@
using AIStudio.Provider;
namespace AIStudio.Models.Hosting.Hosts;
/// <summary>
/// Perplexity's own API.
/// </summary>
public sealed class HostPerplexity : ModelHost
{
/// <inheritdoc />
public override LLMProviders Provider => LLMProviders.PERPLEXITY;
/// <inheritdoc />
public override ModelSource Source => new("https://docs.perplexity.ai/api-reference/chat-completions-post", new DateOnly(2026, 9, 11), "Models are named plainly, and there is one API to reach them through.");
}

View File

@ -0,0 +1,31 @@
using AIStudio.Models.Matching;
using AIStudio.Provider;
namespace AIStudio.Models.Hosting.Hosts;
/// <summary>
/// Somebody's own engine: Ollama, LM Studio, vLLM, llama.cpp, or a proxy in front of them.
/// </summary>
/// <remarks>
/// vLLM serves whatever it was pointed at, and what it was pointed at is usually a hub repository:
/// "meta-llama/Llama-3.3-70B-Instruct", "01-ai/yi-large". So the organization comes off here too.
///
/// The colon does not. Ollama writes the variant after it -- "qwen3.8:27b-mlx" -- and taking that
/// off would leave a name which no longer says which build of the model is running. Only the host
/// which actually has a router treats a colon as routing.
///
/// Whatever the engine can do beyond this, only the engine knows: how large a context window the
/// operator configured, how many images it accepts. Those come from the model list of the running
/// installation, not from a rule written here.
/// </remarks>
public sealed class HostSelfHosted : ModelHost
{
/// <inheritdoc />
public override LLMProviders Provider => LLMProviders.SELF_HOSTED;
/// <inheritdoc />
public override ModelSource Source => new("https://docs.vllm.ai/en/latest/serving/openai_compatible_server.html", new DateOnly(2026, 9, 11), "Models are named as the operator loaded them, often as a hub repository, and served through the OpenAI-compatible chat completion API.");
/// <inheritdoc />
public override bool TryUnwrap(in ModelId id, out ModelId inner, out ModelVendor? declaredVendor) => HostNaming.TrySplitOrganization(id, out inner, out declaredVendor);
}

View File

@ -0,0 +1,15 @@
using AIStudio.Provider;
namespace AIStudio.Models.Hosting.Hosts;
/// <summary>
/// xAI's own API, where Grok comes from.
/// </summary>
public sealed class HostX : ModelHost
{
/// <inheritdoc />
public override LLMProviders Provider => LLMProviders.X;
/// <inheritdoc />
public override ModelSource Source => new("https://docs.x.ai/docs/api-reference", new DateOnly(2026, 9, 11), "Models are named plainly, and the app reaches them through the OpenAI-compatible endpoint.");
}

View File

@ -0,0 +1,56 @@
using AIStudio.Models.Matching;
using AIStudio.Provider;
namespace AIStudio.Models.Hosting;
/// <summary>
/// One place a model can be reached from, and what reaching it that way does to the answer.
/// </summary>
/// <remarks>
/// This is the routing graph, written down instead of grown into the rules. The old code solved
/// gateways and resellers by having one vendor's rules call another's, which turned into mutual
/// recursion -- Mistral into the open weights, the open weights back into Anthropic, Google, and
/// OpenAI -- and nobody could say from reading it which way a name would travel.
///
/// A host does two things, and only these two. It unwraps a name until the model underneath is
/// visible, and it says what the transport takes away. Unwrapping is iterative on purpose, because
/// the wrappings stack: Hugging Face first drops the routing suffix, then the organization prefix.
/// A host which serves other people's models under their plain names unwraps nothing and only
/// trims the transport, which is the same mechanism rather than a special case.
/// </remarks>
public interface IModelHost
{
/// <summary>
/// The provider this host answers for.
/// </summary>
LLMProviders Provider { get; }
/// <summary>
/// Where the statements about this host were read, and when.
/// </summary>
ModelSource Source { get; }
/// <summary>
/// Takes one wrapping off a name, if there is one.
/// </summary>
/// <remarks>
/// Called again with whatever comes out, until it says no. A host which declares who built the
/// model saves the rules from having to guess it from the name.
/// </remarks>
/// <param name="id">The name as it arrived.</param>
/// <param name="inner">The name with one wrapping removed.</param>
/// <param name="declaredVendor">Who the wrapping says built the model, when it says so.</param>
/// <returns>True, when a wrapping was removed.</returns>
bool TryUnwrap(in ModelId id, out ModelId inner, out ModelVendor? declaredVendor);
/// <summary>
/// Takes away what this host cannot offer, whatever the model itself can do.
/// </summary>
/// <remarks>
/// A provider reselling somebody else's model speaks its own dialect, not the vendor's: the
/// model may well be able to answer through a vendor specific API, but not here.
/// </remarks>
/// <param name="profile">What the model can do.</param>
/// <returns>What it can do through this host.</returns>
ModelProfile ApplyTransport(in ModelProfile profile);
}

View File

@ -0,0 +1,68 @@
using AIStudio.Models.Matching;
using AIStudio.Provider;
namespace AIStudio.Models.Hosting;
/// <summary>
/// The ordinary host: it serves models under the names they are known by, through the ordinary API.
/// </summary>
/// <remarks>
/// Most hosts differ from each other in one sentence, and this is what carries the rest. A host
/// which wraps its names says how to unwrap one; a host which speaks an API the others do not says
/// so; everything else is stated here once.
///
/// What a source means for a host: the page names where the behaviour is documented, so that a
/// person can re-check it in a minute. The statements themselves were read off the app's own
/// provider implementations and the model corpus, both of which are in this repository -- the
/// pages are where somebody looks when they doubt them.
/// </remarks>
public abstract class ModelHost : IModelHost
{
/// <summary>
/// The two capabilities which say through which API a model is reached.
/// </summary>
private const Capability THE_APIS = Capability.CHAT_COMPLETION_API | Capability.RESPONSES_API;
/// <inheritdoc />
public abstract LLMProviders Provider { get; }
/// <inheritdoc />
public abstract ModelSource Source { get; }
/// <inheritdoc />
/// <remarks>
/// Nothing is wrapped here: this host serves models under the names they are known by.
/// </remarks>
public virtual bool TryUnwrap(in ModelId id, out ModelId inner, out ModelVendor? declaredVendor)
{
inner = id;
declaredVendor = null;
return false;
}
/// <inheritdoc />
/// <remarks>
/// The Responses API is OpenAI's own, and the app speaks it in exactly one place, its OpenAI
/// provider. Wherever else a model is reached, it is reached through the ordinary chat
/// completion API -- whatever the model itself could do at its vendor.
/// </remarks>
public virtual ModelProfile ApplyTransport(in ModelProfile profile) => ThroughTheOrdinaryApi(profile);
/// <summary>
/// Puts a profile on the ordinary chat completion API.
/// </summary>
/// <remarks>
/// A profile which says nothing about APIs is left alone. An embedding model is reached through
/// neither of the two, and answering that it speaks the chat completion API would be a claim
/// nobody made.
/// </remarks>
/// <param name="profile">What the model can do.</param>
/// <returns>What it can do when reached through the ordinary API.</returns>
public static ModelProfile ThroughTheOrdinaryApi(in ModelProfile profile)
{
if (!profile.HasAny(THE_APIS))
return profile;
return profile with { Capabilities = (profile.Capabilities & ~Capability.RESPONSES_API) | Capability.CHAT_COMPLETION_API };
}
}

View File

@ -0,0 +1,144 @@
using System.Collections.Frozen;
using AIStudio.Models.Matching;
using AIStudio.Provider;
namespace AIStudio.Models.Hosting;
/// <summary>
/// Which host answers for which provider, and the unwrapping walk itself.
/// </summary>
/// <remarks>
/// The walk is why this exists rather than a plain dictionary. Wrappings stack, and how deep they
/// go is the host's business, not the caller's: Hugging Face takes off a routing suffix and then an
/// organization, Fireworks takes off three path segments, and most hosts take off nothing at all.
/// Asking a host over and over until it says no covers all three without anybody counting.
/// </remarks>
public sealed class ModelHostIndex
{
/// <summary>
/// How often a name may be unwrapped before we stop believing the host.
/// </summary>
/// <remarks>
/// The deepest wrapping we know of is the account path Fireworks puts in front, at three
/// segments. The limit is not there for that -- it is there so that a host which hands back a
/// name it never shortened cannot hang the app. A host which needs more than this has gone
/// wrong, and stopping is a better answer than never returning.
/// </remarks>
public const int MAX_UNWRAPPING_STEPS = 8;
private readonly FrozenDictionary<LLMProviders, IModelHost> byProvider;
private ModelHostIndex(FrozenDictionary<LLMProviders, IModelHost> byProvider, IReadOnlyList<IModelHost> hosts, IReadOnlyList<LLMProviders> providersWithoutAHost)
{
this.byProvider = byProvider;
this.Hosts = hosts;
this.ProvidersWithoutAHost = providersWithoutAHost;
}
/// <summary>
/// Every host the index was built from, ordered by provider.
/// </summary>
public IReadOnlyList<IModelHost> Hosts { get; }
/// <summary>
/// The providers a person can configure for which nobody wrote a host.
/// </summary>
/// <remarks>
/// Not an error at runtime, and that is on purpose: a provider added to the app without a host
/// still works, its names are simply taken as they are. It is an error the verification run
/// reports, which is where a missing host should surface -- before the release, not during a
/// chat.
/// </remarks>
public IReadOnlyList<LLMProviders> ProvidersWithoutAHost { get; }
/// <summary>
/// Builds an index over a set of hosts.
/// </summary>
/// <param name="hosts">The hosts, in any order.</param>
/// <returns>The index.</returns>
/// <exception cref="InvalidOperationException">When two hosts answer for the same provider, or a host answers for none.</exception>
public static ModelHostIndex Build(IEnumerable<IModelHost> hosts)
{
var byProvider = new Dictionary<LLMProviders, IModelHost>();
foreach (var host in hosts)
{
if (host.Provider is LLMProviders.NONE)
throw new InvalidOperationException($"The host {host.GetType().Name} answers for no provider. A host has to name the provider it serves, because that is how anything finds it.");
if (byProvider.TryGetValue(host.Provider, out var alreadyThere))
throw new InvalidOperationException($"Both {alreadyThere.GetType().Name} and {host.GetType().Name} answer for {host.Provider}. Only one host can, because there is one way a name arrives from a provider.");
byProvider[host.Provider] = host;
}
var withoutAHost = Enum.GetValues<LLMProviders>()
.Where(provider => provider is not LLMProviders.NONE && !byProvider.ContainsKey(provider))
.ToArray();
var ordered = byProvider.OrderBy(entry => entry.Key).Select(entry => entry.Value).ToArray();
return new(byProvider.ToFrozenDictionary(), ordered, withoutAHost);
}
/// <summary>
/// The host answering for a provider.
/// </summary>
/// <param name="provider">The provider.</param>
/// <returns>The host, or nothing when nobody wrote one.</returns>
public IModelHost? Of(LLMProviders provider) => this.byProvider.GetValueOrDefault(provider);
/// <summary>
/// Takes a name apart until the model underneath is visible.
/// </summary>
/// <remarks>
/// The innermost statement about the vendor is the one that counts. A wrapping closer to the
/// model knows more about it than one further out, and a wrapping which says nothing does not
/// erase what an outer one said.
/// </remarks>
/// <param name="id">The name as the provider reported it.</param>
/// <param name="provider">Who reported it.</param>
/// <param name="declaredVendor">Who the wrappings say built the model, when they say so.</param>
/// <returns>The name with every wrapping taken off.</returns>
public ModelId Unwrap(in ModelId id, LLMProviders provider, out ModelVendor? declaredVendor)
{
declaredVendor = null;
var host = this.Of(provider);
if (host is null)
return id;
var current = id;
for (var step = 0; step < MAX_UNWRAPPING_STEPS; step++)
{
if (!host.TryUnwrap(current, out var inner, out var stated))
break;
// A host handing back what it was given would go round forever:
if (inner.Equals(current))
break;
current = inner;
if (stated is not null)
declaredVendor = stated;
}
return current;
}
/// <summary>
/// Takes away what a provider cannot offer, whatever the model itself can do.
/// </summary>
/// <remarks>
/// A provider without a host gets the answer every host but one gives: the ordinary chat
/// completion API. That is the safe direction -- claiming an API which is not there turns into
/// a failed request, while not claiming one merely means the app does not use it.
/// </remarks>
/// <param name="profile">What the model can do.</param>
/// <param name="provider">Who serves it.</param>
/// <returns>What it can do through this provider.</returns>
public ModelProfile ApplyTransport(in ModelProfile profile, LLMProviders provider)
{
var host = this.Of(provider);
return host?.ApplyTransport(profile) ?? ModelHost.ThroughTheOrdinaryApi(profile);
}
}

View File

@ -0,0 +1,65 @@
using AIStudio.Provider;
using static AIStudio.Provider.Capability;
namespace AIStudio.Models.IBM;
/// <summary>
/// Granite, from IBM.
/// </summary>
/// <remarks>
/// The instruct line calls functions with the OpenAI function definition schema, so the family
/// states it and the vision checkpoints say otherwise: for those, IBM documents no tool template.
/// The thinking came in two steps -- 3.2 and 3.3 have a toggle which starts off, 4.2 thinks unless
/// the request says otherwise, and the generations in between do not think at all.
///
/// Each generation is written twice. Ollama serves them as "granite4.2:8b", with the version glued
/// to the family name, while IBM writes "granite-4.2". The previous rules knew only IBM's spelling,
/// so everything anybody actually ran through Ollama quietly lost its thinking.
/// </remarks>
public sealed class GraniteFamily : ModelFamily
{
/// <inheritdoc />
public override ModelVendor Vendor => ModelVendor.IBM;
/// <inheritdoc />
public override ModelSource Source => new("https://www.ibm.com/granite/docs/models/granite/", new DateOnly(2026, 9, 11), "Ported from the Granite block of ProviderExtensions.OpenSource.cs, with the spelling Ollama uses added to each generation.");
/// <inheritdoc />
protected override void Declare(ModelFamilyBuilder builder)
{
builder.Rule("granite").AsSubstring()
.Capabilities(TEXT_INPUT | TEXT_OUTPUT | FUNCTION_CALLING)
.Apis(CHAT_COMPLETION_API);
// The embedding checkpoints turn text into a vector; there is no conversation in them:
builder.Rule("granite-embedding").AsSubstring()
.Capabilities(TEXT_INPUT | EMBEDDING)
.Kind(ModelKind.EMBEDDING);
// The vision checkpoints look at pictures and have nothing to call a function with:
builder.Rule("granite").AsSubstring().AlsoContains("vision")
.Capabilities(TEXT_INPUT | MULTIPLE_IMAGE_INPUT | TEXT_OUTPUT)
.Apis(CHAT_COMPLETION_API);
// From 4.2 on they think unless the request says otherwise:
builder.Rule("granite-4.2").AsSubstring().NotContains("vision")
.Capabilities(TEXT_INPUT | TEXT_OUTPUT | FUNCTION_CALLING)
.Apis(CHAT_COMPLETION_API)
.Reasoning(ReasoningSupport.ON_BY_DEFAULT);
builder.Rule("granite4.2").AsSubstring().NotContains("vision").Inherits();
// 3.2 and 3.3 have to be asked:
builder.Rule("granite-3.2").AsSubstring().NotContains("vision")
.Capabilities(TEXT_INPUT | TEXT_OUTPUT | FUNCTION_CALLING)
.Apis(CHAT_COMPLETION_API)
.Reasoning(ReasoningSupport.OPTIONAL);
builder.Rule("granite3.2").AsSubstring().NotContains("vision").Inherits();
builder.Rule("granite-3.3").AsSubstring().NotContains("vision").Inherits();
builder.Rule("granite3.3").AsSubstring().NotContains("vision").Inherits();
}
}

View File

@ -0,0 +1,55 @@
namespace AIStudio.Models;
/// <summary>
/// How many images a model accepts, where anybody has said so.
/// </summary>
/// <remarks>
/// Both numbers exist in the wild and they are not the same one: Anthropic documents a limit for a
/// whole request, while vLLM limits each prompt through --limit-mm-per-prompt and ships with that
/// set to one image. A model card may state either without the other, which is why each is optional
/// on its own instead of sharing one "is known" flag.
///
/// Zero is a real answer here, not a stand-in for unknown: an operator can configure an engine to
/// accept no images at all. Unknown is null.
/// </remarks>
/// <param name="MaxPerMessage">How many images fit into one message, or null when nobody has said.</param>
/// <param name="MaxPerRequest">How many images fit into one request, or null when nobody has said.</param>
public readonly record struct ImageLimits(int? MaxPerMessage, int? MaxPerRequest)
{
/// <summary>
/// The number to show a user, or to plan with, where nothing is known.
/// </summary>
/// <remarks>
/// This is a number for whoever needs one, never a limit to enforce. Today, saying that a model
/// takes several images says nothing about how many, and turning that into a hidden ceiling of
/// six would take something away from the models which handle a hundred.
/// </remarks>
public const int DEFAULT_MAX_IMAGES = 6;
/// <summary>
/// The limits of a model nobody has written anything about.
/// </summary>
public static readonly ImageLimits UNKNOWN = new(null, null);
/// <summary>
/// Whether either of the two numbers is known.
/// </summary>
public bool IsKnown => this.MaxPerMessage.HasValue || this.MaxPerRequest.HasValue;
/// <summary>
/// How many images may travel in one message, as far as anybody has said.
/// </summary>
/// <remarks>
/// A message is part of a request, so a message cannot carry more than a whole request may --
/// whichever of the two numbers is smaller decides, and a number nobody stated does not decide
/// anything. Null means nobody stated either, which is a gap and never a limit of zero.
/// </remarks>
public int? MaxInOneMessage => (this.MaxPerMessage, this.MaxPerRequest) switch
{
({ } perMessage, { } perRequest) => Math.Min(perMessage, perRequest),
({ } perMessage, null) => perMessage,
(null, { } perRequest) => perRequest,
_ => null,
};
}

View File

@ -0,0 +1,25 @@
using AIStudio.Provider;
namespace AIStudio.Models.Kinds;
/// <summary>
/// The models that work a screen instead of holding a conversation.
/// </summary>
/// <remarks>
/// They are named after the chat model they grew out of -- gemini-2.5-computer-use-preview -- and a
/// name is all they share with it. A request without the computer use tool is refused outright:
/// "This model requires the use of the Computer Use tool." So the resemblance is exactly the trap,
/// and this is the rule that keeps them out of the list a person picks a chat partner from.
/// </remarks>
public sealed class ComputerUseModelsFamily : ModelFamily
{
/// <inheritdoc />
public override ModelVendor Vendor => ModelVendor.UNKNOWN;
/// <inheritdoc />
public override ModelSource Source => new("https://ai.google.dev/gemini-api/docs/computer-use", new DateOnly(2026, 9, 12), "Found while testing the switch-over: the model stood in the chat list although its API refuses every request which does not carry the computer use tool.");
/// <inheritdoc />
protected override void Declare(ModelFamilyBuilder builder) =>
builder.Modifier("computer-use").AsSegment().Kind(ModelKind.COMPUTER_USE);
}

View File

@ -0,0 +1,59 @@
using AIStudio.Provider;
namespace AIStudio.Models.Kinds;
/// <summary>
/// The models which turn text into a vector, whoever built them.
/// </summary>
/// <remarks>
/// Everything in this folder answers one question: what is a model made for, as opposed to what can
/// it do. The two used to be answered by two different pieces of code walking the same name, and
/// before that by every provider carrying a list of name fragments of its own -- lists which
/// disagreed, so that nomic-embed-text was an embedding model at one provider and a chat model at
/// the next.
///
/// These are modifiers rather than selectors, and that is the whole trick. A model keeps the family
/// it belongs to and this only says what it is for: llama-guard stays a Llama, and an embedding
/// checkpoint of a family we have rules for keeps those rules. Written as selectors they would have
/// to win against the family, and "embed" against "llama" is a contest neither of them should be
/// in -- both are five characters of substring, which is a tie, which is an error.
///
/// What none of them may become is a place for provider-specific knowledge. That "codestral" fills
/// in the middle at Mistral is true for Mistral; such a statement belongs to the family.
/// </remarks>
public sealed class EmbeddingModelsFamily : ModelFamily
{
/// <inheritdoc />
public override ModelVendor Vendor => ModelVendor.UNKNOWN;
/// <inheritdoc />
public override ModelSource Source => new("https://huggingface.co/models?pipeline_tag=feature-extraction", new DateOnly(2026, 9, 12), "Ported from the embedding markers of Provider/ModelKindExtensions.cs. The e5 line says it in its own family, so it is not repeated here.");
/// <inheritdoc />
protected override void Declare(ModelFamilyBuilder builder)
{
builder.Modifier("embed").AsSubstring().Kind(ModelKind.EMBEDDING);
builder.Modifier("bge").AsSubstring().Inherits();
builder.Modifier("mpnet").AsSubstring().Inherits();
builder.Modifier("paraphrase").AsSubstring().Inherits();
//
// The one marker which was really an organization rather than a model. It still holds where
// a name arrives whole, but the host takes the organization off before any rule sees the
// name, so the model this organization is known for has to stand next to it: all-MiniLM-L6-v2
// says nothing about embedding except through who published it.
//
builder.Modifier("sentence-transformers").AsSubstring().Inherits();
builder.Modifier("minilm").AsSubstring().Inherits();
builder.Modifier("gritlm").AsSubstring().Inherits();
// General Text Embeddings, from Alibaba. Written as a name part rather than as a substring,
// because three letters appear inside far too many unrelated words:
builder.Modifier("gte").AsSegment().Inherits();
}
}

View File

@ -0,0 +1,42 @@
using AIStudio.Provider;
namespace AIStudio.Models.Kinds;
/// <summary>
/// The models which draw rather than write.
/// </summary>
/// <remarks>
/// Google names its image models after the chat model they grew out of and appends the word:
/// gemini-3-pro-image, gemini-3.1-flash-image, gemini-2.5-flash-image. Read as a plain substring
/// that word is far too greedy -- it sits inside "imagenet" and "reimagined" as well, and a chat
/// model carrying such a word would disappear from the user's list. As a name part it says what it
/// is meant to say, and it covers OpenAI's gpt-image-1 along the way, which is why that name is not
/// stated a second time.
/// </remarks>
public sealed class ImageGenerationModelsFamily : ModelFamily
{
/// <inheritdoc />
public override ModelVendor Vendor => ModelVendor.UNKNOWN;
/// <inheritdoc />
public override ModelSource Source => new("https://huggingface.co/models?pipeline_tag=text-to-image", new DateOnly(2026, 9, 12), "Ported from the image generation markers of Provider/ModelKindExtensions.cs. Imagen and the Gemini image models state it in their own families as well, where the capabilities stand next to it.");
/// <inheritdoc />
protected override void Declare(ModelFamilyBuilder builder)
{
builder.Modifier("flux").AsSubstring().Kind(ModelKind.IMAGE_GENERATION);
builder.Modifier("stable-diffusion").AsSubstring().Inherits();
builder.Modifier("sdxl").AsSubstring().Inherits();
builder.Modifier("dall-e").AsSubstring().Inherits();
builder.Modifier("midjourney").AsSubstring().Inherits();
builder.Modifier("image").AsSegment().Inherits();
// The other half of Grok Imagine, which the video rule steps aside for:
builder.Modifier("grok-imagine").AsSegment().NotContains("video").Inherits();
}
}

View File

@ -0,0 +1,32 @@
using AIStudio.Provider;
namespace AIStudio.Models.Kinds;
/// <summary>
/// The models which judge content instead of writing it.
/// </summary>
/// <remarks>
/// The guard models are the reason this is stated as a plain substring rather than as a name part:
/// Meta writes Llama-Guard-3-8B, where the word stands on its own, but Alibaba writes Qwen3Guard-Gen-8B,
/// where it is glued to the version. A name part would see the first and miss the second.
///
/// Being a modifier is what makes that harmless. Llama-Guard keeps everything the Llama rules say
/// about it and is merely not offered as something to chat with -- which is also why this does not
/// collide with the family it belongs to, although both are substrings of the same length.
/// </remarks>
public sealed class ModerationModelsFamily : ModelFamily
{
/// <inheritdoc />
public override ModelVendor Vendor => ModelVendor.UNKNOWN;
/// <inheritdoc />
public override ModelSource Source => new("https://platform.openai.com/docs/guides/moderation", new DateOnly(2026, 9, 12), "Ported unchanged from the moderation markers of Provider/ModelKindExtensions.cs.");
/// <inheritdoc />
protected override void Declare(ModelFamilyBuilder builder)
{
builder.Modifier("moderation").AsSubstring().Kind(ModelKind.MODERATION);
builder.Modifier("guard").AsSubstring().Inherits();
}
}

View File

@ -0,0 +1,32 @@
using AIStudio.Provider;
namespace AIStudio.Models.Kinds;
/// <summary>
/// The entries a models endpoint lists which are no models.
/// </summary>
/// <remarks>
/// OpenAI lists its code interpreter's container resource among the models. Talking to it gets an
/// error, so it must not appear in any list the app shows -- and whatever else such a name might
/// suggest, none of the other kinds applies to it. That is why it outranks every one of them
/// instead of competing on the length of a word.
/// </remarks>
public sealed class NotAModelFamily : ModelFamily
{
/// <summary>
/// Why this outranks every other statement about a kind.
/// </summary>
private const string THERE_IS_NO_MODEL_TO_CLASSIFY = "An entry which is no model cannot be a model of some kind. Whatever else its name carries is beside the point, so no other statement may outweigh this one.";
/// <inheritdoc />
public override ModelVendor Vendor => ModelVendor.UNKNOWN;
/// <inheritdoc />
public override ModelSource Source => new("https://platform.openai.com/docs/api-reference/containers", new DateOnly(2026, 9, 12), "Ported from the marker of Provider/ModelKindExtensions.cs which was checked before all others, written as a name part rather than as a substring so that a containerized model keeps its kind.");
/// <inheritdoc />
protected override void Declare(ModelFamilyBuilder builder) =>
builder.Modifier("container").AsSegment()
.Rank(2, THERE_IS_NO_MODEL_TO_CLASSIFY)
.Kind(ModelKind.OTHER);
}

View File

@ -0,0 +1,23 @@
using AIStudio.Provider;
namespace AIStudio.Models.Kinds;
/// <summary>
/// The models which read text off a page.
/// </summary>
/// <remarks>
/// A document goes in and its text comes out. There is no conversation in them, so they answer a
/// chat completion request with an error rather than with a reply.
/// </remarks>
public sealed class OcrModelsFamily : ModelFamily
{
/// <inheritdoc />
public override ModelVendor Vendor => ModelVendor.UNKNOWN;
/// <inheritdoc />
public override ModelSource Source => new("https://docs.mistral.ai/capabilities/OCR/basic_ocr/", new DateOnly(2026, 9, 12), "Ported unchanged from the OCR marker of Provider/ModelKindExtensions.cs.");
/// <inheritdoc />
protected override void Declare(ModelFamilyBuilder builder) =>
builder.Modifier("ocr").AsSubstring().Kind(ModelKind.OCR);
}

View File

@ -0,0 +1,42 @@
using AIStudio.Provider;
namespace AIStudio.Models.Kinds;
/// <summary>
/// The models which hold a spoken conversation over a live connection.
/// </summary>
/// <remarks>
/// They speak a protocol of their own, usually a WebSocket, and answer a chat completion request
/// with an error. Their names are built out of the models they grew from -- gpt-4o-realtime-preview,
/// gpt-realtime-mini -- so a name of this kind regularly carries a word about hearing or speaking as
/// well. Whichever of the two is longer would otherwise decide, and the live connection is the part
/// that makes the model unusable for a chat.
/// </remarks>
public sealed class RealtimeModelsFamily : ModelFamily
{
/// <summary>
/// Why this outranks what a name says about hearing or speaking.
/// </summary>
private const string THE_CONNECTION_DECIDES = "These names are built from the transcription and audio models they grew out of, so those markers match them too. The live connection is what rules out a chat, no matter what else the name says.";
/// <inheritdoc />
public override ModelVendor Vendor => ModelVendor.UNKNOWN;
/// <inheritdoc />
public override ModelSource Source => new("https://developers.openai.com/api/docs/models/gpt-live-1", new DateOnly(2026, 9, 12), "Ported from the realtime marker of Provider/ModelKindExtensions.cs, where the same precedence was written as the order of two if statements. GPT-Live was added after it turned up in the chat list while testing.");
/// <inheritdoc />
protected override void Declare(ModelFamilyBuilder builder)
{
builder.Modifier("realtime").AsSubstring()
.Rank(1, THE_CONNECTION_DECIDES)
.Kind(ModelKind.REALTIME);
//
// The line which dropped the word. GPT-Live listens and speaks at the same time and leaves
// the thinking to a text model behind it, so there is even less of a conversation in it than
// in the realtime models it succeeds -- and nothing in the name says so any more.
//
builder.Modifier("gpt-live").AsSegment().Inherits();
}
}

View File

@ -0,0 +1,34 @@
using AIStudio.Provider;
namespace AIStudio.Models.Kinds;
/// <summary>
/// The models which put search results back into order.
/// </summary>
/// <remarks>
/// A reranker is almost always named after the embedding model it belongs to: bge-reranker sits
/// next to bge, gte-multilingual-reranker next to gte, Qwen3-VL-Reranker next to Qwen3-VL-Embedding.
/// So nearly every one of these names carries an embedding marker as well, and the computed
/// specificity has no way of knowing which of the two statements is the one about the model itself.
/// This is the one place where the order of asking is the knowledge, which is what the explicit rank
/// is for.
/// </remarks>
public sealed class RerankingModelsFamily : ModelFamily
{
/// <summary>
/// Why this outranks every statement about embedding models.
/// </summary>
private const string NAMED_AFTER_THE_EMBEDDING_MODEL = "A reranker carries the name of the embedding model it reorders for, so the embedding markers match it too. Which of them is right cannot be worked out of the text.";
/// <inheritdoc />
public override ModelVendor Vendor => ModelVendor.UNKNOWN;
/// <inheritdoc />
public override ModelSource Source => new("https://huggingface.co/models?pipeline_tag=text-ranking", new DateOnly(2026, 9, 12), "Ported from the reranking markers of Provider/ModelKindExtensions.cs, where the same precedence was written as the order of two if statements.");
/// <inheritdoc />
protected override void Declare(ModelFamilyBuilder builder) =>
builder.Modifier("rerank").AsSubstring()
.Rank(1, NAMED_AFTER_THE_EMBEDDING_MODEL)
.Kind(ModelKind.RERANKING);
}

View File

@ -0,0 +1,38 @@
using AIStudio.Provider;
namespace AIStudio.Models.Kinds;
/// <summary>
/// The models which speak.
/// </summary>
/// <remarks>
/// Besides the pure text-to-speech models this covers the ones which answer in audio, such as
/// gpt-audio and gpt-4o-audio-preview. Those do accept a text-only request, but they are made for
/// spoken conversations, and the providers offering them keep them out of their chat model lists as
/// well.
///
/// All three words are stated as name parts. The markers they replace carried a hyphen on one side
/// to say the same thing, which caught one name these do not: Coqui's XTTS glues the word to an x.
/// It is named outright rather than loosening all three into substrings, where "tts" would be three
/// characters claiming every name that happens to contain them.
/// </remarks>
public sealed class SpeechSynthesisModelsFamily : ModelFamily
{
/// <inheritdoc />
public override ModelVendor Vendor => ModelVendor.UNKNOWN;
/// <inheritdoc />
public override ModelSource Source => new("https://huggingface.co/models?pipeline_tag=text-to-speech", new DateOnly(2026, 9, 12), "Ported from the speech synthesis markers of Provider/ModelKindExtensions.cs, where each of the three was written twice to allow for a separator on either side.");
/// <inheritdoc />
protected override void Declare(ModelFamilyBuilder builder)
{
builder.Modifier("tts").AsSegment().Kind(ModelKind.SPEECH_SYNTHESIS);
builder.Modifier("xtts").AsSegment().Inherits();
builder.Modifier("speech").AsSegment().Inherits();
builder.Modifier("audio").AsSegment().Inherits();
}
}

View File

@ -0,0 +1,36 @@
using AIStudio.Provider;
namespace AIStudio.Models.Kinds;
/// <summary>
/// The models from before chat completions existed.
/// </summary>
/// <remarks>
/// Providers keep offering some of them -- Helmholtz Blablador still reports text-davinci-003 --
/// but asking any of them for a chat completion fails. They only answer through the completions
/// endpoint, which the app does not speak, so they must not stand among the chat models.
///
/// "ada" is deliberately not among these names: three letters appear in far too many unrelated ones,
/// and losing a chat model weighs heavier than keeping a dead one in the list.
/// </remarks>
public sealed class TextCompletionModelsFamily : ModelFamily
{
/// <inheritdoc />
public override ModelVendor Vendor => ModelVendor.UNKNOWN;
/// <inheritdoc />
public override ModelSource Source => new("https://platform.openai.com/docs/api-reference/completions", new DateOnly(2026, 9, 12), "Ported unchanged from the text completion markers of Provider/ModelKindExtensions.cs.");
/// <inheritdoc />
protected override void Declare(ModelFamilyBuilder builder)
{
builder.Modifier("davinci").AsSubstring().Kind(ModelKind.TEXT_COMPLETION);
builder.Modifier("babbage").AsSubstring().Inherits();
builder.Modifier("curie").AsSubstring().Inherits();
// The one model of the 3.5 line which never learned to chat, next to the ones which did:
builder.Modifier("gpt-3.5-turbo-instruct").AsSegment().Inherits();
}
}

View File

@ -0,0 +1,31 @@
using AIStudio.Provider;
namespace AIStudio.Models.Kinds;
/// <summary>
/// The models which listen and write down what they heard.
/// </summary>
/// <remarks>
/// Whisper and Voxtral are missing here on purpose: both have a family of their own, where the
/// statement that they transcribe stands next to what they can do. Repeating it here would be a
/// second place to keep it right.
/// </remarks>
public sealed class TranscriptionModelsFamily : ModelFamily
{
/// <inheritdoc />
public override ModelVendor Vendor => ModelVendor.UNKNOWN;
/// <inheritdoc />
public override ModelSource Source => new("https://huggingface.co/models?pipeline_tag=automatic-speech-recognition", new DateOnly(2026, 9, 12), "Ported from the transcription markers of Provider/ModelKindExtensions.cs, minus the two which their own families now state.");
/// <inheritdoc />
protected override void Declare(ModelFamilyBuilder builder)
{
// OpenAI appends it to the model it grew out of: gpt-4o-transcribe, gpt-4o-mini-transcribe.
builder.Modifier("transcribe").AsSegment().Kind(ModelKind.TRANSCRIPTION);
builder.Modifier("wav2vec").AsSubstring().Inherits();
builder.Modifier("parakeet").AsSubstring().Inherits();
}
}

View File

@ -0,0 +1,39 @@
using AIStudio.Provider;
namespace AIStudio.Models.Kinds;
/// <summary>
/// The models which make video.
/// </summary>
/// <remarks>
/// Two of these names have to stand as a name part of their own. "kling" taken as a plain substring
/// also matches the organization Klingspor, the model Inkling, and the fine-tune
/// Llama-2-7b-chat-klingon -- all of them models to chat with, which would vanish from the user's
/// list. The models themselves are called kling-v1 and kling-video, where the name ends at a
/// separator. Google's veo is the same story with an even shorter word.
/// </remarks>
public sealed class VideoGenerationModelsFamily : ModelFamily
{
/// <inheritdoc />
public override ModelVendor Vendor => ModelVendor.UNKNOWN;
/// <inheritdoc />
public override ModelSource Source => new("https://huggingface.co/models?pipeline_tag=text-to-video", new DateOnly(2026, 9, 12), "Ported from the video generation markers of Provider/ModelKindExtensions.cs, where veo carried a trailing hyphen to say the same thing a name part says here. Grok Imagine was added after it turned up in the chat list while testing; see https://docs.x.ai/docs/models.");
/// <inheritdoc />
protected override void Declare(ModelFamilyBuilder builder)
{
builder.Modifier("sora").AsSubstring().Kind(ModelKind.VIDEO_GENERATION);
builder.Modifier("runway").AsSubstring().Inherits();
builder.Modifier("hailuo").AsSubstring().Inherits();
builder.Modifier("veo").AsSegment().Inherits();
builder.Modifier("kling").AsSegment().Inherits();
// Grok Imagine makes both stills and film; the word next to it says which:
builder.Modifier("grok-imagine").AsSegment().AlsoContains("video").Inherits();
}
}

View File

@ -0,0 +1,86 @@
using System.Collections.Concurrent;
using System.Collections.Frozen;
namespace AIStudio.Models.Live;
/// <summary>
/// What the configured providers last said about the models they serve.
/// </summary>
/// <remarks>
/// One snapshot per configured provider instance, and reporting replaces the snapshot rather than
/// adding to it. That is the same reason the registry replaces what the plugins declare: a model an
/// installation no longer serves has to stop answering, and a window somebody halved by restarting
/// their engine must not go on being reported alongside its correction.
///
/// Nothing here is written to disk. These are statements about a machine as it is running right
/// now, and the app asks that machine again before every chat round anyway. An instance somebody
/// deleted keeps its snapshot until the app is closed -- a few dozen kilobytes at the very worst,
/// which is not worth a second mechanism to watch the settings for.
/// </remarks>
public sealed class ListedModels
{
/// <summary>
/// The one the app reports into and asks.
/// </summary>
public static ListedModels Shared { get; } = new();
/// <summary>
/// Per configured provider instance, what its model list said about each model.
/// </summary>
/// <remarks>
/// Both keys ignore case. The IDs come back from the same list they were stored under, so
/// ordinal would do -- but a model an organization wrote into a configuration plugin by hand
/// was typed by a person, and the availability check already treats such a name as the same
/// model regardless of case. Being stricter here would leave exactly those people without the
/// numbers.
/// </remarks>
private readonly ConcurrentDictionary<string, FrozenDictionary<string, ModelListing>> byProvider = new(StringComparer.OrdinalIgnoreCase);
/// <summary>
/// Takes over what one provider instance said about its models, replacing what it said before.
/// </summary>
/// <remarks>
/// Only ever call this with a whole list in hand. Reporting a filtered part of one would tell
/// this instance that everything left out has stopped existing.
/// </remarks>
/// <param name="configuredProviderId">The instance that was asked. Nothing happens without one.</param>
/// <param name="listings">What its list stated, with the models it stated nothing about left in or out as convenient.</param>
public void Report(string configuredProviderId, IEnumerable<ModelListing> listings)
{
//
// A provider instance nobody has configured yet is not a machine we could ask again later,
// so there is nothing to remember it by. The provider dialog is not such a case: it works
// on a fully built instance from the moment it opens, ID included.
//
if (string.IsNullOrWhiteSpace(configuredProviderId))
return;
var stated = new Dictionary<string, ModelListing>(StringComparer.OrdinalIgnoreCase);
foreach (var listing in listings)
{
if (string.IsNullOrWhiteSpace(listing.ModelId) || !listing.IsKnown)
continue;
stated[listing.ModelId] = listing;
}
this.byProvider[configuredProviderId] = stated.ToFrozenDictionary(StringComparer.OrdinalIgnoreCase);
}
/// <summary>
/// What one provider instance said about one of its models.
/// </summary>
/// <param name="configuredProviderId">The instance serving the model.</param>
/// <param name="modelId">The model, named the way that instance names it.</param>
/// <returns>What it stated, which is nothing when it was never asked or said nothing.</returns>
public ModelListing Of(string configuredProviderId, string modelId)
{
if (string.IsNullOrWhiteSpace(configuredProviderId) || string.IsNullOrWhiteSpace(modelId))
return ModelListing.NOTHING;
if (!this.byProvider.TryGetValue(configuredProviderId, out var stated))
return ModelListing.NOTHING;
return stated.TryGetValue(modelId, out var listing) ? listing : ModelListing.NOTHING;
}
}

View File

@ -0,0 +1,60 @@
namespace AIStudio.Models.Live;
/// <summary>
/// What a provider's own model list says about one of the models it serves.
/// </summary>
/// <remarks>
/// That list is fetched anyway: before every chat round, before every assistant run, and whenever
/// somebody opens the provider dialog. Reading what it already carries therefore costs no request
/// of its own, which is the whole reason these numbers are taken from here and not asked for.
///
/// This describes one installation, never the model as such. Two machines may serve the same
/// weights behind different settings, and a statement about one of them says nothing about the
/// other -- which is why a listing is kept per configured provider instance and is gone with the
/// process. It is also the only source for a self-hosted model: a rule can say what the weights
/// were trained for, but only the engine knows what its operator started it with.
/// </remarks>
/// <param name="ModelId">The model, named the way the provider names it in its list.</param>
/// <param name="Context">The window the provider states for it, or unknown where it states none.</param>
public readonly record struct ModelListing(string ModelId, ContextWindow Context)
{
/// <summary>
/// What we have about a model nobody has reported anything about.
/// </summary>
public static readonly ModelListing NOTHING = new(string.Empty, ContextWindow.UNKNOWN);
/// <summary>
/// Whether this listing states anything at all.
/// </summary>
public bool IsKnown => this.Context.IsKnown;
/// <summary>
/// What a provider stated about one model, as every model list states it: a name and a number.
/// </summary>
/// <remarks>
/// A window of zero or less is dropped rather than repaired, and so is a nameless entry. A
/// provider answering that way is saying something we cannot interpret, and falling back to
/// what the rules say about the model is the one answer nobody has to invent. Every dialect
/// comes through here, so that none of them has to decide that on its own.
/// </remarks>
/// <param name="modelId">The model, named the way the provider names it.</param>
/// <param name="contextWindowTokens">The window the provider stated, where it stated one.</param>
/// <returns>The listing, or nothing when there is nothing usable to keep.</returns>
public static ModelListing For(string modelId, int? contextWindowTokens) => string.IsNullOrWhiteSpace(modelId) || contextWindowTokens is not > 0
? NOTHING
: new(modelId, ContextWindow.Of(contextWindowTokens.Value));
/// <summary>
/// Puts what the provider stated over what the rules worked out.
/// </summary>
/// <remarks>
/// A stated window replaces the whole window, the ceiling included, for the same reason the
/// expert settings do: what a model card says it could be raised to is a statement about the
/// model, while this is a statement about the installation serving it. Whoever started that
/// engine has already decided, and a ceiling nobody can reach without restarting it is not a
/// number to keep showing.
/// </remarks>
/// <param name="profile">What is known about the model without this listing.</param>
/// <returns>The profile, with what the provider stated in it.</returns>
public ModelProfile ApplyTo(in ModelProfile profile) => this.IsKnown ? profile with { Context = this.Context } : profile;
}

View File

@ -0,0 +1,42 @@
namespace AIStudio.Models.Matching;
/// <summary>
/// How tightly a pattern is bound to the name it matches.
/// </summary>
/// <remarks>
/// This is the first thing that decides which of two rules wins, and it is ordered by how much the
/// pattern claims to know: naming the whole model says more than naming how the name begins, which
/// says more than naming a part of it, which says more than appearing somewhere inside it.
/// </remarks>
public enum MatchKind
{
/// <summary>
/// The pattern is the whole name.
/// </summary>
EXACT,
/// <summary>
/// The name begins with the pattern, and a name part ends where the pattern ends.
/// </summary>
PREFIX,
/// <summary>
/// The pattern appears in the name as one or more whole name parts.
/// </summary>
/// <remarks>
/// This is the one to reach for by default. It is what the old rules meant when they said that
/// a family name counts "only where a name part begins", so that looking for the Yi family does
/// not answer for every model whose name happens to contain those two letters.
/// </remarks>
SEGMENT,
/// <summary>
/// The pattern appears anywhere in the name, boundaries or not.
/// </summary>
/// <remarks>
/// The last resort, for the names where a vendor glues things together, such as a version
/// number sitting inside a name part. It claims the least and therefore loses against every
/// other kind, which is what keeps it from swallowing families it was never meant for.
/// </remarks>
SUBSTRING,
}

View File

@ -0,0 +1,165 @@
using AIStudio.Provider;
namespace AIStudio.Models.Matching;
/// <summary>
/// What a rule says about the names it answers for.
/// </summary>
/// <remarks>
/// A pattern is written in the normalized form a model name is brought into: lowercase, hyphens
/// between the parts, dots kept. A pattern which is not in that form can never match anything, so
/// it is a mistake rather than a rule which happens to be quiet.
///
/// The extra conditions and the bindings are not only there to narrow a pattern down. They also
/// make it more specific, which is how a rule earns the right to win against a shorter one without
/// anybody writing an order.
/// </remarks>
public sealed record MatchPattern
{
/// <summary>
/// How tightly the text is bound to the name.
/// </summary>
public required MatchKind Kind { get; init; }
/// <summary>
/// The text to look for, in normalized form.
/// </summary>
public required string Text { get; init; }
/// <summary>
/// Name parts which have to be present as well.
/// </summary>
/// <remarks>
/// Each one is looked for as a whole name part, the same way the SEGMENT kind looks for its
/// text. Writing a hyphen into one of these is therefore both unnecessary and impossible: it
/// would not be a normalized pattern any more.
/// </remarks>
public IReadOnlyList<string> AlsoContains { get; init; } = [];
/// <summary>
/// Name parts whose presence rules this pattern out.
/// </summary>
public IReadOnlyList<string> NotContains { get; init; } = [];
/// <summary>
/// The provider this rule is written for, or null when it holds anywhere.
/// </summary>
/// <remarks>
/// This is what settles the cases where one name means two models depending on who serves it.
/// On Alibaba, "qwq" is qwq-plus, a commercial model; everywhere else it is the open weights
/// built on Qwen 2.5. Two rules, one of them bound.
/// </remarks>
public LLMProviders? OnlyOn { get; init; }
/// <summary>
/// The vendor this rule is written for, or null when it holds for any.
/// </summary>
/// <remarks>
/// A gateway which unwraps "anthropic/claude-sonnet-4-0" knows who built the model, and a rule
/// may insist on that instead of trusting a name.
/// </remarks>
public ModelVendor? OnlyFrom { get; init; }
/// <summary>
/// Moves this rule ahead of, or behind, everything the computed specificity would decide.
/// </summary>
/// <remarks>
/// The emergency exit, and it is meant to stay unused: the whole point of computing specificity
/// is that nobody writes an order by hand any more. A rule which sets this needs a comment
/// saying what the computation gets wrong, because the next person will read the rank as noise
/// otherwise. Negative values push a rule back.
/// </remarks>
public int ExplicitRank { get; init; }
/// <summary>
/// Whether every text of this pattern is written in normalized form.
/// </summary>
/// <remarks>
/// Normalizing is idempotent, so a text is normalized exactly when normalizing does not change
/// it. The compile time rule checks the same thing; this is what the tests and the verification
/// run use, and what catches a pattern which arrived from a plugin rather than from source.
/// </remarks>
public bool IsWellFormed => IsNormalized(this.Text) && this.AlsoContains.All(IsNormalized) && this.NotContains.All(IsNormalized);
/// <summary>
/// Whether this pattern answers for the given model.
/// </summary>
/// <param name="id">The model name, already normalized.</param>
/// <param name="provider">Who serves the model.</param>
/// <param name="vendor">Who built it, as far as anybody knows.</param>
/// <returns>True, when the rule applies.</returns>
public bool Matches(in ModelId id, LLMProviders provider, ModelVendor vendor)
{
if (this.OnlyOn is not null && this.OnlyOn.Value != provider)
return false;
if (this.OnlyFrom is not null && this.OnlyFrom.Value != vendor)
return false;
if (!this.MatchesText(id))
return false;
foreach (var required in this.AlsoContains)
if (!id.ContainsSegments(required))
return false;
foreach (var forbidden in this.NotContains)
if (id.ContainsSegments(forbidden))
return false;
return true;
}
/// <summary>
/// The name part the index files this pattern under, or an empty span when it cannot file it.
/// </summary>
/// <remarks>
/// A pattern which is bound to the start of a name, or to whole name parts, always begins at a
/// name part, so the first part of the pattern has to appear as a part of any name it matches.
/// That is what lets the index skip it for every other name. A substring pattern makes no such
/// promise and has to be checked against every name.
/// </remarks>
/// <returns>The first name part of the pattern, or empty.</returns>
public ReadOnlySpan<char> IndexKey()
{
if (this.Kind is MatchKind.SUBSTRING || string.IsNullOrWhiteSpace(this.Text))
return [];
var text = this.Text.AsSpan();
var separator = text.IndexOf(ModelId.SEGMENT_SEPARATOR);
return separator is -1 ? text : text[..separator];
}
/// <summary>
/// Everything about this pattern which decides what it matches, as one line of text.
/// </summary>
/// <remarks>
/// Two patterns with the same signature match exactly the same names, which is how the index
/// finds the rules that collide without having to reason about what a pattern could match. The
/// conditions are sorted, because stating them in a different order states the same thing.
/// </remarks>
/// <returns>The signature.</returns>
public string Signature()
{
var required = string.Join(',', this.AlsoContains.Order(StringComparer.Ordinal));
var forbidden = string.Join(',', this.NotContains.Order(StringComparer.Ordinal));
return $"{this.Kind}|{this.Text}|{this.OnlyOn}|{this.OnlyFrom}|+{required}|-{forbidden}";
}
/// <summary>
/// Whether a text is written the way a normalized model name is written.
/// </summary>
/// <param name="text">The text to check.</param>
/// <returns>True, when normalizing it would change nothing.</returns>
public static bool IsNormalized(string text) => !string.IsNullOrEmpty(text) && string.Equals(new ModelId(text).Normalized, text, StringComparison.Ordinal);
private bool MatchesText(in ModelId id) => this.Kind switch
{
MatchKind.EXACT => id.EqualsText(this.Text),
MatchKind.PREFIX => id.StartsWithSegments(this.Text),
MatchKind.SEGMENT => id.ContainsSegments(this.Text),
MatchKind.SUBSTRING => id.ContainsText(this.Text),
_ => false,
};
}

View File

@ -0,0 +1,234 @@
using System.Collections.Frozen;
using AIStudio.Provider;
namespace AIStudio.Models.Matching;
/// <summary>
/// Answers what is known about a model name, out of all the rules there are.
/// </summary>
/// <remarks>
/// The old rules asked every question in turn: a name arriving at the open weights block walked
/// past more than a hundred string comparisons before anything answered it, and it did so on every
/// render of every component which shows a provider. Here the name is cut into its parts and each
/// part looks up the handful of rules which mention it, so a name is measured against the rules
/// which could possibly apply to it and against nothing else.
///
/// Building the index costs a sort and a dictionary; that happens once. Answering allocates a small
/// list when several rules apply, which is the cold path -- the registry keeps the answers, so the
/// same model is not resolved twice.
///
/// Nothing here reaches for application state. A test can build an index and ask it questions
/// without the app ever having started.
/// </remarks>
public sealed class ModelFamilyIndex
{
private readonly FrozenDictionary<string, ModelRule[]>.AlternateLookup<ReadOnlySpan<char>> byNamePartLookup;
private readonly bool canLookUpNameParts;
private readonly ModelRule[] alwaysChecked;
private ModelFamilyIndex(ModelRule[] rules, FrozenDictionary<string, ModelRule[]> byNamePart, ModelRule[] alwaysChecked, IReadOnlyList<RuleAmbiguity> ambiguities)
{
this.alwaysChecked = alwaysChecked;
this.Rules = rules;
this.Ambiguities = ambiguities;
//
// Looking a name part up as a span rather than as a string is what keeps the lookup free of
// allocations. It needs a comparer which knows how to hash a span, and an index holding no
// rules at all has no comparer to speak of -- there is nothing to look up in that case
// either, so the flag simply skips the walk.
//
this.canLookUpNameParts = byNamePart.TryGetAlternateLookup(out this.byNamePartLookup);
}
/// <summary>
/// Every rule the index was built from, ordered by name.
/// </summary>
public IReadOnlyList<ModelRule> Rules { get; }
/// <summary>
/// Rules which claim exactly the same names as another rule.
/// </summary>
/// <remarks>
/// Found by comparing what the patterns say, which catches the case of two families claiming
/// one name outright. Two patterns which merely happen to overlap on some name cannot be found
/// this way -- deciding that in general is not a question about text any more. Those show up
/// when a name is actually resolved, as tied selectors, which is why the verification run
/// resolves the whole corpus instead of only reading the rules.
/// </remarks>
public IReadOnlyList<RuleAmbiguity> Ambiguities { get; }
/// <summary>
/// Builds an index over a set of rules.
/// </summary>
/// <param name="rules">The rules, in any order. The order they arrive in changes nothing.</param>
/// <returns>The index.</returns>
public static ModelFamilyIndex Build(IEnumerable<ModelRule> rules)
{
//
// Sorting by name, not by specificity: the comparison does the deciding, and a stable order
// is what makes two builds of the same rules produce the same answers, down to which rule
// is reported first in a conflict.
//
var ordered = rules.OrderBy(rule => rule.Description, StringComparer.Ordinal).ToArray();
var buckets = new Dictionary<string, List<ModelRule>>(StringComparer.Ordinal);
var alwaysChecked = new List<ModelRule>();
foreach (var rule in ordered)
{
var namePart = rule.Pattern.IndexKey();
if (namePart.IsEmpty)
{
alwaysChecked.Add(rule);
continue;
}
var key = namePart.ToString();
if (!buckets.TryGetValue(key, out var bucket))
buckets[key] = bucket = [];
bucket.Add(rule);
}
var byNamePart = buckets.ToFrozenDictionary(bucket => bucket.Key, bucket => bucket.Value.ToArray(), StringComparer.Ordinal);
return new(ordered, byNamePart, alwaysChecked.ToArray(), FindAmbiguities(ordered));
}
/// <summary>
/// Says what is known about a model.
/// </summary>
/// <param name="id">The model name.</param>
/// <param name="provider">Who serves the model.</param>
/// <param name="vendor">Who built it, as far as anybody knows.</param>
/// <returns>The profile, which is empty when no rule knows the name.</returns>
public ModelProfile Resolve(in ModelId id, LLMProviders provider, ModelVendor vendor) => this.Explain(id, provider, vendor).Profile;
/// <summary>
/// Says what is known about a model, and which rules said it.
/// </summary>
/// <param name="id">The model name.</param>
/// <param name="provider">Who serves the model.</param>
/// <param name="vendor">Who built it, as far as anybody knows.</param>
/// <returns>The profile together with the rules behind it.</returns>
public ModelResolution Explain(in ModelId id, LLMProviders provider, ModelVendor vendor)
{
if (id.IsEmpty)
return ModelResolution.NOTHING;
var match = new Match();
Consider(this.alwaysChecked, id, provider, vendor, ref match);
if (this.canLookUpNameParts)
foreach (var namePart in id.Segments)
if (this.byNamePartLookup.TryGetValue(namePart, out var candidates))
Consider(candidates, id, provider, vendor, ref match);
//
// Least specific first, so that the rule saying the most about this name has the last word.
// Sorting a list is not stable, so equally specific modifiers are ordered by name: applying
// them in a different order could otherwise produce a different profile on another machine.
//
match.Modifiers?.Sort(static (left, right) =>
{
var order = left.Specificity.CompareTo(right.Specificity);
return order is not 0 ? order : string.CompareOrdinal(left.Description, right.Description);
});
var profile = match.Selector?.Change.ApplyTo(ModelProfile.UNKNOWN) ?? ModelProfile.UNKNOWN;
if (match.Modifiers is not null)
foreach (var modifier in match.Modifiers)
profile = modifier.Change.ApplyTo(profile);
return new(profile, match.Selector, match.Modifiers ?? [], match.TiedSelectors ?? []);
}
private static void Consider(ModelRule[] candidates, in ModelId id, LLMProviders provider, ModelVendor vendor, ref Match match)
{
foreach (var rule in candidates)
{
if (!rule.Pattern.Matches(id, provider, vendor))
continue;
if (rule.Kind is ModelRuleKind.MODIFIER)
{
//
// A rule can be reached twice when a name repeats one of its parts. Applying a
// modifier twice would change nothing, but reporting it twice would read as if two
// rules had spoken.
//
match.Modifiers ??= [];
if (!match.Modifiers.Contains(rule))
match.Modifiers.Add(rule);
continue;
}
if (match.Selector is null)
{
match.Selector = rule;
continue;
}
if (ReferenceEquals(match.Selector, rule))
continue;
var order = rule.Specificity.CompareTo(match.Selector.Specificity);
if (order > 0)
{
match.Selector = rule;
match.TiedSelectors = null;
continue;
}
if (order < 0)
continue;
//
// Both rules claim the name with the same right, which the rules should not allow. The
// answer still has to be the same one on every machine and in every build, so the name
// of the rule decides rather than the order the rules arrived in.
//
var winner = string.CompareOrdinal(rule.Description, match.Selector.Description) < 0 ? rule : match.Selector;
var loser = ReferenceEquals(winner, rule) ? match.Selector : rule;
match.Selector = winner;
(match.TiedSelectors ??= []).Add(loser);
}
}
private static IReadOnlyList<RuleAmbiguity> FindAmbiguities(IReadOnlyList<ModelRule> rules)
{
var ambiguities = new List<RuleAmbiguity>();
var claimed = new Dictionary<string, ModelRule>(StringComparer.Ordinal);
foreach (var rule in rules)
{
if (rule.Kind is not ModelRuleKind.SELECTOR)
continue;
var signature = rule.Pattern.Signature();
if (claimed.TryGetValue(signature, out var other))
{
ambiguities.Add(new(other, rule, "Two selectors claim exactly the same model names."));
continue;
}
claimed[signature] = rule;
}
return ambiguities;
}
/// <summary>
/// What the walk over the candidate rules has found so far.
/// </summary>
private struct Match
{
public ModelRule? Selector;
public List<ModelRule>? TiedSelectors;
public List<ModelRule>? Modifiers;
}
}

View File

@ -0,0 +1,178 @@
namespace AIStudio.Models.Matching;
/// <summary>
/// A model ID in the form the rules are written in, next to the form the provider reported.
/// </summary>
/// <remarks>
/// Every provider names the same model differently, and the difference is rarely in the words: it
/// is in what sits between them. Ollama separates the variant with a colon ("qwen3.8:27b-mlx"),
/// Blablador answers with a whole sentence ("10 - Muse Glimmer 30b - the newest META model"),
/// Fireworks puts a path in front ("accounts/fireworks/models/llama-v3p1-405b-instruct"), and the
/// hubs use hyphens. Normalizing once, here, is what lets a rule be written once.
///
/// The dots stay. They carry the version boundary: llama3 and llama3.1 are different models, and
/// only the latter calls functions. Dropping them would merge the two. A hyphen, on the other hand,
/// is where one part of a name ends and the next begins -- which is why the patterns can say "at a
/// name part" and mean something.
/// </remarks>
/// <param name="modelId">The model ID as the provider reports it.</param>
public readonly struct ModelId(string modelId) : IEquatable<ModelId>
{
/// <summary>
/// What separates two parts of a normalized name.
/// </summary>
public const char SEGMENT_SEPARATOR = '-';
/// <summary>
/// The longest model ID we normalize without going to the heap.
/// </summary>
private const int MAX_STACK_ALLOCATED_MODEL_ID_LENGTH = 256;
private readonly string normalizedId = Normalize(modelId);
/// <summary>
/// The ID exactly as the provider reported it. This is what a person sees.
/// </summary>
public string Original => modelId ?? string.Empty;
/// <summary>
/// The ID in lowercase, with every separator written as a single hyphen.
/// </summary>
public string Normalized => this.normalizedId ?? string.Empty;
/// <summary>
/// Whether there is nothing here to match against.
/// </summary>
public bool IsEmpty => string.IsNullOrEmpty(this.normalizedId);
/// <summary>
/// The parts of the name, in order, without allocating anything.
/// </summary>
public ModelIdSegments Segments => new(this.Normalized.AsSpan());
/// <summary>
/// Whether the whole name is exactly this text.
/// </summary>
/// <param name="text">The text to compare against, already normalized.</param>
/// <returns>True, when the name and the text are the same.</returns>
public bool EqualsText(ReadOnlySpan<char> text) => !text.IsEmpty && this.Normalized.AsSpan().SequenceEqual(text);
/// <summary>
/// Whether the name begins with this text and a name part ends there.
/// </summary>
/// <remarks>
/// The boundary is what keeps "gpt-5" away from "gpt-55", and what keeps it away from "gpt-5.1"
/// as well: a dot is a version boundary, not a name part boundary, so those are two models and
/// a rule for one of them does not answer for the other.
/// </remarks>
/// <param name="text">The text to look for, already normalized.</param>
/// <returns>True, when the name starts with the text.</returns>
public bool StartsWithSegments(ReadOnlySpan<char> text)
{
if (text.IsEmpty)
return false;
var name = this.Normalized.AsSpan();
return name.StartsWith(text) && IsBoundaryAt(name, text.Length);
}
/// <summary>
/// Whether this text appears in the name as one or more whole name parts.
/// </summary>
/// <param name="text">The text to look for, already normalized.</param>
/// <returns>True, when the text sits between two name part boundaries.</returns>
public bool ContainsSegments(ReadOnlySpan<char> text)
{
if (text.IsEmpty)
return false;
var name = this.Normalized.AsSpan();
var searchedUpTo = 0;
while (searchedUpTo <= name.Length - text.Length)
{
var offset = name[searchedUpTo..].IndexOf(text);
if (offset is -1)
return false;
var start = searchedUpTo + offset;
if (IsBoundaryAt(name, start - 1) && IsBoundaryAt(name, start + text.Length))
return true;
// The same text may appear again further on, at a boundary this time:
searchedUpTo = start + 1;
}
return false;
}
/// <summary>
/// Whether this text appears anywhere in the name, boundaries or not.
/// </summary>
/// <param name="text">The text to look for, already normalized.</param>
/// <returns>True, when the name contains the text.</returns>
public bool ContainsText(ReadOnlySpan<char> text) => !text.IsEmpty && this.Normalized.AsSpan().IndexOf(text) is not -1;
public bool Equals(ModelId other) => string.Equals(this.Normalized, other.Normalized, StringComparison.Ordinal);
public override bool Equals(object? obj) => obj is ModelId other && this.Equals(other);
public override int GetHashCode() => StringComparer.Ordinal.GetHashCode(this.Normalized);
public override string ToString() => this.Original;
/// <summary>
/// Whether a name part begins or ends at this position.
/// </summary>
/// <remarks>
/// Positions outside the name count: the start of the name and its end are boundaries, which is
/// what makes a one part name match a rule written for that part.
/// </remarks>
/// <param name="name">The normalized name.</param>
/// <param name="index">The position to look at, which may be outside the name.</param>
/// <returns>True, when there is a boundary at this position.</returns>
private static bool IsBoundaryAt(ReadOnlySpan<char> name, int index) => index < 0 || index >= name.Length || name[index] is SEGMENT_SEPARATOR;
/// <summary>
/// Brings a model ID into the form the capability rules are written in.
/// </summary>
/// <param name="modelId">The model ID as the provider reports it, which may be nothing at all.</param>
/// <returns>The model ID in lowercase, with every separator written as a single hyphen.</returns>
private static string Normalize(string? modelId)
{
if (string.IsNullOrWhiteSpace(modelId))
return string.Empty;
//
// Normalizing never makes a name longer, so the original length is always enough room.
// Model IDs are short, which is why the buffer lives on the stack: the longest ones we
// know of are the descriptive names Blablador answers with, at around 75 characters. A
// provider reporting something longer still gets a correct answer, just from the heap.
//
Span<char> normalized = modelId.Length <= MAX_STACK_ALLOCATED_MODEL_ID_LENGTH
? stackalloc char[modelId.Length]
: new char[modelId.Length];
var length = 0;
foreach (var character in modelId)
{
if (char.IsAsciiLetterOrDigit(character) || character is '.')
{
normalized[length++] = char.ToLowerInvariant(character);
continue;
}
// Anything else separates two parts of the name. A leading separator, and a repeated
// one, say nothing and would only get in the way of the patterns:
if (length is 0 || normalized[length - 1] is SEGMENT_SEPARATOR)
continue;
normalized[length++] = SEGMENT_SEPARATOR;
}
// A trailing separator carries no meaning either:
if (length > 0 && normalized[length - 1] is SEGMENT_SEPARATOR)
length--;
return new string(normalized[..length]);
}
}

View File

@ -0,0 +1,57 @@
namespace AIStudio.Models.Matching;
/// <summary>
/// Walks the parts of a normalized model name without cutting it into strings.
/// </summary>
/// <remarks>
/// The index looks up every part of a name to find the rules which could possibly apply to it. That
/// happens for every model of every configured provider, so the walk itself must not allocate: the
/// parts stay slices of the name they came from. This is both the enumerable and the enumerator,
/// which is what lets foreach use it without an interface in between.
/// </remarks>
/// <param name="normalizedId">The normalized model name to walk.</param>
public ref struct ModelIdSegments(ReadOnlySpan<char> normalizedId)
{
private ReadOnlySpan<char> remaining = normalizedId;
/// <summary>
/// The part the walk currently stands on.
/// </summary>
public ReadOnlySpan<char> Current { get; private set; } = default;
/// <summary>
/// Hands foreach the walk itself.
/// </summary>
/// <returns>This walk, at its beginning.</returns>
public readonly ModelIdSegments GetEnumerator() => this;
/// <summary>
/// Steps to the next part of the name.
/// </summary>
/// <returns>True, as long as there was one.</returns>
public bool MoveNext()
{
while (!this.remaining.IsEmpty)
{
var separator = this.remaining.IndexOf(ModelId.SEGMENT_SEPARATOR);
if (separator is -1)
{
this.Current = this.remaining;
this.remaining = default;
return true;
}
this.Current = this.remaining[..separator];
this.remaining = this.remaining[(separator + 1)..];
//
// Normalizing leaves no empty part behind, so this only guards against a name which
// never went through it. Skipping is the right answer: an empty part matches nothing.
//
if (!this.Current.IsEmpty)
return true;
}
return false;
}
}

View File

@ -0,0 +1,37 @@
namespace AIStudio.Models.Matching;
/// <summary>
/// What the index made of one model name, and how it got there.
/// </summary>
/// <remarks>
/// The profile alone is what the app asks for. The rest is for the people maintaining the rules:
/// which rule answered, what adjusted the answer afterwards, and whether two rules claimed the name
/// with the same right. The verification run reads all of it; a test that wants to know why a model
/// came out the way it did reads it too.
/// </remarks>
/// <param name="Profile">Everything known about the model.</param>
/// <param name="Selector">The rule which chose the model, or null when no rule knows the name.</param>
/// <param name="Modifiers">The rules which adjusted the answer, in the order they were applied.</param>
/// <param name="TiedSelectors">Rules which claimed the name just as strongly as the selector did.</param>
public sealed record ModelResolution(ModelProfile Profile, ModelRule? Selector, IReadOnlyList<ModelRule> Modifiers, IReadOnlyList<ModelRule> TiedSelectors)
{
/// <summary>
/// The answer for a name no rule was even asked about.
/// </summary>
public static readonly ModelResolution NOTHING = new(ModelProfile.UNKNOWN, null, [], []);
/// <summary>
/// Whether more than one rule claimed this name with the same specificity.
/// </summary>
/// <remarks>
/// Always a mistake in the rules. The answer is still the same one every time, so a build never
/// depends on the order the rules were registered in, but which of the two was meant is
/// something only a person can say.
/// </remarks>
public bool IsAmbiguous => this.TiedSelectors.Count > 0;
/// <summary>
/// Whether any rule at all knew this name.
/// </summary>
public bool IsKnown => this.Selector is not null;
}

View File

@ -0,0 +1,43 @@
namespace AIStudio.Models.Matching;
/// <summary>
/// One statement about a set of model names: which names, and what holds for them.
/// </summary>
/// <param name="pattern">Which names this rule answers for.</param>
/// <param name="kind">Whether the rule chooses the model or adjusts the choice.</param>
/// <param name="change">What the rule states.</param>
/// <param name="origin">Who wrote the rule, so that a conflict can name both sides.</param>
public sealed class ModelRule(MatchPattern pattern, ModelRuleKind kind, ModelProfileChange change, string origin)
{
/// <summary>
/// Which names this rule answers for.
/// </summary>
public MatchPattern Pattern { get; } = pattern;
/// <summary>
/// Whether the rule chooses the model or adjusts the choice.
/// </summary>
public ModelRuleKind Kind { get; } = kind;
/// <summary>
/// What the rule states.
/// </summary>
public ModelProfileChange Change { get; } = change;
/// <summary>
/// Who wrote the rule: a family, a host, or a plugin.
/// </summary>
public string Origin { get; } = origin;
/// <summary>
/// How much this rule claims to know, worked out once when the rule is built.
/// </summary>
public RuleSpecificity Specificity { get; } = RuleSpecificity.Of(pattern);
/// <summary>
/// Names the rule in one line, for conflict reports and for breaking ties the same way twice.
/// </summary>
public string Description { get; } = $"{origin}: {kind} {pattern.Kind} \"{pattern.Text}\"";
public override string ToString() => this.Description;
}

View File

@ -0,0 +1,24 @@
namespace AIStudio.Models.Matching;
/// <summary>
/// What a rule does once it matches.
/// </summary>
public enum ModelRuleKind
{
/// <summary>
/// Chooses which model this is. Exactly one selector wins, the most specific one.
/// </summary>
SELECTOR,
/// <summary>
/// Adjusts whatever the selector chose. Every matching modifier applies.
/// </summary>
/// <remarks>
/// This is for the statements which hold across families, and which every family would
/// otherwise have to repeat: a base checkpoint was never instruction tuned no matter who built
/// it, and a gateway serving somebody else's model cannot offer that vendor's own API. In the
/// old rules those had to sit at the very top of the file, which is why anything below them
/// could not state an exception.
/// </remarks>
MODIFIER,
}

View File

@ -0,0 +1,12 @@
namespace AIStudio.Models.Matching;
/// <summary>
/// Two rules which claim the same names with the same right.
/// </summary>
/// <param name="First">One of the two rules.</param>
/// <param name="Second">The other one.</param>
/// <param name="Reason">What makes them collide, in a sentence a person can act on.</param>
public sealed record RuleAmbiguity(ModelRule First, ModelRule Second, string Reason)
{
public override string ToString() => $"{this.Reason} ({this.First.Description} <-> {this.Second.Description})";
}

View File

@ -0,0 +1,59 @@
namespace AIStudio.Models.Matching;
/// <summary>
/// How much a rule claims to know, computed from the rule itself.
/// </summary>
/// <remarks>
/// This is the heart of the whole rebuild. In the old rules, which branch won was decided by where
/// it stood in the file, so a block for one family could swallow another one -- the Llama block ate
/// the DeepSeek distills because it happened to come first -- and nothing in the language noticed.
/// Here nobody writes an order. A rule saying more about a name beats a rule saying less, and
/// "deepseek-r1" says more than "llama" without anyone deciding that it should.
///
/// Two rules of equal specificity which can match the same name are a mistake, not a coin toss.
/// The index reports them, and resolving still picks the same one every time, so a build never
/// depends on which rule was registered first.
/// </remarks>
/// <param name="ExplicitRank">What a rule wrote down by hand to override all of the below.</param>
/// <param name="Kind">How tightly the pattern is bound to the name.</param>
/// <param name="PatternLength">How much of the name the pattern spells out.</param>
/// <param name="Conditions">How many further name parts the rule requires or forbids.</param>
/// <param name="Binding">Whether the rule is tied to a provider, a vendor, or both.</param>
public readonly record struct RuleSpecificity(int ExplicitRank, int Kind, int PatternLength, int Conditions, int Binding) : IComparable<RuleSpecificity>
{
/// <summary>
/// Works out how specific a pattern is.
/// </summary>
/// <param name="pattern">The pattern to measure.</param>
/// <returns>Its specificity.</returns>
public static RuleSpecificity Of(MatchPattern pattern) => new(
ExplicitRank: pattern.ExplicitRank,
Kind: WeightOf(pattern.Kind),
PatternLength: pattern.Text.Length,
Conditions: pattern.AlsoContains.Count + pattern.NotContains.Count,
Binding: (pattern.OnlyOn is null ? 0 : 1) + (pattern.OnlyFrom is null ? 0 : 1));
/// <summary>
/// Compares two specificities, most specific last.
/// </summary>
/// <remarks>
/// The criteria are weighed in the order they are written in this type, and a tuple compares
/// exactly that way: the first difference decides, the rest is never looked at. The hand
/// written rank comes first because an emergency exit which the length of some other pattern
/// can overrule is not an exit at all.
/// </remarks>
/// <param name="other">The specificity to compare against.</param>
/// <returns>A negative number when this one is less specific, zero when they are equal.</returns>
public int CompareTo(RuleSpecificity other) =>
(this.ExplicitRank, this.Kind, this.PatternLength, this.Conditions, this.Binding)
.CompareTo((other.ExplicitRank, other.Kind, other.PatternLength, other.Conditions, other.Binding));
private static int WeightOf(MatchKind kind) => kind switch
{
MatchKind.EXACT => 3,
MatchKind.PREFIX => 2,
MatchKind.SEGMENT => 1,
_ => 0,
};
}

View File

@ -0,0 +1,68 @@
using static AIStudio.Provider.Capability;
namespace AIStudio.Models.Meta;
/// <summary>
/// Llama, from the text-only generations to the natively multimodal 4 line.
/// </summary>
/// <remarks>
/// Every rule here is written as a substring, which no other family needs and this one cannot do
/// without. The same checkpoint arrives as "llama3.1", as "meta-llama-3.1", and as "llama-v3p1",
/// because Fireworks writes a version with a "p" where the dot belongs. There is no name part all
/// three share to anchor a rule to, so the three spellings are stated as three rules.
///
/// What decides is the generation: 3.1 was the first Llama trained to call functions, which is why
/// the rules carrying the dot are the ones stating it. "llama3" without a dot is Llama 3.0 and does
/// not get it -- the dot in the pattern is what keeps the two apart.
/// </remarks>
public sealed class LlamaFamily : ModelFamily
{
/// <inheritdoc />
public override ModelVendor Vendor => ModelVendor.META;
/// <inheritdoc />
public override ModelSource Source => new("https://www.llama.com/docs/model-cards-and-prompt-formats/", new DateOnly(2026, 9, 12), "Capabilities ported unchanged from the Llama block of ProviderExtensions.OpenSource.cs. The model cards give the 3.x generations a 128k window; the 4 line is not stated here, because Scout and Maverick differ by an order of magnitude and the name alone does not say which one it is.");
/// <inheritdoc />
protected override void Declare(ModelFamilyBuilder builder)
{
// Whatever else a Llama is, it reads and writes text:
builder.Rule("llama").AsSubstring()
.Capabilities(TEXT_INPUT | TEXT_OUTPUT)
.Apis(CHAT_COMPLETION_API);
//
// The 3.2 vision checkpoints look at pictures and were never trained for tools. The word
// sits wherever the provider puts it -- "llama3.2-vision:11b" on Ollama, but
// "Llama-3.2-11B-Vision-Instruct" on the hub -- so there is nothing to anchor to here
// either, and the generations below have to step aside for it by name.
//
builder.Rule("llama").AsSubstring().AlsoContains("vision")
.Capabilities(TEXT_INPUT | MULTIPLE_IMAGE_INPUT | TEXT_OUTPUT)
.Apis(CHAT_COMPLETION_API);
//
// From 3.1 on, Llama calls functions and reads 128k tokens. Three spellings, one statement.
// What an operator actually serves is another matter: Ollama ships with a far smaller window
// until somebody raises num_ctx, which is why the window of a self-hosted model is a ceiling
// rather than a promise.
//
builder.Rule("llama3.").AsSubstring().NotContains("vision")
.Capabilities(TEXT_INPUT | TEXT_OUTPUT | FUNCTION_CALLING)
.Apis(CHAT_COMPLETION_API)
.ContextWindow(131_072);
builder.Rule("llama-3.").AsSubstring().NotContains("vision").Inherits();
builder.Rule("llama-v3p").AsSubstring().NotContains("vision").Inherits();
// The 4 line was trained on text and images together, so every one of them sees:
builder.Rule("llama4").AsSubstring()
.Capabilities(TEXT_INPUT | MULTIPLE_IMAGE_INPUT | TEXT_OUTPUT | FUNCTION_CALLING)
.Apis(CHAT_COMPLETION_API);
builder.Rule("llama-4").AsSubstring().Inherits();
builder.Rule("llama-v4").AsSubstring().Inherits();
}
}

View File

@ -0,0 +1,30 @@
using static AIStudio.Provider.Capability;
namespace AIStudio.Models.Meta;
/// <summary>
/// Muse, the Meta models whose names do not say Llama.
/// </summary>
/// <remarks>
/// That is the whole reason this is a family of its own: nothing about "muse-glimmer-30b" tells the
/// Llama rules that Meta built it, and a rule for one name is cheaper than teaching them.
///
/// Glimmer always thinks. Its chat template opens the thinking channel whatever the request says,
/// and only the strength of the thinking can be turned down, so there is no mode in which it
/// answers straight away.
/// </remarks>
public sealed class MuseFamily : ModelFamily
{
/// <inheritdoc />
public override ModelVendor Vendor => ModelVendor.META;
/// <inheritdoc />
public override ModelSource Source => new("https://huggingface.co/meta-llama", new DateOnly(2026, 9, 11), "Ported unchanged from the Muse block of ProviderExtensions.OpenSource.cs.");
/// <inheritdoc />
protected override void Declare(ModelFamilyBuilder builder) =>
builder.Rule("muse-glimmer").AsSegment()
.Capabilities(TEXT_INPUT | MULTIPLE_IMAGE_INPUT | TEXT_OUTPUT | FUNCTION_CALLING)
.Apis(CHAT_COMPLETION_API)
.Reasoning(ReasoningSupport.ALWAYS);
}

View File

@ -0,0 +1,31 @@
using AIStudio.Provider;
using static AIStudio.Provider.Capability;
namespace AIStudio.Models.Microsoft;
/// <summary>
/// E5, the embedding models built on somebody else's weights.
/// </summary>
/// <remarks>
/// "e5-mistral-7b-instruct" is what made this a family of its own. It is an embedding model, and it
/// carries the name of the model it was trained from, so the Mistral rules answer for it and tell
/// it that it chats and calls functions. Saying which name means what it says is cheaper than
/// teaching every family whose weights somebody built an embedder from.
///
/// The E5 part is the whole statement: the rest of the name says nothing about what the model does.
/// </remarks>
public sealed class E5Family : ModelFamily
{
/// <inheritdoc />
public override ModelVendor Vendor => ModelVendor.MICROSOFT;
/// <inheritdoc />
public override ModelSource Source => new("https://huggingface.co/intfloat/e5-mistral-7b-instruct", new DateOnly(2026, 9, 11), "The app lists this under IProvider.GetEmbeddingModels, which is where the statement that it embeds comes from.");
/// <inheritdoc />
protected override void Declare(ModelFamilyBuilder builder) =>
builder.Rule("e5").AsSegment()
.Capabilities(TEXT_INPUT | EMBEDDING)
.Kind(ModelKind.EMBEDDING);
}

Some files were not shown because too many files have changed in this diff Show More