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