using System; using System.Collections; using System.Collections.Generic; using System.Text; using UnityEngine; using UnityEngine.Networking; /// /// Anthropic Messages API 를 UnityWebRequest 로 직접 친다. SSE 스트리밍. /// /// 왜 공식 C# SDK 를 쓰지 않는가: /// SDK 는 netstandard2.0 타겟이 있지만 의존성(System.Text.Json 10.x, /// Microsoft.Extensions.AI.Abstractions)이 Unity Mono/IL2CPP 에서 AOT 문제를 낸다. /// 그게 원래 .NET 8 사이드카를 두기로 한 이유였다. 하지만 문제는 SDK 의존성이지 /// HTTP 자체가 아니다. 순수 HTTP + 최소 JSON 은 Unity 에서 그냥 된다. /// ASR/TTS(sherpa-onnx)를 붙일 때가 되면 그때 사이드카로 옮기고 /// IChatBackend 만 다시 구현하면 된다. /// /// JSON 은 JsonUtility 로 처리한다. 요청은 모양이 고정이라 문제없고, /// 응답은 스트리밍 델타에서 text 하나만 뽑으면 되므로 모르는 필드는 무시된다. /// public class AnthropicChatBackend : MonoBehaviour, IChatBackend { const string Endpoint = "https://api.anthropic.com/v1/messages"; const string ModelsEndpoint = "https://api.anthropic.com/v1/models"; const string ApiVersion = "2023-06-01"; /// /// 설정 화면에 보여줄 모델 선택지. 표시 이름과 실제 ID 쌍. /// /// 데스크톱 비서는 상시 떠 있으면서 잡담을 주고받으므로 비용과 지연이 그대로 체감된다. /// 그래서 성능순이 아니라 "일단 이거" 순으로 놓는다. /// public static readonly (string Label, string Id)[] SelectableModels = { ("Opus 5", "claude-opus-5"), ("Sonnet 5", "claude-sonnet-5"), ("Haiku 4.5", "claude-haiku-4-5"), }; /// 응답 깊이 선택지. 값이 클수록 더 생각하고, 더 느리고, 더 비싸다. public static readonly (string Label, string Id)[] SelectableEfforts = { ("빠르게", "low"), ("보통", "medium"), ("깊게", "high"), }; [Tooltip("응답이 아예 오지 않을 때 포기하는 시간(초). 0 이면 무제한")] [SerializeField] int timeoutSeconds = 60; [Tooltip("요청/응답 진행 상황을 콘솔에 남긴다. 응답이 안 올 때 어디서 막혔는지 보려면 켠다")] [SerializeField] bool verboseLog = true; ChatConfig config; UnityWebRequest inflight; Coroutine routine; public bool IsBusy => inflight != null; /// 설정을 주입한다. ChatController 가 시작할 때 한 번 부른다. public void Configure(ChatConfig chatConfig) { config = chatConfig; } public void Send(IReadOnlyList history, Action onDelta, Action onComplete, Action onError) { if (IsBusy) { onError?.Invoke("아직 이전 응답을 받는 중이야."); return; } if (config == null) config = ChatConfig.Load(); string apiKey = config.ResolveAnthropicKey(); if (string.IsNullOrEmpty(apiKey)) { onError?.Invoke("Claude API 키가 없어. 설정에서 넣어줘."); return; } routine = StartCoroutine(SendRoutine(apiKey, history, onDelta, onComplete, onError)); } /// /// 키와 모델 이름이 쓸 만한지 확인한다. 설정 화면의 "연결 테스트" 버튼이 부른다. /// /// 메시지를 보내보는 대신 Models API 를 조회한다. 토큰을 한 개도 쓰지 않으면서 /// 키가 유효한지(401)와 모델 이름이 맞는지(404)를 한 번에 가른다. /// 대화 요청과 별개의 요청 객체를 쓰므로 응답을 받는 중에도 눌러볼 수 있다. /// public void TestConnection(ChatConfig configToTest, Action onResult) { if (configToTest == null) { onResult?.Invoke(false, "설정이 비어 있어요."); return; } string apiKey = configToTest.ResolveAnthropicKey(); if (string.IsNullOrEmpty(apiKey)) { onResult?.Invoke(false, "API 키를 먼저 입력해주세요."); return; } StartCoroutine(TestRoutine(apiKey, configToTest.model, onResult)); } IEnumerator TestRoutine(string apiKey, string model, Action onResult) { string url = ModelsEndpoint + "/" + UnityWebRequest.EscapeURL(model); using (var request = UnityWebRequest.Get(url)) { request.SetRequestHeader("anthropic-version", ApiVersion); request.SetRequestHeader("x-api-key", apiKey); request.timeout = 20; yield return request.SendWebRequest(); if (request.result == UnityWebRequest.Result.ConnectionError) { onResult?.Invoke(false, "네트워크에 연결할 수 없어요."); yield break; } switch (request.responseCode) { case 200: onResult?.Invoke(true, $"연결 성공 — {model}"); break; case 401: case 403: onResult?.Invoke(false, "API 키가 올바르지 않아요."); break; case 404: onResult?.Invoke(false, $"'{model}' 모델을 찾을 수 없어요."); break; case 429: // 키 자체는 유효하다. 한도만 찬 상태. onResult?.Invoke(true, "키는 정상인데 요청 한도에 걸려 있어요."); break; default: onResult?.Invoke(false, $"확인 실패 (오류 {request.responseCode})"); break; } } } public void Cancel() { if (routine != null) { StopCoroutine(routine); routine = null; } // Abort 는 다음 프레임에 완료되므로 참조를 먼저 끊어 IsBusy 를 즉시 내린다. var request = inflight; inflight = null; if (request != null) { try { request.Abort(); } catch (Exception) { } request.Dispose(); } } void OnDestroy() { Cancel(); } IEnumerator SendRoutine(string apiKey, IReadOnlyList history, Action onDelta, Action onComplete, Action onError) { string body = BuildRequestJson(history); var request = new UnityWebRequest(Endpoint, UnityWebRequest.kHttpVerbPOST); var handler = new SseDownloadHandler(); request.uploadHandler = new UploadHandlerRaw(Encoding.UTF8.GetBytes(body)); request.downloadHandler = handler; request.SetRequestHeader("content-type", "application/json"); request.SetRequestHeader("accept", "text/event-stream"); request.SetRequestHeader("anthropic-version", ApiVersion); request.SetRequestHeader("x-api-key", apiKey); if (timeoutSeconds > 0) request.timeout = timeoutSeconds; // 핸들러가 본문을 SSE 로 팔지 오류로 모아둘지는 상태 코드를 봐야 정해진다. handler.Bind(request, onDelta); inflight = request; float startedAt = Time.realtimeSinceStartup; if (verboseLog) { Debug.Log($"[Anthropic] 요청 전송: model={config.model} effort={config.effort} " + $"메시지 {history.Count}개 본문 {body.Length}자"); } yield return request.SendWebRequest(); routine = null; if (verboseLog) { float elapsed = Time.realtimeSinceStartup - startedAt; Debug.Log($"[Anthropic] 응답 종료: HTTP {request.responseCode} result={request.result} " + $"{elapsed:F1}초 / 받은 글자 {handler.Text.Length} / " + $"첫 글자까지 {(handler.FirstTextAt > 0f ? (handler.FirstTextAt - startedAt).ToString("F1") + "초" : "없음")} / " + $"사고 블록 {(handler.SawThinking ? "있음" : "없음")}"); } // Cancel() 이 먼저 참조를 끊었다면 이 응답은 버려진 것이다. if (inflight != request) { request.Dispose(); yield break; } inflight = null; bool failed = request.result != UnityWebRequest.Result.Success || request.responseCode >= 400; string failure = failed ? DescribeFailure(request, handler) : null; string full = failed ? null : handler.Text; request.Dispose(); if (failed) { onError?.Invoke(failure); yield break; } if (string.IsNullOrWhiteSpace(full)) { onError?.Invoke("응답이 비어 있어. 잠시 뒤 다시 시도해줘."); yield break; } onComplete?.Invoke(full); } /// 실패를 사용자에게 그대로 보여줄 수 있는 한 문장으로 만든다. static string DescribeFailure(UnityWebRequest request, SseDownloadHandler handler) { // 본문에 구조화된 오류가 있으면 그게 가장 정확하다. string apiMessage = handler.ErrorMessage; if (!string.IsNullOrEmpty(apiMessage)) { if (request.responseCode == 401) return "API 키가 거부됐어: " + apiMessage; if (request.responseCode == 429) return "요청이 너무 잦아. 잠시 뒤 다시 해줘: " + apiMessage; return "오류 " + request.responseCode + ": " + apiMessage; } if (request.result == UnityWebRequest.Result.ConnectionError) return "연결에 실패했어. 네트워크를 확인해줘. (" + request.error + ")"; return "오류 " + request.responseCode + ": " + request.error; } string BuildRequestJson(IReadOnlyList history) { var messages = new ReqMessage[history.Count]; for (int i = 0; i < history.Count; i++) { messages[i] = new ReqMessage { role = history[i].RoleName, content = history[i].Text, }; } var request = new Request { model = config.model, max_tokens = Mathf.Max(64, config.maxTokens), stream = true, // 페르소나는 매 요청 동일한 접두사라 캐시 대상이다. 다만 캐시는 // 1024 토큰 이상부터 걸리므로 짧은 페르소나에서는 그냥 무시된다. system = new[] { new ReqSystemBlock { type = "text", text = config.persona, cache_control = new ReqCacheControl { type = "ephemeral" }, }, }, messages = messages, // 사고는 켜두되 깊이를 낮춘다. 완전히 끄면 Opus 5 에서 사고 흔적이 // 본문에 새는 사례가 있고, effort 를 낮추는 쪽이 비용도 지연도 낫다. thinking = new ReqThinking { type = "adaptive" }, output_config = new ReqOutputConfig { effort = config.effort }, }; return JsonUtility.ToJson(request); } // --- 요청 본문 --- // JsonUtility 는 선언한 필드를 빠짐없이 직렬화한다. 모양이 고정이라 그게 문제되지 않는다. [Serializable] class Request { public string model; public int max_tokens; public bool stream; public ReqSystemBlock[] system; public ReqMessage[] messages; public ReqThinking thinking; public ReqOutputConfig output_config; } [Serializable] class ReqSystemBlock { public string type; public string text; public ReqCacheControl cache_control; } [Serializable] class ReqCacheControl { public string type; } [Serializable] class ReqMessage { public string role; public string content; } [Serializable] class ReqThinking { public string type; } [Serializable] class ReqOutputConfig { public string effort; } /// /// SSE 스트림을 줄 단위로 끊어 text_delta 만 뽑아낸다. /// /// 기본 DownloadHandlerBuffer 는 응답이 다 와야 내용을 주므로 스트리밍이 되지 않는다. /// DownloadHandlerScript 는 청크가 도착할 때마다 ReceiveData 를 부른다 /// (메인 스레드에서 불리므로 Unity 객체를 만져도 안전하다). /// /// 청크 경계는 UTF-8 문자 중간에서도 잘리고 SSE 줄 중간에서도 잘린다. /// 그래서 상태를 가진 Decoder 와 줄 버퍼를 둘 다 유지한다. /// class SseDownloadHandler : DownloadHandlerScript { readonly Decoder decoder = Encoding.UTF8.GetDecoder(); readonly StringBuilder lineBuffer = new StringBuilder(); readonly StringBuilder accumulated = new StringBuilder(); readonly StringBuilder rawError = new StringBuilder(); char[] charBuffer = new char[2048]; UnityWebRequest request; Action onDelta; // 버퍼를 미리 잡아두면 청크마다 새 배열이 생기지 않는다. // 스트리밍은 초당 수십 번 콜백이 오므로 여기서 나오는 쓰레기가 무시할 양이 아니다. public SseDownloadHandler() : base(new byte[16 * 1024]) { } /// 지금까지 이어 붙인 응답 전체. public string Text => accumulated.ToString(); /// API 가 구조화된 오류를 보냈다면 그 message. 없으면 null. public string ErrorMessage { get; private set; } /// /// 사고 델타를 한 번이라도 봤는지. display 가 기본값(omitted)이면 사고 델타의 /// 글자가 비어 있어서, 겉보기엔 아무것도 안 오는 것과 구분되지 않는다. /// 응답이 늦을 때 "멈춘 것"인지 "생각 중"인지 가르는 단서다. /// public bool SawThinking { get; private set; } /// 첫 글자가 도착한 시각(realtimeSinceStartup). 아직이면 0. public float FirstTextAt { get; private set; } public void Bind(UnityWebRequest owner, Action deltaCallback) { request = owner; onDelta = deltaCallback; } protected override bool ReceiveData(byte[] data, int dataLength) { // 여기서 false 를 돌려주면 UnityWebRequest 가 다운로드를 중단한다. // 빈 청크는 스트림이 끝났다는 뜻이 아니므로 계속 받아야 한다. if (data == null || dataLength == 0) return true; int needed = decoder.GetCharCount(data, 0, dataLength, false); if (charBuffer.Length < needed) charBuffer = new char[Mathf.NextPowerOfTwo(needed)]; int decoded = decoder.GetChars(data, 0, dataLength, charBuffer, 0, false); // 오류 응답은 SSE 가 아니라 그냥 JSON 한 덩어리로 온다. 통째로 모았다가 끝에 판다. if (request != null && request.responseCode >= 400) { rawError.Append(charBuffer, 0, decoded); return true; } for (int i = 0; i < decoded; i++) { char c = charBuffer[i]; if (c == '\n') { HandleLine(lineBuffer.ToString()); lineBuffer.Length = 0; } else if (c != '\r') { lineBuffer.Append(c); } } return true; } protected override void CompleteContent() { if (lineBuffer.Length > 0) { HandleLine(lineBuffer.ToString()); lineBuffer.Length = 0; } if (rawError.Length > 0 && ErrorMessage == null) { ParseError(rawError.ToString()); } } void HandleLine(string line) { // "event: ..." 줄은 볼 필요가 없다. data 의 JSON 안에 같은 type 이 들어 있다. if (!line.StartsWith("data:", StringComparison.Ordinal)) return; string json = line.Substring(5).Trim(); if (json.Length == 0) return; SseEnvelope evt; try { evt = JsonUtility.FromJson(json); } catch (Exception) { return; // 모르는 모양의 이벤트는 조용히 넘긴다 } if (evt == null) return; if (evt.type == "error") { ErrorMessage = evt.error != null && !string.IsNullOrEmpty(evt.error.message) ? evt.error.message : "알 수 없는 오류"; return; } if (evt.type != "content_block_delta" || evt.delta == null) return; // 사고 델타는 display 가 omitted 면 글자가 비어서 온다. 버리되, 왔다는 // 사실은 남긴다 — 응답이 늦을 때 멈춘 건지 생각 중인지 가르는 단서다. if (evt.delta.type == "thinking_delta") { SawThinking = true; return; } if (evt.delta.type == "text_delta" && !string.IsNullOrEmpty(evt.delta.text)) { if (FirstTextAt <= 0f) FirstTextAt = Time.realtimeSinceStartup; accumulated.Append(evt.delta.text); onDelta?.Invoke(evt.delta.text); } } void ParseError(string json) { try { var envelope = JsonUtility.FromJson(json); if (envelope != null && envelope.error != null && !string.IsNullOrEmpty(envelope.error.message)) { ErrorMessage = envelope.error.message; } } catch (Exception) { // 본문이 JSON 이 아니면 상태 코드만으로 보고한다. } } } // --- 응답 파싱 --- // 필요한 필드만 선언한다. JsonUtility 는 모르는 필드를 조용히 버린다. // 값을 넣는 쪽이 JsonUtility 라 컴파일러는 "한 번도 대입되지 않았다"고 본다. #pragma warning disable 0649 [Serializable] class SseEnvelope { public string type; public SseDelta delta; public SseError error; } [Serializable] class SseDelta { public string type; public string text; } [Serializable] class SseError { public string type; public string message; } #pragma warning restore 0649 }