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
18 changes: 14 additions & 4 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@

Token Pilot is evolving from a Spring AI usage-tracking starter into a framework-independent Java LLM control and accounting core with optional framework and observability adapters.

Current truth: post-call usage normalization, cost calculation, ledger events, Micrometer publishing, Clock-based monthly budget windows, basic non-atomic budget evaluation, Spring AI integration, and starter autoconfiguration are implemented. Preflight estimation, context admission, atomic reservation, and estimate/actual reconciliation are 30-day MVP targets, not current capabilities.
Current truth: post-call usage normalization, cost calculation, ledger events, Micrometer publishing, Clock-based monthly budget windows, pure budget decisions, legacy provider-boundary BLOCK enforcement, Spring AI integration, and starter autoconfiguration are implemented. Candidate-aware preflight admission, context admission, atomic reservation, and estimate/actual reconciliation are 30-day MVP targets, not current capabilities.

Distribution direction: publish a framework-independent core and an optional Spring AI convenience starter from the same repository and release train. The existing starter artifact is `token-pilot-starter`; `token-pilot-spring-ai-starter` is only a target name until a compatibility ADR and module change land.

Expand Down Expand Up @@ -72,9 +72,9 @@ Token Pilot의 제품 포지션은 framework-independent Java LLM control and ac
| Module | Status | Notes |
| --- | --- | --- |
| `token-pilot-core` | Basic implementation complete | Domain records, pricing, calculator, registry, ledger manager |
| `token-pilot-spring-ai` | Basic implementation complete | Spring AI 2.0.0 `UsageExtractor`, `LedgerAdvisor`, response usage recording |
| `token-pilot-spring-ai` | Basic implementation complete | Spring AI 2.0.0 `UsageExtractor`, `LedgerAdvisor`, response usage recording, and legacy provider-boundary BLOCK enforcement |
| `token-pilot-micrometer` | Basic implementation complete | `MetricsOptions`, tag whitelist, and metric metadata exist; metric ownership must be narrowed |
| `token-pilot-budget` | Basic non-atomic implementation | Typed monthly keys and Clock/ZoneId windows implemented; needs BLOCK enforcement, reservation, idempotency, and reconciliation |
| `token-pilot-budget` | Basic non-atomic implementation | Typed monthly keys, Clock/ZoneId windows, and pure status/admission decisions implemented; needs candidate estimation, reservation, idempotency, and reconciliation |
| `token-pilot-notification` | Basic implementation complete | Event API and deduplication exist; not yet connected to the full advisor/budget lifecycle |
| `token-pilot-autoconfigure` | Basic implementation complete | Bean registration, property binding, pricing/budget/notification wiring, and `ChatClientBuilderCustomizer` implemented |
| `token-pilot-starter` | Basic implementation complete | Thin final user entrypoint that brings runtime modules together |
Expand Down Expand Up @@ -328,7 +328,7 @@ The active checklist is in `docs/30_DAY_MVP_REPORT.md`; detailed long-term works
- Budget money interfaces now use `Cost` while preserving `BudgetKey`, `BudgetPolicy`, Clock/ZoneId monthly windows, and per-key policy snapshots.
- Until the typed missing-pricing policy lands, `DefaultLedgerManager` preserves the legacy fail-open result as an explicit zero USD `Cost`; do not confuse that compatibility behavior with a priced zero-rate plan.
- 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.
- The legacy provider boundary blocks an already-exhausted budget decision before provider invocation. Its candidate-free `STATUS` input is a regression guard, not admission evidence; the flow remains check-then-add and is not an atomic reservation.
- Current Micrometer `ai.token.*` metrics may duplicate Spring AI Observability; preserve compatibility while deciding default suppression or replacement.
- The verified Spring AI 2.0.0 path is synchronous `ChatClient` usage recording with a fake provider. Streaming cancellation and reconciliation remain outside the current compatibility guarantee.
- The repository, README, JReleaser configuration, and every published module POM use the MIT License. `verifyPublicationMetadata` guards this release contract and ensures the sample app is not published.
Expand Down Expand Up @@ -396,6 +396,16 @@ Stage and deploy a Central release:

## Update History

### 2026-08-04

- Preserved the deprecated `BudgetNotificationEvent.currentUsage()` compatibility accessor through 0.1.x while migrating handlers to `projectedUsage()`; removal is planned for 0.2.0.
Comment on lines +399 to +401

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

업데이트 이력의 날짜를 실제 변경일과 일치시키세요.

현재 리뷰 기준일은 2026년 8월 3일입니다. 2026-08-04는 미래 날짜입니다. 이 항목이 현재 변경을 기록한다면 2026-08-03 또는 실제 변경일로 수정하세요. 실제 변경일이 2026년 8월 4일이라면 해당 날짜 이후에 항목을 추가하세요.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@AGENTS.md` around lines 399 - 401, Update the changelog entry heading
associated with the BudgetNotificationEvent.currentUsage() compatibility note to
use the actual change date, ensuring it is not later than the review date of
2026-08-03; use 2026-08-03 if this records the current change, or omit/defer the
entry if the change occurred on 2026-08-04.


### 2026-07-29

- Separated pure budget decisions from provider-boundary enforcement: evaluator BLOCK and currency mismatch outcomes now return structured decisions.
- Distinguished committed usage from candidate-inclusive projected usage and marked candidate-free status results as non-admission evidence.
- Added legacy Spring AI BLOCK enforcement before provider invocation while leaving candidate-aware admission, atomic reservation, and reconciliation to #39, #36, and #37.

### 2026-07-27

- Fixed the 0.1.0 compatibility baseline to Java 25 bytecode/runtime, Spring Boot 4.1.0, and Spring AI 2.0.0.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -231,7 +231,7 @@ void shouldUseUserClockForMonthlyBudgetWindow() {

assertThat(decision.key().window()).isEqualTo(BudgetWindow.parse("2026-08"));
assertThat(decision.threshold()).isEqualTo(BudgetThreshold.HALF);
assertThat(decision.currentUsage().value()).isEqualByComparingTo("50.00");
assertThat(decision.committedUsage().value()).isEqualByComparingTo("50.00");
});
}

Expand Down Expand Up @@ -411,10 +411,12 @@ public BudgetDecision evaluate(Map<String, String> tags) {
tags.get("tenant_id"),
BudgetWindow.parse("2026-07")
),
BudgetDecision.EvaluationType.STATUS,
BudgetState.ALLOW,
BudgetThreshold.NONE,
"allowed",
Cost.zero(Currency.getInstance("USD")),
Cost.zero(Currency.getInstance("USD")),
Cost.of(BigDecimal.TEN, Currency.getInstance("USD"))
);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,21 +2,76 @@

import io.tokenpilot.core.domain.Cost;

import java.util.Objects;

/**
* 예산 평가 결과를 나타내는 객체
* 부수 효과 없는 예산 평가 결과입니다.
*
* @param key 평가 시점에 확정된 예산 bucket 식별자
* @param evaluationType 후보 비용 포함 여부
* @param state ALLOW / WARN / BLOCK / CURRENCY_MISMATCH 상태
* @param threshold 예상 사용량이 도달한 예산 임계치
* @param reason 상태 설명
* @param committedUsage 현재 확정된 누적 사용량
* @param projectedUsage 후보 비용을 포함한 예상 사용량. 상태 조회이거나 통화가 일치하지 않으면
* {@code committedUsage}와 같습니다.
* @param limit 통화를 포함한 총 예산
*
* - state: ALLOW / WARN / BLOCK / CURRENCY_MISMATCH 상태
* - threshold: 현재 도달한 예산 임계치
* - reason: 상태 설명
* - currentUsage: 통화를 포함한 현재 사용량
* - limit: 통화를 포함한 총 예산
* - key: 평가 시점에 확정된 예산 bucket 식별자
* <p><strong>Migration note:</strong> {@link EvaluationType#STATUS} 결과는 현재 상태 조회용이며
* provider 호출 허가의 근거가 아닙니다. 기존의 모호한 {@code currentUsage} 대신 확정
* 사용량은 {@code committedUsage}, 후보 포함 사용량은 {@code projectedUsage}를 사용합니다.
*/
public record BudgetDecision(
BudgetKey key,
EvaluationType evaluationType,
BudgetState state,
BudgetThreshold threshold,
String reason,
Cost currentUsage,
Cost committedUsage,
Cost projectedUsage,
Cost limit
) {}
) {

public BudgetDecision {
Objects.requireNonNull(key, "key must not be null");
Objects.requireNonNull(evaluationType, "evaluationType must not be null");
Objects.requireNonNull(state, "state must not be null");
Objects.requireNonNull(threshold, "threshold must not be null");
Objects.requireNonNull(reason, "reason must not be null");
Objects.requireNonNull(committedUsage, "committedUsage must not be null");
Objects.requireNonNull(projectedUsage, "projectedUsage must not be null");
Objects.requireNonNull(limit, "limit must not be null");

if (!committedUsage.currency().equals(projectedUsage.currency())
|| !committedUsage.currency().equals(limit.currency())) {
throw new IllegalArgumentException(
"committedUsage, projectedUsage and limit must use the same currency"
);
}
if (evaluationType == EvaluationType.STATUS
&& committedUsage.compareTo(projectedUsage) != 0) {
throw new IllegalArgumentException(
"STATUS decisions must not include candidate cost"
);
}
}

/**
* 후보 비용을 포함해 provider admission을 판단한 결과인지 반환합니다.
*/
public boolean isAdmissionDecision() {
return evaluationType == EvaluationType.ADMISSION;
}

public enum EvaluationType {
/**
* 후보 비용 없는 조회 전용 결과입니다. Provider 호출 허가로 사용할 수 없습니다.
*/
STATUS,

/**
* 후보 비용을 포함한 admission 판단 결과입니다.
*/
ADMISSION
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -6,24 +6,32 @@


/**
* AI 호출 전 예산 초과 여부를 판단하는 인터페이스입니다.
* 부수 효과 없이 예산 상태를 판단하는 인터페이스입니다.
* <p>
* 구현체는 현재까지 누적된 비용과
* 이번 호출로 발생할 비용을 기준으로
* 호출을 허용하거나 차단하는 역할을 합니다.
* 구현체는 판단 결과를 구조화된 {@link BudgetDecision}으로 반환하며 provider 호출을 직접
* 차단하거나 알림/metric listener를 호출하지 않습니다. Provider 경계는 반환된 decision을
* 별도로 집행해야 합니다.
*/
public interface BudgetEvaluator {

/**
* 단순히 현재의 누적 비용이 예산 한도를 초과했는지만 판단합니다. (부수 효과 없음)
* 현재 확정 사용량만 조회합니다.
*
* @return {@link BudgetDecision.EvaluationType#STATUS}인 조회 전용 결과. 후보 비용이 없으므로
* provider 호출 허가의 근거로 사용할 수 없습니다.
*/
BudgetDecision evaluate(Map<String, String> tags);

/**
* 이번 호출로 발생할 예상 비용을 포함하여 예산 초과 여부를 판단합니다. (부수 효과 없음)
* 후보 요청의 통화가 포함된 안전 상한 비용을 더해 admission 상태를 판단합니다.
* <p>
* {@code projectedUsage >= limit}이면 BLOCK입니다. BLOCK과 CURRENCY_MISMATCH도 예외를
* 던지지 않고 decision으로 반환합니다.
*
* @return {@link BudgetDecision.EvaluationType#ADMISSION}인 판단 결과
*/
BudgetDecision evaluate(
Map<String, String> tags,
Cost cost
Cost candidateCost
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,23 +2,20 @@

import io.tokenpilot.budget.BudgetDecision;

import java.util.Objects;

/**
* BudgetExceededException은 "LLM 호출을 멈추기 위해" 던지는 예외이다.
*
* 이 예외가 던져지는 순간:
* - Spring AI 호출 체인이 중단된다
* - 실제 LLM API 요청은 나가지 않는다
*
* 즉, 이것이 바로 비용 기반 Circuit Breaker 역할을 한다.
* Provider 경계에서 BLOCK decision을 집행할 때 던지는 예외입니다.
* <p>
* {@code BudgetEvaluator}는 이 예외를 직접 던지지 않습니다. Provider invocation 직전의
* 경계가 원래 decision을 보존한 이 예외를 던져 호출 체인을 중단합니다.
*/
public class BudgetExceededException extends RuntimeException {

// 어떤 판단으로 차단되었는지 담고 있음
private final BudgetDecision decision;

public BudgetExceededException(BudgetDecision decision) {
// Exception 메시지로 reason을 사용
super(decision.reason());
super(Objects.requireNonNull(decision, "decision must not be null").reason());
this.decision = decision;
}

Expand Down
Original file line number Diff line number Diff line change
@@ -1,21 +1,26 @@
package io.tokenpilot.budget.internal;

import io.tokenpilot.budget.BudgetDecision;
import io.tokenpilot.budget.BudgetDecision.EvaluationType;
import io.tokenpilot.budget.BudgetEvaluator;
import io.tokenpilot.budget.BudgetKey;
import io.tokenpilot.budget.BudgetPolicy;
import io.tokenpilot.budget.BudgetState;
import io.tokenpilot.budget.BudgetStateStore;
import io.tokenpilot.budget.BudgetThreshold;
import io.tokenpilot.budget.BudgetWindow;
import io.tokenpilot.budget.exception.BudgetExceededException;
import io.tokenpilot.core.domain.Cost;

import java.math.BigDecimal;
import java.time.Clock;
import java.util.Map;
import java.util.Objects;

/**
* 현재 구현이 지원하는 snapshot은 확정 비용과 후보 비용입니다.
* {@code projectedUsage = committedUsage + candidateCost}이며 active reservation과
* reconciliation liability는 #36, #37에서 추가됩니다.
*/
public class DefaultBudgetEvaluator implements BudgetEvaluator {

private final BudgetStateStore store;
Expand All @@ -29,33 +34,41 @@ public DefaultBudgetEvaluator(BudgetStateStore store, BudgetPolicy policy, Clock
}

@Override
public BudgetDecision evaluate(Map<String, String> tags, Cost cost) {
Objects.requireNonNull(cost, "cost must not be null");
public BudgetDecision evaluate(Map<String, String> tags, Cost candidateCost) {
Objects.requireNonNull(candidateCost, "candidateCost must not be null");
BudgetKey key = resolveKey(tags);
Cost currentUsage = store.getAccumulatedCost(key, policy.monthlyLimit());
if (!policy.monthlyLimit().currency().equals(cost.currency())) {
Cost committedUsage = store.getAccumulatedCost(key, policy.monthlyLimit());
if (!policy.monthlyLimit().currency().equals(candidateCost.currency())) {
return decision(
key,
EvaluationType.ADMISSION,
BudgetState.CURRENCY_MISMATCH,
BudgetThreshold.NONE,
"예산 통화와 비용 통화가 일치하지 않습니다",
currentUsage
committedUsage,
committedUsage
);
}

Cost usage = currentUsage.add(cost);
BudgetDecision decision = decide(key, usage);
if (decision.state() == BudgetState.BLOCK) {
throw new BudgetExceededException(decision);
}
return decision;
Cost projectedUsage = committedUsage.add(candidateCost);
return decide(
key,
EvaluationType.ADMISSION,
committedUsage,
projectedUsage
);
}

@Override
public BudgetDecision evaluate(Map<String, String> tags) {
BudgetKey key = resolveKey(tags);
Cost usage = store.getAccumulatedCost(key, policy.monthlyLimit());
return decide(key, usage);
Cost committedUsage = store.getAccumulatedCost(key, policy.monthlyLimit());
return decide(
key,
EvaluationType.STATUS,
committedUsage,
committedUsage
);
}

private BudgetKey resolveKey(Map<String, String> tags) {
Expand All @@ -77,35 +90,76 @@ private BudgetKey resolveKey(Map<String, String> tags) {
);
}

private BudgetDecision decide(BudgetKey key, Cost usage) {
private BudgetDecision decide(
BudgetKey key,
EvaluationType evaluationType,
Cost committedUsage,
Cost projectedUsage
) {
Cost halfThreshold = threshold("0.5");
Cost warningThreshold = threshold("0.8");

if (usage.compareTo(policy.monthlyLimit()) >= 0) {
return decision(key, BudgetState.BLOCK, BudgetThreshold.EXCEEDED, "월 예산을 초과했습니다", usage);
if (projectedUsage.compareTo(policy.monthlyLimit()) >= 0) {
return decision(
key,
evaluationType,
BudgetState.BLOCK,
BudgetThreshold.EXCEEDED,
"월 예산을 초과했습니다",
committedUsage,
projectedUsage
);
}
if (usage.compareTo(warningThreshold) >= 0) {
return decision(key, BudgetState.WARN, BudgetThreshold.WARNING, "월 예산의 80% 이상 사용", usage);
if (projectedUsage.compareTo(warningThreshold) >= 0) {
return decision(
key,
evaluationType,
BudgetState.WARN,
BudgetThreshold.WARNING,
"월 예산의 80% 이상 사용",
committedUsage,
projectedUsage
);
}
if (usage.compareTo(halfThreshold) >= 0) {
return decision(key, BudgetState.ALLOW, BudgetThreshold.HALF, "월 예산의 50% 이상 사용", usage);
if (projectedUsage.compareTo(halfThreshold) >= 0) {
return decision(
key,
evaluationType,
BudgetState.ALLOW,
BudgetThreshold.HALF,
"월 예산의 50% 이상 사용",
committedUsage,
projectedUsage
);
}
return decision(key, BudgetState.ALLOW, BudgetThreshold.NONE, "정상 범위 사용", usage);
return decision(
key,
evaluationType,
BudgetState.ALLOW,
BudgetThreshold.NONE,
"정상 범위 사용",
committedUsage,
projectedUsage
);
}

private BudgetDecision decision(
BudgetKey key,
EvaluationType evaluationType,
BudgetState state,
BudgetThreshold threshold,
String reason,
Cost usage
Cost committedUsage,
Cost projectedUsage
) {
return new BudgetDecision(
key,
evaluationType,
state,
threshold,
reason,
usage,
committedUsage,
projectedUsage,
policy.monthlyLimit()
);
}
Expand Down
Loading
Loading