Compare commits

...
2 Commits
Author SHA1 Message Date
lacvet 817fc94f41 IBM 연동형 vLLM 인증 실패 원인 수정
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)
2026-04-06 15:02:42 +09:00
lacvet 3feb1f0be4 권한·도구 결과 카드 액션 문구 고도화
파일 기반 transcript 카드의 액션 버튼 라벨을 상태와 종류에 맞게 세분화했습니다.

변경 확인, 작성 내용 보기, 부분 결과 보기, 오류 파일 보기, 승인 전 미리보기 등 맥락별 문구를 적용했습니다.

README와 DEVELOPMENT 문서를 갱신했고 dotnet build 기준 경고 0 / 오류 0을 확인했습니다.
2026-04-06 14:29:05 +09:00
9 changed files with 182 additions and 10 deletions
+6
View File
@@ -1224,3 +1224,9 @@ MIT License
- 파일 경로가 있는 권한 요청/도구 결과 카드에는 [ChatWindow.AgentEventRendering.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.AgentEventRendering.cs) 에서 `프리뷰 열기` 액션을 직접 붙였다. 이제 `미리보기 권장` 상태나 파일 기반 결과에서 transcript 카드만 보고 끝나는 것이 아니라, 바로 우측 preview panel을 열어 확인 흐름으로 이어질 수 있다.
- 업데이트: 2026-04-06 13:31 (KST)
- [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 (토큰 교환)`이 보이고 저장/표시되도록 맞췄다.
+3
View File
@@ -4951,3 +4951,6 @@ ow + toggle ?쒓컖 ?몄뼱濡??ㅼ떆 ?뺣젹?덈떎.
- Document update: 2026-04-06 13:20 (KST) - Replaced the plain guidance line with typed callout boxes in `ChatWindow.AgentEventRendering.cs`. Permission requests now show an `확인 포인트` callout, while tool results show a `다음 권장 작업` callout, making the two transcript surfaces read differently even when they share the same chip language.
- 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.
+1 -1
View File
@@ -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";
@@ -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(); }
}
}
+13 -2
View File
@@ -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;
@@ -148,6 +148,34 @@ public partial class ChatWindow
};
}
private static (string label, string icon) GetPreviewActionMeta(
PermissionRequestPresentation? permissionPresentation,
ToolResultPresentation? toolResultPresentation)
{
if (permissionPresentation != null)
{
return permissionPresentation.Kind switch
{
"file_edit" => ("변경 확인", "\uE70F"),
"file_write" => ("작성 내용 보기", "\uE8A1"),
_ => ("프리뷰 열기", "\uE8A1"),
};
}
if (toolResultPresentation != null)
{
return toolResultPresentation.StatusKind switch
{
"error" => ("오류 파일 보기", "\uE783"),
"partial" => ("부분 결과 보기", "\uE8A1"),
"approval_required" => ("승인 전 미리보기", "\uE8D7"),
_ => ("결과 보기", "\uE8A1"),
};
}
return ("프리뷰 열기", "\uE8A1");
}
private Border CreateAgentInlineActionButton(string text, string icon, Brush foreground, Brush borderBrush, Action onClick)
{
var button = new Border
@@ -379,9 +407,10 @@ public partial class ChatWindow
|| toolResultPresentation != null))
{
var previewPath = evt.FilePath!;
var (actionLabel, actionIcon) = GetPreviewActionMeta(permissionPresentation, toolResultPresentation);
stack.Children.Add(CreateAgentInlineActionButton(
"프리뷰 열기",
"\uE8A1",
actionLabel,
actionIcon,
BrushFromHex("#2563EB"),
BrushFromHex("#BFDBFE"),
() => ShowPreviewPanel(previewPath)));
+6 -1
View File
@@ -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 키 입력 ────────────────────────────────────────