Skip to content

[Core] TokenUsage 총량과 세부량 불변식 정비#23

Merged
HuitaePark merged 6 commits into
mainfrom
refactor/core
Jul 20, 2026
Merged

[Core] TokenUsage 총량과 세부량 불변식 정비#23
HuitaePark merged 6 commits into
mainfrom
refactor/core

Conversation

@HuitaePark

@HuitaePark HuitaePark commented Jul 20, 2026

Copy link
Copy Markdown
Member

변경 사항

  • TokenUsageinputTokens/outputTokens를 provider 차이를 정규화한 포괄 총량으로 정의했습니다.
  • TokenUsageDetailscacheReadInputTokens, cacheCreationInputTokens, reasoningOutputTokens로 재구성하고 cachedOutputTokens를 제거했습니다.
  • detail의 null은 provider 미보고, 0은 보고된 0으로 구분합니다.
  • UsageSource로 provider 직접 보고, provider 필드 기반 파생, 로컬 계산, 휴리스틱 추정, 미확인 상태를 구분합니다.
  • cache read와 cache creation의 합이 input total을 넘지 않고 reasoning이 output total을 넘지 않도록 검증합니다.
  • 비용 계산 전에 포괄 총량을 일반/cache-read/cache-creation/reasoning 과금 구간으로 분리해 중복 과금을 제거했습니다.
  • Spring AI adapter가 OpenAI식 breakdown, Anthropic식 비포괄 input, Gemini식 candidates/thoughts를 TokenPilot의 포괄 총량으로 정규화하도록 확장했습니다.
  • 공개 도메인·비용 계산·Spring AI extraction seam의 회귀 테스트와 README/AGENTS 문서를 갱신했습니다.

배경

Provider의 input/output total과 cache/reasoning detail은 서로 독립적인 토큰 종류가 아니라 포함 관계입니다. 또한 provider마다 raw usage 의미가 다릅니다. OpenAI/Gemini input total은 cached input을 포함하지만 Anthropic의 raw input_tokens는 cache read/create를 제외하므로 adapter 정규화가 필요합니다.

비용 계산기는 다음과 같이 서로 겹치지 않는 구간에만 단가를 적용합니다.

regularInput = inputTokens - cacheReadInputTokens - cacheCreationInputTokens
regularOutput = outputTokens - reasoningOutputTokens

세부량이 보고되지 않으면 해당 값을 0으로 가정해 전체 input/output에 기본 단가를 적용합니다. 세부 단가가 등록되지 않은 경우에도 기존 input/output 단가로 fallback하므로 총량은 한 번만 과금됩니다.

호환성

  • TokenUsage canonical constructor에 UsageSource가 추가됩니다.
  • TokenUsageDetails(cachedInput, reasoningOutput, cachedOutput)TokenUsageDetails(cacheReadInput, cacheCreationInput, reasoningOutput)으로 변경됩니다.
  • CACHED_PROMPT/CACHED_COMPLETIONCACHE_READ_PROMPT/CACHE_CREATION_PROMPT으로 변경됩니다.
  • TokenUsage.from(input, output)TokenUsage.from(input, output, reasoning) 편의 factory는 유지합니다.

Closes #22

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

critical

현재 TokenUsage 생성자에서 Map.copyOf(metadata)를 사용하여 방어적 복사를 수행하고 있습니다. 이로 인해 메타데이터에 null value가 하나라도 포함되어 있으면 NullPointerException이 발생하며, 테스트 코드(shouldRejectMetadataWithNullValue)에서도 이를 검증하고 있습니다.

하지만 Spring AI의 ChatResponseMetadata나 외부 LLM 프로바이더가 제공하는 메타데이터에는 null value가 포함되는 경우가 매우 흔합니다.
예를 들어, DefaultUsageExtractor.copyMetadatametadata.keySet()을 순회하며 모든 값을 복사하는데, 특정 키의 값이 null이면 metadataMapnull이 들어가고, 결국 TokenUsage 생성 시 NullPointerException이 발생하여 전체 LLM 호출이 실패하게 됩니다.

메타데이터는 외부 시스템에서 제공하는 임의의 정보이므로 null value를 허용하거나, 복사 시점에 null인 항목들을 필터링하여 제거하는 것이 안전합니다.

Suggested change
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);
}

Comment on lines +41 to +48
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())
);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

외부 LLM 프로바이더가 반환하는 메타데이터는 간혹 일관성이 없거나 예기치 못한 값(예: reasoning_tokenscompletion_tokens보다 크게 보고되는 경우 등)을 포함할 수 있습니다.
현재 구현에서는 reasoning 값이 completion보다 크면 TokenUsage 생성자에서 IllegalArgumentException이 발생하여 전체 LLM 호출이 실패(crash)하게 됩니다.

외부 입력에 대해 더 방어적으로 대응하기 위해, 추출된 reasoning 토큰 수가 0보다 작거나 completion 총량을 초과하지 않도록 범위를 제한(clamping)하는 것이 안전합니다.

Suggested change
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())
);

Comment on lines 25 to 29
for (TokenType type : TokenType.values()) {
long count = usage.getCount(type);

if (count > 0) {
BigDecimal rate = plan.getRate(type);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

현재 DefaultCostCalculatorTokenType.values()를 단순 순회하며 모든 타입의 토큰 수를 합산하고 있습니다. 하지만 inputTokens(PROMPT)와 outputTokens(COMPLETION)는 세부 토큰(CACHED_PROMPT, REASONING, CACHED_COMPLETION)을 이미 포함하는 **포괄 총량(inclusive total)**입니다.

따라서 이대로 계산하면 세부 토큰이 존재하는 경우 비용이 중복으로 누적되는 심각한 계산 오류가 발생합니다. (예: 캐시된 입력 토큰이 PROMPT 요율과 CACHED_PROMPT 요율로 각각 한 번씩, 총 두 번 계산됨)

PR 설명에 언급된 대로 후속 PR에서 수정될 예정이더라도, 현재 코드 상태에서는 명백한 계산 오류를 유발하므로 빠른 시일 내에 세부 토큰 수만큼 총량에서 차감한 '순수 일반 토큰 수'를 기준으로 계산하도록 비즈니스 로직을 보완해야 합니다.

);
}

private Long extractReasoningTokens(ChatResponseMetadata metadata) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

extractReasoningTokens 메서드가 기본 타입인 long 대신 래퍼 클래스인 Long을 반환하고 있습니다. 이로 인해 불필요한 박싱(boxing)과 언박싱(unboxing)이 발생할 수 있으므로, 반환 타입을 long으로 변경하는 것이 성능과 메모리 효율 측면에서 더 좋습니다.

Suggested change
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");
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

기존의 calculateReasoningTokensWithFallback 테스트는 reasoningcompletion을 초과하여 새로운 불변식과 충돌했기 때문에 제거된 것으로 보입니다.

하지만 테스트를 완전히 삭제하는 대신, TokenUsage.from(1000, 1000, 1000)과 같이 올바른 불변식을 만족하는 값으로 수정하여 추론 토큰 요율 미지정 시 일반 출력 요율을 따르는 폴백(fallback) 로직에 대한 검증을 유지하는 것이 좋습니다.

@HuitaePark
HuitaePark marked this pull request as ready for review July 20, 2026 05:56
@HuitaePark
HuitaePark merged commit 09d5058 into main Jul 20, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Core] TokenUsage 불변식과 토큰 합산 의미 교정

1 participant