diff --git a/README.md b/README.md
index 00c2e82..29299a0 100644
--- a/README.md
+++ b/README.md
@@ -1226,3 +1226,7 @@ MIT License
- [ChatWindow.AgentEventRendering.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.AgentEventRendering.cs)의 callout을 상태별 제목/강조선 구조로 다듬었다. 권한 요청은 `확인 포인트`, 승인 완료는 `적용 내용`, 도구 결과는 `승인 필요`, `오류 확인`, `부분 완료 점검`, `다음 권장 작업`처럼 제목이 달라져 카드 의미가 더 즉시 읽히게 정리했다.
- 업데이트: 2026-04-06 13:36 (KST)
- 파일 기반 transcript 카드의 액션 라벨도 상태별로 다르게 정리했다. [ChatWindow.AgentEventRendering.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.AgentEventRendering.cs) 에서 권한 요청은 `변경 확인`, `작성 내용 보기`, 도구 결과는 `결과 보기`, `부분 결과 보기`, `오류 파일 보기`, `승인 전 미리보기`처럼 더 맥락에 맞는 버튼 라벨을 사용한다.
+- 업데이트: 2026-04-06 14:06 (KST)
+ - IBM 연동형 vLLM 인증 경로를 점검한 결과, 기존 AX Agent는 등록 모델 인증 방식으로 `Bearer`와 `CP4D`만 지원하고 `IBM IAM` 토큰 교환은 지원하지 않았다. 이 때문에 IBM Cloud 계열 watsonx/vLLM 게이트웨이에 API 키를 직접 Bearer로 보내면 `인증 실패 - API 키가 유효하지 않습니다.` 오류가 발생할 수 있었다.
+ - [IbmIamTokenService.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/IbmIamTokenService.cs)를 추가하고 [LlmService.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/LlmService.cs)에 `ibm_iam` 인증 타입을 연결해, 등록 모델의 API 키를 IBM IAM access token으로 교환한 뒤 Bearer 헤더에 넣도록 보강했다.
+ - [ModelRegistrationDialog.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ModelRegistrationDialog.cs), [SettingsViewModel.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/ViewModels/SettingsViewModel.cs), [ChatWindow.xaml.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.xaml.cs), [AppSettings.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Models/AppSettings.cs)도 함께 갱신해 등록 모델 인증 방식에 `IBM IAM (토큰 교환)`이 보이고 저장/표시되도록 맞췄다.
diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md
index d11596e..1dc9cdc 100644
--- a/docs/DEVELOPMENT.md
+++ b/docs/DEVELOPMENT.md
@@ -4952,3 +4952,5 @@ ow + toggle ?쒓컖 ?몄뼱濡??ㅼ떆 ?뺣젹?덈떎.
- Document update: 2026-04-06 13:26 (KST) - Added an inline `프리뷰 열기` action to file-backed permission/tool-result banners in `ChatWindow.AgentEventRendering.cs`. When a permission request requires preview or a tool result carries a real file path, the transcript card can now open the preview panel directly instead of forcing the user to find the file elsewhere first.
- Document update: 2026-04-06 13:31 (KST) - Refined the callout presentation in `ChatWindow.AgentEventRendering.cs` so the title and left accent strip vary by state. Permission requests now read as `확인 포인트` or `적용 내용`, while tool results read as `승인 필요`, `오류 확인`, `부분 완료 점검`, or `다음 권장 작업` depending on `StatusKind`.
- Document update: 2026-04-06 13:36 (KST) - Made file-backed transcript actions state-aware in `ChatWindow.AgentEventRendering.cs`. The inline preview button now changes label by context, such as `변경 확인`, `작성 내용 보기`, `부분 결과 보기`, `오류 파일 보기`, or `승인 전 미리보기`, instead of showing the same generic action for every case.
+- Document update: 2026-04-06 14:06 (KST) - Diagnosed IBM-connected vLLM authentication failures and confirmed AX only supported `bearer` and `cp4d` registered-model auth modes. Added `IbmIamTokenService.cs` and wired a new `ibm_iam` auth mode into `LlmService.cs` so IBM Cloud API keys are exchanged for IAM access tokens before being sent as Bearer credentials.
+- Document update: 2026-04-06 14:06 (KST) - Updated the registered-model schema and UI surfaces to expose the new auth mode. `AppSettings.cs`, `SettingsViewModel.cs`, `ModelRegistrationDialog.cs`, and the AX Agent overlay model list in `ChatWindow.xaml.cs` now save/display `IBM IAM` alongside existing `Bearer` and `CP4D` modes.
diff --git a/src/AxCopilot/Models/AppSettings.cs b/src/AxCopilot/Models/AppSettings.cs
index 5065a86..0ad32b7 100644
--- a/src/AxCopilot/Models/AppSettings.cs
+++ b/src/AxCopilot/Models/AppSettings.cs
@@ -1383,7 +1383,7 @@ public class RegisteredModel
// ── CP4D (IBM Cloud Pak for Data) 인증 ──────────────────────────────
- /// 인증 방식. bearer (기본) | cp4d
+ /// 인증 방식. bearer (기본) | ibm_iam | cp4d
[JsonPropertyName("authType")]
public string AuthType { get; set; } = "bearer";
diff --git a/src/AxCopilot/Services/IbmIamTokenService.cs b/src/AxCopilot/Services/IbmIamTokenService.cs
new file mode 100644
index 0000000..3ea2375
--- /dev/null
+++ b/src/AxCopilot/Services/IbmIamTokenService.cs
@@ -0,0 +1,106 @@
+using System.Net.Http;
+using System.Text;
+using System.Text.Json;
+
+namespace AxCopilot.Services;
+
+///
+/// IBM Cloud IAM 액세스 토큰 발급 및 캐싱 서비스.
+/// API 키를 IAM 토큰으로 교환한 뒤 Bearer 토큰으로 재사용합니다.
+///
+internal sealed class IbmIamTokenService
+{
+ private static readonly HttpClient _http = new()
+ {
+ Timeout = TimeSpan.FromSeconds(15)
+ };
+
+ private static readonly Dictionary _cache = new();
+ private static readonly object _lock = new();
+ private const string DefaultIamUrl = "https://iam.cloud.ibm.com/identity/token";
+
+ public static async Task 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(); }
+ }
+}
diff --git a/src/AxCopilot/Services/LlmService.cs b/src/AxCopilot/Services/LlmService.cs
index 960ba2b..d55556d 100644
--- a/src/AxCopilot/Services/LlmService.cs
+++ b/src/AxCopilot/Services/LlmService.cs
@@ -322,7 +322,7 @@ public partial class LlmService : IDisposable
///
/// 현재 활성 모델의 인증 헤더 값을 반환합니다.
- /// CP4D 인증인 경우 토큰을 자동 발급/캐싱하여 반환합니다.
+ /// IBM IAM / CP4D 인증인 경우 토큰을 자동 발급/캐싱하여 반환합니다.
///
internal async Task 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
///
/// HttpRequestMessage에 인증 헤더를 적용합니다.
- /// CP4D 인증인 경우 자동 토큰 발급, 일반 Bearer인 경우 API 키를 사용합니다.
+ /// IBM IAM / CP4D 인증인 경우 자동 토큰 발급, 일반 Bearer인 경우 API 키를 사용합니다.
///
private async Task ApplyAuthHeaderAsync(HttpRequestMessage req, CancellationToken ct)
{
diff --git a/src/AxCopilot/ViewModels/SettingsViewModel.cs b/src/AxCopilot/ViewModels/SettingsViewModel.cs
index 475a8e0..5702360 100644
--- a/src/AxCopilot/ViewModels/SettingsViewModel.cs
+++ b/src/AxCopilot/ViewModels/SettingsViewModel.cs
@@ -2028,7 +2028,7 @@ public class RegisteredModelRow : INotifyPropertyChanged
private string _cp4dUsername = "";
private string _cp4dPassword = "";
- /// 인증 방식. bearer | cp4d
+ /// 인증 방식. bearer | ibm_iam | cp4d
public string AuthType
{
get => _authType;
@@ -2057,7 +2057,12 @@ public class RegisteredModelRow : INotifyPropertyChanged
}
/// 인증 방식 라벨
- public string AuthLabel => _authType == "cp4d" ? "CP4D" : "Bearer";
+ public string AuthLabel => (_authType ?? "bearer").ToLowerInvariant() switch
+ {
+ "cp4d" => "CP4D",
+ "ibm_iam" => "IBM IAM",
+ _ => "Bearer",
+ };
/// UI에 표시할 엔드포인트 요약
public string EndpointDisplay => string.IsNullOrEmpty(_endpoint) ? "(기본 서버)" : _endpoint;
diff --git a/src/AxCopilot/Views/ChatWindow.xaml.cs b/src/AxCopilot/Views/ChatWindow.xaml.cs
index b572378..9410056 100644
--- a/src/AxCopilot/Views/ChatWindow.xaml.cs
+++ b/src/AxCopilot/Views/ChatWindow.xaml.cs
@@ -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);
diff --git a/src/AxCopilot/Views/ModelRegistrationDialog.cs b/src/AxCopilot/Views/ModelRegistrationDialog.cs
index 4138a53..47f95f2 100644
--- a/src/AxCopilot/Views/ModelRegistrationDialog.cs
+++ b/src/AxCopilot/Views/ModelRegistrationDialog.cs
@@ -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 키 입력 ────────────────────────────────────────