-
Notifications
You must be signed in to change notification settings - Fork 1
[Core] TokenUsage 총량과 세부량 불변식 정비 #23
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
6 commits
Select commit
Hold shift + click to select a range
035721f
feat(TokenUsage): TokenUsage의 추론 토큰 계산 부분을 제거
HuitaePark ba91929
refactor(TokenUsage): 상세 모델과 호출부를 새 필드로 이관
HuitaePark 940d450
feat: TokenUsageDetails의 검증 추가
HuitaePark e5ac8fc
feat: TokenUsage의 필드 검증 추가
HuitaePark 6368e2d
feat(TokenUsage): 총량과 세부량 불변식을 완성
HuitaePark 30e9a49
refactor(TokenUsage): provider breakdown 모델을 정교화
HuitaePark File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
149 changes: 121 additions & 28 deletions
149
token-pilot-core/src/main/java/io/tokenpilot/core/domain/TokenUsage.java
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,77 +1,170 @@ | ||
| package io.tokenpilot.core.domain; | ||
|
|
||
| import java.util.Collections; | ||
| import java.util.EnumMap; | ||
| import java.util.Map; | ||
| import java.util.Objects; | ||
|
|
||
| /** | ||
| * AI 모델 호출 시 발생하는 토큰 사용량 정보. | ||
| * 세부적인 {@link TokenType} 별로 사용량을 관리합니다. | ||
| * 전체 입력/출력 토큰과 세부 사용량을 관리합니다. | ||
| * | ||
| * @param tokenCounts 토큰 타입별 사용량 (Map) | ||
| * @param metadata 추가 메타데이터 (예: 모델 정보 등) | ||
| * @param inputTokens 전체 입력 토큰 | ||
| * @param outputTokens 전체 출력 토큰 | ||
| * @param details 세부 토큰 사용량 | ||
| * @param source 사용량 값의 출처 | ||
| * @param metadata 추가 메타데이터 (예: 모델 정보 등) | ||
| */ | ||
| public record TokenUsage( | ||
| Map<TokenType, Long> tokenCounts, | ||
| long inputTokens, | ||
| long outputTokens, | ||
| TokenUsageDetails details, | ||
| UsageSource source, | ||
| Map<String, Object> metadata | ||
| ) { | ||
| /** | ||
| * 토큰 총량과 세부량의 포함 관계를 검증하고 metadata를 불변 복사합니다. | ||
| * | ||
| * @throws IllegalArgumentException 토큰 수가 음수이거나 세부량이 총량을 초과한 경우 | ||
| * @throws NullPointerException details/source가 null이거나 metadata에 null key/value가 있는 경우 | ||
| */ | ||
| public TokenUsage { | ||
| tokenCounts = Collections.unmodifiableMap(new EnumMap<>(tokenCounts)); | ||
| metadata = (metadata != null) ? Collections.unmodifiableMap(metadata) : Map.of(); | ||
| if (inputTokens < 0) { | ||
| throw new IllegalArgumentException( | ||
| "inputTokens must be non-negative" | ||
| ); | ||
| } | ||
|
|
||
| if (outputTokens < 0) { | ||
| throw new IllegalArgumentException( | ||
| "outputTokens must be non-negative" | ||
| ); | ||
| } | ||
|
|
||
| details = Objects.requireNonNull( | ||
| details, | ||
| "details must not be null" | ||
| ); | ||
| source = Objects.requireNonNull( | ||
| source, | ||
| "source must not be null" | ||
| ); | ||
|
|
||
| long cacheRead = countOrZero(details.cacheReadInputTokens()); | ||
| long cacheCreation = countOrZero(details.cacheCreationInputTokens()); | ||
| if (cacheRead > inputTokens | ||
| || cacheCreation > inputTokens - cacheRead) { | ||
| throw new IllegalArgumentException( | ||
| "Input details must not exceed inputTokens" | ||
| ); | ||
| } | ||
|
|
||
| long reasoning = countOrZero(details.reasoningOutputTokens()); | ||
| if (reasoning > outputTokens) { | ||
| throw new IllegalArgumentException( | ||
| "reasoningOutputTokens must not exceed outputTokens" | ||
| ); | ||
| } | ||
|
|
||
| metadata = metadata == null ? Map.of() : Map.copyOf(metadata); | ||
| } | ||
|
|
||
| /** | ||
| * 기본 입력/출력 토큰을 사용하는 {@link TokenUsage}를 생성합니다. | ||
| * | ||
| * @param prompt 전체 입력 토큰 | ||
| * @param completion 전체 출력 토큰 | ||
| * @return 세부량과 metadata가 비어 있는 사용량 | ||
| */ | ||
| public static TokenUsage from(long prompt, long completion) { | ||
| Map<TokenType, Long> counts = new EnumMap<>(TokenType.class); | ||
| counts.put(TokenType.PROMPT, prompt); | ||
| counts.put(TokenType.COMPLETION, completion); | ||
| return new TokenUsage(counts, Map.of()); | ||
| return new TokenUsage( | ||
| prompt, | ||
| completion, | ||
| TokenUsageDetails.unreported(), | ||
| UsageSource.PROVIDER_REPORTED, | ||
| Map.of() | ||
| ); | ||
| } | ||
|
|
||
| /** | ||
| * 입력/출력/추론 토큰을 포함하는 {@link TokenUsage}를 생성합니다. | ||
| * | ||
| * @param prompt 전체 입력 토큰 | ||
| * @param completion 전체 출력 토큰 | ||
| * @param reasoning 전체 출력에 포함된 reasoning 토큰 | ||
| * @return reasoning 세부량을 포함하는 사용량 | ||
| */ | ||
| public static TokenUsage from(long prompt, long completion, long reasoning) { | ||
| Map<TokenType, Long> counts = new EnumMap<>(TokenType.class); | ||
| counts.put(TokenType.PROMPT, prompt); | ||
| counts.put(TokenType.COMPLETION, completion); | ||
| counts.put(TokenType.REASONING, reasoning); | ||
| return new TokenUsage(counts, Map.of()); | ||
| return new TokenUsage( | ||
| prompt, | ||
| completion, | ||
| new TokenUsageDetails(null, null, reasoning), | ||
| UsageSource.PROVIDER_REPORTED, | ||
| Map.of() | ||
| ); | ||
| } | ||
|
|
||
| /** | ||
| * provider 응답에서 사용량 정보를 얻지 못한 상태를 생성합니다. | ||
| * | ||
| * @param metadata 보존할 응답 메타데이터 | ||
| * @return 출처가 {@link UsageSource#UNAVAILABLE}인 0 토큰 사용량 | ||
| */ | ||
| public static TokenUsage unavailable(Map<String, Object> metadata) { | ||
| return new TokenUsage( | ||
| 0, | ||
| 0, | ||
| TokenUsageDetails.unreported(), | ||
| UsageSource.UNAVAILABLE, | ||
| metadata | ||
| ); | ||
| } | ||
|
|
||
| /** | ||
| * 모든 종류의 입력/출력 토큰 수의 합계를 반환합니다. | ||
| * | ||
| * @return 전체 입력 토큰 | ||
| */ | ||
| public long promptTokens() { | ||
| return tokenCounts.entrySet().stream() | ||
| .filter(e -> e.getKey().isPrompt()) | ||
| .mapToLong(Map.Entry::getValue) | ||
| .sum(); | ||
| return inputTokens; | ||
| } | ||
|
|
||
| /** | ||
| * 모든 종류의 출력(추론 포함) 토큰 수의 합계를 반환합니다. | ||
| * | ||
| * @return 전체 출력 토큰 | ||
| */ | ||
| public long completionTokens() { | ||
| return tokenCounts.entrySet().stream() | ||
| .filter(e -> e.getKey().isCompletion()) | ||
| .mapToLong(Map.Entry::getValue) | ||
| .sum(); | ||
| return outputTokens; | ||
| } | ||
|
|
||
| /** | ||
| * 전체 사용 토큰 수의 합계를 반환합니다. | ||
| * | ||
| * @return 전체 입력과 출력 토큰의 합 | ||
| * @throws ArithmeticException 합계가 {@code long} 범위를 초과한 경우 | ||
| */ | ||
| public long totalTokens() { | ||
| return tokenCounts.values().stream().mapToLong(Long::longValue).sum(); | ||
| return Math.addExact(inputTokens, outputTokens); | ||
| } | ||
|
|
||
| /** | ||
| * 특정 토큰 타입의 사용량을 가져옵니다. 없을 시 0을 반환합니다. | ||
| * 특정 토큰 타입의 전체 또는 세부 사용량을 반환합니다. | ||
| * 보고되지 않은 세부량은 이 호환 projection에서 {@code 0}으로 반환되며, | ||
| * 미보고 여부는 {@link #details()}의 nullable 필드로 확인해야 합니다. | ||
| * | ||
| * @param type 조회할 토큰 타입 | ||
| * @return 해당 토큰 타입의 사용량 | ||
| */ | ||
| public long getCount(TokenType type) { | ||
| return tokenCounts.getOrDefault(type, 0L); | ||
| return switch (type) { | ||
| case PROMPT -> inputTokens; | ||
| case COMPLETION -> outputTokens; | ||
| case REASONING -> countOrZero(details.reasoningOutputTokens()); | ||
| case CACHE_READ_PROMPT -> countOrZero(details.cacheReadInputTokens()); | ||
| case CACHE_CREATION_PROMPT -> countOrZero(details.cacheCreationInputTokens()); | ||
| }; | ||
| } | ||
|
|
||
| private static long countOrZero(Long count) { | ||
| return count == null ? 0L : count; | ||
| } | ||
| } | ||
42 changes: 42 additions & 0 deletions
42
token-pilot-core/src/main/java/io/tokenpilot/core/domain/TokenUsageDetails.java
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,42 @@ | ||
| package io.tokenpilot.core.domain; | ||
|
|
||
| /** | ||
| * 정규화된 전체 입력/출력 토큰에 포함되는 선택적 세부 사용량. | ||
| * {@code null}은 provider가 값을 보고하지 않았음을, {@code 0}은 값을 | ||
| * 보고했으나 실제 사용량이 없었음을 뜻합니다. | ||
| * | ||
| * @param cacheReadInputTokens 전체 입력에 포함된 cache read 토큰 또는 미보고 시 {@code null} | ||
| * @param cacheCreationInputTokens 전체 입력에 포함된 cache creation 토큰 또는 미보고 시 {@code null} | ||
| * @param reasoningOutputTokens 전체 출력에 포함된 reasoning 토큰 또는 미보고 시 {@code null} | ||
| */ | ||
| public record TokenUsageDetails( | ||
| Long cacheReadInputTokens, | ||
| Long cacheCreationInputTokens, | ||
| Long reasoningOutputTokens | ||
| ) { | ||
| /** | ||
| * 모든 세부 토큰 수가 0 이상인지 검증합니다. | ||
| * | ||
| * @throws IllegalArgumentException 세부 토큰 수가 음수인 경우 | ||
| */ | ||
| public TokenUsageDetails { | ||
| if (cacheReadInputTokens != null && cacheReadInputTokens < 0) { | ||
| throw new IllegalArgumentException("cacheReadInputTokens must be non-negative"); | ||
| } | ||
| if (cacheCreationInputTokens != null && cacheCreationInputTokens < 0) { | ||
| throw new IllegalArgumentException("cacheCreationInputTokens must be non-negative"); | ||
| } | ||
| if (reasoningOutputTokens != null && reasoningOutputTokens < 0) { | ||
| throw new IllegalArgumentException("reasoningOutputTokens must be non-negative"); | ||
| } | ||
| } | ||
|
|
||
| /** | ||
| * provider가 세부 사용량을 보고하지 않은 상태를 생성합니다. | ||
| * | ||
| * @return 모든 세부량이 미보고 상태인 객체 | ||
| */ | ||
| public static TokenUsageDetails unreported() { | ||
| return new TokenUsageDetails(null, null, null); | ||
| } | ||
| } |
17 changes: 17 additions & 0 deletions
17
token-pilot-core/src/main/java/io/tokenpilot/core/domain/UsageSource.java
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,17 @@ | ||
| package io.tokenpilot.core.domain; | ||
|
|
||
| /** | ||
| * 토큰 사용량 값의 출처와 생성 방식을 나타냅니다. | ||
| */ | ||
| public enum UsageSource { | ||
| /** provider가 포괄 총량을 직접 보고한 사용량 */ | ||
| PROVIDER_REPORTED, | ||
| /** provider가 보고한 여러 필드를 어댑터가 정규화해 만든 사용량 */ | ||
| PROVIDER_DERIVED, | ||
| /** 로컬 tokenizer가 계산한 사용량 */ | ||
| LOCAL_TOKENIZER, | ||
| /** 휴리스틱으로 근사 추정한 사용량 */ | ||
| HEURISTIC_ESTIMATE, | ||
| /** 사용량 정보를 얻을 수 없음 */ | ||
| UNAVAILABLE | ||
| } |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
현재
TokenUsage생성자에서Map.copyOf(metadata)를 사용하여 방어적 복사를 수행하고 있습니다. 이로 인해 메타데이터에nullvalue가 하나라도 포함되어 있으면NullPointerException이 발생하며, 테스트 코드(shouldRejectMetadataWithNullValue)에서도 이를 검증하고 있습니다.하지만 Spring AI의
ChatResponseMetadata나 외부 LLM 프로바이더가 제공하는 메타데이터에는nullvalue가 포함되는 경우가 매우 흔합니다.예를 들어,
DefaultUsageExtractor.copyMetadata는metadata.keySet()을 순회하며 모든 값을 복사하는데, 특정 키의 값이null이면metadataMap에null이 들어가고, 결국TokenUsage생성 시NullPointerException이 발생하여 전체 LLM 호출이 실패하게 됩니다.메타데이터는 외부 시스템에서 제공하는 임의의 정보이므로
nullvalue를 허용하거나, 복사 시점에null인 항목들을 필터링하여 제거하는 것이 안전합니다.