Some checks failed
Release Gate / gate (push) Has been cancelled
IBM Cloud 계열 vLLM 연결에서 등록 모델 인증 방식이 Bearer와 CP4D만 지원하던 문제를 점검하고, IBM IAM 토큰 교환 경로를 추가했습니다. - RegisteredModel/AuthType에 ibm_iam 경로를 반영했습니다. - IbmIamTokenService를 추가해 API 키를 IAM access token으로 교환한 뒤 Bearer 헤더로 적용하도록 했습니다. - 모델 등록 다이얼로그, 설정 ViewModel, AX Agent 오버레이 모델 목록에도 IBM IAM 표시를 추가했습니다. - README.md와 docs/DEVELOPMENT.md에 2026-04-06 14:06 (KST) 기준 이력을 반영했습니다. 검증: dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify\\ -p:IntermediateOutputPath=obj\\verify\\ (경고 0 / 오류 0)
107 lines
3.8 KiB
C#
107 lines
3.8 KiB
C#
using System.Net.Http;
|
|
using System.Text;
|
|
using System.Text.Json;
|
|
|
|
namespace AxCopilot.Services;
|
|
|
|
/// <summary>
|
|
/// IBM Cloud IAM 액세스 토큰 발급 및 캐싱 서비스.
|
|
/// API 키를 IAM 토큰으로 교환한 뒤 Bearer 토큰으로 재사용합니다.
|
|
/// </summary>
|
|
internal sealed class IbmIamTokenService
|
|
{
|
|
private static readonly HttpClient _http = new()
|
|
{
|
|
Timeout = TimeSpan.FromSeconds(15)
|
|
};
|
|
|
|
private static readonly Dictionary<string, (string Token, DateTime Expiry)> _cache = new();
|
|
private static readonly object _lock = new();
|
|
private const string DefaultIamUrl = "https://iam.cloud.ibm.com/identity/token";
|
|
|
|
public static async Task<string?> GetTokenAsync(string apiKey, string? iamUrl = null, CancellationToken ct = default)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(apiKey))
|
|
return null;
|
|
|
|
var tokenUrl = string.IsNullOrWhiteSpace(iamUrl) ? DefaultIamUrl : iamUrl.Trim();
|
|
var cacheKey = $"{tokenUrl}|{apiKey}";
|
|
|
|
lock (_lock)
|
|
{
|
|
if (_cache.TryGetValue(cacheKey, out var cached) && cached.Expiry > DateTime.UtcNow.AddMinutes(1))
|
|
return cached.Token;
|
|
}
|
|
|
|
try
|
|
{
|
|
using var req = new HttpRequestMessage(HttpMethod.Post, tokenUrl);
|
|
req.Content = new StringContent(
|
|
$"grant_type=urn:ibm:params:oauth:grant-type:apikey&apikey={Uri.EscapeDataString(apiKey)}",
|
|
Encoding.UTF8,
|
|
"application/x-www-form-urlencoded");
|
|
req.Headers.Accept.ParseAdd("application/json");
|
|
|
|
using var resp = await _http.SendAsync(req, ct);
|
|
if (!resp.IsSuccessStatusCode)
|
|
{
|
|
var errBody = await resp.Content.ReadAsStringAsync(ct);
|
|
LogService.Warn($"IBM IAM 토큰 발급 실패: {resp.StatusCode} - {errBody}");
|
|
return null;
|
|
}
|
|
|
|
var json = await resp.Content.ReadAsStringAsync(ct);
|
|
using var doc = JsonDocument.Parse(json);
|
|
if (!doc.RootElement.TryGetProperty("access_token", out var tokenProp))
|
|
{
|
|
LogService.Warn("IBM IAM 응답에 access_token 필드가 없습니다.");
|
|
return null;
|
|
}
|
|
|
|
var token = tokenProp.GetString();
|
|
if (string.IsNullOrWhiteSpace(token))
|
|
return null;
|
|
|
|
var expiry = DateTime.UtcNow.AddMinutes(55);
|
|
if (doc.RootElement.TryGetProperty("expiration", out var expirationProp) &&
|
|
expirationProp.TryGetInt64(out var expirationEpoch))
|
|
{
|
|
expiry = DateTimeOffset.FromUnixTimeSeconds(expirationEpoch).UtcDateTime;
|
|
}
|
|
else if (doc.RootElement.TryGetProperty("expires_in", out var expiresInProp) &&
|
|
expiresInProp.TryGetInt64(out var expiresInSeconds))
|
|
{
|
|
expiry = DateTime.UtcNow.AddSeconds(expiresInSeconds);
|
|
}
|
|
|
|
lock (_lock)
|
|
{
|
|
_cache[cacheKey] = (token, expiry);
|
|
}
|
|
|
|
LogService.Info($"IBM IAM 토큰 발급 완료: {tokenUrl} (만료: {expiry:yyyy-MM-dd HH:mm} UTC)");
|
|
return token;
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
LogService.Error($"IBM IAM 토큰 발급 오류: {ex.Message}");
|
|
return null;
|
|
}
|
|
}
|
|
|
|
public static void InvalidateToken(string apiKey, string? iamUrl = null)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(apiKey))
|
|
return;
|
|
|
|
var tokenUrl = string.IsNullOrWhiteSpace(iamUrl) ? DefaultIamUrl : iamUrl.Trim();
|
|
var cacheKey = $"{tokenUrl}|{apiKey}";
|
|
lock (_lock) { _cache.Remove(cacheKey); }
|
|
}
|
|
|
|
public static void ClearAllTokens()
|
|
{
|
|
lock (_lock) { _cache.Clear(); }
|
|
}
|
|
}
|