using System; using System.Collections; using System.Collections.Generic; using System.Text; using UnityEngine; using UnityEngine.Networking; /// /// Google Gemini API 를 UnityWebRequest 로 직접 친다. SSE 스트리밍. /// /// AnthropicChatBackend 와 형태가 거의 같다. 다른 점만 정리하면: /// - 인증: x-goog-api-key 헤더 (쿼리스트링 ?key= 도 되지만 URL 은 로그에 남는다) /// - 대화 역할: "user" / "model" — assistant 가 아니다 /// - 페르소나: system 이 아니라 systemInstruction, contents 와 형제 /// - 델타 위치: candidates[0].content.parts[0].text /// - 사고 깊이(effort)에 해당하는 설정은 넣지 않는다. 모델 세대마다 thinkingConfig /// 모양이 달라서 잘못 보내면 400 이 난다. 기본값에 맡긴다. /// /// 무료 티어 주의: Google 가격 정책상 무료 티어는 주고받은 내용이 제품 개선에 /// 사용된다. 유료 티어는 사용되지 않는다. 설정 화면에서 사용자에게 그대로 알린다. /// public class GeminiChatBackend : MonoBehaviour, IChatBackend { const string BaseUrl = "https://generativelanguage.googleapis.com/v1beta/models/"; const string ModelsListUrl = "https://generativelanguage.googleapis.com/v1beta/models"; const string KeyHeader = "x-goog-api-key"; [Tooltip("응답이 아예 오지 않을 때 포기하는 시간(초). 0 이면 무제한")] [SerializeField] int timeoutSeconds = 60; [Tooltip("요청/응답 진행 상황을 콘솔에 남긴다. 응답이 안 올 때 어디서 막혔는지 보려면 켠다")] [SerializeField] bool verboseLog = true; /// /// 설정 화면에 보여줄 모델 선택지. 데스크톱 비서는 짧게 여러 번 주고받으므로 /// Pro 보다 Flash 가 맞다. /// /// 이 목록은 언제든 낡는다. 실제로 2.5 계열은 신규 사용자에게 제공이 끊겼고 /// 서버가 404 와 함께 "3.6 을 쓰라"고 회신한다. 그래서 막혔을 때는 /// ListUsableModels 가 이 키로 실제 쓸 수 있는 목록을 서버에 물어본다. /// 목록이 또 낡으면 그쪽 안내를 따르면 된다. /// public static readonly (string Label, string Id)[] SelectableModels = { ("3.6 Flash", "gemini-3.6-flash"), ("3.7 Flash", "gemini-3.7-flash"), ("3.5 Flash", "gemini-3.5-flash"), }; ChatConfig config; UnityWebRequest inflight; Coroutine routine; public bool IsBusy => inflight != null; 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.ResolveGeminiKey(); if (string.IsNullOrEmpty(apiKey)) { onError?.Invoke("Gemini API 키가 없어. 설정에서 넣어줘."); return; } routine = StartCoroutine(SendRoutine(apiKey, history, onDelta, onComplete, onError)); } public void Cancel() { if (routine != null) { StopCoroutine(routine); routine = null; } 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); // 모델 ID 는 영숫자와 . - 뿐이라 이스케이프할 것이 없다. EscapeURL 을 거치면 // 오히려 예상 못 한 변환이 끼어들 여지만 생긴다. string url = BaseUrl + config.geminiModel + ":streamGenerateContent?alt=sse"; var request = new UnityWebRequest(url, 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(KeyHeader, apiKey); if (timeoutSeconds > 0) request.timeout = timeoutSeconds; handler.Bind(request, onDelta); inflight = request; float startedAt = Time.realtimeSinceStartup; if (verboseLog) { Debug.Log($"[Gemini] 요청 전송: model={config.geminiModel} " + $"메시지 {history.Count}개 본문 {body.Length}자"); } yield return request.SendWebRequest(); routine = null; if (verboseLog) { float elapsed = Time.realtimeSinceStartup - startedAt; Debug.Log($"[Gemini] 응답 종료: HTTP {request.responseCode} result={request.result} " + $"{elapsed:F1}초 / 받은 글자 {handler.Text.Length} / " + $"finishReason={handler.FinishReason ?? "-"}\nURL: {url}"); // 오류 본문에 이유가 그대로 적혀 있다. 404 라면 어떤 모델명이 왜 거부됐는지 나온다. if (!string.IsNullOrEmpty(handler.RawError)) { Debug.LogWarning($"[Gemini] 오류 본문: {handler.RawError}"); } } // 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)) { // 안전 필터에 걸리면 텍스트 없이 finishReason 만 온다. onError?.Invoke(string.IsNullOrEmpty(handler.FinishReason) || handler.FinishReason == "STOP" ? "응답이 비어 있어. 잠시 뒤 다시 시도해줘." : $"응답이 중단됐어 ({handler.FinishReason})."); yield break; } onComplete?.Invoke(full); } static string DescribeFailure(UnityWebRequest request, SseDownloadHandler handler) { string apiMessage = handler.ErrorMessage; if (!string.IsNullOrEmpty(apiMessage)) { if (request.responseCode == 400) return "요청이 거부됐어: " + apiMessage; if (request.responseCode == 401 || request.responseCode == 403) return "API 키가 올바르지 않아: " + apiMessage; if (request.responseCode == 429) return "무료 한도에 걸렸어. 잠시 뒤 다시 해줘."; return "오류 " + request.responseCode + ": " + apiMessage; } if (request.result == UnityWebRequest.Result.ConnectionError) return "연결에 실패했어. 네트워크를 확인해줘. (" + request.error + ")"; return "오류 " + request.responseCode + ": " + request.error; } string BuildRequestJson(IReadOnlyList history) { var contents = new Content[history.Count]; for (int i = 0; i < history.Count; i++) { contents[i] = new Content { // Anthropic 은 assistant, Gemini 는 model 이다. role = history[i].Role == ChatRole.User ? "user" : "model", parts = new[] { new Part { text = history[i].Text } }, }; } var request = new Request { contents = contents, systemInstruction = new SystemInstruction { parts = new[] { new Part { text = config.persona } }, }, generationConfig = new GenerationConfig { maxOutputTokens = Mathf.Max(64, config.maxTokens), }, }; return JsonUtility.ToJson(request); } // ------------------------------------------------------------------ 연결 테스트 /// /// 메시지를 보내지 않고 모델 정보만 조회한다. 토큰을 쓰지 않으면서 /// 키(403)와 모델 이름(404)을 한 번에 가른다. /// public void TestConnection(ChatConfig candidate, Action onResult) { if (candidate == null) { onResult?.Invoke(false, "설정이 비어 있어요."); return; } string apiKey = candidate.ResolveGeminiKey(); if (string.IsNullOrEmpty(apiKey)) { onResult?.Invoke(false, "API 키를 먼저 입력해주세요."); return; } StartCoroutine(TestRoutine(apiKey, candidate.geminiModel, onResult)); } IEnumerator TestRoutine(string apiKey, string model, Action onResult) { long code; string errorBody; using (var request = UnityWebRequest.Get(BaseUrl + model)) { request.SetRequestHeader(KeyHeader, apiKey); request.timeout = 20; yield return request.SendWebRequest(); if (request.result == UnityWebRequest.Result.ConnectionError) { onResult?.Invoke(false, "네트워크에 연결할 수 없어요."); yield break; } code = request.responseCode; errorBody = request.downloadHandler != null ? request.downloadHandler.text : null; } switch (code) { case 200: onResult?.Invoke(true, $"연결 성공 — {model}"); yield break; case 400: case 401: case 403: if (verboseLog && !string.IsNullOrEmpty(errorBody)) { Debug.LogWarning($"[Gemini] 키 확인 실패 본문: {errorBody}"); } onResult?.Invoke(false, "API 키가 올바르지 않아요."); yield break; case 429: onResult?.Invoke(true, "키는 정상인데 요청 한도에 걸려 있어요."); yield break; case 404: // 모델 이름이 거부됐다. 문서를 뒤지는 대신 이 키로 실제 쓸 수 있는 // 목록을 서버에 물어본다. 어떤 이름을 골라야 하는지 그게 정답이다. yield return ListUsableModels(apiKey, model, onResult); yield break; default: onResult?.Invoke(false, $"확인 실패 (오류 {code})"); yield break; } } /// /// 이 키로 쓸 수 있고 streamGenerateContent 를 지원하는 모델을 찾아 알려준다. /// /// 모델 이름은 세대마다 바뀌고 계정/지역에 따라 접근 권한도 다르다. 문서에 있다고 /// 내 키로 된다는 보장이 없으므로, 막혔을 때는 서버에 직접 묻는 쪽이 확실하다. /// IEnumerator ListUsableModels(string apiKey, string triedModel, Action onResult) { using (var request = UnityWebRequest.Get(ModelsListUrl)) { request.SetRequestHeader(KeyHeader, apiKey); request.timeout = 20; yield return request.SendWebRequest(); if (request.responseCode != 200) { onResult?.Invoke(false, $"'{triedModel}' 모델을 찾을 수 없어요."); yield break; } ModelListResponse list = null; try { list = JsonUtility.FromJson(request.downloadHandler.text); } catch (Exception) { // 목록을 못 읽어도 원래 결과는 알려줘야 한다. } var usable = new List(); if (list != null && list.models != null) { foreach (var m in list.models) { if (m == null || string.IsNullOrEmpty(m.name)) continue; if (m.supportedGenerationMethods == null) continue; if (Array.IndexOf(m.supportedGenerationMethods, "streamGenerateContent") < 0) continue; // "models/gemini-2.5-flash" -> "gemini-2.5-flash" string id = m.name.StartsWith("models/", StringComparison.Ordinal) ? m.name.Substring(7) : m.name; usable.Add(id); } } if (verboseLog) { Debug.Log($"[Gemini] 이 키로 쓸 수 있는 모델 {usable.Count}개:\n" + string.Join("\n", usable)); } if (usable.Count == 0) { onResult?.Invoke(false, $"'{triedModel}' 을 쓸 수 없어요. 쓸 수 있는 모델도 없습니다."); yield break; } // 잡담용으로는 Flash 계열이 맞다. 있으면 그걸 먼저 권한다. string suggestion = usable.Find(id => id.Contains("flash")) ?? usable[0]; onResult?.Invoke(false, $"'{triedModel}' 은 못 써요. 예: {suggestion} (전체 목록은 Console 참고)"); } } // ------------------------------------------------------------------ 요청 본문 [Serializable] class Request { public Content[] contents; public SystemInstruction systemInstruction; public GenerationConfig generationConfig; } [Serializable] class Content { public string role; public Part[] parts; } /// 페르소나. Content 와 달리 role 이 없다. [Serializable] class SystemInstruction { public Part[] parts; } [Serializable] class Part { public string text; } [Serializable] class GenerationConfig { public int maxOutputTokens; } // ------------------------------------------------------------------ 응답 /// /// AnthropicChatBackend 의 것과 같은 구조다. 이벤트 모양만 다르다. /// (공통 부모로 빼는 것도 생각했지만, 스트림 형식이 제공자마다 또 달라질 수 있어 /// 섣불리 묶기보다 각자 두는 쪽이 나중에 덜 아프다) /// 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 string Text => accumulated.ToString(); public string ErrorMessage { get; private set; } /// STOP 이 아니면 안전 필터 등으로 잘린 것이다. public string FinishReason { get; private set; } /// 4xx 일 때 서버가 보낸 본문 원문. 진단용. public string RawError => rawError.ToString(); public SseDownloadHandler() : base(new byte[16 * 1024]) { } 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) { if (!line.StartsWith("data:", StringComparison.Ordinal)) return; string json = line.Substring(5).Trim(); if (json.Length == 0) return; Chunk chunk; try { chunk = JsonUtility.FromJson(json); } catch (Exception) { return; } if (chunk == null) return; if (chunk.error != null && !string.IsNullOrEmpty(chunk.error.message)) { ErrorMessage = chunk.error.message; return; } if (chunk.candidates == null || chunk.candidates.Length == 0) return; var candidate = chunk.candidates[0]; if (!string.IsNullOrEmpty(candidate.finishReason)) FinishReason = candidate.finishReason; if (candidate.content == null || candidate.content.parts == null) return; // 한 청크에 조각이 여러 개 들어올 수 있다. for (int i = 0; i < candidate.content.parts.Length; i++) { string text = candidate.content.parts[i].text; if (string.IsNullOrEmpty(text)) continue; accumulated.Append(text); onDelta?.Invoke(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 라 컴파일러는 "한 번도 대입되지 않았다"고 본다. #pragma warning disable 0649 [Serializable] class Chunk { public Candidate[] candidates; public ErrorBody error; } [Serializable] class Candidate { public ResponseContent content; public string finishReason; } [Serializable] class ResponseContent { public ResponsePart[] parts; public string role; } [Serializable] class ResponsePart { public string text; } [Serializable] class ErrorBody { public int code; public string message; public string status; } /// models.list 응답. 어떤 모델을 쓸 수 있는지 서버에 물을 때만 쓴다. [Serializable] class ModelListResponse { public ModelInfo[] models; } [Serializable] class ModelInfo { public string name; // "models/gemini-2.5-flash" public string[] supportedGenerationMethods; // "generateContent", "streamGenerateContent" 등 } #pragma warning restore 0649 }