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)
This commit is contained in:
@@ -1383,7 +1383,7 @@ public class RegisteredModel
|
||||
|
||||
// ── CP4D (IBM Cloud Pak for Data) 인증 ──────────────────────────────
|
||||
|
||||
/// <summary>인증 방식. bearer (기본) | cp4d</summary>
|
||||
/// <summary>인증 방식. bearer (기본) | ibm_iam | cp4d</summary>
|
||||
[JsonPropertyName("authType")]
|
||||
public string AuthType { get; set; } = "bearer";
|
||||
|
||||
|
||||
106
src/AxCopilot/Services/IbmIamTokenService.cs
Normal file
106
src/AxCopilot/Services/IbmIamTokenService.cs
Normal file
@@ -0,0 +1,106 @@
|
||||
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(); }
|
||||
}
|
||||
}
|
||||
@@ -322,7 +322,7 @@ public partial class LlmService : IDisposable
|
||||
|
||||
/// <summary>
|
||||
/// 현재 활성 모델의 인증 헤더 값을 반환합니다.
|
||||
/// CP4D 인증인 경우 토큰을 자동 발급/캐싱하여 반환합니다.
|
||||
/// IBM IAM / CP4D 인증인 경우 토큰을 자동 발급/캐싱하여 반환합니다.
|
||||
/// </summary>
|
||||
internal async Task<string?> ResolveAuthTokenAsync(CancellationToken ct = default)
|
||||
{
|
||||
@@ -331,6 +331,17 @@ public partial class LlmService : IDisposable
|
||||
var modelName = ResolveModel();
|
||||
var registered = FindRegisteredModel(llm, activeService, modelName);
|
||||
|
||||
// IBM Cloud IAM 인증 방식인 경우
|
||||
if (registered != null &&
|
||||
registered.AuthType.Equals("ibm_iam", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var ibmApiKey = !string.IsNullOrWhiteSpace(registered.ApiKey)
|
||||
? ResolveSecretValue(registered.ApiKey, llm.EncryptionEnabled)
|
||||
: GetDefaultApiKey(llm, activeService);
|
||||
var token = await IbmIamTokenService.GetTokenAsync(ibmApiKey, ct: ct);
|
||||
return token;
|
||||
}
|
||||
|
||||
// CP4D 인증 방식인 경우
|
||||
if (registered != null &&
|
||||
registered.AuthType.Equals("cp4d", StringComparison.OrdinalIgnoreCase) &&
|
||||
@@ -349,7 +360,7 @@ public partial class LlmService : IDisposable
|
||||
|
||||
/// <summary>
|
||||
/// HttpRequestMessage에 인증 헤더를 적용합니다.
|
||||
/// CP4D 인증인 경우 자동 토큰 발급, 일반 Bearer인 경우 API 키를 사용합니다.
|
||||
/// IBM IAM / CP4D 인증인 경우 자동 토큰 발급, 일반 Bearer인 경우 API 키를 사용합니다.
|
||||
/// </summary>
|
||||
private async Task ApplyAuthHeaderAsync(HttpRequestMessage req, CancellationToken ct)
|
||||
{
|
||||
|
||||
@@ -2028,7 +2028,7 @@ public class RegisteredModelRow : INotifyPropertyChanged
|
||||
private string _cp4dUsername = "";
|
||||
private string _cp4dPassword = "";
|
||||
|
||||
/// <summary>인증 방식. bearer | cp4d</summary>
|
||||
/// <summary>인증 방식. bearer | ibm_iam | cp4d</summary>
|
||||
public string AuthType
|
||||
{
|
||||
get => _authType;
|
||||
@@ -2057,7 +2057,12 @@ public class RegisteredModelRow : INotifyPropertyChanged
|
||||
}
|
||||
|
||||
/// <summary>인증 방식 라벨</summary>
|
||||
public string AuthLabel => _authType == "cp4d" ? "CP4D" : "Bearer";
|
||||
public string AuthLabel => (_authType ?? "bearer").ToLowerInvariant() switch
|
||||
{
|
||||
"cp4d" => "CP4D",
|
||||
"ibm_iam" => "IBM IAM",
|
||||
_ => "Bearer",
|
||||
};
|
||||
|
||||
/// <summary>UI에 표시할 엔드포인트 요약</summary>
|
||||
public string EndpointDisplay => string.IsNullOrEmpty(_endpoint) ? "(기본 서버)" : _endpoint;
|
||||
|
||||
@@ -12345,7 +12345,12 @@ public partial class ChatWindow : Window
|
||||
var decryptedModelName = Services.CryptoService.DecryptIfEnabled(model.EncryptedModelName, IsOverlayEncryptionEnabled);
|
||||
var displayName = string.IsNullOrWhiteSpace(model.Alias) ? decryptedModelName : model.Alias;
|
||||
var endpointText = string.IsNullOrWhiteSpace(model.Endpoint) ? "기본 서버 사용" : model.Endpoint;
|
||||
var authLabel = string.Equals(model.AuthType, "cp4d", StringComparison.OrdinalIgnoreCase) ? "CP4D" : "Bearer";
|
||||
var authLabel = (model.AuthType ?? "bearer").ToLowerInvariant() switch
|
||||
{
|
||||
"cp4d" => "CP4D",
|
||||
"ibm_iam" => "IBM IAM",
|
||||
_ => "Bearer",
|
||||
};
|
||||
var isActive = string.Equals(model.EncryptedModelName, _settings.Settings.Llm.Model, StringComparison.OrdinalIgnoreCase)
|
||||
|| string.Equals(decryptedModelName, _settings.Settings.Llm.Model, StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@ internal sealed class ModelRegistrationDialog : Window
|
||||
private readonly TextBox _apiKeyBox;
|
||||
private readonly CheckBox _allowInsecureTlsCheck;
|
||||
|
||||
// CP4D 인증 필드
|
||||
// IBM/CP4D 인증 필드
|
||||
private readonly ComboBox _authTypeBox;
|
||||
private readonly StackPanel _cp4dPanel;
|
||||
private readonly TextBox _cp4dUrlBox;
|
||||
@@ -271,10 +271,17 @@ internal sealed class ModelRegistrationDialog : Window
|
||||
BorderBrush = borderBrush, BorderThickness = new Thickness(1),
|
||||
};
|
||||
var bearerItem = new ComboBoxItem { Content = "Bearer 토큰 (API 키)", Tag = "bearer" };
|
||||
var ibmIamItem = new ComboBoxItem { Content = "IBM IAM (토큰 교환)", Tag = "ibm_iam" };
|
||||
var cp4dItem = new ComboBoxItem { Content = "CP4D (IBM Cloud Pak for Data)", Tag = "cp4d" };
|
||||
_authTypeBox.Items.Add(bearerItem);
|
||||
_authTypeBox.Items.Add(ibmIamItem);
|
||||
_authTypeBox.Items.Add(cp4dItem);
|
||||
_authTypeBox.SelectedItem = existingAuthType == "cp4d" ? cp4dItem : bearerItem;
|
||||
_authTypeBox.SelectedItem = existingAuthType switch
|
||||
{
|
||||
"cp4d" => cp4dItem,
|
||||
"ibm_iam" => ibmIamItem,
|
||||
_ => bearerItem,
|
||||
};
|
||||
stack.Children.Add(new Border { CornerRadius = new CornerRadius(8), ClipToBounds = true, Child = _authTypeBox });
|
||||
|
||||
// ── Bearer 인증: API 키 입력 ────────────────────────────────────────
|
||||
|
||||
Reference in New Issue
Block a user