mirror of
https://github.com/MindWorkAI/AI-Studio.git
synced 2026-09-13 17:53:37 +00:00
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
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:
parent
d21e09dd1e
commit
d85b4e71b6
87
.github/workflows/build-and-release.yml
vendored
87
.github/workflows/build-and-release.yml
vendored
@ -724,9 +724,94 @@ jobs:
|
||||
overwrite: true
|
||||
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:
|
||||
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'
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
@ -172,3 +172,6 @@ orleans.codegen.cs
|
||||
|
||||
# Tauri generated schemas/manifests
|
||||
/runtime/gen/
|
||||
|
||||
# Ignore what a failing snapshot test leaves behind for comparison:
|
||||
/app/Tests/Models/Corpus/CapabilitySnapshot.actual.txt
|
||||
|
||||
34
AGENTS.md
34
AGENTS.md
@ -80,7 +80,21 @@ Notes:
|
||||
troubleshooting, no matter whether it came from the MCP server or from the user.
|
||||
|
||||
### 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
|
||||
|
||||
@ -141,7 +155,8 @@ Key structure:
|
||||
Plugins are written in Lua and provide:
|
||||
- **Language plugins** - I18N translations (e.g., German language pack)
|
||||
- **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`
|
||||
|
||||
@ -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.
|
||||
|
||||
## 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 integration is currently in development (preview feature). Architecture:
|
||||
|
||||
@ -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 teach AI Studio what a model can do? [Read the model capabilities guide here](documentation/Models.md).
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
|
||||
@ -87,8 +87,10 @@ public sealed partial class UpdateMetadataCommands
|
||||
await new CollectI18NKeysCommand().CollectI18NKeys();
|
||||
|
||||
// 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.:
|
||||
await this.Build(offline);
|
||||
// artifacts are already in place, and .NET knows the updated web assets, etc.
|
||||
// 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")]
|
||||
@ -221,11 +223,21 @@ public sealed partial class UpdateMetadataCommands
|
||||
|
||||
[Command("build", Description = "Build MindWork AI Studio")]
|
||||
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())
|
||||
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:
|
||||
//
|
||||
@ -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.
|
||||
// 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);
|
||||
|
||||
var lineEnding = metainfo.Contains("\r\n", StringComparison.Ordinal) ? "\r\n" : "\n";
|
||||
|
||||
92
app/Build/Commands/VerifyCommand.cs
Normal file
92
app/Build/Commands/VerifyCommand.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
178
app/Build/Commands/VerifyModelsCommand.cs
Normal file
178
app/Build/Commands/VerifyModelsCommand.cs
Normal 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);
|
||||
}
|
||||
@ -7,4 +7,6 @@ app.AddCommands<UpdateMetadataCommands>();
|
||||
app.AddCommands<UpdateWebAssetsCommand>();
|
||||
app.AddCommands<CollectI18NKeysCommand>();
|
||||
app.AddCommands<AssistantPluginHashCommand>();
|
||||
app.AddCommands<VerifyModelsCommand>();
|
||||
app.AddCommands<VerifyCommand>();
|
||||
app.Run();
|
||||
|
||||
62
app/Build/Tools/CommandRunner.cs
Normal file
62
app/Build/Tools/CommandRunner.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -34,6 +34,28 @@ public static class Environment
|
||||
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()
|
||||
{
|
||||
var currentDirectory = Directory.GetCurrentDirectory();
|
||||
|
||||
@ -10,6 +10,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SharedTools", "SharedTools\
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SourceGeneratedMappings", "SourceGeneratedMappings\SourceGeneratedMappings.csproj", "{4D7141D5-9C22-4D85-B748-290D15FF484C}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Tests", "Tests\Tests.csproj", "{CD46329B-D135-4594-9A70-55D3480F8FEE}"
|
||||
EndProject
|
||||
Global
|
||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||
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}.Release|Any CPU.ActiveCfg = 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
|
||||
GlobalSection(NestedProjects) = preSolution
|
||||
EndGlobalSection
|
||||
|
||||
@ -3571,9 +3571,15 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::CHATCOMPONENT::T1849313532"] = "Type your
|
||||
-- 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
|
||||
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
|
||||
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
|
||||
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
|
||||
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}'
|
||||
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
|
||||
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
|
||||
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
|
||||
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:
|
||||
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
|
||||
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.
|
||||
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
|
||||
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PROVIDERDIALOG::T1324664716"] = "API Key"
|
||||
|
||||
-- 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.
|
||||
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
|
||||
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.
|
||||
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}.
|
||||
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:
|
||||
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
|
||||
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PROVIDERDIALOG::T2626085950"] = "Enabled"
|
||||
|
||||
@ -7003,6 +7045,9 @@ UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PROVIDERDIALOG::T2842060373"] = "Instance Na
|
||||
-- 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.
|
||||
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.
|
||||
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
|
||||
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PROVIDERDIALOG::T3217987877"] = "Disabled"
|
||||
|
||||
@ -7048,6 +7099,12 @@ UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PROVIDERDIALOG::T3804472591"] = "Duplicate k
|
||||
-- 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.
|
||||
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.
|
||||
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}
|
||||
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.
|
||||
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
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::PLUGINSYSTEM::PLUGINTARGETGROUPEXTENSIONS::T1142248183"] = "Artists"
|
||||
|
||||
@ -11464,6 +11527,9 @@ UI_TEXT_CONTENT["AISTUDIO::TOOLS::PLUGINSYSTEM::PLUGINTARGETGROUPEXTENSIONS::T62
|
||||
-- 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
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::PLUGINSYSTEM::PLUGINTYPEEXTENSIONS::T1682350097"] = "Theme plugin"
|
||||
|
||||
|
||||
@ -267,17 +267,31 @@ internal sealed partial class VisualBriefingBuildOrchestrator
|
||||
FileTypes.IsAllowedPath(source.Path, FileTypes.IMAGE)).ToArray();
|
||||
if (imageSources.Length == 0)
|
||||
return;
|
||||
var capabilities = provider.GetModelCapabilities();
|
||||
var profile = provider.GetModelProfile();
|
||||
var acceptsImages = imageSources.Length == 1
|
||||
? capabilities.Contains(Capability.SINGLE_IMAGE_INPUT) ||
|
||||
capabilities.Contains(Capability.MULTIPLE_IMAGE_INPUT)
|
||||
: capabilities.Contains(Capability.MULTIPLE_IMAGE_INPUT);
|
||||
? profile.HasAny(Capability.SINGLE_IMAGE_INPUT | Capability.MULTIPLE_IMAGE_INPUT)
|
||||
: profile.Has(Capability.MULTIPLE_IMAGE_INPUT);
|
||||
if (!acceptsImages)
|
||||
throw new VisualBriefingBuildException(
|
||||
VisualBriefingFailureCode.MODEL_CAPABILITY_MISSING,
|
||||
VisualBriefingBuildStage.SOURCE_PREPARATION,
|
||||
"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>
|
||||
|
||||
@ -146,10 +146,8 @@ public sealed record ChatThread
|
||||
/// </summary>
|
||||
public bool MayRunTools(SettingsManager settingsManager) => this.RuntimeToolsAreAssistantManaged || settingsManager.IsToolSelectionVisible(this.RuntimeComponent);
|
||||
|
||||
private bool allowProfile = true;
|
||||
|
||||
/// <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>
|
||||
/// <remarks>
|
||||
/// 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>
|
||||
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
|
||||
@ -186,18 +212,12 @@ public sealed record ChatThread
|
||||
else
|
||||
{
|
||||
logMessage = $"Using chat template '{chatTemplate.Name}' for chat thread '{this.Name}'.";
|
||||
this.allowProfile = chatTemplate.AllowProfileUsage;
|
||||
allowProfile = chatTemplate.AllowProfileUsage;
|
||||
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:
|
||||
@ -214,18 +234,16 @@ public sealed record ChatThread
|
||||
false => systemPromptTextWithChatTemplate,
|
||||
};
|
||||
|
||||
if(isAugmentedDataAvailable)
|
||||
LOGGER.LogInformation("Augmented data is available for the chat thread.");
|
||||
else
|
||||
LOGGER.LogInformation("No augmented data is available for the chat thread.");
|
||||
|
||||
|
||||
logMessage = isAugmentedDataAvailable
|
||||
? $"{logMessage} Augmented data is available for the chat thread."
|
||||
: $"{logMessage} No augmented data is available for the chat thread.";
|
||||
|
||||
//
|
||||
// Add information from the profile if available and allowed:
|
||||
//
|
||||
string systemPromptText;
|
||||
logMessage = $"Using no profile for chat thread '{this.Name}'.";
|
||||
if (string.IsNullOrWhiteSpace(this.SelectedProfile) || !this.allowProfile)
|
||||
var profileNote = $"Using no profile for chat thread '{this.Name}'.";
|
||||
if (string.IsNullOrWhiteSpace(this.SelectedProfile) || !allowProfile)
|
||||
systemPromptText = systemPromptWithAugmentedData;
|
||||
else
|
||||
{
|
||||
@ -242,7 +260,7 @@ public sealed record ChatThread
|
||||
systemPromptText = systemPromptWithAugmentedData;
|
||||
else
|
||||
{
|
||||
logMessage = $"Using profile '{profile.Name}' for chat thread '{this.Name}'.";
|
||||
profileNote = $"Using profile '{profile.Name}' for chat thread '{this.Name}'.";
|
||||
systemPromptText = $"""
|
||||
{systemPromptWithAugmentedData}
|
||||
|
||||
@ -252,8 +270,6 @@ public sealed record ChatThread
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
LOGGER.LogInformation(logMessage);
|
||||
|
||||
var toolPolicy = ToolSelectionRules.BuildToolPolicyPrompt(runnableToolDefinitions ?? []);
|
||||
if (!string.IsNullOrWhiteSpace(toolPolicy))
|
||||
@ -265,9 +281,10 @@ public sealed record ChatThread
|
||||
""";
|
||||
}
|
||||
|
||||
var explanation = $"{logMessage} {profileNote}";
|
||||
if(!this.IncludeDateTime)
|
||||
return systemPromptText;
|
||||
|
||||
return new(systemPromptText, systemPromptTextWithChatTemplate, allowProfile, explanation);
|
||||
|
||||
//
|
||||
// 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)."
|
||||
);
|
||||
|
||||
return $"""
|
||||
{currentDateTime}
|
||||
var withDateTime = $"""
|
||||
{currentDateTime}
|
||||
|
||||
{systemPromptText}
|
||||
""";
|
||||
{systemPromptText}
|
||||
""";
|
||||
|
||||
return new(withDateTime, systemPromptTextWithChatTemplate, allowProfile, explanation);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
136
app/MindWork AI Studio/Chat/ConversationParts.cs
Normal file
136
app/MindWork AI Studio/Chat/ConversationParts.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
172
app/MindWork AI Studio/Chat/ConversationTokenTracker.cs
Normal file
172
app/MindWork AI Studio/Chat/ConversationTokenTracker.cs
Normal 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
|
||||
}
|
||||
83
app/MindWork AI Studio/Chat/ConversationTokens.cs
Normal file
83
app/MindWork AI Studio/Chat/ConversationTokens.cs
Normal 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;
|
||||
}
|
||||
@ -11,23 +11,25 @@ public static class ListContentBlockExtensions
|
||||
/// </summary>
|
||||
/// <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="selectedProvider">The selected LLM provider.</param>
|
||||
/// <param name="selectedModel">The selected model.</param>
|
||||
/// <param name="provider">The configured provider, whose model is being written to.</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>
|
||||
/// <returns>An asynchronous task that resolves to a list of transformed results.</returns>
|
||||
public static async Task<IList<IMessageBase>> BuildMessagesAsync(
|
||||
this List<ContentBlock> blocks,
|
||||
LLMProviders selectedProvider,
|
||||
Model selectedModel,
|
||||
AIStudio.Settings.Provider provider,
|
||||
Func<ChatRole, string> roleTransformer,
|
||||
Func<string, ISubContent> textSubContentFactory,
|
||||
Func<FileAttachmentImage, Task<ISubContent>> imageSubContentFactory)
|
||||
{
|
||||
var capabilities = selectedProvider.GetModelCapabilities(selectedModel);
|
||||
var canProcessImages = capabilities.Contains(Capability.MULTIPLE_IMAGE_INPUT) ||
|
||||
capabilities.Contains(Capability.SINGLE_IMAGE_INPUT);
|
||||
|
||||
//
|
||||
// Asked through the configured provider, so that what a person set in their expert settings
|
||||
// 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);
|
||||
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.
|
||||
/// </summary>
|
||||
/// <param name="blocks">The list of content blocks to process.</param>
|
||||
/// <param name="selectedProvider">The selected LLM provider.</param>
|
||||
/// <param name="selectedModel">The selected model.</param>
|
||||
/// <param name="provider">The configured provider, whose model is being written to.</param>
|
||||
/// <returns>An asynchronous task that resolves to a list of transformed message results.</returns>
|
||||
/// <remarks>
|
||||
/// 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>
|
||||
public static async Task<IList<IMessageBase>> BuildMessagesUsingDirectImageUrlAsync(
|
||||
this List<ContentBlock> blocks,
|
||||
LLMProviders selectedProvider,
|
||||
Model selectedModel) => await blocks.BuildMessagesAsync(
|
||||
selectedProvider,
|
||||
selectedModel,
|
||||
AIStudio.Settings.Provider provider) => await blocks.BuildMessagesAsync(
|
||||
provider,
|
||||
StandardRoleTransformer,
|
||||
StandardTextSubContentFactory,
|
||||
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.
|
||||
/// </summary>
|
||||
/// <param name="blocks">The list of content blocks to process.</param>
|
||||
/// <param name="selectedProvider">The selected LLM provider.</param>
|
||||
/// <param name="selectedModel">The selected model.</param>
|
||||
/// <param name="provider">The configured provider, whose model is being written to.</param>
|
||||
/// <returns>An asynchronous task that resolves to a list of transformed message results.</returns>
|
||||
/// <remarks>
|
||||
/// Uses nested image URL format where the image data is wrapped in an object:
|
||||
@ -138,10 +136,8 @@ public static class ListContentBlockExtensions
|
||||
/// </remarks>
|
||||
public static async Task<IList<IMessageBase>> BuildMessagesUsingNestedImageUrlAsync(
|
||||
this List<ContentBlock> blocks,
|
||||
LLMProviders selectedProvider,
|
||||
Model selectedModel) => await blocks.BuildMessagesAsync(
|
||||
selectedProvider,
|
||||
selectedModel,
|
||||
AIStudio.Settings.Provider provider) => await blocks.BuildMessagesAsync(
|
||||
provider,
|
||||
StandardRoleTransformer,
|
||||
StandardTextSubContentFactory,
|
||||
NestedImageSubContentFactory);
|
||||
|
||||
19
app/MindWork AI Studio/Chat/PreparedSystemPrompt.cs
Normal file
19
app/MindWork AI Studio/Chat/PreparedSystemPrompt.cs
Normal 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);
|
||||
47
app/MindWork AI Studio/Chat/TokenAmount.cs
Normal file
47
app/MindWork AI Studio/Chat/TokenAmount.cs
Normal 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";
|
||||
}
|
||||
}
|
||||
@ -258,8 +258,16 @@ public partial class AttachDocuments : MSGComponentBase
|
||||
this.DocumentPaths = await ReviewAttachmentsDialog.OpenDialogAsync(this.DialogService, this.DocumentPaths);
|
||||
foreach (var removedAttachment in previousAttachments.Except(this.DocumentPaths))
|
||||
ManagedTranscriptAttachment.TryDeleteOwnedFile(removedAttachment);
|
||||
|
||||
|
||||
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()
|
||||
|
||||
@ -51,7 +51,6 @@
|
||||
Disabled="@this.IsInputForbidden()"
|
||||
Immediate="@true"
|
||||
OnKeyUp="@this.InputKeyEvent"
|
||||
WhenTextChangedAsync="@(_ =>this.CalculateTokenCount())"
|
||||
UserAttributes="@USER_INPUT_ATTRIBUTES"
|
||||
Class="@this.UserInputClass"
|
||||
DebounceTime="TimeSpan.FromSeconds(1)"
|
||||
|
||||
@ -1,3 +1,5 @@
|
||||
using System.Globalization;
|
||||
|
||||
using AIStudio.Chat;
|
||||
using AIStudio.Dialogs;
|
||||
using AIStudio.Provider;
|
||||
@ -54,9 +56,10 @@ public partial class ChatComponent : MSGComponentBase
|
||||
|
||||
[Inject]
|
||||
private IDialogService DialogService { get; init; } = null!;
|
||||
|
||||
[Inject]
|
||||
private ConversationTokenCounter ConversationTokenCounter { get; init; } = null!;
|
||||
|
||||
[Inject]
|
||||
private RustService RustService { get; init; } = null!;
|
||||
[Inject]
|
||||
private IJSRuntime JsRuntime { get; init; } = null!;
|
||||
|
||||
@ -93,11 +96,112 @@ public partial class ChatComponent : MSGComponentBase
|
||||
private Guid loadedParameterWorkspaceId = Guid.Empty;
|
||||
private Guid foregroundChatId = Guid.Empty;
|
||||
private int workspaceHeaderSyncVersion;
|
||||
private string tokenCount = "0";
|
||||
private bool HasCustomTokenizer => !string.IsNullOrWhiteSpace(this.Provider.TokenizerPath);
|
||||
private string TokenCountMessage => this.HasCustomTokenizer
|
||||
? $"{this.T("Estimated amount of tokens:")} {this.tokenCount}"
|
||||
: string.Empty;
|
||||
private ConversationTokens conversationTokens = ConversationTokens.UNAVAILABLE;
|
||||
|
||||
/// <summary>
|
||||
/// How much of the window must be used before the number starts saying so.
|
||||
/// </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);
|
||||
|
||||
@ -125,6 +229,15 @@ public partial class ChatComponent : MSGComponentBase
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
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:
|
||||
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();
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
protected override async Task OnParametersSetAsync()
|
||||
{
|
||||
var incomingChatId = this.ChatThread?.ChatId ?? Guid.Empty;
|
||||
var providerChanged = this.Provider != this.lastSeenProvider;
|
||||
if (incomingChatId != this.lastSeenChatId || this.Provider != this.lastSeenProvider)
|
||||
{
|
||||
this.lastSeenChatId = incomingChatId;
|
||||
this.lastSeenProvider = this.Provider;
|
||||
if (providerChanged)
|
||||
this.tokenCount = "0";
|
||||
|
||||
this.previousInputForbidden = true;
|
||||
}
|
||||
|
||||
await this.ApplyLoadedChatParameterAsync();
|
||||
await this.SyncForegroundChatAsync();
|
||||
if (providerChanged && this.HasCustomTokenizer)
|
||||
await this.CalculateTokenCount();
|
||||
|
||||
await this.ConsumeMediaOutcomeAsync();
|
||||
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 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()
|
||||
{
|
||||
@ -603,15 +756,20 @@ public partial class ChatComponent : MSGComponentBase
|
||||
private async Task ProfileWasChanged(Profile profile)
|
||||
{
|
||||
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,
|
||||
};
|
||||
|
||||
await this.ChatThreadChanged.InvokeAsync(this.ChatThread);
|
||||
this.ChatThread = this.ChatThread with
|
||||
{
|
||||
SelectedProfile = this.currentProfile.Id,
|
||||
};
|
||||
|
||||
await this.ChatThreadChanged.InvokeAsync(this.ChatThread);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task ChatTemplateWasChanged(ChatTemplate chatTemplate)
|
||||
@ -623,10 +781,8 @@ public partial class ChatComponent : MSGComponentBase
|
||||
// Apply template's file attachments (replaces existing):
|
||||
this.ComposerState.ReplaceFileAttachments(this.currentChatTemplate.FileAttachments);
|
||||
|
||||
if(this.ChatThread is null)
|
||||
return;
|
||||
|
||||
await this.StartNewChat(true);
|
||||
if (this.ChatThread is not null)
|
||||
await this.StartNewChat(true);
|
||||
}
|
||||
|
||||
private void RefreshCurrentProfileAndChatTemplate()
|
||||
@ -726,10 +882,7 @@ public partial class ChatComponent : MSGComponentBase
|
||||
|
||||
// Was a modifier key pressed as well?
|
||||
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:
|
||||
switch (this.SettingsManager.ConfigurationData.Chat.ShortcutSendBehavior)
|
||||
{
|
||||
@ -774,21 +927,13 @@ public partial class ChatComponent : MSGComponentBase
|
||||
|
||||
this.RefreshCurrentProfileAndChatTemplate();
|
||||
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,
|
||||
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);
|
||||
@ -819,21 +964,11 @@ public partial class ChatComponent : MSGComponentBase
|
||||
// Create a new chat thread if necessary:
|
||||
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,
|
||||
Name = this.ExtractThreadName(this.ComposerState.UserInput),
|
||||
Blocks = this.currentChatTemplate == ChatTemplate.NO_CHAT_TEMPLATE ? [] : this.currentChatTemplate.ExampleConversation.Select(x => x.DeepClone()).ToList(),
|
||||
};
|
||||
|
||||
|
||||
this.MarkCurrentChatAsLoadedParameter();
|
||||
await this.ChatThreadChanged.InvokeAsync(this.ChatThread);
|
||||
}
|
||||
@ -916,8 +1051,7 @@ public partial class ChatComponent : MSGComponentBase
|
||||
this.ComposerState.Clear();
|
||||
|
||||
await this.inputField.BlurAsync();
|
||||
this.tokenCount = "0";
|
||||
|
||||
|
||||
// Enable the stream state for the chat component:
|
||||
this.hasUnsavedChanges = true;
|
||||
|
||||
@ -962,7 +1096,7 @@ public partial class ChatComponent : MSGComponentBase
|
||||
private void ApplyToolSelectionOfLoadedChat() =>
|
||||
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);
|
||||
|
||||
@ -977,8 +1111,6 @@ public partial class ChatComponent : MSGComponentBase
|
||||
this.ChatThread.SelectedToolIds = [..this.selectedToolIds];
|
||||
this.hasUnsavedChanges = true;
|
||||
}
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
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
|
||||
// the same:
|
||||
//
|
||||
this.ChatThread = 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 = string.Empty,
|
||||
Blocks = this.currentChatTemplate == ChatTemplate.NO_CHAT_TEMPLATE ? [] : this.currentChatTemplate.ExampleConversation.Select(x => x.DeepClone()).ToList(),
|
||||
};
|
||||
this.ChatThread = this.NewChatThread(string.Empty);
|
||||
}
|
||||
|
||||
this.ComposerState.ApplyTemplate(this.currentChatTemplate);
|
||||
@ -1112,7 +1232,7 @@ public partial class ChatComponent : MSGComponentBase
|
||||
this.MarkCurrentChatAsLoadedParameter();
|
||||
await this.ChatThreadChanged.InvokeAsync(this.ChatThread);
|
||||
}
|
||||
|
||||
|
||||
private async Task MoveChatToWorkspace()
|
||||
{
|
||||
if(this.ChatThread is null)
|
||||
@ -1215,7 +1335,7 @@ public partial class ChatComponent : MSGComponentBase
|
||||
this.ApplyStandardDataSourceOptions();
|
||||
await this.ChatThreadChanged.InvokeAsync(this.ChatThread);
|
||||
}
|
||||
|
||||
|
||||
private async Task SelectProviderWhenLoadingChat()
|
||||
{
|
||||
var chatProvider = this.ChatThread?.SelectedProvider;
|
||||
@ -1270,37 +1390,35 @@ public partial class ChatComponent : MSGComponentBase
|
||||
{
|
||||
if(this.ChatThread is null)
|
||||
return Task.CompletedTask;
|
||||
|
||||
|
||||
if (block is not ContentText textBlock)
|
||||
return Task.CompletedTask;
|
||||
|
||||
|
||||
var lastBlock = this.ChatThread.Blocks.Last();
|
||||
var lastBlockContent = lastBlock.Content;
|
||||
if(lastBlockContent is null)
|
||||
return Task.CompletedTask;
|
||||
|
||||
|
||||
this.RestoreComposerFromTextBlock(textBlock);
|
||||
this.ChatThread.Remove(block);
|
||||
this.ChatThread.Remove(lastBlockContent);
|
||||
this.hasUnsavedChanges = true;
|
||||
this.StateHasChanged();
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
|
||||
private Task EditLastBlock(IContent block)
|
||||
{
|
||||
if(this.ChatThread is null)
|
||||
return Task.CompletedTask;
|
||||
|
||||
|
||||
if (block is not ContentText textBlock)
|
||||
return Task.CompletedTask;
|
||||
|
||||
|
||||
this.RestoreComposerFromTextBlock(textBlock);
|
||||
this.ChatThread.Remove(block);
|
||||
this.hasUnsavedChanges = true;
|
||||
this.StateHasChanged();
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
@ -1309,42 +1427,115 @@ public partial class ChatComponent : MSGComponentBase
|
||||
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)
|
||||
{
|
||||
if (this.tokenCount != "0")
|
||||
{
|
||||
this.tokenCount = "0";
|
||||
this.StateHasChanged();
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
var provider = AIStudio.Settings.Provider.NONE;
|
||||
var parts = ConversationParts.NOTHING;
|
||||
|
||||
//
|
||||
// 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. Counting is also
|
||||
// triggered while parameters are set, which happens before that.
|
||||
// Collected on the render thread, counted off it. Counting may take an IPC call per text,
|
||||
// and while it runs, the background job which writes the answer appends to the very list
|
||||
// which is walked here.
|
||||
//
|
||||
var currentInput = this.UserInput;
|
||||
if (string.IsNullOrEmpty(currentInput))
|
||||
await this.InvokeAsync(() =>
|
||||
{
|
||||
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);
|
||||
if (response is null)
|
||||
var counted = await this.ConversationTokenCounter.CountAsync(provider, parts, token);
|
||||
if (token.IsCancellationRequested)
|
||||
return;
|
||||
if (!response.Value.Success)
|
||||
|
||||
await this.InvokeAsync(() =>
|
||||
{
|
||||
this.Logger.LogWarning("Failed to calculate token count: reason='{Reason}'", response.Value.Message);
|
||||
return;
|
||||
}
|
||||
this.tokenCount = response.Value.TokenCount.ToString();
|
||||
this.StateHasChanged();
|
||||
if (counted == this.conversationTokens)
|
||||
return;
|
||||
|
||||
this.conversationTokens = counted;
|
||||
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
|
||||
|
||||
@ -1372,6 +1563,7 @@ public partial class ChatComponent : MSGComponentBase
|
||||
|
||||
case Event.CONFIGURATION_CHANGED:
|
||||
case Event.PLUGINS_RELOADED:
|
||||
await this.RefreshCulture();
|
||||
await this.RefreshChatSelectionsAfterConfigurationChange();
|
||||
this.StateHasChanged();
|
||||
break;
|
||||
@ -1419,6 +1611,10 @@ public partial class ChatComponent : MSGComponentBase
|
||||
protected override async ValueTask DisposeResourcesAsync()
|
||||
{
|
||||
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)
|
||||
{
|
||||
await this.SaveThread();
|
||||
|
||||
@ -55,16 +55,16 @@ public partial class ProviderSelection : MSGComponentBase
|
||||
|
||||
private IReadOnlyList<CapabilityIcon> GetCapabilityIcons(AIStudio.Settings.Provider provider)
|
||||
{
|
||||
var capabilities = provider.GetModelCapabilities();
|
||||
var profile = provider.GetModelProfile();
|
||||
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")));
|
||||
|
||||
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")));
|
||||
|
||||
if (capabilities.Contains(Capability.SPEECH_INPUT))
|
||||
if (profile.Has(Capability.SPEECH_INPUT))
|
||||
capabilityIcons.Add(new(Icons.Material.Filled.Mic, this.T("Speech input possible")));
|
||||
|
||||
var reasoningIndicatorState = provider.GetReasoningIndicatorState();
|
||||
|
||||
6
app/MindWork AI Studio/Components/TokenizerHint.razor
Normal file
6
app/MindWork AI Studio/Components/TokenizerHint.razor
Normal file
@ -0,0 +1,6 @@
|
||||
@if (!string.IsNullOrWhiteSpace(this.Text))
|
||||
{
|
||||
<MudJustifiedText Typo="Typo.body2" Class="@this.Class">
|
||||
@this.Text
|
||||
</MudJustifiedText>
|
||||
}
|
||||
70
app/MindWork AI Studio/Components/TokenizerHint.razor.cs
Normal file
70
app/MindWork AI Studio/Components/TokenizerHint.razor.cs
Normal 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,
|
||||
};
|
||||
}
|
||||
@ -187,6 +187,7 @@
|
||||
Disabled="@this.IsEnterpriseConfiguration"
|
||||
Validation="@this.ValidateEmbeddingBatchSize"
|
||||
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">
|
||||
<MudStack Row="@true" Spacing="3" Class="mb-3" StretchItems="StretchItems.None" AlignItems="AlignItems.Center">
|
||||
<MudTextField
|
||||
|
||||
@ -242,6 +242,64 @@
|
||||
</MudSelect>
|
||||
</MudPaper>
|
||||
</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">
|
||||
@string.Format(T("The current model uses the {0}."), this.GetCurrentModelApiLabel())
|
||||
</MudJustifiedText>
|
||||
@ -251,6 +309,7 @@
|
||||
<MudJustifiedText Typo="Typo.body1" Class="mt-4 mb-3">
|
||||
@T("For better token estimates, you can configure a custom tokenizer for this provider.")
|
||||
</MudJustifiedText>
|
||||
<TokenizerHint LLMProvider="@this.DataLLMProvider" Model="@this.DataModel"/>
|
||||
<PathDropZone IdPrefix="tokenizer" Disabled="@(() => this.IsEnterpriseConfiguration)" OnPathsDropped="@this.OnTokenizerPathsDropped">
|
||||
<MudStack Row="@true" Spacing="3" Class="mb-3" StretchItems="StretchItems.None" AlignItems="AlignItems.Center">
|
||||
<MudTextField
|
||||
|
||||
@ -1,7 +1,9 @@
|
||||
using System.Globalization;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
|
||||
using AIStudio.Components;
|
||||
using AIStudio.Models;
|
||||
using AIStudio.Provider;
|
||||
using AIStudio.Provider.HuggingFace;
|
||||
using AIStudio.Tools.Rust;
|
||||
@ -163,6 +165,16 @@ public partial class ProviderDialog : MSGComponentBase, ISecretId
|
||||
private bool usesLegacySystemModelFallback;
|
||||
private bool showExpertSettings;
|
||||
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:
|
||||
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:
|
||||
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:
|
||||
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)
|
||||
return ReasoningOverrideMode.AUTOMATIC;
|
||||
|
||||
var capabilities = this.GetCurrentModelCapabilities();
|
||||
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;
|
||||
return ModeOf(this.GetCurrentModelProfile().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();
|
||||
if (capabilities.Contains(Capability.ALWAYS_REASONING))
|
||||
return ReasoningOverrideMode.ALWAYS_ON;
|
||||
ReasoningSupport.ALWAYS => ReasoningOverrideMode.ALWAYS_ON,
|
||||
ReasoningSupport.ON_BY_DEFAULT => ReasoningOverrideMode.ON_BY_DEFAULT,
|
||||
ReasoningSupport.OPTIONAL => ReasoningOverrideMode.CAN_BE_ENABLED,
|
||||
|
||||
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;
|
||||
}
|
||||
_ => ReasoningOverrideMode.NO_REASONING,
|
||||
};
|
||||
|
||||
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 IsCapabilityEnabled(Capability capability)
|
||||
{
|
||||
var capabilities = this.GetCurrentModelCapabilities();
|
||||
return capabilities.Contains(capability);
|
||||
}
|
||||
private bool IsCapabilityEnabled(Capability capability) => this.GetCurrentModelProfile().Has(capability);
|
||||
|
||||
private string GetCapabilityEffectiveLabel(Capability capability)
|
||||
{
|
||||
@ -761,21 +769,107 @@ public partial class ProviderDialog : MSGComponentBase, ISecretId
|
||||
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();
|
||||
return currentProviderSettings.GetModelCapabilities();
|
||||
get
|
||||
{
|
||||
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()
|
||||
{
|
||||
var capabilities = this.GetCurrentModelCapabilities();
|
||||
if (capabilities.Contains(Capability.RESPONSES_API))
|
||||
var profile = this.GetCurrentModelProfile();
|
||||
if (profile.Has(Capability.RESPONSES_API))
|
||||
return "Responses API";
|
||||
|
||||
if (capabilities.Contains(Capability.CHAT_COMPLETION_API))
|
||||
if (profile.Has(Capability.CHAT_COMPLETION_API))
|
||||
return "Chat Completions API";
|
||||
|
||||
return "Unknown";
|
||||
|
||||
@ -82,6 +82,15 @@
|
||||
<Folder Include="Plugins\assistants\assets\" />
|
||||
</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 -->
|
||||
<Target Name="ReadMetaData" BeforeTargets="BeforeBuild">
|
||||
<Error Text="The ../../metadata.txt file was not found!" Condition="!Exists('../../metadata.txt')" />
|
||||
|
||||
@ -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);
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
77
app/MindWork AI Studio/Models/Alibaba/QwenFamily.cs
Normal file
77
app/MindWork AI Studio/Models/Alibaba/QwenFamily.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
31
app/MindWork AI Studio/Models/Alibaba/QwqFamily.cs
Normal file
31
app/MindWork AI Studio/Models/Alibaba/QwqFamily.cs
Normal 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);
|
||||
}
|
||||
130
app/MindWork AI Studio/Models/Anthropic/ClaudeFamily.cs
Normal file
130
app/MindWork AI Studio/Models/Anthropic/ClaudeFamily.cs
Normal 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");
|
||||
}
|
||||
}
|
||||
41
app/MindWork AI Studio/Models/Baidu/ErnieFamily.cs
Normal file
41
app/MindWork AI Studio/Models/Baidu/ErnieFamily.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
30
app/MindWork AI Studio/Models/Cohere/AyaFamily.cs
Normal file
30
app/MindWork AI Studio/Models/Cohere/AyaFamily.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
41
app/MindWork AI Studio/Models/Cohere/CommandFamily.cs
Normal file
41
app/MindWork AI Studio/Models/Cohere/CommandFamily.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
57
app/MindWork AI Studio/Models/ContextWindow.cs
Normal file
57
app/MindWork AI Studio/Models/ContextWindow.cs
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
78
app/MindWork AI Studio/Models/DeepSeek/DeepSeekFamily.cs
Normal file
78
app/MindWork AI Studio/Models/DeepSeek/DeepSeekFamily.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
91
app/MindWork AI Studio/Models/Google/GeminiFamily.cs
Normal file
91
app/MindWork AI Studio/Models/Google/GeminiFamily.cs
Normal 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");
|
||||
}
|
||||
}
|
||||
42
app/MindWork AI Studio/Models/Google/GeminiImageFamily.cs
Normal file
42
app/MindWork AI Studio/Models/Google/GeminiImageFamily.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
78
app/MindWork AI Studio/Models/Google/GemmaFamily.cs
Normal file
78
app/MindWork AI Studio/Models/Google/GemmaFamily.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
32
app/MindWork AI Studio/Models/Google/ImagenFamily.cs
Normal file
32
app/MindWork AI Studio/Models/Google/ImagenFamily.cs
Normal 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);
|
||||
}
|
||||
183
app/MindWork AI Studio/Models/Hosting/HostNaming.cs
Normal file
183
app/MindWork AI Studio/Models/Hosting/HostNaming.cs
Normal 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,
|
||||
};
|
||||
}
|
||||
@ -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.");
|
||||
}
|
||||
15
app/MindWork AI Studio/Models/Hosting/Hosts/HostAnthropic.cs
Normal file
15
app/MindWork AI Studio/Models/Hosting/Hosts/HostAnthropic.cs
Normal 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.");
|
||||
}
|
||||
20
app/MindWork AI Studio/Models/Hosting/Hosts/HostDeepSeek.cs
Normal file
20
app/MindWork AI Studio/Models/Hosting/Hosts/HostDeepSeek.cs
Normal 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.");
|
||||
}
|
||||
25
app/MindWork AI Studio/Models/Hosting/Hosts/HostFireworks.cs
Normal file
25
app/MindWork AI Studio/Models/Hosting/Hosts/HostFireworks.cs
Normal 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);
|
||||
}
|
||||
26
app/MindWork AI Studio/Models/Hosting/Hosts/HostGWDG.cs
Normal file
26
app/MindWork AI Studio/Models/Hosting/Hosts/HostGWDG.cs
Normal 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.");
|
||||
}
|
||||
15
app/MindWork AI Studio/Models/Hosting/Hosts/HostGoogle.cs
Normal file
15
app/MindWork AI Studio/Models/Hosting/Hosts/HostGoogle.cs
Normal 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.");
|
||||
}
|
||||
24
app/MindWork AI Studio/Models/Hosting/Hosts/HostGroq.cs
Normal file
24
app/MindWork AI Studio/Models/Hosting/Hosts/HostGroq.cs
Normal 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);
|
||||
}
|
||||
29
app/MindWork AI Studio/Models/Hosting/Hosts/HostHelmholtz.cs
Normal file
29
app/MindWork AI Studio/Models/Hosting/Hosts/HostHelmholtz.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
15
app/MindWork AI Studio/Models/Hosting/Hosts/HostHetzner.cs
Normal file
15
app/MindWork AI Studio/Models/Hosting/Hosts/HostHetzner.cs
Normal 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.");
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
24
app/MindWork AI Studio/Models/Hosting/Hosts/HostIONOS.cs
Normal file
24
app/MindWork AI Studio/Models/Hosting/Hosts/HostIONOS.cs
Normal 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);
|
||||
}
|
||||
30
app/MindWork AI Studio/Models/Hosting/Hosts/HostLiteLLM.cs
Normal file
30
app/MindWork AI Studio/Models/Hosting/Hosts/HostLiteLLM.cs
Normal 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);
|
||||
}
|
||||
20
app/MindWork AI Studio/Models/Hosting/Hosts/HostMistral.cs
Normal file
20
app/MindWork AI Studio/Models/Hosting/Hosts/HostMistral.cs
Normal 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.");
|
||||
}
|
||||
28
app/MindWork AI Studio/Models/Hosting/Hosts/HostOpenAI.cs
Normal file
28
app/MindWork AI Studio/Models/Hosting/Hosts/HostOpenAI.cs
Normal 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;
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
@ -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.");
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
15
app/MindWork AI Studio/Models/Hosting/Hosts/HostX.cs
Normal file
15
app/MindWork AI Studio/Models/Hosting/Hosts/HostX.cs
Normal 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.");
|
||||
}
|
||||
56
app/MindWork AI Studio/Models/Hosting/IModelHost.cs
Normal file
56
app/MindWork AI Studio/Models/Hosting/IModelHost.cs
Normal 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);
|
||||
}
|
||||
68
app/MindWork AI Studio/Models/Hosting/ModelHost.cs
Normal file
68
app/MindWork AI Studio/Models/Hosting/ModelHost.cs
Normal 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 };
|
||||
}
|
||||
}
|
||||
144
app/MindWork AI Studio/Models/Hosting/ModelHostIndex.cs
Normal file
144
app/MindWork AI Studio/Models/Hosting/ModelHostIndex.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
65
app/MindWork AI Studio/Models/IBM/GraniteFamily.cs
Normal file
65
app/MindWork AI Studio/Models/IBM/GraniteFamily.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
55
app/MindWork AI Studio/Models/ImageLimits.cs
Normal file
55
app/MindWork AI Studio/Models/ImageLimits.cs
Normal 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,
|
||||
};
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
59
app/MindWork AI Studio/Models/Kinds/EmbeddingModelsFamily.cs
Normal file
59
app/MindWork AI Studio/Models/Kinds/EmbeddingModelsFamily.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
32
app/MindWork AI Studio/Models/Kinds/NotAModelFamily.cs
Normal file
32
app/MindWork AI Studio/Models/Kinds/NotAModelFamily.cs
Normal 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);
|
||||
}
|
||||
23
app/MindWork AI Studio/Models/Kinds/OcrModelsFamily.cs
Normal file
23
app/MindWork AI Studio/Models/Kinds/OcrModelsFamily.cs
Normal 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);
|
||||
}
|
||||
42
app/MindWork AI Studio/Models/Kinds/RealtimeModelsFamily.cs
Normal file
42
app/MindWork AI Studio/Models/Kinds/RealtimeModelsFamily.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
34
app/MindWork AI Studio/Models/Kinds/RerankingModelsFamily.cs
Normal file
34
app/MindWork AI Studio/Models/Kinds/RerankingModelsFamily.cs
Normal 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);
|
||||
}
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
86
app/MindWork AI Studio/Models/Live/ListedModels.cs
Normal file
86
app/MindWork AI Studio/Models/Live/ListedModels.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
60
app/MindWork AI Studio/Models/Live/ModelListing.cs
Normal file
60
app/MindWork AI Studio/Models/Live/ModelListing.cs
Normal 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;
|
||||
}
|
||||
42
app/MindWork AI Studio/Models/Matching/MatchKind.cs
Normal file
42
app/MindWork AI Studio/Models/Matching/MatchKind.cs
Normal 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,
|
||||
}
|
||||
165
app/MindWork AI Studio/Models/Matching/MatchPattern.cs
Normal file
165
app/MindWork AI Studio/Models/Matching/MatchPattern.cs
Normal 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,
|
||||
};
|
||||
}
|
||||
234
app/MindWork AI Studio/Models/Matching/ModelFamilyIndex.cs
Normal file
234
app/MindWork AI Studio/Models/Matching/ModelFamilyIndex.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
178
app/MindWork AI Studio/Models/Matching/ModelId.cs
Normal file
178
app/MindWork AI Studio/Models/Matching/ModelId.cs
Normal 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]);
|
||||
}
|
||||
}
|
||||
57
app/MindWork AI Studio/Models/Matching/ModelIdSegments.cs
Normal file
57
app/MindWork AI Studio/Models/Matching/ModelIdSegments.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
37
app/MindWork AI Studio/Models/Matching/ModelResolution.cs
Normal file
37
app/MindWork AI Studio/Models/Matching/ModelResolution.cs
Normal 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;
|
||||
}
|
||||
43
app/MindWork AI Studio/Models/Matching/ModelRule.cs
Normal file
43
app/MindWork AI Studio/Models/Matching/ModelRule.cs
Normal 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;
|
||||
}
|
||||
24
app/MindWork AI Studio/Models/Matching/ModelRuleKind.cs
Normal file
24
app/MindWork AI Studio/Models/Matching/ModelRuleKind.cs
Normal 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,
|
||||
}
|
||||
12
app/MindWork AI Studio/Models/Matching/RuleAmbiguity.cs
Normal file
12
app/MindWork AI Studio/Models/Matching/RuleAmbiguity.cs
Normal 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})";
|
||||
}
|
||||
59
app/MindWork AI Studio/Models/Matching/RuleSpecificity.cs
Normal file
59
app/MindWork AI Studio/Models/Matching/RuleSpecificity.cs
Normal 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,
|
||||
};
|
||||
}
|
||||
68
app/MindWork AI Studio/Models/Meta/LlamaFamily.cs
Normal file
68
app/MindWork AI Studio/Models/Meta/LlamaFamily.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
30
app/MindWork AI Studio/Models/Meta/MuseFamily.cs
Normal file
30
app/MindWork AI Studio/Models/Meta/MuseFamily.cs
Normal 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);
|
||||
}
|
||||
31
app/MindWork AI Studio/Models/Microsoft/E5Family.cs
Normal file
31
app/MindWork AI Studio/Models/Microsoft/E5Family.cs
Normal 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
Loading…
Reference in New Issue
Block a user