[Core] TokenUsage 총량과 세부량 불변식 정비#23
Conversation
There was a problem hiding this comment.
Code Review
This pull request refactors TokenUsage to replace the map-based token counts with explicit inputTokens, outputTokens, and a TokenUsageDetails record, enforcing validation invariants to prevent double-counting. Feedback on these changes highlights a critical calculation bug in DefaultCostCalculator where inclusive totals and details are still iterated independently, leading to double-counting. Additionally, reviewers recommend filtering null values from metadata to prevent NullPointerExceptions, clamping extracted reasoning tokens to avoid runtime crashes on inconsistent provider data, using primitive long in extractReasoningTokens to avoid boxing, and restoring a deleted fallback test with updated invariants.
Important
The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.
| ); | ||
| } | ||
|
|
||
| metadata = metadata == null ? Map.of() : Map.copyOf(metadata); |
There was a problem hiding this comment.
현재 TokenUsage 생성자에서 Map.copyOf(metadata)를 사용하여 방어적 복사를 수행하고 있습니다. 이로 인해 메타데이터에 null value가 하나라도 포함되어 있으면 NullPointerException이 발생하며, 테스트 코드(shouldRejectMetadataWithNullValue)에서도 이를 검증하고 있습니다.
하지만 Spring AI의 ChatResponseMetadata나 외부 LLM 프로바이더가 제공하는 메타데이터에는 null value가 포함되는 경우가 매우 흔합니다.
예를 들어, DefaultUsageExtractor.copyMetadata는 metadata.keySet()을 순회하며 모든 값을 복사하는데, 특정 키의 값이 null이면 metadataMap에 null이 들어가고, 결국 TokenUsage 생성 시 NullPointerException이 발생하여 전체 LLM 호출이 실패하게 됩니다.
메타데이터는 외부 시스템에서 제공하는 임의의 정보이므로 null value를 허용하거나, 복사 시점에 null인 항목들을 필터링하여 제거하는 것이 안전합니다.
| metadata = metadata == null ? Map.of() : Map.copyOf(metadata); | |
| if (metadata == null) { | |
| metadata = Map.of(); | |
| } else { | |
| Map<String, Object> copy = new java.util.HashMap<>(); | |
| metadata.forEach((k, v) -> { | |
| if (k != null && v != null) { | |
| copy.put(k, v); | |
| } | |
| }); | |
| metadata = Map.copyOf(copy); | |
| } |
| Long reasoning = extractReasoningTokens(metadata); | ||
| if (reasoning > 0) { | ||
| counts.put(TokenType.REASONING, reasoning); | ||
| } | ||
|
|
||
| return new TokenUsage(counts, copyMetadata(metadata, usage.getNativeUsage())); | ||
| return new TokenUsage( | ||
| prompt, | ||
| completion, | ||
| new TokenUsageDetails(0, reasoning, 0), | ||
| copyMetadata(metadata, usage.getNativeUsage()) | ||
| ); |
There was a problem hiding this comment.
외부 LLM 프로바이더가 반환하는 메타데이터는 간혹 일관성이 없거나 예기치 못한 값(예: reasoning_tokens가 completion_tokens보다 크게 보고되는 경우 등)을 포함할 수 있습니다.
현재 구현에서는 reasoning 값이 completion보다 크면 TokenUsage 생성자에서 IllegalArgumentException이 발생하여 전체 LLM 호출이 실패(crash)하게 됩니다.
외부 입력에 대해 더 방어적으로 대응하기 위해, 추출된 reasoning 토큰 수가 0보다 작거나 completion 총량을 초과하지 않도록 범위를 제한(clamping)하는 것이 안전합니다.
| Long reasoning = extractReasoningTokens(metadata); | |
| if (reasoning > 0) { | |
| counts.put(TokenType.REASONING, reasoning); | |
| } | |
| return new TokenUsage(counts, copyMetadata(metadata, usage.getNativeUsage())); | |
| return new TokenUsage( | |
| prompt, | |
| completion, | |
| new TokenUsageDetails(0, reasoning, 0), | |
| copyMetadata(metadata, usage.getNativeUsage()) | |
| ); | |
| long reasoning = Math.max(0L, Math.min(extractReasoningTokens(metadata), completion)); | |
| return new TokenUsage( | |
| prompt, | |
| completion, | |
| new TokenUsageDetails(0, reasoning, 0), | |
| copyMetadata(metadata, usage.getNativeUsage()) | |
| ); |
| for (TokenType type : TokenType.values()) { | ||
| long count = usage.getCount(type); | ||
|
|
||
| if (count > 0) { | ||
| BigDecimal rate = plan.getRate(type); |
There was a problem hiding this comment.
현재 DefaultCostCalculator는 TokenType.values()를 단순 순회하며 모든 타입의 토큰 수를 합산하고 있습니다. 하지만 inputTokens(PROMPT)와 outputTokens(COMPLETION)는 세부 토큰(CACHED_PROMPT, REASONING, CACHED_COMPLETION)을 이미 포함하는 **포괄 총량(inclusive total)**입니다.
따라서 이대로 계산하면 세부 토큰이 존재하는 경우 비용이 중복으로 누적되는 심각한 계산 오류가 발생합니다. (예: 캐시된 입력 토큰이 PROMPT 요율과 CACHED_PROMPT 요율로 각각 한 번씩, 총 두 번 계산됨)
PR 설명에 언급된 대로 후속 PR에서 수정될 예정이더라도, 현재 코드 상태에서는 명백한 계산 오류를 유발하므로 빠른 시일 내에 세부 토큰 수만큼 총량에서 차감한 '순수 일반 토큰 수'를 기준으로 계산하도록 비즈니스 로직을 보완해야 합니다.
| ); | ||
| } | ||
|
|
||
| private Long extractReasoningTokens(ChatResponseMetadata metadata) { |
There was a problem hiding this comment.
extractReasoningTokens 메서드가 기본 타입인 long 대신 래퍼 클래스인 Long을 반환하고 있습니다. 이로 인해 불필요한 박싱(boxing)과 언박싱(unboxing)이 발생할 수 있으므로, 반환 타입을 long으로 변경하는 것이 성능과 메모리 효율 측면에서 더 좋습니다.
| private Long extractReasoningTokens(ChatResponseMetadata metadata) { | |
| private long extractReasoningTokens(ChatResponseMetadata metadata) { |
| // Then: 1000 * 0.01 / 1000 + 1000 * 0.03 / 1000 = 0.04 | ||
| assertThat(cost.value()).isEqualByComparingTo("0.040000"); | ||
| } | ||
| } |
변경 사항
TokenUsage의inputTokens/outputTokens를 provider 차이를 정규화한 포괄 총량으로 정의했습니다.TokenUsageDetails를cacheReadInputTokens,cacheCreationInputTokens,reasoningOutputTokens로 재구성하고cachedOutputTokens를 제거했습니다.null은 provider 미보고,0은 보고된 0으로 구분합니다.UsageSource로 provider 직접 보고, provider 필드 기반 파생, 로컬 계산, 휴리스틱 추정, 미확인 상태를 구분합니다.배경
Provider의 input/output total과 cache/reasoning detail은 서로 독립적인 토큰 종류가 아니라 포함 관계입니다. 또한 provider마다 raw usage 의미가 다릅니다. OpenAI/Gemini input total은 cached input을 포함하지만 Anthropic의 raw
input_tokens는 cache read/create를 제외하므로 adapter 정규화가 필요합니다.비용 계산기는 다음과 같이 서로 겹치지 않는 구간에만 단가를 적용합니다.
세부량이 보고되지 않으면 해당 값을 0으로 가정해 전체 input/output에 기본 단가를 적용합니다. 세부 단가가 등록되지 않은 경우에도 기존 input/output 단가로 fallback하므로 총량은 한 번만 과금됩니다.
호환성
TokenUsagecanonical constructor에UsageSource가 추가됩니다.TokenUsageDetails(cachedInput, reasoningOutput, cachedOutput)은TokenUsageDetails(cacheReadInput, cacheCreationInput, reasoningOutput)으로 변경됩니다.CACHED_PROMPT/CACHED_COMPLETION은CACHE_READ_PROMPT/CACHE_CREATION_PROMPT으로 변경됩니다.TokenUsage.from(input, output)과TokenUsage.from(input, output, reasoning)편의 factory는 유지합니다.Closes #22