From 5ac7c529739c72d9f6734b20898f9cb69138921d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=A1=B0=EC=9E=AC=EC=A4=91?= <126754298+m-a-king@users.noreply.github.com> Date: Sun, 16 Aug 2026 19:37:56 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20Gemini=20=ED=98=B8=EC=B6=9C=EB=8B=B9=20?= =?UTF-8?q?=ED=86=A0=ED=81=B0=20=EC=82=AC=EC=9A=A9=EB=9F=89=EC=9D=84=20?= =?UTF-8?q?=EB=A1=9C=EA=B7=B8=EB=A1=9C=20=EB=82=A8=EA=B8=B4=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 응답의 usageMetadata 를 버리고 있어 호출당 토큰이 얼마인지 관측값이 없었다. media_resolution 설정을 검토하다 드러났는데, "Gemini 3 계열은 기본이 이미 이미지 1120 토큰" 이라는 판단이 전부 문서 근거이고 실측이 아니었다 - promptTokensDetails 의 modality 별 내역에서 이미지 몫을 분리해 함께 남긴다. 이미지·링크 경로의 토큰 비중을 로그만으로 가를 수 있게 하려는 것이다 - 메트릭이 아니라 로그로 둔다. 지금 필요한 건 추세가 아니라 "이 호출이 얼마였나" 의 원장이고, 라벨 축(모델 x modality)이 붙으면 카디널리티가 는다 - usage 가 없는 응답(구버전·부분 실패)은 usageOrEmpty 로 좁혀 호출부가 null 분기를 하지 않게 했다 --- .../gemini/GeminiGenerateContentResponse.java | 41 ++++++++++++++++++- .../extraction/gemini/GeminiHttpClient.java | 15 +++++++ .../GeminiGenerateContentResponseTest.java | 36 ++++++++++++++-- 3 files changed, 88 insertions(+), 4 deletions(-) diff --git a/src/main/java/com/depromeet/piki/extractor/extraction/gemini/GeminiGenerateContentResponse.java b/src/main/java/com/depromeet/piki/extractor/extraction/gemini/GeminiGenerateContentResponse.java index e2cd1b8..b7cca34 100644 --- a/src/main/java/com/depromeet/piki/extractor/extraction/gemini/GeminiGenerateContentResponse.java +++ b/src/main/java/com/depromeet/piki/extractor/extraction/gemini/GeminiGenerateContentResponse.java @@ -12,9 +12,15 @@ */ @JsonIgnoreProperties(ignoreUnknown = true) public record GeminiGenerateContentResponse( - List candidates + List candidates, + UsageMetadata usageMetadata ) { + /** 응답이 usage 를 안 실어 보내는 경우(구버전·부분 실패)를 호출부가 분기하지 않게 빈 값으로 좁힌다. */ + public UsageMetadata usageOrEmpty() { + return usageMetadata != null ? usageMetadata : UsageMetadata.EMPTY; + } + public String extractText() { if (candidates == null || candidates.isEmpty()) { throw GeminiApiException.noTextPart(); @@ -50,6 +56,39 @@ public record Part( String text ) {} + /** + * 호출당 토큰 사용량. 비용 추적과 {@code media_resolution} 같은 설정 변경의 근거로 쓴다 — 문서상 기본값이 + * 무엇인지와 별개로, 실제로 이미지에 몇 토큰이 붙는지는 이 값으로만 확인된다. + */ + @JsonIgnoreProperties(ignoreUnknown = true) + public record UsageMetadata( + Integer promptTokenCount, + Integer candidatesTokenCount, + Integer totalTokenCount, + List promptTokensDetails + ) { + + static final UsageMetadata EMPTY = new UsageMetadata(null, null, null, null); + + /** 입력 토큰 중 이미지 몫. modality 별 내역이 없으면 null 이고, 로그에선 그대로 비워 둔다. */ + public Integer imageTokenCount() { + if (promptTokensDetails == null) { + return null; + } + return promptTokensDetails.stream() + .filter(detail -> "IMAGE".equalsIgnoreCase(detail.modality())) + .map(ModalityTokenCount::tokenCount) + .findFirst() + .orElse(null); + } + } + + @JsonIgnoreProperties(ignoreUnknown = true) + public record ModalityTokenCount( + String modality, + Integer tokenCount + ) {} + @JsonIgnoreProperties(ignoreUnknown = true) public record UrlContextMetadata( List urlMetadata diff --git a/src/main/java/com/depromeet/piki/extractor/extraction/gemini/GeminiHttpClient.java b/src/main/java/com/depromeet/piki/extractor/extraction/gemini/GeminiHttpClient.java index 608d6c6..358e77f 100644 --- a/src/main/java/com/depromeet/piki/extractor/extraction/gemini/GeminiHttpClient.java +++ b/src/main/java/com/depromeet/piki/extractor/extraction/gemini/GeminiHttpClient.java @@ -270,6 +270,20 @@ public Res generateContentExactly(Req request, Class resultType, return callWithRetry(request, resultType, model, paidTier()); } + /** + * 호출당 토큰 사용량 원장. 메트릭이 아니라 로그인 이유는 지금 필요한 것이 추세가 아니라 "이 호출이 얼마였나" + * 이고, 라벨 축(모델 x modality)이 붙으면 카디널리티가 늘기 때문이다. imageTokens 는 이미지 경로에만 찍힌다. + */ + private void logUsage(String model, GeminiGenerateContentResponse.UsageMetadata usage) { + log.info( + "gemini usage model={} promptTokens={} candidateTokens={} totalTokens={} imageTokens={}", + model, + usage.promptTokenCount(), + usage.candidatesTokenCount(), + usage.totalTokenCount(), + usage.imageTokenCount()); + } + private Res callWithRetry(Req request, Class resultType, String model, Tier tier) { return geminiRetry.execute(() -> { GeminiGenerateContentResponse response; @@ -296,6 +310,7 @@ private Res callWithRetry(Req request, Class resultType, String if (response == null) { throw GeminiApiException.emptyResponse(); } + logUsage(model, response.usageOrEmpty()); String text = response.extractText(); try { diff --git a/src/test/java/com/depromeet/piki/extractor/extraction/gemini/GeminiGenerateContentResponseTest.java b/src/test/java/com/depromeet/piki/extractor/extraction/gemini/GeminiGenerateContentResponseTest.java index 8b3af8f..17ecfd2 100644 --- a/src/test/java/com/depromeet/piki/extractor/extraction/gemini/GeminiGenerateContentResponseTest.java +++ b/src/test/java/com/depromeet/piki/extractor/extraction/gemini/GeminiGenerateContentResponseTest.java @@ -1,11 +1,14 @@ package com.depromeet.piki.extractor.extraction.gemini; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertThrows; import com.depromeet.piki.extractor.extraction.gemini.GeminiGenerateContentResponse.Candidate; import com.depromeet.piki.extractor.extraction.gemini.GeminiGenerateContentResponse.Content; +import com.depromeet.piki.extractor.extraction.gemini.GeminiGenerateContentResponse.ModalityTokenCount; import com.depromeet.piki.extractor.extraction.gemini.GeminiGenerateContentResponse.Part; +import com.depromeet.piki.extractor.extraction.gemini.GeminiGenerateContentResponse.UsageMetadata; import java.util.List; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; @@ -15,7 +18,7 @@ class GeminiGenerateContentResponseTest { @Test @DisplayName("candidates 가 비어 있으면 noTextPart 예외를 던진다") void emptyCandidatesThrows() { - GeminiGenerateContentResponse response = new GeminiGenerateContentResponse(List.of()); + GeminiGenerateContentResponse response = new GeminiGenerateContentResponse(List.of(), null); assertThrows(GeminiApiException.class, response::extractText); } @@ -24,7 +27,7 @@ void emptyCandidatesThrows() { @DisplayName("parts 가 비어 있으면 noTextPart 예외를 던진다") void emptyPartsThrows() { GeminiGenerateContentResponse response = - new GeminiGenerateContentResponse(List.of(new Candidate(new Content(List.of()), null))); + new GeminiGenerateContentResponse(List.of(new Candidate(new Content(List.of()), null)), null); assertThrows(GeminiApiException.class, response::extractText); } @@ -33,9 +36,36 @@ void emptyPartsThrows() { @DisplayName("정상 응답은 첫번째 candidate 의 첫번째 part text 를 반환한다") void returnsFirstPartText() { GeminiGenerateContentResponse response = new GeminiGenerateContentResponse( - List.of(new Candidate(new Content(List.of(new Part("{\"isProductPage\":true}"))), null)) + List.of(new Candidate(new Content(List.of(new Part("{\"isProductPage\":true}"))), null)), + null ); assertEquals("{\"isProductPage\":true}", response.extractText()); } + + @Test + @DisplayName("modality 내역에서 이미지 토큰만 골라낸다") + void picksImageTokenCount() { + UsageMetadata usage = new UsageMetadata( + 1300, 40, 1340, + List.of(new ModalityTokenCount("TEXT", 180), new ModalityTokenCount("IMAGE", 1120)) + ); + + assertEquals(1120, usage.imageTokenCount()); + } + + @Test + @DisplayName("modality 내역이 없거나 이미지가 없으면 이미지 토큰은 null 이다") + void imageTokenCountIsNullWithoutDetails() { + assertNull(new UsageMetadata(180, 40, 220, null).imageTokenCount()); + assertNull(new UsageMetadata(180, 40, 220, List.of(new ModalityTokenCount("TEXT", 180))).imageTokenCount()); + } + + @Test + @DisplayName("usage 가 없는 응답도 호출부가 분기 없이 읽는다") + void usageOrEmptyNeverNull() { + GeminiGenerateContentResponse response = new GeminiGenerateContentResponse(List.of(), null); + + assertNull(response.usageOrEmpty().totalTokenCount()); + } }