Files
AX-Copilot-Codex/src/AxCopilot/Services/IbmIamTokenService.cs
lacvet 817fc94f41
Some checks failed
Release Gate / gate (push) Has been cancelled
IBM 연동형 vLLM 인증 실패 원인 수정
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)
2026-04-06 15:02:42 +09:00

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(); }
}
}