mirror of
https://github.com/MindWorkAI/AI-Studio.git
synced 2026-09-27 01:53:36 +00:00
Add one model host per provider and the unwrapping walk
This commit is contained in:
parent
d3b28134d9
commit
31b655cf3b
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.");
|
||||
}
|
||||
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);
|
||||
}
|
||||
}
|
||||
140
app/Tests/Models/Hosting/HostNamingTests.cs
Normal file
140
app/Tests/Models/Hosting/HostNamingTests.cs
Normal file
@ -0,0 +1,140 @@
|
||||
using AIStudio.Models;
|
||||
using AIStudio.Models.Hosting;
|
||||
using AIStudio.Models.Matching;
|
||||
|
||||
namespace AIStudio.Tests.Models.Hosting;
|
||||
|
||||
/// <summary>
|
||||
/// Checks how one wrapping is taken off a name.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// All of this works on the name as the provider reported it, never on the normalized one, and
|
||||
/// that is the point worth testing: normalizing writes the slash, the colon, and the spaces all as
|
||||
/// hyphens, so afterwards there is nothing left to recognize a wrapping by.
|
||||
/// </remarks>
|
||||
[TestFixture]
|
||||
public sealed class HostNamingTests
|
||||
{
|
||||
[Test]
|
||||
public void TheOrganizationComesOffAndSaysWhoBuiltTheModel()
|
||||
{
|
||||
var taken = HostNaming.TrySplitOrganization(new ModelId("anthropic/claude-opus-5"), out var inner, out var vendor);
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(taken, Is.True);
|
||||
Assert.That(inner.Original, Is.EqualTo("claude-opus-5"));
|
||||
Assert.That(vendor, Is.EqualTo(ModelVendor.ANTHROPIC));
|
||||
});
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void AnOrganizationNobodyRecognizesStatesNoVendorRatherThanAnUnknownOne()
|
||||
{
|
||||
//
|
||||
// "azure" is where the model is running, not who built it. Saying "unknown" here would be a
|
||||
// statement, and it would stop the rules from working out the vendor from the name itself.
|
||||
//
|
||||
var taken = HostNaming.TrySplitOrganization(new ModelId("azure/gpt-5.6"), out var inner, out var vendor);
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(taken, Is.True);
|
||||
Assert.That(inner.Original, Is.EqualTo("gpt-5.6"));
|
||||
Assert.That(vendor, Is.Null);
|
||||
});
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void AnOrganizationIsRecognizedWhicheverWayTheHostSpellsIt()
|
||||
{
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(HostNaming.VendorOfOrganization("meta-llama"), Is.EqualTo(ModelVendor.META));
|
||||
Assert.That(HostNaming.VendorOfOrganization("Qwen"), Is.EqualTo(ModelVendor.ALIBABA));
|
||||
Assert.That(HostNaming.VendorOfOrganization("deepseek-ai"), Is.EqualTo(ModelVendor.DEEP_SEEK));
|
||||
Assert.That(HostNaming.VendorOfOrganization("HuggingFaceTB"), Is.EqualTo(ModelVendor.HUGGING_FACE));
|
||||
Assert.That(HostNaming.VendorOfOrganization("somebody-else"), Is.EqualTo(ModelVendor.UNKNOWN));
|
||||
});
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void ANameWithoutAnOrganizationIsLeftAlone()
|
||||
{
|
||||
var taken = HostNaming.TrySplitOrganization(new ModelId("llama-3.3-70b-versatile"), out var inner, out var vendor);
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(taken, Is.False);
|
||||
Assert.That(inner.Original, Is.EqualTo("llama-3.3-70b-versatile"));
|
||||
Assert.That(vendor, Is.Null);
|
||||
});
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void OnlyOneSegmentComesOffAtATime()
|
||||
{
|
||||
//
|
||||
// The account path Fireworks puts in front is three segments deep. Nothing here counts
|
||||
// them: the walk asks again, which is also what covers the two wrappings of Hugging Face.
|
||||
//
|
||||
var taken = HostNaming.TrySplitOrganization(new ModelId("accounts/fireworks/models/llama-v3p1-405b-instruct"), out var inner, out _);
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(taken, Is.True);
|
||||
Assert.That(inner.Original, Is.EqualTo("fireworks/models/llama-v3p1-405b-instruct"));
|
||||
});
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void AnOrganizationWithNothingBehindItIsNotAWrapping()
|
||||
{
|
||||
var taken = HostNaming.TrySplitOrganization(new ModelId("openai/"), out var inner, out _);
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(taken, Is.False);
|
||||
Assert.That(inner.Original, Is.EqualTo("openai/"));
|
||||
});
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void TheRoutingSuffixComesOffAndTheModelStaysWhatItWas()
|
||||
{
|
||||
var taken = HostNaming.TryStripRoutingSuffix(new ModelId("google/gemma-4-31B-it:novita"), out var inner);
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(taken, Is.True);
|
||||
Assert.That(inner.Original, Is.EqualTo("google/gemma-4-31B-it"));
|
||||
});
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void AMenuPositionComesOff()
|
||||
{
|
||||
var taken = HostNaming.TryStripMenuPosition(new ModelId("10 - Muse Glimmer 30b - the newest META model"), out var inner);
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(taken, Is.True);
|
||||
Assert.That(inner.Original, Is.EqualTo("Muse Glimmer 30b - the newest META model"));
|
||||
});
|
||||
}
|
||||
|
||||
[TestCase("70b-instruct", TestName = "A number the model is named after is not a menu position")]
|
||||
[TestCase("3-mini", TestName = "A number followed straight by a hyphen is not a menu position")]
|
||||
[TestCase("alias-qwen38-27b", TestName = "A name not starting with a number is not a menu position")]
|
||||
[TestCase("Qwen 3.8-27B with DFlash on haicluster", TestName = "A sentence without a leading number is not a menu position")]
|
||||
public void WhatOnlyLooksLikeAMenuPositionIsLeftAlone(string modelId)
|
||||
{
|
||||
var taken = HostNaming.TryStripMenuPosition(new ModelId(modelId), out var inner);
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(taken, Is.False);
|
||||
Assert.That(inner.Original, Is.EqualTo(modelId));
|
||||
});
|
||||
}
|
||||
}
|
||||
187
app/Tests/Models/Hosting/ModelHostIndexTests.cs
Normal file
187
app/Tests/Models/Hosting/ModelHostIndexTests.cs
Normal file
@ -0,0 +1,187 @@
|
||||
using AIStudio.Models;
|
||||
using AIStudio.Models.Hosting;
|
||||
using AIStudio.Models.Matching;
|
||||
using AIStudio.Provider;
|
||||
|
||||
namespace AIStudio.Tests.Models.Hosting;
|
||||
|
||||
/// <summary>
|
||||
/// Checks the walk which takes a name apart, and what happens when nobody wrote a host.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// How deep a wrapping goes is the host's business, not the caller's: Hugging Face has two, Fireworks
|
||||
/// has three, most have none. Asking over and over until the host says no is what covers all of
|
||||
/// them, and what has to be bounded so that a host which never says no cannot hang the app.
|
||||
/// </remarks>
|
||||
[TestFixture]
|
||||
public sealed class ModelHostIndexTests
|
||||
{
|
||||
[Test]
|
||||
public void TheHostsAreKeptInTheOrderOfTheProvidersTheyAnswerFor()
|
||||
{
|
||||
var index = ModelHostIndex.Build([new SplittingHost(), new StubbornHost()]);
|
||||
|
||||
Assert.That(index.Hosts.Select(host => host.Provider), Is.EqualTo(new[] { LLMProviders.OPEN_ROUTER, LLMProviders.LITE_LLM }));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void TwoHostsForOneProviderIsRefused()
|
||||
{
|
||||
var refused = Assert.Throws<InvalidOperationException>(() => ModelHostIndex.Build([new SplittingHost(), new SecondHostForTheSameProvider()]));
|
||||
|
||||
Assert.That(refused?.Message, Does.Contain("OPEN_ROUTER"));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void AHostAnsweringForNoProviderIsRefused()
|
||||
{
|
||||
//
|
||||
// The default value of the provider enum is NONE, so a host which gets this wrong gets it
|
||||
// wrong quietly: it would sit in the index answering for a provider nobody can configure.
|
||||
//
|
||||
var refused = Assert.Throws<InvalidOperationException>(() => ModelHostIndex.Build([new HostForNobody()]));
|
||||
|
||||
Assert.That(refused?.Message, Does.Contain(nameof(HostForNobody)));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void ProvidersNobodyWroteAHostForAreNamed()
|
||||
{
|
||||
var index = ModelHostIndex.Build([new SplittingHost()]);
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(index.ProvidersWithoutAHost, Does.Contain(LLMProviders.ANTHROPIC));
|
||||
Assert.That(index.ProvidersWithoutAHost, Does.Not.Contain(LLMProviders.OPEN_ROUTER));
|
||||
Assert.That(index.ProvidersWithoutAHost, Does.Not.Contain(LLMProviders.NONE), "Nobody can configure it, so nobody has to write a host for it.");
|
||||
});
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void AProviderWithoutAHostGetsItsNameBackUntouched()
|
||||
{
|
||||
var index = ModelHostIndex.Build([new SplittingHost()]);
|
||||
var unwrapped = index.Unwrap(new ModelId("anthropic/claude-opus-5"), LLMProviders.ANTHROPIC, out var vendor);
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(index.Of(LLMProviders.ANTHROPIC), Is.Null);
|
||||
Assert.That(unwrapped.Original, Is.EqualTo("anthropic/claude-opus-5"));
|
||||
Assert.That(vendor, Is.Null);
|
||||
});
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void AProviderWithoutAHostStillLosesTheResponsesApi()
|
||||
{
|
||||
//
|
||||
// The safe direction: claiming an API which is not there turns into a failed request, while
|
||||
// not claiming one only means the app does not use it.
|
||||
//
|
||||
var index = ModelHostIndex.Build([new SplittingHost()]);
|
||||
var profile = new ModelProfile { Capabilities = Capability.TEXT_INPUT | Capability.RESPONSES_API };
|
||||
var throughTheProvider = index.ApplyTransport(profile, LLMProviders.ANTHROPIC);
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(throughTheProvider.Has(Capability.RESPONSES_API), Is.False);
|
||||
Assert.That(throughTheProvider.Has(Capability.CHAT_COMPLETION_API), Is.True);
|
||||
});
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void TheWalkKeepsAskingUntilTheHostSaysNo()
|
||||
{
|
||||
var index = ModelHostIndex.Build([new SplittingHost()]);
|
||||
var unwrapped = index.Unwrap(new ModelId("accounts/fireworks/models/llama-v3p1-405b-instruct"), LLMProviders.OPEN_ROUTER, out _);
|
||||
|
||||
Assert.That(unwrapped.Original, Is.EqualTo("llama-v3p1-405b-instruct"));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void TheInnermostWrappingIsTheOneWhichSaysWhoBuiltTheModel()
|
||||
{
|
||||
var index = ModelHostIndex.Build([new SplittingHost()]);
|
||||
index.Unwrap(new ModelId("anthropic/openai/gpt-5"), LLMProviders.OPEN_ROUTER, out var vendor);
|
||||
|
||||
Assert.That(vendor, Is.EqualTo(ModelVendor.OPEN_AI), "A wrapping closer to the model knows more about it than one further out.");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void AWrappingWhichSaysNothingDoesNotEraseWhatAnOuterOneSaid()
|
||||
{
|
||||
var index = ModelHostIndex.Build([new SplittingHost()]);
|
||||
index.Unwrap(new ModelId("anthropic/somebody-else/claude-opus-5"), LLMProviders.OPEN_ROUTER, out var vendor);
|
||||
|
||||
Assert.That(vendor, Is.EqualTo(ModelVendor.ANTHROPIC));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void AHostHandingBackWhatItWasGivenIsNotAskedAgain()
|
||||
{
|
||||
var index = ModelHostIndex.Build([new StubbornHost()]);
|
||||
var unwrapped = index.Unwrap(new ModelId("the-fast-one"), LLMProviders.LITE_LLM, out _);
|
||||
|
||||
Assert.That(unwrapped.Original, Is.EqualTo("the-fast-one"));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void AHostWhichNeverSaysNoIsStoppedRatherThanFollowedForever()
|
||||
{
|
||||
var index = ModelHostIndex.Build([new GrowingHost()]);
|
||||
var unwrapped = index.Unwrap(new ModelId("thing"), LLMProviders.GROQ, out _);
|
||||
|
||||
Assert.That(unwrapped.Original.Split("-more"), Has.Length.EqualTo(ModelHostIndex.MAX_UNWRAPPING_STEPS + 1));
|
||||
}
|
||||
|
||||
private sealed class SplittingHost : ModelHost
|
||||
{
|
||||
public override LLMProviders Provider => LLMProviders.OPEN_ROUTER;
|
||||
|
||||
public override ModelSource Source => new("https://example.invalid/splitting", new DateOnly(2026, 9, 11), "A host taking off one organization at a time.");
|
||||
|
||||
public override bool TryUnwrap(in ModelId id, out ModelId inner, out ModelVendor? declaredVendor) => HostNaming.TrySplitOrganization(id, out inner, out declaredVendor);
|
||||
}
|
||||
|
||||
private sealed class SecondHostForTheSameProvider : ModelHost
|
||||
{
|
||||
public override LLMProviders Provider => LLMProviders.OPEN_ROUTER;
|
||||
|
||||
public override ModelSource Source => new("https://example.invalid/second", new DateOnly(2026, 9, 11), "A second host claiming a provider which already has one.");
|
||||
}
|
||||
|
||||
private sealed class HostForNobody : ModelHost
|
||||
{
|
||||
public override LLMProviders Provider => LLMProviders.NONE;
|
||||
|
||||
public override ModelSource Source => new("https://example.invalid/nobody", new DateOnly(2026, 9, 11), "A host which names no provider.");
|
||||
}
|
||||
|
||||
private sealed class StubbornHost : ModelHost
|
||||
{
|
||||
public override LLMProviders Provider => LLMProviders.LITE_LLM;
|
||||
|
||||
public override ModelSource Source => new("https://example.invalid/stubborn", new DateOnly(2026, 9, 11), "A host saying it unwrapped something without shortening anything.");
|
||||
|
||||
public override bool TryUnwrap(in ModelId id, out ModelId inner, out ModelVendor? declaredVendor)
|
||||
{
|
||||
inner = id;
|
||||
declaredVendor = null;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class GrowingHost : ModelHost
|
||||
{
|
||||
public override LLMProviders Provider => LLMProviders.GROQ;
|
||||
|
||||
public override ModelSource Source => new("https://example.invalid/growing", new DateOnly(2026, 9, 11), "A host handing back a longer name every time it is asked.");
|
||||
|
||||
public override bool TryUnwrap(in ModelId id, out ModelId inner, out ModelVendor? declaredVendor)
|
||||
{
|
||||
inner = new($"{id.Original}-more");
|
||||
declaredVendor = null;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
193
app/Tests/Models/Hosting/ModelHostTests.cs
Normal file
193
app/Tests/Models/Hosting/ModelHostTests.cs
Normal file
@ -0,0 +1,193 @@
|
||||
using AIStudio.Models;
|
||||
using AIStudio.Models.Hosting;
|
||||
using AIStudio.Models.Matching;
|
||||
using AIStudio.Models.Registry;
|
||||
using AIStudio.Provider;
|
||||
|
||||
namespace AIStudio.Tests.Models.Hosting;
|
||||
|
||||
/// <summary>
|
||||
/// Checks the hosts the app actually ships, against the names the providers actually answer with.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The names in here are the ones from the corpus, which came out of the provider lists and the
|
||||
/// audit rather than out of somebody's head. What is being asked is the routing question only --
|
||||
/// what is left of a name once the way it arrived has been accounted for, and which APIs survive
|
||||
/// the trip. Which model it then is remains a question for the rules.
|
||||
/// </remarks>
|
||||
[TestFixture]
|
||||
public sealed class ModelHostTests
|
||||
{
|
||||
/// <summary>
|
||||
/// The hosts as the app has them, found by the generator rather than listed here.
|
||||
/// </summary>
|
||||
private static readonly ModelHostIndex INDEX = ModelHostIndex.Build(ModelRegistrations.CreateHosts());
|
||||
|
||||
[Test]
|
||||
public void EveryProviderAPersonCanConfigureHasAHost()
|
||||
{
|
||||
//
|
||||
// This is the one which fails when somebody adds a provider to the app and stops there. It
|
||||
// is not a runtime error -- names would simply be taken as they arrive -- so nothing else
|
||||
// would ever point it out.
|
||||
//
|
||||
Assert.That(INDEX.ProvidersWithoutAHost, Is.Empty);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void EveryHostSaysWhereItsBehaviourCanBeCheckedAndWhen()
|
||||
{
|
||||
var unstated = INDEX.Hosts.Where(host => !host.Source.IsStated).Select(host => host.GetType().Name);
|
||||
|
||||
Assert.That(unstated, Is.Empty);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void AGatewayNameFallsApartIntoTheModelAndWhoBuiltIt()
|
||||
{
|
||||
var unwrapped = Unwrap(LLMProviders.OPEN_ROUTER, "anthropic/claude-opus-5", out var vendor);
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(unwrapped.Original, Is.EqualTo("claude-opus-5"));
|
||||
Assert.That(vendor, Is.EqualTo(ModelVendor.ANTHROPIC));
|
||||
});
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void TheHuggingFaceRouterTakesOffTheRouteFirstAndTheOrganizationSecond()
|
||||
{
|
||||
var unwrapped = Unwrap(LLMProviders.HUGGINGFACE, "openai/gpt-oss-120b:fireworks-ai", out var vendor);
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(unwrapped.Original, Is.EqualTo("gpt-oss-120b"));
|
||||
Assert.That(vendor, Is.EqualTo(ModelVendor.OPEN_AI), "OpenAI published the weights, whoever is serving them today.");
|
||||
});
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void AHuggingFaceNameWithoutARouteIsStillTakenApart()
|
||||
{
|
||||
var unwrapped = Unwrap(LLMProviders.HUGGINGFACE, "deepseek-ai/DeepSeek-R1-Distill-Qwen-32B", out var vendor);
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(unwrapped.Original, Is.EqualTo("DeepSeek-R1-Distill-Qwen-32B"));
|
||||
Assert.That(vendor, Is.EqualTo(ModelVendor.DEEP_SEEK));
|
||||
});
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void TheFireworksAccountPathComesOffWholeWithoutAnybodyCountingItsSegments()
|
||||
{
|
||||
var unwrapped = Unwrap(LLMProviders.FIREWORKS, "accounts/fireworks/models/llama-v3p1-405b-instruct", out var vendor);
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(unwrapped.Original, Is.EqualTo("llama-v3p1-405b-instruct"));
|
||||
Assert.That(vendor, Is.Null, "None of the three path segments names a vendor.");
|
||||
});
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void BlabladorLosesItsPlaceInTheMenu()
|
||||
{
|
||||
var unwrapped = Unwrap(LLMProviders.HELMHOLTZ, "1 - Llama3 405 the best general model", out _);
|
||||
|
||||
Assert.That(unwrapped.Original, Is.EqualTo("Llama3 405 the best general model"));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void AnEngineServingAHubRepositoryHasItReadAsOne()
|
||||
{
|
||||
var unwrapped = Unwrap(LLMProviders.SELF_HOSTED, "meta-llama/Llama-3.3-70B-Instruct", out var vendor);
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(unwrapped.Original, Is.EqualTo("Llama-3.3-70B-Instruct"));
|
||||
Assert.That(vendor, Is.EqualTo(ModelVendor.META));
|
||||
});
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void TheVariantOllamaWritesAfterAColonSurvives()
|
||||
{
|
||||
//
|
||||
// The colon means two different things at two different hosts. On the router it says where
|
||||
// the request goes; on Ollama it says which build is running, and taking it off would leave
|
||||
// a name which no longer identifies the model.
|
||||
//
|
||||
var unwrapped = Unwrap(LLMProviders.SELF_HOSTED, "qwen3.8:27b-mlx", out _);
|
||||
|
||||
Assert.That(unwrapped.Original, Is.EqualTo("qwen3.8:27b-mlx"));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void AResellerLeavesTheNameAloneAndOnlyTakesTheApiAway()
|
||||
{
|
||||
//
|
||||
// This is the GWDG case: it offers Claude and GPT under the names their vendors use, so the
|
||||
// rules recognize them and answer with everything those models can do. Everything except
|
||||
// the API -- the request goes to Göttingen, and the Responses API is not served there.
|
||||
//
|
||||
var atItsVendor = new ModelProfile { Capabilities = Capability.TEXT_INPUT | Capability.FUNCTION_CALLING | Capability.RESPONSES_API };
|
||||
var throughTheReseller = Transport(LLMProviders.GWDG, atItsVendor);
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(Unwrap(LLMProviders.GWDG, "claude-sonnet-5", out _).Original, Is.EqualTo("claude-sonnet-5"));
|
||||
Assert.That(throughTheReseller.Has(Capability.FUNCTION_CALLING), Is.True);
|
||||
Assert.That(throughTheReseller.Has(Capability.RESPONSES_API), Is.False);
|
||||
Assert.That(throughTheReseller.Has(Capability.CHAT_COMPLETION_API), Is.True);
|
||||
});
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void OnlyOpenAIsOwnCloudKeepsTheResponsesApi()
|
||||
{
|
||||
var withBothApis = new ModelProfile { Capabilities = Capability.RESPONSES_API | Capability.CHAT_COMPLETION_API };
|
||||
var elsewhere = INDEX.Hosts
|
||||
.Where(host => host.Provider is not LLMProviders.OPEN_AI)
|
||||
.Where(host => host.ApplyTransport(withBothApis).Has(Capability.RESPONSES_API))
|
||||
.Select(host => host.GetType().Name);
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(Transport(LLMProviders.OPEN_AI, withBothApis).Has(Capability.RESPONSES_API), Is.True);
|
||||
Assert.That(elsewhere, Is.Empty, "The app sends a Responses API request from exactly one place.");
|
||||
});
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void AModelReachedThroughNeitherApiIsNotGivenOne()
|
||||
{
|
||||
//
|
||||
// An embedding model is reached through neither of the two. Answering that it speaks the
|
||||
// chat completion API would be a claim nobody made.
|
||||
//
|
||||
var embedding = new ModelProfile { Capabilities = Capability.EMBEDDING };
|
||||
var throughAGateway = Transport(LLMProviders.OPEN_ROUTER, embedding);
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(throughAGateway.Has(Capability.EMBEDDING), Is.True);
|
||||
Assert.That(throughAGateway.HasAny(Capability.CHAT_COMPLETION_API | Capability.RESPONSES_API), Is.False);
|
||||
});
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void ANameWithoutAWrappingComesBackAsItWas()
|
||||
{
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(Unwrap(LLMProviders.OPEN_AI, "gpt-5.6", out _).Original, Is.EqualTo("gpt-5.6"));
|
||||
Assert.That(Unwrap(LLMProviders.GROQ, "llama-3.3-70b-versatile", out _).Original, Is.EqualTo("llama-3.3-70b-versatile"));
|
||||
Assert.That(Unwrap(LLMProviders.LITE_LLM, "the-fast-one", out _).Original, Is.EqualTo("the-fast-one"));
|
||||
});
|
||||
}
|
||||
|
||||
private static ModelId Unwrap(LLMProviders provider, string modelId, out ModelVendor? declaredVendor) => INDEX.Unwrap(new ModelId(modelId), provider, out declaredVendor);
|
||||
|
||||
private static ModelProfile Transport(LLMProviders provider, in ModelProfile profile) => INDEX.ApplyTransport(profile, provider);
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user