Skip to content

Commit d7e2a5f

Browse files
committed
final improvements
Add a VertexExample.Setup helper and update examples to call it for Google service account configuration. Refactor GoogleServiceAccountTokenProvider: use a per-client-email ConcurrentDictionary cache and per-email SemaphoreSlim locks, parse token responses via HttpClient.Json APIs, target oauth2.googleapis.com/token, add IDisposable to dispose RSA, improve error messages and token expiry handling. Refactor VertexService: centralize GoogleServiceAccountConfig access via Auth property, build endpoints from Auth.ProjectId, streamline GetApiKey/GetApiName/ValidateApiKey, and reorganize multimodal file processing by introducing AppendDocument, BuildQuery and MergeInlineContent helpers, renaming native extensions and cleaning up temp file handling. Small logging and clarity improvements throughout.
1 parent af17c31 commit d7e2a5f

5 files changed

Lines changed: 114 additions & 121 deletions

File tree

Examples/Examples/Chat/ChatExampleVertex.cs

Lines changed: 2 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,6 @@
1-
using MaIN.Core;
1+
using Examples.Utils;
22
using MaIN.Core.Hub;
3-
using MaIN.Domain.Configuration;
43
using MaIN.Domain.Configuration.BackendInferenceParams;
5-
using MaIN.Domain.Configuration.Vertex;
64
using MaIN.Domain.Models;
75

86
namespace Examples.Chat;
@@ -11,17 +9,7 @@ public class ChatExampleVertex : IExample
119
{
1210
public async Task Start()
1311
{
14-
MaINBootstrapper.Initialize(configureSettings: options =>
15-
{
16-
options.BackendType = BackendType.Vertex;
17-
options.GoogleServiceAccountAuth = new GoogleServiceAccountConfig
18-
{
19-
ProjectId = "<YOUR_GCP_PROJECT_ID>",
20-
ClientEmail = "<YOUR_SERVICE_ACCOUNT_EMAIL>",
21-
PrivateKey = "<YOUR_PRIVATE_KEY>"
22-
};
23-
});
24-
12+
VertexExample.Setup(); //We need to provide Google service account config
2513
Console.WriteLine("(Vertex AI) ChatExample is running!");
2614

2715
await AIHub.Chat()
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
using MaIN.Core;
2+
using MaIN.Domain.Configuration;
3+
using MaIN.Domain.Configuration.Vertex;
4+
5+
namespace Examples.Utils;
6+
7+
public class VertexExample
8+
{
9+
public static void Setup()
10+
{
11+
MaINBootstrapper.Initialize(configureSettings: options =>
12+
{
13+
options.BackendType = BackendType.Vertex;
14+
options.GoogleServiceAccountAuth = new GoogleServiceAccountConfig
15+
{
16+
ProjectId = "<YOUR_GCP_PROJECT_ID>",
17+
ClientEmail = "<YOUR_SERVICE_ACCOUNT_EMAIL>",
18+
PrivateKey = @"<YOUR_PRIVATE_KEY>"
19+
};
20+
});
21+
}
22+
}

src/MaIN.InferPage/.claude/settings.local.json

Lines changed: 0 additions & 12 deletions
This file was deleted.

src/MaIN.Services/Services/LLMService/Auth/GoogleServiceAccountTokenProvider.cs

Lines changed: 27 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,13 @@
1+
using System.Collections.Concurrent;
2+
using System.Net.Http.Json;
13
using System.Security.Cryptography;
24
using System.Text;
3-
using System.Text.Json;
45
using System.Text.Json.Serialization;
56
using MaIN.Domain.Configuration.Vertex;
67

78
namespace MaIN.Services.Services.LLMService.Auth;
89

9-
internal sealed class GoogleServiceAccountTokenProvider
10+
internal sealed class GoogleServiceAccountTokenProvider : IDisposable
1011
{
1112
private const string Scope = "https://www.googleapis.com/auth/cloud-platform";
1213
private const int TokenLifetimeSeconds = 3600;
@@ -15,9 +16,8 @@ internal sealed class GoogleServiceAccountTokenProvider
1516
private readonly GoogleServiceAccountConfig _config;
1617
private readonly RSA _rsa;
1718

18-
// Static cache shared across all VertexService instances (keyed by ClientEmail)
19-
private static readonly System.Collections.Concurrent.ConcurrentDictionary<string, CachedToken> _tokenCache = new();
20-
private static readonly SemaphoreSlim _refreshLock = new(1, 1);
19+
private static readonly ConcurrentDictionary<string, CachedToken> TokenCache = new();
20+
private static readonly ConcurrentDictionary<string, SemaphoreSlim> RefreshLocks = new();
2121

2222
public GoogleServiceAccountTokenProvider(GoogleServiceAccountConfig config)
2323
{
@@ -28,45 +28,46 @@ public GoogleServiceAccountTokenProvider(GoogleServiceAccountConfig config)
2828

2929
public async Task<string> GetAccessTokenAsync(HttpClient httpClient)
3030
{
31-
if (_tokenCache.TryGetValue(_config.ClientEmail, out var cached) && DateTime.UtcNow < cached.Expiry)
31+
var email = _config.ClientEmail;
32+
33+
if (TokenCache.TryGetValue(email, out var cached) && !cached.IsExpired)
3234
return cached.Token;
3335

34-
await _refreshLock.WaitAsync();
36+
var refreshLock = RefreshLocks.GetOrAdd(email, _ => new SemaphoreSlim(1, 1));
37+
await refreshLock.WaitAsync();
3538
try
3639
{
3740
// Double-check after acquiring lock
38-
if (_tokenCache.TryGetValue(_config.ClientEmail, out cached) && DateTime.UtcNow < cached.Expiry)
41+
if (TokenCache.TryGetValue(email, out cached) && !cached.IsExpired)
3942
return cached.Token;
4043

4144
var jwt = BuildSignedJwt();
4245
var token = await ExchangeJwtForTokenAsync(httpClient, jwt);
4346

4447
var accessToken = token.AccessToken
45-
?? throw new InvalidOperationException("Token response missing access_token.");
48+
?? throw new InvalidOperationException("Vertex AI token response missing access_token.");
4649
var expiry = DateTime.UtcNow.AddSeconds(token.ExpiresIn).AddMinutes(-RefreshBufferMinutes);
4750

48-
_tokenCache[_config.ClientEmail] = new CachedToken(accessToken, expiry);
51+
TokenCache[email] = new CachedToken(accessToken, expiry);
4952
return accessToken;
5053
}
5154
finally
5255
{
53-
_refreshLock.Release();
56+
refreshLock.Release();
5457
}
5558
}
5659

57-
private sealed record CachedToken(string Token, DateTime Expiry);
58-
5960
private string BuildSignedJwt()
6061
{
6162
var now = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
6263

63-
var header = Base64UrlEncode(JsonSerializer.SerializeToUtf8Bytes(new
64+
var header = Base64UrlEncode(System.Text.Json.JsonSerializer.SerializeToUtf8Bytes(new
6465
{
6566
alg = "RS256",
6667
typ = "JWT"
6768
}));
6869

69-
var payload = Base64UrlEncode(JsonSerializer.SerializeToUtf8Bytes(new
70+
var payload = Base64UrlEncode(System.Text.Json.JsonSerializer.SerializeToUtf8Bytes(new
7071
{
7172
iss = _config.ClientEmail,
7273
scope = Scope,
@@ -81,15 +82,15 @@ private string BuildSignedJwt()
8182
return $"{header}.{payload}.{Base64UrlEncode(signature)}";
8283
}
8384

84-
private async Task<TokenResponse> ExchangeJwtForTokenAsync(HttpClient httpClient, string jwt)
85+
private static async Task<TokenResponse> ExchangeJwtForTokenAsync(HttpClient httpClient, string jwt)
8586
{
86-
var content = new FormUrlEncodedContent(new Dictionary<string, string>
87+
using var content = new FormUrlEncodedContent(new Dictionary<string, string>
8788
{
8889
["grant_type"] = "urn:ietf:params:oauth:grant-type:jwt-bearer",
8990
["assertion"] = jwt
9091
});
9192

92-
using var response = await httpClient.PostAsync(_config.TokenUri, content);
93+
using var response = await httpClient.PostAsync("https://oauth2.googleapis.com/token", content);
9394

9495
if (!response.IsSuccessStatusCode)
9596
{
@@ -98,14 +99,20 @@ private async Task<TokenResponse> ExchangeJwtForTokenAsync(HttpClient httpClient
9899
$"Vertex AI token exchange failed ({response.StatusCode}): {error}");
99100
}
100101

101-
var json = await response.Content.ReadAsStringAsync();
102-
return JsonSerializer.Deserialize<TokenResponse>(json)
102+
return await response.Content.ReadFromJsonAsync<TokenResponse>()
103103
?? throw new InvalidOperationException("Failed to parse Vertex AI token response.");
104104
}
105105

106+
public void Dispose() => _rsa.Dispose();
107+
106108
private static string Base64UrlEncode(byte[] data)
107109
=> Convert.ToBase64String(data).TrimEnd('=').Replace('+', '-').Replace('/', '_');
108110

111+
private sealed record CachedToken(string Token, DateTime Expiry)
112+
{
113+
public bool IsExpired => DateTime.UtcNow >= Expiry;
114+
}
115+
109116
private sealed class TokenResponse
110117
{
111118
[JsonPropertyName("access_token")] public string? AccessToken { get; set; }

0 commit comments

Comments
 (0)