Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
a2d4921
feat(core): 가격 결정 결과를 성공/실패가 구분되는 typed result로 표현한다.
Rigu1 Jul 29, 2026
eefc6c3
feat(core): 가격 정보가 없는 상태와 명시적인 0원 가격을 서로 다른 결과로 표현한다.
Rigu1 Jul 29, 2026
774362c
refactor(core): 가격 결정 결과를 enum으로 단순화
Rigu1 Jul 30, 2026
8381927
feat(core): fallback rate는 기준 token type rate가 실제로 등록된 경우에만 적용한다.
Rigu1 Jul 30, 2026
ab896d6
test(core): PricingRegistry 가격 결정 위임 검증 추가
Rigu1 Jul 30, 2026
d1f0021
feat(core): 기대 통화와 pricing plan 통화가 다르면 가격이 resolved된 것으로 취급하지 않는다.
Rigu1 Jul 30, 2026
9b4f592
feat(core): MissingPricingPolicy 구현
Rigu1 Jul 30, 2026
f8b13b9
feat(core): ModelRegistry alias는 canonical model id를 반환한다.
Rigu1 Jul 30, 2026
93289e4
feat(core): pricing lookup은 model id와 pricing policy id를 사용한다.
Rigu1 Jul 30, 2026
9f773db
feat(core): provider 호출 전에 pricing plan을 한 번 resolve한다.
Rigu1 Jul 30, 2026
3dae279
feat(core): provider 호출 전에 resolve된 pricing 정보를 요청 단위 immutable snaps…
Rigu1 Jul 30, 2026
bd0a415
feat(core): 요청 진행 중 registry가 변경되어도 actual reconciliation은 예약 시점 pric…
Rigu1 Jul 30, 2026
d9ebd03
feat(core): provider response model이 요청 시점 snapshot의 model id와 다르면 기존…
Rigu1 Jul 30, 2026
2bf8ee6
refactor(core): core ledger 정산 중복 로직 정리
Rigu1 Jul 30, 2026
4ef88b0
feat(core): missing pricing 정책의 fail closed/open 동작 추가
Rigu1 Jul 30, 2026
3517662
test(core): 명시적 0원 pricing의 resolved 정산 경로 검증
Rigu1 Jul 30, 2026
c21db99
test(core): missing pricing의 fail open과 typed reason 보존 검증
Rigu1 Jul 30, 2026
212cd87
feat(core): FAIL_CLOSED에서 누락 rate의 provider 호출 차단 검증
Rigu1 Jul 30, 2026
1d1e80e
test(core): currency mismatch 상태 무변경 테스트 추가
Rigu1 Jul 30, 2026
3cea005
test(core): pricing miss reason low-cardinality 값 식별 테스트 추가
Rigu1 Jul 30, 2026
c2d8145
test(core): 동일 pricing policy snapshot 테스트 추가
Rigu1 Jul 30, 2026
a4026ca
feat(core): budget/preflight의 기본 missing policy는 FAIL_CLOSED다.
Rigu1 Jul 30, 2026
fede723
feat(core): missing pricing 정책 경계와 검증 보강
Rigu1 Jul 30, 2026
0c038e4
fix(core): FAIL_CLOSED의 model id 누락 차단 추가
Rigu1 Jul 30, 2026
a160f41
fix(core): 빈 pricing rates 복사 오류 수정
Rigu1 Jul 30, 2026
38da265
test(autoconfigure): budget advisor 테스트에 model id와 pricing 설정 추가
Rigu1 Jul 30, 2026
63fc068
fix(core): FAIL_OPEN model id 누락 시 pricing context 보존
Rigu1 Jul 30, 2026
1324ce1
fix(spring-ai): provider 호출 전 부분 pricing snapshot을 MISSING_RATE로 판정
Rigu1 Aug 3, 2026
0a33ed9
fix(pricing): actual 사용량의 누락 rate를 0원으로 정산하지 않도록 수정
Rigu1 Aug 3, 2026
f1d1986
fix(pricing): ChatClient 가격 사전 해석 경로 연결
Rigu1 Aug 3, 2026
065efa1
refactor(core): pricing 검증과 reconciliation 정책을 core 계약으로 분리
Rigu1 Aug 3, 2026
8788042
refactor(spring-ai): DefaultLedgerAdvisor context 및 모델 추출 로직 정리
Rigu1 Aug 3, 2026
12ecaf5
fix(spring-ai): #55 충돌 해결과 BLOCK 집행 보존
HuitaePark Aug 4, 2026
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
9 changes: 5 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, 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.
Current truth: post-call usage normalization, cost calculation, ledger events, Micrometer publishing, Clock-based monthly budget windows, pure budget decisions, typed missing-pricing policies, pricing snapshots, legacy provider-boundary BLOCK enforcement, Spring AI integration, and starter autoconfiguration are implemented. Candidate-aware preflight admission, context admission, atomic reservation, and estimate/actual cost 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 @@ -71,8 +71,8 @@ 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, and legacy provider-boundary BLOCK enforcement |
| `token-pilot-core` | Basic implementation complete | Domain records, pricing, calculator, registry, ledger manager, pricing snapshots, and missing-pricing evaluator |
| `token-pilot-spring-ai` | Basic implementation complete | Spring AI 2.0.0 `UsageExtractor`, `LedgerAdvisor`, pricing snapshot resolution, response usage recording, reconciliation decisions, 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, 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 |
Expand Down Expand Up @@ -326,7 +326,7 @@ The active checklist is in `docs/30_DAY_MVP_REPORT.md`; detailed long-term works
- `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.
- `Cost` now requires an explicit currency, preserves exact internal `BigDecimal` precision, rejects negative values and cross-currency operations, and defers scale-6 `HALF_UP` rounding to `RoundingPolicy.COST_BOUNDARY_ROUNDING`.
- 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.
- The legacy `DefaultLedgerManager.record(String, ...)` path preserves an explicit zero USD fail-open result for a missing plan; the pricing-snapshot path applies `MissingPricingPolicy` and records `UNPRICED` or rejects before provider invocation, so neither behavior is 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.
- 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.
Expand Down Expand Up @@ -398,6 +398,7 @@ Stage and deploy a Central release:

### 2026-08-04

- Added typed missing-pricing policies, immutable pricing snapshots, core rate validation/reconciliation decisions, and Spring AI pre-call pricing resolution; `FAIL_CLOSED` rejects missing plans/rates before provider invocation and `FAIL_OPEN` preserves `UNPRICED`.
- Preserved the deprecated `BudgetNotificationEvent.currentUsage()` compatibility accessor through 0.1.x while migrating handlers to `projectedUsage()`; removal is planned for 0.2.0.

### 2026-07-29
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,10 @@
import io.tokenpilot.core.CostCalculator;
import io.tokenpilot.core.LedgerListener;
import io.tokenpilot.core.LedgerManager;
import io.tokenpilot.core.PricingEvaluator;
import io.tokenpilot.core.PricingProvider;
import io.tokenpilot.core.PricingRegistry;
import io.tokenpilot.core.domain.MissingPricingPolicy;
import io.tokenpilot.core.internal.LedgerComponents;
import io.tokenpilot.micrometer.internal.LedgerMicrometerComponents;
import io.tokenpilot.springai.LedgerAdvisor;
Expand Down Expand Up @@ -70,6 +72,15 @@ public CostCalculator costCalculator() {
return LedgerComponents.defaultCostCalculator();
}

/**
* Pricing snapshot rate와 actual model 정합성을 평가하는 정책을 등록합니다.
*/
@Bean
@ConditionalOnMissingBean
public PricingEvaluator pricingEvaluator() {
return LedgerComponents.defaultPricingEvaluator();
}

/**
* 비용 기록 및 리스너 관리를 담당하는 LedgerManager를 등록합니다.
*/
Expand Down Expand Up @@ -108,7 +119,8 @@ public LedgerAdvisor ledgerAdvisor(
ObjectProvider<BudgetEvaluator> budgetEvaluator,
ObjectProvider<BudgetStateStore> budgetStateStore,
CostCalculator costCalculator,
PricingRegistry pricingRegistry
PricingRegistry pricingRegistry,
PricingEvaluator pricingEvaluator
) {
BudgetEvaluator evaluator = budgetEvaluator.getIfAvailable();
BudgetStateStore stateStore = budgetStateStore.getIfAvailable();
Expand All @@ -120,11 +132,19 @@ public LedgerAdvisor ledgerAdvisor(
evaluator,
stateStore,
costCalculator,
pricingRegistry
pricingRegistry,
pricingEvaluator,
MissingPricingPolicy.FAIL_CLOSED
);
}

return LedgerSpringAiComponents.defaultLedgerAdvisor(ledgerManager, usageExtractor);
return LedgerSpringAiComponents.defaultLedgerAdvisor(
ledgerManager,
usageExtractor,
costCalculator,
pricingRegistry,
pricingEvaluator
);
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,17 @@
import io.tokenpilot.budget.BudgetWindow;
import io.tokenpilot.core.CostCalculator;
import io.tokenpilot.core.LedgerManager;
import io.tokenpilot.core.PricingEvaluator;
import io.tokenpilot.core.PricingProvider;
import io.tokenpilot.core.PricingRegistry;
import io.tokenpilot.core.domain.Cost;
import io.tokenpilot.core.domain.PricingPlan;
import io.tokenpilot.core.domain.PricingReconciliationResult;
import io.tokenpilot.core.domain.PricingResolution;
import io.tokenpilot.core.domain.PricingSnapshot;
import io.tokenpilot.core.domain.TokenType;
import io.tokenpilot.core.domain.TokenUsage;
import io.tokenpilot.core.exception.MissingPricingException;
import io.tokenpilot.notification.BudgetNotificationHandler;
import io.tokenpilot.notification.BudgetNotificationService;
import io.tokenpilot.notification.NotificationStateStore;
Expand All @@ -29,6 +35,7 @@
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.ChatOptions;
import org.springframework.ai.chat.prompt.Prompt;
import org.springframework.boot.autoconfigure.AutoConfigurations;
import org.springframework.boot.test.context.runner.ApplicationContextRunner;
Expand All @@ -47,6 +54,7 @@
import static io.tokenpilot.core.domain.TokenType.COMPLETION;
import static io.tokenpilot.core.domain.TokenType.PROMPT;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.junit.jupiter.params.provider.Arguments.argumentSet;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
Expand All @@ -69,6 +77,7 @@ void shouldRegisterDefaultBeans() {
assertThat(context).hasSingleBean(PricingProvider.class);
assertThat(context).hasSingleBean(PricingRegistry.class);
assertThat(context).hasSingleBean(CostCalculator.class);
assertThat(context).hasSingleBean(PricingEvaluator.class);
assertThat(context).hasSingleBean(LedgerManager.class);

assertThat(context).hasSingleBean(UsageExtractor.class);
Expand All @@ -82,6 +91,54 @@ void shouldRegisterDefaultBeans() {
});
}

@Test
@DisplayName("Ledger-only advisor는 Prompt model과 기본 policy로 pricing snapshot을 resolve해야 한다")
void shouldResolvePricingSnapshotInLedgerOnlyAdvisor() {
this.contextRunner
.withPropertyValues(
"token-pilot.budget.enabled=false",
PROP_MODEL_ID + "=gpt-4o",
PROP_PROMPT + "=0.005",
PROP_COMPLETION + "=0.015",
PROP_CURRENCY + "=USD"
)
.run(context -> {
LedgerAdvisor advisor = context.getBean(LedgerAdvisor.class);
ChatClientRequest request = new ChatClientRequest(
new Prompt(
"test",
ChatOptions.builder().model("gpt-4o").build()
),
Map.of()
);

ChatClientRequest resolvedRequest = advisor.before(
request,
mock(AdvisorChain.class)
);
Optional<PricingSnapshot> snapshot = resolvedRequest.context()
.values()
.stream()
.filter(PricingSnapshot.class::isInstance)
.map(PricingSnapshot.class::cast)
.findFirst();

assertThat(resolvedRequest.context().values())
.contains(PricingResolution.RESOLVED);
assertThat(snapshot)
.isPresent()
.get()
.satisfies(resolvedSnapshot -> {
assertThat(resolvedSnapshot.modelId())
.isEqualTo("gpt-4o");
assertThat(resolvedSnapshot.pricingPolicyId())
.isEqualTo(PricingPlan.DEFAULT_PRICING_POLICY_ID);
assertThat(resolvedSnapshot.currency())
.isEqualTo(Currency.getInstance("USD"));
});
});
}

@Test
@DisplayName("설정 값이 없을 경우 빈 목록을 가진 PricingProvider가 생성되어야 한다")
void shouldRegisterDefaultPricingProviderWhenNoProperties() {
Expand Down Expand Up @@ -240,14 +297,23 @@ void shouldUseUserClockForMonthlyBudgetWindow() {
void shouldWireBudgetEvaluatorIntoLedgerAdvisorWhenBudgetEnabled() {
this.contextRunner
.withUserConfiguration(RecordingBudgetEvaluatorConfiguration.class)
.withPropertyValues("token-pilot.budget.enabled=true")
.withPropertyValues(
"token-pilot.budget.enabled=true",
PROP_MODEL_ID + "=gpt-4o",
PROP_PROMPT + "=0.005",
PROP_COMPLETION + "=0.015",
PROP_CURRENCY + "=USD"
)
.run(context -> {
LedgerAdvisor advisor = context.getBean(LedgerAdvisor.class);
RecordingBudgetEvaluator evaluator = context.getBean(RecordingBudgetEvaluator.class);

ChatClientRequest request = new ChatClientRequest(
new Prompt("test"),
Map.of("tenant_id", "tenant-abc")
Map.of(
"tenant_id", "tenant-abc",
"tokenpilot.model.id", "gpt-4o"
)
);

advisor.before(request, mock(AdvisorChain.class));
Expand All @@ -261,6 +327,30 @@ void shouldWireBudgetEvaluatorIntoLedgerAdvisorWhenBudgetEnabled() {
});
}

@Test
@DisplayName("Budget가 활성화되면 missing pricing policy 기본값은 FAIL_CLOSED여야 한다")
void shouldUseFailClosedMissingPricingPolicyWhenBudgetEnabled() {
this.contextRunner
.withUserConfiguration(RecordingBudgetEvaluatorConfiguration.class)
.withPropertyValues("token-pilot.budget.enabled=true")
.run(context -> {
LedgerAdvisor advisor = context.getBean(LedgerAdvisor.class);
ChatClientRequest request = new ChatClientRequest(
new Prompt("test"),
Map.of(
"tenant_id", "tenant-abc",
"tokenpilot.model.id", "missing-model"
)
);

assertThatThrownBy(() -> advisor.before(request, mock(AdvisorChain.class)))
.isInstanceOf(MissingPricingException.class)
.hasMessage("MISSING_PLAN")
.extracting(exception -> ((MissingPricingException) exception).getResolution())
.isEqualTo(PricingResolution.MISSING_PLAN);
});
}

@Test
@DisplayName("token-pilot.budget.enabled=false 일 때 Budget 관련 빈이 등록되지 않아야 한다")
void shouldNotRegisterBudgetBeansWhenDisabled() {
Expand Down Expand Up @@ -302,6 +392,9 @@ void shouldNotOverrideUserDefinedBeans() {
assertThat(context).hasSingleBean(PricingRegistry.class);
assertThat(context.getBean(PricingRegistry.class))
.isInstanceOf(UserCustomPricingRegistry.class);
assertThat(context).hasSingleBean(PricingEvaluator.class);
assertThat(context.getBean(PricingEvaluator.class))
.isInstanceOf(UserCustomPricingEvaluator.class);
});
}

Expand Down Expand Up @@ -373,11 +466,35 @@ static class UserCustomConfiguration {
public PricingRegistry pricingRegistry() {
return new UserCustomPricingRegistry();
}

@Bean
public PricingEvaluator pricingEvaluator() {
return new UserCustomPricingEvaluator();
}
}

static class UserCustomPricingEvaluator implements PricingEvaluator {
@Override
public PricingResolution validateSnapshotRates(Optional<PricingSnapshot> snapshot) {
return PricingResolution.MISSING_PLAN;
}

@Override
public PricingReconciliationResult determineReconciliation(
Optional<PricingSnapshot> snapshot,
String actualModelId
) {
return PricingReconciliationResult.RECONCILIATION_REQUIRED;
}
}

static class UserCustomPricingRegistry implements PricingRegistry {
@Override public void registerPlan(PricingPlan plan) {}
@Override public Optional<PricingPlan> getPlan(String modelId) { return Optional.empty(); }
@Override public Optional<PricingPlan> getPlan(String modelId, String pricingPolicyId) { return Optional.empty(); }
@Override public Optional<PricingSnapshot> resolveSnapshot(String modelId, String pricingPolicyId) { return Optional.empty(); }
@Override public PricingResolution resolveRate(String modelId, TokenType tokenType) { return PricingResolution.MISSING_PLAN; }
@Override public PricingResolution resolveRate(String modelId, TokenType tokenType, Currency expectedCurrency) { return PricingResolution.MISSING_PLAN; }
}

@Configuration(proxyBeanMethods = false)
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
package io.tokenpilot.core;

import io.tokenpilot.core.domain.Cost;
import io.tokenpilot.core.domain.PricingPlan;
import io.tokenpilot.core.domain.PricingSnapshot;
import io.tokenpilot.core.domain.TokenUsage;

import java.util.Map;
Expand All @@ -17,4 +19,22 @@ public interface LedgerManager {
* @return 산출된 비용
*/
Cost record(String modelId, TokenUsage usage, Map<String, String> tags);

/**
* 이미 resolve된 가격 정책으로 호출 정보를 기록하고 최종 비용을 계산합니다.
* @param plan provider 호출 전에 resolve된 가격 정책
* @param usage 토큰 사용량
* @param tags 추가 메타데이터 (tenant_id, user_id 등)
* @return 산출된 비용
*/
Cost record(PricingPlan plan, TokenUsage usage, Map<String, String> tags);

/**
* 요청 단위 pricing snapshot으로 호출 정보를 기록하고 최종 비용을 계산합니다.
* @param snapshot provider 호출 전에 보존된 pricing snapshot
* @param usage 토큰 사용량
* @param tags 추가 메타데이터 (tenant_id, user_id 등)
* @return 산출된 비용
*/
Cost record(PricingSnapshot snapshot, TokenUsage usage, Map<String, String> tags);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
package io.tokenpilot.core;

import io.tokenpilot.core.domain.PricingReconciliationResult;
import io.tokenpilot.core.domain.PricingResolution;
import io.tokenpilot.core.domain.PricingSnapshot;

import java.util.Optional;

/**
* Pricing snapshot의 사용 가능 여부와 actual model 정합성을 판단하는 정책 계약.
*/
public interface PricingEvaluator {

/**
* Snapshot에 요청 처리에 필요한 rate가 있는지 검증합니다.
*
* @param snapshot 검증할 pricing snapshot, 조회되지 않은 경우 empty
* @return snapshot 및 필수 rate의 resolution
*/
PricingResolution validateSnapshotRates(Optional<PricingSnapshot> snapshot);

/**
* 호출 전 snapshot을 actual 응답 모델에 적용할 수 있는지 판단합니다.
*
* @param snapshot 호출 전에 확정한 pricing snapshot, 확정되지 않은 경우 empty
* @param actualModelId provider가 반환한 actual model id
* @return pricing reconciliation 판단 결과
*/
PricingReconciliationResult determineReconciliation(
Optional<PricingSnapshot> snapshot,
String actualModelId
);
}
Loading
Loading