|
| 1 | +// Licensed to the .NET Foundation under one or more agreements. |
| 2 | +// The .NET Foundation licenses this file to you under the MIT license. |
| 3 | +#:property PublishAot false |
| 4 | + |
| 5 | +using System.Globalization; |
| 6 | +using System.Text; |
| 7 | +using System.Text.Json; |
| 8 | +using System.Text.Json.Serialization; |
| 9 | + |
| 10 | +var token = Environment.GetEnvironmentVariable("GITHUB_TOKEN") ?? throw new InvalidOperationException("GITHUB_TOKEN is not set"); |
| 11 | +using var ghClient = new GitHubModelClient(token); |
| 12 | +var ghModels = await ghClient.GetModelsAsync().ConfigureAwait(false); |
| 13 | +var ghGenerator = new GitHubModelClassGenerator(); |
| 14 | +var ghCode = GitHubModelClassGenerator.GenerateCode("Aspire.Hosting.GitHub", ghModels); |
| 15 | +File.WriteAllText(Path.Combine("..", "GitHubModel.Generated.cs"), ghCode); |
| 16 | +Console.WriteLine("Generated GitHub model descriptors written to GitHubModel.Generated.cs"); |
| 17 | +Console.WriteLine("\nGitHub Model data:"); |
| 18 | +Console.WriteLine(JsonSerializer.Serialize(ghModels, new JsonSerializerOptions |
| 19 | +{ |
| 20 | + WriteIndented = true, |
| 21 | + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull |
| 22 | +})); |
| 23 | + |
| 24 | +public sealed class GitHubModel |
| 25 | +{ |
| 26 | + [JsonPropertyName("id")] public string Id { get; set; } = string.Empty; |
| 27 | + [JsonPropertyName("name")] public string Name { get; set; } = string.Empty; |
| 28 | + [JsonPropertyName("registry")] public string Registry { get; set; } = string.Empty; |
| 29 | + [JsonPropertyName("publisher")] public string Publisher { get; set; } = string.Empty; |
| 30 | + [JsonPropertyName("summary")] public string Summary { get; set; } = string.Empty; |
| 31 | + [JsonPropertyName("rate_limit_tier")] public string RateLimitTier { get; set; } = string.Empty; |
| 32 | + [JsonPropertyName("html_url")] public string HtmlUrl { get; set; } = string.Empty; |
| 33 | + [JsonPropertyName("version")] public string Version { get; set; } = string.Empty; |
| 34 | + [JsonPropertyName("capabilities")] public List<string> Capabilities { get; set; } = new(); |
| 35 | + [JsonPropertyName("limits")] public GitHubModelLimits? Limits { get; set; } |
| 36 | + [JsonPropertyName("tags")] public List<string> Tags { get; set; } = new(); |
| 37 | + [JsonPropertyName("supported_input_modalities")] public List<string> SupportedInputModalities { get; set; } = new(); |
| 38 | + [JsonPropertyName("supported_output_modalities")] public List<string> SupportedOutputModalities { get; set; } = new(); |
| 39 | +} |
| 40 | + |
| 41 | +public sealed class GitHubModelLimits |
| 42 | +{ |
| 43 | + [JsonPropertyName("max_input_tokens")] public int? MaxInputTokens { get; set; } |
| 44 | + [JsonPropertyName("max_output_tokens")] public int? MaxOutputTokens { get; set; } |
| 45 | +} |
| 46 | + |
| 47 | +internal sealed class GitHubModelClient : IDisposable |
| 48 | +{ |
| 49 | + private readonly HttpClient _http = new(); |
| 50 | + private readonly string? _token; |
| 51 | + public GitHubModelClient(string? token) => _token = token; |
| 52 | + public async Task<List<GitHubModel>> GetModelsAsync() |
| 53 | + { |
| 54 | + using var req = new HttpRequestMessage(HttpMethod.Get, "https://models.github.ai/catalog/models"); |
| 55 | + req.Headers.TryAddWithoutValidation("Accept", "application/vnd.github+json"); |
| 56 | + if (!string.IsNullOrWhiteSpace(_token)) |
| 57 | + { |
| 58 | + req.Headers.TryAddWithoutValidation("Authorization", $"Bearer {_token}"); |
| 59 | + } |
| 60 | + req.Headers.TryAddWithoutValidation("X-GitHub-Api-Version", "2022-11-28"); |
| 61 | + using var resp = await _http.SendAsync(req).ConfigureAwait(false); |
| 62 | + resp.EnsureSuccessStatusCode(); |
| 63 | + await using var stream = await resp.Content.ReadAsStreamAsync().ConfigureAwait(false); |
| 64 | + var models = await JsonSerializer.DeserializeAsync<List<GitHubModel>>(stream, new JsonSerializerOptions { PropertyNameCaseInsensitive = true }).ConfigureAwait(false); |
| 65 | + return models ?? new(); |
| 66 | + } |
| 67 | + public void Dispose() => _http.Dispose(); |
| 68 | +} |
| 69 | + |
| 70 | +internal sealed class GitHubModelClassGenerator |
| 71 | +{ |
| 72 | + public static string GenerateCode(string ns, List<GitHubModel> models) |
| 73 | + { |
| 74 | + var sb = new StringBuilder(); |
| 75 | + sb.AppendLine("// Licensed to the .NET Foundation under one or more agreements."); |
| 76 | + sb.AppendLine("// The .NET Foundation licenses this file to you under the MIT license."); |
| 77 | + sb.AppendLine(CultureInfo.InvariantCulture, $"namespace {ns};"); |
| 78 | + sb.AppendLine(); |
| 79 | + sb.AppendLine("/// <summary>"); |
| 80 | + sb.AppendLine("/// Generated strongly typed model descriptors for GitHub Models."); |
| 81 | + sb.AppendLine("/// </summary>"); |
| 82 | + sb.AppendLine("public partial class GitHubModel"); |
| 83 | + sb.AppendLine("{"); |
| 84 | + var groups = models.Where(m => !string.IsNullOrEmpty(m.Publisher) && !string.IsNullOrEmpty(m.Name)) |
| 85 | + .GroupBy(m => m.Publisher) |
| 86 | + .OrderBy(g => g.Key, StringComparer.OrdinalIgnoreCase); |
| 87 | + foreach (var g in groups) |
| 88 | + { |
| 89 | + var className = ToId(g.Key); |
| 90 | + sb.AppendLine(" /// <summary>"); |
| 91 | + sb.AppendLine(CultureInfo.InvariantCulture, $" /// Models published by {EscapeXml(g.Key)}."); |
| 92 | + sb.AppendLine(" /// </summary>"); |
| 93 | + sb.AppendLine(CultureInfo.InvariantCulture, $" public static class {className}"); |
| 94 | + sb.AppendLine(" {"); |
| 95 | + foreach (var m in g.OrderBy(m => m.Name, StringComparer.OrdinalIgnoreCase)) |
| 96 | + { |
| 97 | + var prop = ToId(m.Name); |
| 98 | + var desc = EscapeXml(Clean(m.Summary ?? $"Descriptor for {m.Name}")); |
| 99 | + sb.AppendLine(" /// <summary>"); |
| 100 | + sb.AppendLine(CultureInfo.InvariantCulture, $" /// {desc}"); |
| 101 | + sb.AppendLine(" /// </summary>"); |
| 102 | + sb.AppendLine(CultureInfo.InvariantCulture, $" public static readonly GitHubModel {prop} = new() {{ Id = \"{Esc(m.Id)}\" }};"); |
| 103 | + sb.AppendLine(); |
| 104 | + } |
| 105 | + sb.AppendLine(" }"); |
| 106 | + sb.AppendLine(); |
| 107 | + } |
| 108 | + sb.AppendLine("}"); |
| 109 | + return sb.ToString(); |
| 110 | + } |
| 111 | + private static string ToId(string value) |
| 112 | + { |
| 113 | + // First, remove or replace invalid characters with spaces, but preserve + as Plus |
| 114 | + var cleaned = value.Replace('-', ' ').Replace('.', ' ').Replace('_', ' ') |
| 115 | + .Replace("+", " Plus ") // Preserve + as "Plus" to avoid clashes |
| 116 | + .Replace('(', ' ').Replace(')', ' ').Replace('[', ' ').Replace(']', ' ') |
| 117 | + .Replace('{', ' ').Replace('}', ' ').Replace('/', ' ').Replace('\\', ' ') |
| 118 | + .Replace(':', ' ').Replace(';', ' ').Replace(',', ' ').Replace('|', ' ') |
| 119 | + .Replace('&', ' ').Replace('%', ' ').Replace('$', ' ').Replace('#', ' ') |
| 120 | + .Replace('@', ' ').Replace('!', ' ').Replace('?', ' ').Replace('<', ' ') |
| 121 | + .Replace('>', ' ').Replace('=', ' ').Replace('~', ' ') |
| 122 | + .Replace('`', ' ').Replace('^', ' ').Replace('*', ' '); |
| 123 | + |
| 124 | + var parts = cleaned.Split(' ', StringSplitOptions.RemoveEmptyEntries); |
| 125 | + var sb = new StringBuilder(); |
| 126 | + foreach (var p in parts) |
| 127 | + { |
| 128 | + if (p.Length == 0) |
| 129 | + { |
| 130 | + continue; |
| 131 | + } |
| 132 | + if (char.IsDigit(p[0])) |
| 133 | + { |
| 134 | + sb.Append(p); |
| 135 | + continue; |
| 136 | + } |
| 137 | + // Preserve original casing; only capitalize a leading lowercase letter for each token. |
| 138 | + if (char.IsLower(p[0])) |
| 139 | + { |
| 140 | + sb.Append(char.ToUpperInvariant(p[0])); |
| 141 | + if (p.Length > 1) |
| 142 | + { |
| 143 | + sb.Append(p.AsSpan(1)); |
| 144 | + } |
| 145 | + } |
| 146 | + else |
| 147 | + { |
| 148 | + sb.Append(p); |
| 149 | + } |
| 150 | + } |
| 151 | + var result = sb.ToString(); |
| 152 | + |
| 153 | + // Ensure we have a valid identifier (start with letter or underscore) |
| 154 | + if (result.Length == 0 || char.IsDigit(result[0])) |
| 155 | + { |
| 156 | + result = "_" + result; |
| 157 | + } |
| 158 | + |
| 159 | + return result; |
| 160 | + } |
| 161 | + private static string Clean(string s) => s.Replace('\n', ' ').Replace('\r', ' ').Replace(" ", " ").Trim(); |
| 162 | + private static string Esc(string s) => s.Replace("\\", "\\\\").Replace("\"", "\\\""); |
| 163 | + private static string EscapeXml(string s) => s.Replace("&", "&").Replace("<", "<").Replace(">", ">").Replace("\"", """).Replace("'", "'"); |
| 164 | +} |
0 commit comments