Add one model host per provider and the unwrapping walk

This commit is contained in:
Thorsten Sommer 2026-09-11 19:28:06 +02:00
parent d3b28134d9
commit 31b655cf3b
Signed by untrusted user who does not match committer: tsommer
GPG Key ID: 371BBA77A02C0108
24 changed files with 1329 additions and 0 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,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));
});
}
}

View 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;
}
}
}

View 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);
}