diff --git a/app/MindWork AI Studio/Models/Hosting/HostNaming.cs b/app/MindWork AI Studio/Models/Hosting/HostNaming.cs
new file mode 100644
index 00000000..387b1df4
--- /dev/null
+++ b/app/MindWork AI Studio/Models/Hosting/HostNaming.cs
@@ -0,0 +1,183 @@
+using AIStudio.Models.Matching;
+
+namespace AIStudio.Models.Hosting;
+
+///
+/// The ways a host wraps a model name, and how to take one wrapping off again.
+///
+///
+/// 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.
+///
+public static class HostNaming
+{
+ ///
+ /// What separates the organization from the model on a hub.
+ ///
+ private const char ORGANIZATION_SEPARATOR = '/';
+
+ ///
+ /// What separates the model from the inference provider it should be routed to.
+ ///
+ private const char ROUTING_SEPARATOR = ':';
+
+ ///
+ /// Takes the organization off a hub style name.
+ ///
+ ///
+ /// 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.
+ ///
+ /// The name as it arrived.
+ /// The name without its first path segment.
+ /// Who the organization says built the model, when we recognize it.
+ /// True, when there was an organization to take off.
+ 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;
+ }
+
+ ///
+ /// Takes the routing suffix off a name.
+ ///
+ ///
+ /// 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.
+ ///
+ /// The name as it arrived.
+ /// The name without its routing suffix.
+ /// True, when there was a suffix to take off.
+ 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;
+ }
+
+ ///
+ /// Takes the position in a menu off a name.
+ ///
+ ///
+ /// 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.
+ ///
+ /// The name as it arrived.
+ /// The name without its leading number.
+ /// True, when there was a menu position to take off.
+ 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;
+ }
+
+ ///
+ /// Who an organization on a hub stands for.
+ ///
+ ///
+ /// 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.
+ ///
+ /// The organization as the host wrote it, in any casing.
+ /// The vendor, or unknown.
+ 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,
+ };
+}
\ No newline at end of file
diff --git a/app/MindWork AI Studio/Models/Hosting/Hosts/HostAlibabaCloud.cs b/app/MindWork AI Studio/Models/Hosting/Hosts/HostAlibabaCloud.cs
new file mode 100644
index 00000000..99399f91
--- /dev/null
+++ b/app/MindWork AI Studio/Models/Hosting/Hosts/HostAlibabaCloud.cs
@@ -0,0 +1,21 @@
+using AIStudio.Provider;
+
+namespace AIStudio.Models.Hosting.Hosts;
+
+///
+/// Alibaba Cloud Model Studio.
+///
+///
+/// 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.
+///
+public sealed class HostAlibabaCloud : ModelHost
+{
+ ///
+ public override LLMProviders Provider => LLMProviders.ALIBABA_CLOUD;
+
+ ///
+ 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.");
+}
\ No newline at end of file
diff --git a/app/MindWork AI Studio/Models/Hosting/Hosts/HostAnthropic.cs b/app/MindWork AI Studio/Models/Hosting/Hosts/HostAnthropic.cs
new file mode 100644
index 00000000..8b1fa702
--- /dev/null
+++ b/app/MindWork AI Studio/Models/Hosting/Hosts/HostAnthropic.cs
@@ -0,0 +1,15 @@
+using AIStudio.Provider;
+
+namespace AIStudio.Models.Hosting.Hosts;
+
+///
+/// Anthropic's own cloud.
+///
+public sealed class HostAnthropic : ModelHost
+{
+ ///
+ public override LLMProviders Provider => LLMProviders.ANTHROPIC;
+
+ ///
+ 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.");
+}
\ No newline at end of file
diff --git a/app/MindWork AI Studio/Models/Hosting/Hosts/HostDeepSeek.cs b/app/MindWork AI Studio/Models/Hosting/Hosts/HostDeepSeek.cs
new file mode 100644
index 00000000..420c19c2
--- /dev/null
+++ b/app/MindWork AI Studio/Models/Hosting/Hosts/HostDeepSeek.cs
@@ -0,0 +1,20 @@
+using AIStudio.Provider;
+
+namespace AIStudio.Models.Hosting.Hosts;
+
+///
+/// DeepSeek's own platform.
+///
+///
+/// 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.
+///
+public sealed class HostDeepSeek : ModelHost
+{
+ ///
+ public override LLMProviders Provider => LLMProviders.DEEP_SEEK;
+
+ ///
+ 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.");
+}
\ No newline at end of file
diff --git a/app/MindWork AI Studio/Models/Hosting/Hosts/HostFireworks.cs b/app/MindWork AI Studio/Models/Hosting/Hosts/HostFireworks.cs
new file mode 100644
index 00000000..35e0af80
--- /dev/null
+++ b/app/MindWork AI Studio/Models/Hosting/Hosts/HostFireworks.cs
@@ -0,0 +1,25 @@
+using AIStudio.Models.Matching;
+using AIStudio.Provider;
+
+namespace AIStudio.Models.Hosting.Hosts;
+
+///
+/// Fireworks AI, which puts a whole account path in front of every model.
+///
+///
+/// "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.
+///
+public sealed class HostFireworks : ModelHost
+{
+ ///
+ public override LLMProviders Provider => LLMProviders.FIREWORKS;
+
+ ///
+ public override ModelSource Source => new("https://fireworks.ai/models?show=Serverless", new DateOnly(2026, 9, 11), "Models are named \"accounts//models/\", served through the OpenAI-compatible chat completion API.");
+
+ ///
+ public override bool TryUnwrap(in ModelId id, out ModelId inner, out ModelVendor? declaredVendor) => HostNaming.TrySplitOrganization(id, out inner, out declaredVendor);
+}
\ No newline at end of file
diff --git a/app/MindWork AI Studio/Models/Hosting/Hosts/HostGWDG.cs b/app/MindWork AI Studio/Models/Hosting/Hosts/HostGWDG.cs
new file mode 100644
index 00000000..4c99a13f
--- /dev/null
+++ b/app/MindWork AI Studio/Models/Hosting/Hosts/HostGWDG.cs
@@ -0,0 +1,26 @@
+using AIStudio.Provider;
+
+namespace AIStudio.Models.Hosting.Hosts;
+
+///
+/// The GWDG's academic cloud, which resells commercial models next to the open weights it runs.
+///
+///
+/// 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.
+///
+public sealed class HostGWDG : ModelHost
+{
+ ///
+ public override LLMProviders Provider => LLMProviders.GWDG;
+
+ ///
+ 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.");
+}
\ No newline at end of file
diff --git a/app/MindWork AI Studio/Models/Hosting/Hosts/HostGoogle.cs b/app/MindWork AI Studio/Models/Hosting/Hosts/HostGoogle.cs
new file mode 100644
index 00000000..86e142a7
--- /dev/null
+++ b/app/MindWork AI Studio/Models/Hosting/Hosts/HostGoogle.cs
@@ -0,0 +1,15 @@
+using AIStudio.Provider;
+
+namespace AIStudio.Models.Hosting.Hosts;
+
+///
+/// Google's own cloud.
+///
+public sealed class HostGoogle : ModelHost
+{
+ ///
+ public override LLMProviders Provider => LLMProviders.GOOGLE;
+
+ ///
+ 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.");
+}
\ No newline at end of file
diff --git a/app/MindWork AI Studio/Models/Hosting/Hosts/HostGroq.cs b/app/MindWork AI Studio/Models/Hosting/Hosts/HostGroq.cs
new file mode 100644
index 00000000..dc666f73
--- /dev/null
+++ b/app/MindWork AI Studio/Models/Hosting/Hosts/HostGroq.cs
@@ -0,0 +1,24 @@
+using AIStudio.Models.Matching;
+using AIStudio.Provider;
+
+namespace AIStudio.Models.Hosting.Hosts;
+
+///
+/// Groq, which serves open weights and writes some of their names the way the hub does.
+///
+///
+/// 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.
+///
+public sealed class HostGroq : ModelHost
+{
+ ///
+ public override LLMProviders Provider => LLMProviders.GROQ;
+
+ ///
+ 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.");
+
+ ///
+ public override bool TryUnwrap(in ModelId id, out ModelId inner, out ModelVendor? declaredVendor) => HostNaming.TrySplitOrganization(id, out inner, out declaredVendor);
+}
\ No newline at end of file
diff --git a/app/MindWork AI Studio/Models/Hosting/Hosts/HostHelmholtz.cs b/app/MindWork AI Studio/Models/Hosting/Hosts/HostHelmholtz.cs
new file mode 100644
index 00000000..6793d3be
--- /dev/null
+++ b/app/MindWork AI Studio/Models/Hosting/Hosts/HostHelmholtz.cs
@@ -0,0 +1,29 @@
+using AIStudio.Models.Matching;
+using AIStudio.Provider;
+
+namespace AIStudio.Models.Hosting.Hosts;
+
+///
+/// Helmholtz Blablador, which answers with the line a person would read in a menu.
+///
+///
+/// "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.
+///
+public sealed class HostHelmholtz : ModelHost
+{
+ ///
+ public override LLMProviders Provider => LLMProviders.HELMHOLTZ;
+
+ ///
+ 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, \" - \", and served through the OpenAI-compatible chat completion API.");
+
+ ///
+ public override bool TryUnwrap(in ModelId id, out ModelId inner, out ModelVendor? declaredVendor)
+ {
+ declaredVendor = null;
+ return HostNaming.TryStripMenuPosition(id, out inner);
+ }
+}
\ No newline at end of file
diff --git a/app/MindWork AI Studio/Models/Hosting/Hosts/HostHetzner.cs b/app/MindWork AI Studio/Models/Hosting/Hosts/HostHetzner.cs
new file mode 100644
index 00000000..37190c8b
--- /dev/null
+++ b/app/MindWork AI Studio/Models/Hosting/Hosts/HostHetzner.cs
@@ -0,0 +1,15 @@
+using AIStudio.Provider;
+
+namespace AIStudio.Models.Hosting.Hosts;
+
+///
+/// Hetzner's inference offering, which serves open weights under their plain names.
+///
+public sealed class HostHetzner : ModelHost
+{
+ ///
+ public override LLMProviders Provider => LLMProviders.HETZNER;
+
+ ///
+ 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.");
+}
\ No newline at end of file
diff --git a/app/MindWork AI Studio/Models/Hosting/Hosts/HostHuggingFace.cs b/app/MindWork AI Studio/Models/Hosting/Hosts/HostHuggingFace.cs
new file mode 100644
index 00000000..601876fd
--- /dev/null
+++ b/app/MindWork AI Studio/Models/Hosting/Hosts/HostHuggingFace.cs
@@ -0,0 +1,36 @@
+using AIStudio.Models.Matching;
+using AIStudio.Provider;
+
+namespace AIStudio.Models.Hosting.Hosts;
+
+///
+/// The Hugging Face router, whose names carry two wrappings rather than one.
+///
+///
+/// "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.
+///
+public sealed class HostHuggingFace : ModelHost
+{
+ ///
+ public override LLMProviders Provider => LLMProviders.HUGGINGFACE;
+
+ ///
+ 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.");
+
+ ///
+ 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);
+ }
+}
\ No newline at end of file
diff --git a/app/MindWork AI Studio/Models/Hosting/Hosts/HostIONOS.cs b/app/MindWork AI Studio/Models/Hosting/Hosts/HostIONOS.cs
new file mode 100644
index 00000000..08c591de
--- /dev/null
+++ b/app/MindWork AI Studio/Models/Hosting/Hosts/HostIONOS.cs
@@ -0,0 +1,24 @@
+using AIStudio.Models.Matching;
+using AIStudio.Provider;
+
+namespace AIStudio.Models.Hosting.Hosts;
+
+///
+/// The IONOS AI Model Hub, which keeps the hub spelling of the models it serves.
+///
+///
+/// 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.
+///
+public sealed class HostIONOS : ModelHost
+{
+ ///
+ public override LLMProviders Provider => LLMProviders.IONOS;
+
+ ///
+ 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.");
+
+ ///
+ public override bool TryUnwrap(in ModelId id, out ModelId inner, out ModelVendor? declaredVendor) => HostNaming.TrySplitOrganization(id, out inner, out declaredVendor);
+}
\ No newline at end of file
diff --git a/app/MindWork AI Studio/Models/Hosting/Hosts/HostLiteLLM.cs b/app/MindWork AI Studio/Models/Hosting/Hosts/HostLiteLLM.cs
new file mode 100644
index 00000000..7c65a1ec
--- /dev/null
+++ b/app/MindWork AI Studio/Models/Hosting/Hosts/HostLiteLLM.cs
@@ -0,0 +1,30 @@
+using AIStudio.Models.Matching;
+using AIStudio.Provider;
+
+namespace AIStudio.Models.Hosting.Hosts;
+
+///
+/// A LiteLLM proxy, which somebody operates themselves and names as they please.
+///
+///
+/// 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.
+///
+public sealed class HostLiteLLM : ModelHost
+{
+ ///
+ public override LLMProviders Provider => LLMProviders.LITE_LLM;
+
+ ///
+ 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.");
+
+ ///
+ public override bool TryUnwrap(in ModelId id, out ModelId inner, out ModelVendor? declaredVendor) => HostNaming.TrySplitOrganization(id, out inner, out declaredVendor);
+}
\ No newline at end of file
diff --git a/app/MindWork AI Studio/Models/Hosting/Hosts/HostMistral.cs b/app/MindWork AI Studio/Models/Hosting/Hosts/HostMistral.cs
new file mode 100644
index 00000000..17bd8cfa
--- /dev/null
+++ b/app/MindWork AI Studio/Models/Hosting/Hosts/HostMistral.cs
@@ -0,0 +1,20 @@
+using AIStudio.Provider;
+
+namespace AIStudio.Models.Hosting.Hosts;
+
+///
+/// Mistral's own platform, which by now also serves models Mistral did not build.
+///
+///
+/// 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.
+///
+public sealed class HostMistral : ModelHost
+{
+ ///
+ public override LLMProviders Provider => LLMProviders.MISTRAL;
+
+ ///
+ 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.");
+}
\ No newline at end of file
diff --git a/app/MindWork AI Studio/Models/Hosting/Hosts/HostOpenAI.cs b/app/MindWork AI Studio/Models/Hosting/Hosts/HostOpenAI.cs
new file mode 100644
index 00000000..d9b51f26
--- /dev/null
+++ b/app/MindWork AI Studio/Models/Hosting/Hosts/HostOpenAI.cs
@@ -0,0 +1,28 @@
+using AIStudio.Provider;
+
+namespace AIStudio.Models.Hosting.Hosts;
+
+///
+/// OpenAI's own cloud, the one place where the Responses API is actually spoken.
+///
+///
+/// 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.
+///
+public sealed class HostOpenAI : ModelHost
+{
+ ///
+ public override LLMProviders Provider => LLMProviders.OPEN_AI;
+
+ ///
+ 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.");
+
+ ///
+ ///
+ /// Nothing is taken away: whichever of the two APIs a model states, it can be reached through
+ /// it here.
+ ///
+ public override ModelProfile ApplyTransport(in ModelProfile profile) => profile;
+}
\ No newline at end of file
diff --git a/app/MindWork AI Studio/Models/Hosting/Hosts/HostOpenRouter.cs b/app/MindWork AI Studio/Models/Hosting/Hosts/HostOpenRouter.cs
new file mode 100644
index 00000000..bb625828
--- /dev/null
+++ b/app/MindWork AI Studio/Models/Hosting/Hosts/HostOpenRouter.cs
@@ -0,0 +1,25 @@
+using AIStudio.Models.Matching;
+using AIStudio.Provider;
+
+namespace AIStudio.Models.Hosting.Hosts;
+
+///
+/// OpenRouter, which serves other people's models and says whose they are.
+///
+///
+/// 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.
+///
+public sealed class HostOpenRouter : ModelHost
+{
+ ///
+ public override LLMProviders Provider => LLMProviders.OPEN_ROUTER;
+
+ ///
+ 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.");
+
+ ///
+ public override bool TryUnwrap(in ModelId id, out ModelId inner, out ModelVendor? declaredVendor) => HostNaming.TrySplitOrganization(id, out inner, out declaredVendor);
+}
\ No newline at end of file
diff --git a/app/MindWork AI Studio/Models/Hosting/Hosts/HostPerplexity.cs b/app/MindWork AI Studio/Models/Hosting/Hosts/HostPerplexity.cs
new file mode 100644
index 00000000..ccb80b52
--- /dev/null
+++ b/app/MindWork AI Studio/Models/Hosting/Hosts/HostPerplexity.cs
@@ -0,0 +1,15 @@
+using AIStudio.Provider;
+
+namespace AIStudio.Models.Hosting.Hosts;
+
+///
+/// Perplexity's own API.
+///
+public sealed class HostPerplexity : ModelHost
+{
+ ///
+ public override LLMProviders Provider => LLMProviders.PERPLEXITY;
+
+ ///
+ 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.");
+}
\ No newline at end of file
diff --git a/app/MindWork AI Studio/Models/Hosting/Hosts/HostSelfHosted.cs b/app/MindWork AI Studio/Models/Hosting/Hosts/HostSelfHosted.cs
new file mode 100644
index 00000000..a45f601b
--- /dev/null
+++ b/app/MindWork AI Studio/Models/Hosting/Hosts/HostSelfHosted.cs
@@ -0,0 +1,31 @@
+using AIStudio.Models.Matching;
+using AIStudio.Provider;
+
+namespace AIStudio.Models.Hosting.Hosts;
+
+///
+/// Somebody's own engine: Ollama, LM Studio, vLLM, llama.cpp, or a proxy in front of them.
+///
+///
+/// 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.
+///
+public sealed class HostSelfHosted : ModelHost
+{
+ ///
+ public override LLMProviders Provider => LLMProviders.SELF_HOSTED;
+
+ ///
+ 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.");
+
+ ///
+ public override bool TryUnwrap(in ModelId id, out ModelId inner, out ModelVendor? declaredVendor) => HostNaming.TrySplitOrganization(id, out inner, out declaredVendor);
+}
\ No newline at end of file
diff --git a/app/MindWork AI Studio/Models/Hosting/Hosts/HostX.cs b/app/MindWork AI Studio/Models/Hosting/Hosts/HostX.cs
new file mode 100644
index 00000000..32238ef0
--- /dev/null
+++ b/app/MindWork AI Studio/Models/Hosting/Hosts/HostX.cs
@@ -0,0 +1,15 @@
+using AIStudio.Provider;
+
+namespace AIStudio.Models.Hosting.Hosts;
+
+///
+/// xAI's own API, where Grok comes from.
+///
+public sealed class HostX : ModelHost
+{
+ ///
+ public override LLMProviders Provider => LLMProviders.X;
+
+ ///
+ 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.");
+}
\ No newline at end of file
diff --git a/app/MindWork AI Studio/Models/Hosting/ModelHost.cs b/app/MindWork AI Studio/Models/Hosting/ModelHost.cs
new file mode 100644
index 00000000..1491724c
--- /dev/null
+++ b/app/MindWork AI Studio/Models/Hosting/ModelHost.cs
@@ -0,0 +1,68 @@
+using AIStudio.Models.Matching;
+using AIStudio.Provider;
+
+namespace AIStudio.Models.Hosting;
+
+///
+/// The ordinary host: it serves models under the names they are known by, through the ordinary API.
+///
+///
+/// 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.
+///
+public abstract class ModelHost : IModelHost
+{
+ ///
+ /// The two capabilities which say through which API a model is reached.
+ ///
+ private const Capability THE_APIS = Capability.CHAT_COMPLETION_API | Capability.RESPONSES_API;
+
+ ///
+ public abstract LLMProviders Provider { get; }
+
+ ///
+ public abstract ModelSource Source { get; }
+
+ ///
+ ///
+ /// Nothing is wrapped here: this host serves models under the names they are known by.
+ ///
+ public virtual bool TryUnwrap(in ModelId id, out ModelId inner, out ModelVendor? declaredVendor)
+ {
+ inner = id;
+ declaredVendor = null;
+ return false;
+ }
+
+ ///
+ ///
+ /// 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.
+ ///
+ public virtual ModelProfile ApplyTransport(in ModelProfile profile) => ThroughTheOrdinaryApi(profile);
+
+ ///
+ /// Puts a profile on the ordinary chat completion API.
+ ///
+ ///
+ /// 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.
+ ///
+ /// What the model can do.
+ /// What it can do when reached through the ordinary API.
+ 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 };
+ }
+}
\ No newline at end of file
diff --git a/app/MindWork AI Studio/Models/Hosting/ModelHostIndex.cs b/app/MindWork AI Studio/Models/Hosting/ModelHostIndex.cs
new file mode 100644
index 00000000..2c95eab9
--- /dev/null
+++ b/app/MindWork AI Studio/Models/Hosting/ModelHostIndex.cs
@@ -0,0 +1,144 @@
+using System.Collections.Frozen;
+
+using AIStudio.Models.Matching;
+using AIStudio.Provider;
+
+namespace AIStudio.Models.Hosting;
+
+///
+/// Which host answers for which provider, and the unwrapping walk itself.
+///
+///
+/// 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.
+///
+public sealed class ModelHostIndex
+{
+ ///
+ /// How often a name may be unwrapped before we stop believing the host.
+ ///
+ ///
+ /// 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.
+ ///
+ public const int MAX_UNWRAPPING_STEPS = 8;
+
+ private readonly FrozenDictionary byProvider;
+
+ private ModelHostIndex(FrozenDictionary byProvider, IReadOnlyList hosts, IReadOnlyList providersWithoutAHost)
+ {
+ this.byProvider = byProvider;
+ this.Hosts = hosts;
+ this.ProvidersWithoutAHost = providersWithoutAHost;
+ }
+
+ ///
+ /// Every host the index was built from, ordered by provider.
+ ///
+ public IReadOnlyList Hosts { get; }
+
+ ///
+ /// The providers a person can configure for which nobody wrote a host.
+ ///
+ ///
+ /// 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.
+ ///
+ public IReadOnlyList ProvidersWithoutAHost { get; }
+
+ ///
+ /// Builds an index over a set of hosts.
+ ///
+ /// The hosts, in any order.
+ /// The index.
+ /// When two hosts answer for the same provider, or a host answers for none.
+ public static ModelHostIndex Build(IEnumerable hosts)
+ {
+ var byProvider = new Dictionary();
+ 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()
+ .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);
+ }
+
+ ///
+ /// The host answering for a provider.
+ ///
+ /// The provider.
+ /// The host, or nothing when nobody wrote one.
+ public IModelHost? Of(LLMProviders provider) => this.byProvider.GetValueOrDefault(provider);
+
+ ///
+ /// Takes a name apart until the model underneath is visible.
+ ///
+ ///
+ /// 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.
+ ///
+ /// The name as the provider reported it.
+ /// Who reported it.
+ /// Who the wrappings say built the model, when they say so.
+ /// The name with every wrapping taken off.
+ 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;
+ }
+
+ ///
+ /// Takes away what a provider cannot offer, whatever the model itself can do.
+ ///
+ ///
+ /// 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.
+ ///
+ /// What the model can do.
+ /// Who serves it.
+ /// What it can do through this provider.
+ public ModelProfile ApplyTransport(in ModelProfile profile, LLMProviders provider)
+ {
+ var host = this.Of(provider);
+ return host?.ApplyTransport(profile) ?? ModelHost.ThroughTheOrdinaryApi(profile);
+ }
+}
\ No newline at end of file
diff --git a/app/Tests/Models/Hosting/HostNamingTests.cs b/app/Tests/Models/Hosting/HostNamingTests.cs
new file mode 100644
index 00000000..693f37bb
--- /dev/null
+++ b/app/Tests/Models/Hosting/HostNamingTests.cs
@@ -0,0 +1,140 @@
+using AIStudio.Models;
+using AIStudio.Models.Hosting;
+using AIStudio.Models.Matching;
+
+namespace AIStudio.Tests.Models.Hosting;
+
+///
+/// Checks how one wrapping is taken off a name.
+///
+///
+/// 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.
+///
+[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));
+ });
+ }
+}
\ No newline at end of file
diff --git a/app/Tests/Models/Hosting/ModelHostIndexTests.cs b/app/Tests/Models/Hosting/ModelHostIndexTests.cs
new file mode 100644
index 00000000..4a723c91
--- /dev/null
+++ b/app/Tests/Models/Hosting/ModelHostIndexTests.cs
@@ -0,0 +1,187 @@
+using AIStudio.Models;
+using AIStudio.Models.Hosting;
+using AIStudio.Models.Matching;
+using AIStudio.Provider;
+
+namespace AIStudio.Tests.Models.Hosting;
+
+///
+/// Checks the walk which takes a name apart, and what happens when nobody wrote a host.
+///
+///
+/// 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.
+///
+[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(() => 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(() => 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;
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/Tests/Models/Hosting/ModelHostTests.cs b/app/Tests/Models/Hosting/ModelHostTests.cs
new file mode 100644
index 00000000..4294d9e4
--- /dev/null
+++ b/app/Tests/Models/Hosting/ModelHostTests.cs
@@ -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;
+
+///
+/// Checks the hosts the app actually ships, against the names the providers actually answer with.
+///
+///
+/// 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.
+///
+[TestFixture]
+public sealed class ModelHostTests
+{
+ ///
+ /// The hosts as the app has them, found by the generator rather than listed here.
+ ///
+ 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);
+}
\ No newline at end of file