Skip to content

Commit 7d362b0

Browse files
davidfowlCopilot
andauthored
Add strongly typed github models classes to make them more discoverable (dotnet#11101)
* Add strongly typed github models classes to make them more discoverable * Refactor GitHub model initialization to use strongly typed reference for OpenAI GPT-4o Mini * Add GitHub Actions workflow for models * Update src/Aspire.Hosting.GitHub.Models/tools/GenModel.cs Co-authored-by: Copilot <[email protected]> * Update .github/workflows/update-github-models.yml Co-authored-by: Copilot <[email protected]> * Fix casing for GitHub model identifiers and improve ID generation logic --------- Co-authored-by: Copilot <[email protected]>
1 parent 53c74d8 commit 7d362b0

File tree

10 files changed

+619
-2
lines changed

10 files changed

+619
-2
lines changed

.github/workflows/update-ai-foundry-models.yml renamed to .github/workflows/update-ai-foundry-models copy.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ jobs:
3232
base: main
3333
commit-message: "[Automated] Update AI Foundry Models"
3434
labels: |
35-
area-app-model
35+
area-integrations
3636
area-engineering-systems
3737
title: "[Automated] Update AI Foundry Models"
3838
body: "Auto-generated update of Azure AI Foundry model descriptors (AIFoundryModel.Generated.cs)."
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
name: Update Github Models
2+
3+
on:
4+
workflow_dispatch:
5+
schedule:
6+
- cron: '0 5 * * *' # Daily at 05:00 UTC
7+
8+
permissions:
9+
contents: write
10+
pull-requests: write
11+
12+
jobs:
13+
generate-and-pr:
14+
runs-on: ubuntu-latest
15+
if: ${{ github.repository_owner == 'dotnet' }}
16+
steps:
17+
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
18+
19+
- name: Generate updated Github model descriptors
20+
working-directory: src/Aspire.Hosting.GitHub.Models/tools
21+
env:
22+
GITHUB_TOKEN: ${{ github.token }}
23+
CI: false
24+
run: |
25+
set -e
26+
"$GITHUB_WORKSPACE/dotnet.sh" run GenModel.cs
27+
28+
- name: Create or update pull request
29+
uses: dotnet/actions-create-pull-request@e8d799aa1f8b17f324f9513832811b0a62f1e0b1
30+
with:
31+
token: ${{ secrets.GITHUB_TOKEN }}
32+
branch: update-github-models
33+
base: main
34+
commit-message: "[Automated] Update Github Models"
35+
labels: |
36+
area-integrations
37+
area-engineering-systems
38+
title: "[Automated] Update Github Models"
39+
body: "Auto-generated update of Github model descriptors (GithubModel.Generated.cs)."

playground/GitHubModelsEndToEnd/GitHubModelsEndToEnd.AppHost/Program.cs

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
11
// Licensed to the .NET Foundation under one or more agreements.
22
// The .NET Foundation licenses this file to you under the MIT license.
33

4+
using Aspire.Hosting.GitHub;
5+
46
var builder = DistributedApplication.CreateBuilder(args);
57
builder.AddAzureContainerAppEnvironment("env");
68

7-
var chat = builder.AddGitHubModel("chat", "openai/gpt-4o-mini");
9+
var chat = builder.AddGitHubModel("chat", GitHubModel.OpenAI.OpenAIGPT4oMini);
810

911
builder.AddProject<Projects.GitHubModelsEndToEnd_WebStory>("webstory")
1012
.WithExternalHttpEndpoints()

src/Aspire.Hosting.GitHub.Models/Aspire.Hosting.GitHub.Models.csproj

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,10 @@
99
<SuppressFinalPackageVersion>true</SuppressFinalPackageVersion>
1010
</PropertyGroup>
1111

12+
<ItemGroup>
13+
<Compile Remove="tools\**\*.cs" />
14+
</ItemGroup>
15+
1216
<ItemGroup>
1317
<ProjectReference Include="..\Aspire.Hosting\Aspire.Hosting.csproj" />
1418
</ItemGroup>

src/Aspire.Hosting.GitHub.Models/GitHubModel.Generated.cs

Lines changed: 363 additions & 0 deletions
Large diffs are not rendered by default.

src/Aspire.Hosting.GitHub.Models/GitHubModelsExtensions.cs

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
// The .NET Foundation licenses this file to you under the MIT license.
33

44
using Aspire.Hosting.ApplicationModel;
5+
using Aspire.Hosting.GitHub;
56
using Aspire.Hosting.GitHub.Models;
67
using Microsoft.Extensions.DependencyInjection;
78
using Microsoft.Extensions.Diagnostics.HealthChecks;
@@ -73,6 +74,31 @@ await evt.Eventing.PublishAsync(new ConnectionStringAvailableEvent(r, evt.Servic
7374
});
7475
}
7576

77+
/// <summary>
78+
/// Adds a GitHub Model resource to the application model using a <see cref="GitHubModel"/>.
79+
/// </summary>
80+
/// <param name="builder">The <see cref="IDistributedApplicationBuilder"/>.</param>
81+
/// <param name="name">The name of the resource. This name will be used as the connection string name when referenced in a dependency.</param>
82+
/// <param name="model">The model descriptor, using the <see cref="GitHubModel"/> class like so: <code lang="csharp">builder.AddGitHubModel(name: "chat", model: GitHubModel.Microsoft.Phi3MediumInstruct)</code></param>
83+
/// <param name="organization">The organization login associated with the organization to which the request is to be attributed.</param>
84+
/// <returns>A reference to the <see cref="IResourceBuilder{T}"/>.</returns>
85+
/// <remarks>
86+
/// <example>
87+
/// Create a GitHub Model resource for the Microsoft Phi-3 Medium Instruct model:
88+
/// <code lang="csharp">
89+
/// var builder = DistributedApplication.CreateBuilder(args);
90+
///
91+
/// var githubModel = builder.AddGitHubModel("chat", GitHubModel.Microsoft.Phi3MediumInstruct);
92+
/// </code>
93+
/// </example>
94+
/// </remarks>
95+
public static IResourceBuilder<GitHubModelResource> AddGitHubModel(this IDistributedApplicationBuilder builder, [ResourceName] string name, GitHubModel model, IResourceBuilder<ParameterResource>? organization = null)
96+
{
97+
ArgumentNullException.ThrowIfNull(model);
98+
99+
return AddGitHubModel(builder, name, model.Id, organization);
100+
}
101+
76102
/// <summary>
77103
/// Configures the API key for the GitHub Model resource from a parameter.
78104
/// </summary>
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
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+
4+
namespace Aspire.Hosting.GitHub;
5+
6+
/// <summary>
7+
/// Represents a model published on GitHub.
8+
/// </summary>
9+
public partial class GitHubModel
10+
{
11+
/// <summary>
12+
/// The unique identifier for the model.
13+
/// </summary>
14+
public required string Id { get; init; }
15+
}
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
<Project>
2+
</Project>
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
<Project>
2+
</Project>
Lines changed: 164 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,164 @@
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("&", "&amp;").Replace("<", "&lt;").Replace(">", "&gt;").Replace("\"", "&quot;").Replace("'", "&apos;");
164+
}

0 commit comments

Comments
 (0)