// TEMPLATE: ModelRegistry.cs // PURPOSE: Reads config/model-registry.json at startup and exposes IChatClient by alias. // READS STANDARD: ai-agents-providers-model-registry // PROVIDERS: openai + ollama implemented below. To add google / anthropic / azure-openai, // follow the same pattern — see ai-agents-providers-model-registry §providers // (that standard is the source of truth for provider SDKs). // PLACEHOLDERS: // {{PROJECT_NAMESPACE}} — root namespace of the project (e.g., MyProject) // {{REGISTRY_PATH}} — path to model-registry.json relative to app base // (default: "config/model-registry.json") // LAST-VERIFIED: 2026-05-19 (MAF 1.0 GA, OpenAI SDK 2.x) using System.ClientModel; using System.Text.Json; using Microsoft.Extensions.AI; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Hosting; using OpenAI; using OpenAI.Chat; namespace {{PROJECT_NAMESPACE}}.AI; public sealed class ModelRegistry { private readonly Dictionary _aliases; private readonly Dictionary _chatClientCache = new(); private readonly IConfiguration _config; private readonly object _lock = new(); public ModelRegistry(IConfiguration config, IHostEnvironment env) { _config = config; var path = Path.Combine(env.ContentRootPath, "{{REGISTRY_PATH}}"); if (!File.Exists(path)) throw new InvalidOperationException($"Model registry not found at {path}"); var json = File.ReadAllText(path); var doc = JsonSerializer.Deserialize(json, JsonOpts) ?? throw new InvalidOperationException("Empty model-registry.json"); _aliases = doc.Aliases; DefaultAlias = doc.DefaultAlias; } /// The alias used when a caller does not specify one (from model-registry.json). public string DefaultAlias { get; } public IChatClient GetChatClient(string alias) { if (!_aliases.TryGetValue(alias, out var spec)) throw new InvalidOperationException($"Unknown model alias: {alias}"); lock (_lock) { if (_chatClientCache.TryGetValue(alias, out var cached)) return cached; var client = spec.Provider switch { "openai" => BuildOpenAI(spec), "ollama" => BuildOllama(spec), // To add a provider, implement Build(spec) following the // pattern below and add a case here. Confirm the NuGet SDK first — // see ai-agents-providers-model-registry §providers. // "google" => BuildGoogle(spec), // "anthropic" => BuildAnthropic(spec), // "azure-openai" => BuildAzureOpenAI(spec), _ => throw new NotSupportedException( $"Provider '{spec.Provider}' has no branch. Add it following " + $"the pattern in ai-agents-providers-model-registry.") }; _chatClientCache[alias] = client; return client; } } private IChatClient BuildOpenAI(ModelAlias spec) { var apiKey = _config["OpenAI:ApiKey"] ?? throw new InvalidOperationException("OpenAI:ApiKey not configured"); return new ChatClient(spec.Model, apiKey).AsIChatClient(); } private IChatClient BuildOllama(ModelAlias spec) { // Ollama exposes an OpenAI-compatible endpoint — reuse the OpenAI ChatClient. var endpoint = spec.Options?.GetValueOrDefault("endpoint")?.ToString() ?? "http://localhost:11434/v1"; return new ChatClient( spec.Model, new ApiKeyCredential("ollama"), // any non-empty string new OpenAIClientOptions { Endpoint = new Uri(endpoint) }) .AsIChatClient(); } private static readonly JsonSerializerOptions JsonOpts = new() { PropertyNameCaseInsensitive = true, ReadCommentHandling = JsonCommentHandling.Skip, AllowTrailingCommas = true }; // NOTE: Fallback is parsed but not yet acted on. Implementing automatic // provider failover (retry on rate-limit/outage) is reserved for a later wave. private sealed record RegistryDocument( string Version, string DefaultAlias, Dictionary Aliases, Dictionary? Fallback ); public sealed record ModelAlias( string Provider, string Model, Dictionary? Options ); }