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
}