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
5 changes: 4 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -317,7 +317,8 @@ The active checklist is in `docs/30_DAY_MVP_REPORT.md`; detailed long-term works
## Known Risks

- `core.internal` implementation classes are package-private by design. Cross-module construction should continue through public factory/configuration APIs.
- Current usage totals and cached/reasoning breakdown semantics can double-count; fix the domain invariant before expanding pricing.
- `TokenUsage` now enforces normalized inclusive totals, optional cache-read/cache-creation/reasoning details, and explicit usage provenance. `DefaultCostCalculator` partitions overlapping totals into disjoint billable amounts before applying rates.
- Spring AI usage extraction converts map/JSON-compatible native usage objects into the normalized core model. Real-provider compatibility fixtures remain required because provider and Spring AI usage shapes can change independently.
- Current budget flow is check-then-add, is not an atomic reservation, and may not enforce `BLOCK` before provider invocation.
- Current Micrometer `ai.token.*` metrics may duplicate Spring AI Observability; preserve compatibility while deciding default suppression or replacement.
- Spring AI 2.0.0 documentation cannot be assumed to describe the current 1.1.4 runtime exactly; capability detection and supported-version tests are release requirements.
Expand Down Expand Up @@ -382,6 +383,8 @@ Stage and deploy a Central release:

### 2026-07-20

- Replaced generic cached input/cached output details with optional cache-read input, cache-creation input, and reasoning-output breakdowns; `null` now means unreported and `0` means reported zero.
- Added `UsageSource`, provider-specific total normalization in the Spring AI adapter, and disjoint cost partitioning to prevent details from being charged twice.
- Positioned Token Pilot as a framework-independent Java LLM control and accounting core with Spring AI as an optional adapter.
- Chose to reuse Spring AI Observability for standard latency, trace, and token telemetry while keeping Token Pilot metrics focused on cost, policy, budget, and reconciliation.
- Kept the existing starter as a Spring AI convenience distribution rather than the sole product identity.
Expand Down
8 changes: 6 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -89,8 +89,8 @@ This configuration describes the current starter path. The 30-day MVP will exten

| Capability | Status | MVP decision |
| --- | --- | --- |
| Provider usage -> `TokenUsage` normalization | Basic implementation | Fix total/breakdown invariants first |
| `BigDecimal` cost calculation and pricing registry | Basic implementation | Define precision, rounding, and missing-price policy |
| Provider usage -> `TokenUsage` normalization | Inclusive totals, optional cache/reasoning breakdown, and source implemented | Add supported-provider compatibility fixtures |
| `BigDecimal` cost calculation and pricing registry | Disjoint billable partition implemented | Define precision, rounding, and missing-price policy |
| `LedgerManager` event publication | Basic implementation | Add idempotent estimate/actual reconciliation contract |
| Spring AI `LedgerAdvisor` | Basic implementation | Keep as an adapter; enforce preflight decisions before the call |
| Micrometer metrics | Basic implementation | Avoid duplicating Spring AI token/latency telemetry by default |
Expand All @@ -99,6 +99,10 @@ This configuration describes the current starter path. The 30-day MVP will exten
| Exact byte-level BPE and JMH optimization | Post-MVP | Keep the estimator SPI so it can be added without API churn |
| Provider routing, retry, fallback, gateway runtime | Future | Build only after accounting correctness is demonstrated |

`TokenUsage` represents normalized input/output as inclusive totals. `TokenUsageDetails` stores optional cache-read input, cache-creation input, and reasoning-output breakdowns; `null` means the provider did not report the value, while `0` means it reported zero usage. `UsageSource` distinguishes reported, provider-derived, locally calculated, estimated, and unavailable values. Cost calculation partitions these overlapping totals into disjoint billable amounts before applying rates, so details are never charged twice.

Before the first `0.1.0` release, the former `Map<TokenType, Long>` record component was replaced by `inputTokens`, `outputTokens`, `TokenUsageDetails`, `UsageSource`, and `metadata`. `CACHED_PROMPT`/`CACHED_COMPLETION` were replaced by `CACHE_READ_PROMPT`/`CACHE_CREATION_PROMPT`; consumers of the pre-release source API must migrate constructor calls and pricing keys accordingly.

## Modules

| Module | Purpose | Status |
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -70,8 +70,7 @@ public BigDecimal completionPricePerK() {
/**
* 특정 토큰 타입의 단가를 가져옵니다. 없을 시 계층 구조에 따라 대체값을 반환합니다.
* REASONING -> COMPLETION
* CACHED_PROMPT -> PROMPT
* CACHED_COMPLETION -> COMPLETION
* CACHE_READ_PROMPT, CACHE_CREATION_PROMPT -> PROMPT
*/
public BigDecimal getRate(TokenType type) {
if (rates.containsKey(type)) {
Expand All @@ -80,8 +79,9 @@ public BigDecimal getRate(TokenType type) {

// Fallback Logic
return switch (type) {
case REASONING, CACHED_COMPLETION -> rates.getOrDefault(TokenType.COMPLETION, BigDecimal.ZERO);
case CACHED_PROMPT -> rates.getOrDefault(TokenType.PROMPT, BigDecimal.ZERO);
case REASONING -> rates.getOrDefault(TokenType.COMPLETION, BigDecimal.ZERO);
case CACHE_READ_PROMPT, CACHE_CREATION_PROMPT ->
rates.getOrDefault(TokenType.PROMPT, BigDecimal.ZERO);
default -> rates.getOrDefault(type, BigDecimal.ZERO);
};
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,22 +10,26 @@ public enum TokenType {
COMPLETION,
/** 추론 (Reasoning) - 주로 출력 계열 */
REASONING,
/** 캐시된 입력 (Cached Prompt) */
CACHED_PROMPT,
/** 캐시된 출력 (Cached Completion) */
CACHED_COMPLETION;
/** 캐시에서 읽은 입력 (Cache Read Prompt) */
CACHE_READ_PROMPT,
/** 캐시에 새로 저장한 입력 (Cache Creation Prompt) */
CACHE_CREATION_PROMPT;

/**
* 해당 토큰 타입이 입력(Prompt) 계열인지 확인합니다.
*
* @return 입력 계열이면 {@code true}
*/
public boolean isPrompt() {
return this == PROMPT || this == CACHED_PROMPT;
return this == PROMPT || this == CACHE_READ_PROMPT || this == CACHE_CREATION_PROMPT;
}

/**
* 해당 토큰 타입이 출력(Completion) 계열인지 확인합니다.
*
* @return 출력 계열이면 {@code true}
*/
public boolean isCompletion() {
return this == COMPLETION || this == REASONING || this == CACHED_COMPLETION;
return this == COMPLETION || this == REASONING;
}
}
149 changes: 121 additions & 28 deletions token-pilot-core/src/main/java/io/tokenpilot/core/domain/TokenUsage.java
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);

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

}

/**
* 기본 입력/출력 토큰을 사용하는 {@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;
}
}
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);
}
}
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
}
Loading
Loading