Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,15 @@
*/
@JsonIgnoreProperties(ignoreUnknown = true)
public record GeminiGenerateContentResponse(
List<Candidate> candidates
List<Candidate> candidates,
UsageMetadata usageMetadata
) {

/** 응답이 usage 를 안 실어 보내는 경우(구버전·부분 실패)를 호출부가 분기하지 않게 빈 값으로 좁힌다. */
public UsageMetadata usageOrEmpty() {
return usageMetadata != null ? usageMetadata : UsageMetadata.EMPTY;
}

public String extractText() {
if (candidates == null || candidates.isEmpty()) {
throw GeminiApiException.noTextPart();
Expand Down Expand Up @@ -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<ModalityTokenCount> 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> urlMetadata
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -270,6 +270,20 @@ public <Req, Res> Res generateContentExactly(Req request, Class<Res> 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 <Req, Res> Res callWithRetry(Req request, Class<Res> resultType, String model, Tier tier) {
return geminiRetry.execute(() -> {
GeminiGenerateContentResponse response;
Expand All @@ -296,6 +310,7 @@ private <Req, Res> Res callWithRetry(Req request, Class<Res> resultType, String
if (response == null) {
throw GeminiApiException.emptyResponse();
}
logUsage(model, response.usageOrEmpty());

String text = response.extractText();
try {
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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);
}
Expand All @@ -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);
}
Expand All @@ -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());
}
}
Loading