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
17 changes: 14 additions & 3 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, 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, 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.

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 @@ -74,7 +74,7 @@ Token Pilot의 제품 포지션은 framework-independent Java LLM control and ac
| `token-pilot-core` | Basic implementation complete | Domain records, pricing, calculator, registry, ledger manager |
| `token-pilot-spring-ai` | Basic implementation complete | `UsageExtractor`, `LedgerAdvisor`, response usage recording |
| `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 | Needs BLOCK enforcement, window semantics, reservation, idempotency, and reconciliation |
| `token-pilot-budget` | Basic non-atomic implementation | Typed monthly keys and Clock/ZoneId windows implemented; needs BLOCK enforcement, 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 ChatClient customizer implemented |
| `token-pilot-starter` | Basic implementation complete | Thin final user entrypoint that brings runtime modules together |
Expand Down Expand Up @@ -233,7 +233,7 @@ class MailBudgetNotificationHandler implements BudgetNotificationHandler {

- `BudgetNotificationHandler` 빈이 없으면 `BudgetNotificationService`는 등록되지 않는다 (no-op).
- `token-pilot.notification.enabled=true` 설정 시에만 notification 빈이 등록된다.
- 알림 중복 방지는 `(targetId, budgetWindow)` 조합으로 처리된다.
- 알림 중복 방지는 evaluator가 확정한 `BudgetKey(policyId, targetType, targetId, window)`로 처리된다.
- 같은 window 안에서는 낮거나 같은 threshold 재발송이 방지된다.
- 새 window에서는 50/80/100% 알림이 다시 가능하다.

Expand All @@ -256,7 +256,13 @@ token-pilot:
- model
budget:
enabled: false
policy-id: default-monthly
target-type: tenant
target-tag-key: tenant_id
# fallback-target-id: shared
monthly-limit: 10.00
currency: USD
zone-id: UTC
notification:
enabled: false
```
Expand Down Expand Up @@ -381,6 +387,11 @@ Stage and deploy a Central release:

## Update History

### 2026-07-22

- Added immutable monthly `BudgetKey`/`BudgetWindow` identity resolved from an injected `Clock` and configured `ZoneId`.
- Made budget policy identity, target fallback, limit, and currency explicit, and propagated the resolved key through state updates and notifications.

### 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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@
import io.tokenpilot.notification.InMemoryNotificationStateStore;
import io.tokenpilot.notification.NotificationStateStore;

import java.time.Clock;

/**
* Token Pilot 라이브러리의 자동 설정을 담당하는 클래스.
*/
Expand Down Expand Up @@ -165,9 +167,16 @@ public BudgetStateStore budgetStateStore() {
@ConditionalOnMissingBean
@ConditionalOnClass(LedgerBudgetComponents.class)
@ConditionalOnProperty(prefix = "token-pilot.budget", name = "enabled", havingValue = "true")
public BudgetEvaluator budgetEvaluator(BudgetStateStore budgetStateStore, TokenPilotProperties properties) {
return LedgerBudgetComponents.defaultBudgetEvaluator(budgetStateStore, properties.getBudget()
.getMonthlyLimit());
public BudgetEvaluator budgetEvaluator(
BudgetStateStore budgetStateStore,
TokenPilotProperties properties,
ObjectProvider<Clock> clock
) {
return LedgerBudgetComponents.defaultBudgetEvaluator(
budgetStateStore,
properties.toBudgetPolicy(),
clock.getIfAvailable(Clock::systemUTC)
);
}

/**
Expand Down
Original file line number Diff line number Diff line change
@@ -1,10 +1,13 @@
package io.tokenpilot.autoconfigure;

import io.tokenpilot.budget.BudgetPolicy;
import io.tokenpilot.core.domain.PricingPlan;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.boot.context.properties.NestedConfigurationProperty;

import java.time.ZoneId;
import java.util.ArrayList;
import java.util.Currency;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
Expand Down Expand Up @@ -80,6 +83,18 @@ public List<PricingPlan> toPricingPlans() {
.toList();
}

public BudgetPolicy toBudgetPolicy() {
return new BudgetPolicy(
budget.getPolicyId(),
budget.getTargetType(),
budget.getTargetTagKey(),
budget.getFallbackTargetId(),
budget.getMonthlyLimit(),
Currency.getInstance(budget.getCurrency()),
ZoneId.of(budget.getZoneId())
);
}

public static class PricingProperties {
private List<PricingPlanProperties> plans = new ArrayList<>();

Expand Down Expand Up @@ -116,6 +131,12 @@ public void setTagWhitelist(Set<String> tagWhitelist) {
public static class BudgetProperties {
private boolean enabled = false;
private java.math.BigDecimal monthlyLimit = new java.math.BigDecimal("10.00");
private String policyId = "default-monthly";
private String targetType = "tenant";
private String targetTagKey = "tenant_id";
private String fallbackTargetId;
private String currency = "USD";
private String zoneId = "UTC";

public boolean isEnabled() {
return enabled;
Expand All @@ -132,6 +153,60 @@ public java.math.BigDecimal getMonthlyLimit() {
public void setMonthlyLimit(java.math.BigDecimal monthlyLimit) {
this.monthlyLimit = monthlyLimit;
}

public String getPolicyId() {
return policyId;
}

public void setPolicyId(String policyId) {
this.policyId = policyId;
}

public String getTargetType() {
return targetType;
}

public void setTargetType(String targetType) {
this.targetType = targetType;
}

public String getTargetTagKey() {
return targetTagKey;
}

public void setTargetTagKey(String targetTagKey) {
this.targetTagKey = targetTagKey;
}

/**
* 대상 tag가 없을 때 사용할 명시적 fallback입니다. 미설정 시 평가는 fail-closed 됩니다.
*/
public String getFallbackTargetId() {
return fallbackTargetId;
}

public void setFallbackTargetId(String fallbackTargetId) {
this.fallbackTargetId = fallbackTargetId;
}

public String getCurrency() {
return currency;
}

public void setCurrency(String currency) {
this.currency = currency;
}

/**
* 월별 budget window 경계를 계산하는 IANA ZoneId입니다. 기본값은 UTC입니다.
*/
public String getZoneId() {
return zoneId;
}

public void setZoneId(String zoneId) {
this.zoneId = zoneId;
}
}

public static class NotificationProperties {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,11 @@
import io.micrometer.core.instrument.simple.SimpleMeterRegistry;
import io.tokenpilot.budget.BudgetDecision;
import io.tokenpilot.budget.BudgetEvaluator;
import io.tokenpilot.budget.BudgetKey;
import io.tokenpilot.budget.BudgetState;
import io.tokenpilot.budget.BudgetStateStore;
import io.tokenpilot.budget.BudgetThreshold;
import io.tokenpilot.budget.BudgetWindow;
import io.tokenpilot.core.CostCalculator;
import io.tokenpilot.core.LedgerManager;
import io.tokenpilot.core.PricingProvider;
Expand All @@ -27,12 +29,17 @@
import org.junit.jupiter.params.provider.MethodSource;
import org.springframework.ai.chat.client.ChatClientRequest;
import org.springframework.ai.chat.client.advisor.api.AdvisorChain;
import org.springframework.ai.chat.prompt.Prompt;
import org.springframework.boot.autoconfigure.AutoConfigurations;
import org.springframework.boot.test.context.runner.ApplicationContextRunner;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

import java.math.BigDecimal;
import java.time.Clock;
import java.time.Instant;
import java.time.ZoneOffset;
import java.util.Currency;
import java.util.Map;
import java.util.Optional;
import java.util.stream.Stream;
Expand Down Expand Up @@ -187,6 +194,49 @@ void shouldRegisterBudgetBeansWhenEnabled() {
});
}

@Test
@DisplayName("Budget 빈은 사용자 Clock 기준의 월별 window를 공유해야 한다")
void shouldUseUserClockForMonthlyBudgetWindow() {
this.contextRunner
.withUserConfiguration(FixedClockConfiguration.class)
.withPropertyValues(
"token-pilot.budget.enabled=true",
"token-pilot.budget.monthly-limit=100.00",
"token-pilot.budget.zone-id=Asia/Seoul"
)
.run(context -> {
BudgetStateStore store = context.getBean(BudgetStateStore.class);
BudgetEvaluator evaluator = context.getBean(BudgetEvaluator.class);
Map<String, String> tags = Map.of("tenant_id", "tenant-clock");
BudgetDecision initial = evaluator.evaluate(tags);
BudgetKey july = new BudgetKey(
initial.key().policyId(),
initial.key().targetType(),
initial.key().targetId(),
BudgetWindow.parse("2026-07")
);

store.addCost(
july,
initial.limit(),
initial.currency(),
new Cost(new BigDecimal("100.00"), initial.currency())
);
store.addCost(
initial.key(),
initial.limit(),
initial.currency(),
new Cost(new BigDecimal("50.00"), initial.currency())
);

BudgetDecision decision = evaluator.evaluate(tags);

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

@Test
@DisplayName("Budget가 활성화되면 LedgerAdvisor가 BudgetEvaluator를 사용해야 한다")
void shouldWireBudgetEvaluatorIntoLedgerAdvisorWhenBudgetEnabled() {
Expand All @@ -197,8 +247,10 @@ void shouldWireBudgetEvaluatorIntoLedgerAdvisorWhenBudgetEnabled() {
LedgerAdvisor advisor = context.getBean(LedgerAdvisor.class);
RecordingBudgetEvaluator evaluator = context.getBean(RecordingBudgetEvaluator.class);

ChatClientRequest request = mock(ChatClientRequest.class);
when(request.context()).thenReturn(Map.of("tenant_id", "tenant-abc"));
ChatClientRequest request = new ChatClientRequest(
new Prompt("test"),
Map.of("tenant_id", "tenant-abc")
);

advisor.before(request, mock(AdvisorChain.class));

Expand Down Expand Up @@ -338,6 +390,14 @@ public RecordingBudgetEvaluator budgetEvaluator() {
}
}

@Configuration(proxyBeanMethods = false)
static class FixedClockConfiguration {
@Bean
public Clock clock() {
return Clock.fixed(Instant.parse("2026-07-31T15:30:00Z"), ZoneOffset.UTC);
}
}

static class RecordingBudgetEvaluator implements BudgetEvaluator {
private int evaluateCalls;
private Map<String, String> lastTags = Map.of();
Expand All @@ -346,7 +406,20 @@ static class RecordingBudgetEvaluator implements BudgetEvaluator {
public BudgetDecision evaluate(Map<String, String> tags) {
this.evaluateCalls++;
this.lastTags = tags;
return new BudgetDecision(BudgetState.ALLOW, BudgetThreshold.NONE, "allowed", BigDecimal.ZERO, BigDecimal.TEN);
return new BudgetDecision(
new BudgetKey(
"policy-a",
"tenant",
tags.get("tenant_id"),
BudgetWindow.parse("2026-07")
),
BudgetState.ALLOW,
BudgetThreshold.NONE,
"allowed",
BigDecimal.ZERO,
BigDecimal.TEN,
Currency.getInstance("USD")
);
}

@Override
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package io.tokenpilot.budget;

import java.math.BigDecimal;
import java.util.Currency;

/**
* 예산 평가 결과를 나타내는 객체
Expand All @@ -10,11 +11,15 @@
* - reason: 상태 설명
* - currentUsage: 현재 사용량
* - limit: 총 예산
* - key: 평가 시점에 확정된 예산 bucket 식별자
* - currency: 예산 통화
*/
public record BudgetDecision(
BudgetKey key,
BudgetState state,
BudgetThreshold threshold,
String reason,
BigDecimal currentUsage,
BigDecimal limit
BigDecimal limit,
Currency currency
) {}
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
package io.tokenpilot.budget;

import java.util.Objects;

/**
* 월별 예산 bucket을 식별하는 불변 key입니다.
*
* @param policyId 예산 정책 식별자
* @param targetType 예산 대상 종류
* @param targetId 예산 대상 식별자
* @param window 월별 예산 기간
*/
public record BudgetKey(
String policyId,
String targetType,
String targetId,
BudgetWindow window
) {

public BudgetKey {
policyId = requireText(policyId, "policyId");
targetType = requireText(targetType, "targetType");
targetId = requireText(targetId, "targetId");
Objects.requireNonNull(window, "window must not be null");
}

private static String requireText(String value, String name) {
if (value == null || value.isBlank()) {
throw new IllegalArgumentException(name + " must not be blank");
}
return value;
}
}
Loading
Loading