Skip to content
Closed
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
11 changes: 9 additions & 2 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, typed missing-pricing policies, pricing snapshots, framework-independent token count results, a UTF-8 byte heuristic estimator, a preflight cost-bound projection, versioned model metadata, conservative context admission, a plain-Java core consumer verification path, and the framework-independent in-memory atomic reservation/idempotency foundation are implemented. Candidate-aware request production and estimate/actual cost 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, framework-independent token count results, a UTF-8 byte heuristic estimator, a preflight cost-bound projection, versioned model metadata, conservative context admission, a plain-Java core consumer verification path, framework-independent in-memory atomic reservations, and estimate/actual reconciliation with best-effort accounting events are implemented. Candidate-aware request production and Spring AI lifecycle integration remain 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, pricing snapshots, versioned model catalog, token count results, UTF-8 byte heuristic estimation, preflight cost-bound projection, conservative context admission, and public plain-Java consumer verification |
| `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 atomic reservation implementation | Typed monthly keys, Clock/ZoneId windows, pure status/admission decisions, safe-upper-bound reservations, bucket-scoped atomicity, idempotency, and framework-independent snapshots implemented; candidate production, lifecycle reconciliation, and durable stores remain |
| `token-pilot-budget` | Atomic reservation and reconciliation implemented | Typed monthly keys, Clock/ZoneId windows, safe-upper-bound reservations, commit/release/write-off lifecycle, pending reconciliation liability, estimate/actual token and cost deltas, duplicate callback protection, and framework-independent best-effort accounting events implemented; candidate production and durable stores remain |
| `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 @@ -329,6 +329,8 @@ The active checklist is in `docs/30_DAY_MVP_REPORT.md`; detailed long-term works
- 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 connected to the new atomic reservation lifecycle until #39.
- In-memory reservation reconciliation uses the reservation-time pricing snapshot, moves estimate liability atomically between active, pending, and committed totals, and skips cost calculation for exact duplicate callbacks. Spring AI callback integration remains #39.
- Accounting listeners run synchronously after the bucket lock is released. Runtime listener failures do not roll back a committed transition, stop later listeners, or trigger redelivery on duplicate callbacks, but delivery remains best-effort at-most-once without a durable outbox; failure observation remains #40.
- 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 @@ -400,6 +402,11 @@ Stage and deploy a Central release:

## Update History

### 2026-08-20

- Added the reservation accounting lifecycle for dispatch, commit, release, unresolved actual usage, late actual reconciliation, and write-off with bucket-scoped atomic liability movement and idempotent terminal outcomes.
- Added estimate/actual token and cost deltas, over-limit results, bounded accounting reasons, exact callback fingerprinting, and framework-independent accounting events delivered best-effort at most once without rolling back successful transitions on runtime listener failures.

### 2026-08-15

- Added the framework-independent `BudgetStateStore` atomic safe-upper-bound reservation contract with immutable reservation IDs, idempotency fingerprints, bucket-scoped concurrency control, currency-safe outcomes, and effective-usage snapshots.
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
package io.tokenpilot.budget;

/**
* 예약 생성 이후 회계 상태 변경 명령의 결과입니다.
*
* <p>{@link #APPLIED}만 새로운 회계 변경이 적용되었음을 뜻합니다. 나머지 결과는 기존 상태와
* 금액을 바꾸지 않았음을 뜻하므로 호출자는 예외 메시지나 저장소 내부 구현에 의존하지 않고
* 재시도, 충돌 처리, 입력 보정을 결정할 수 있습니다.</p>
*/
public enum AccountingTransitionStatus {
/** 요청한 회계 변경이 새로 적용되었습니다. */
APPLIED,

/** 같은 회계 명령과 값이 이미 적용되어 기존 결과가 재사용되었습니다. */
REUSED,

/** 기존에 적용된 종료 명령 또는 값이 새 요청과 충돌합니다. */
CONFLICT,

/** 대상 예약을 찾을 수 없습니다. */
NOT_FOUND,

/** 명령 금액의 통화가 예약 통화와 다릅니다. */
CURRENCY_MISMATCH,

/** 명령 인자가 계약을 만족하지 않습니다. */
INVALID_ARGUMENT,

/** 현재 예약 상태에서는 요청한 전이가 허용되지 않습니다. */
NOT_ALLOWED;

/** 새로운 회계 변경이 적용되었는지 반환합니다. */
public boolean isApplied() {
return this == APPLIED;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
package io.tokenpilot.budget;

import io.tokenpilot.core.domain.TokenUsage;
import io.tokenpilot.core.domain.UsageSource;

import java.util.Objects;

/**
* provider가 보고한 actual usage를 하나의 예약에 정산하기 위한 명령입니다.
*/
public record ActualUsageCommand(
String requestId,
String attemptId,
ReservationId reservationId,
TokenUsage usage,
String responseModelId
) {

public ActualUsageCommand {
requestId = requireText(requestId, "requestId");
attemptId = requireText(attemptId, "attemptId");
Objects.requireNonNull(reservationId, "reservationId must not be null");
Objects.requireNonNull(usage, "usage must not be null");
if (usage.source() == UsageSource.UNAVAILABLE) {
throw new IllegalArgumentException(
"usage must be available for actual reconciliation"
);
}
responseModelId = requireText(responseModelId, "responseModelId");
}

private static String requireText(String value, String name) {
if (value == null || value.isBlank()) {
throw new IllegalArgumentException(name + " must not be blank");
}
return value;
}
}
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
package io.tokenpilot.budget;

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

import java.time.Instant;
import java.util.Objects;
import java.util.Optional;

/**
* 예산 bucket에 생성된 immutable 예약 snapshot입니다.
Expand All @@ -13,10 +15,13 @@ public record BudgetReservation(
BudgetKey key,
Cost limit,
Cost amount,
String requestId,
IdempotencyKey idempotencyKey,
String modelId,
String pricingPolicyId,
String catalogVersion,
Optional<PricingSnapshot> pricingSnapshot,
Optional<ReservationTokenEstimate> tokenEstimate,
ReservationState state,
Instant createdAt
) {
Expand All @@ -26,6 +31,7 @@ public record BudgetReservation(
Objects.requireNonNull(key, "key must not be null");
Objects.requireNonNull(limit, "limit must not be null");
Objects.requireNonNull(amount, "amount must not be null");
requestId = requireText(requestId, "requestId");
Objects.requireNonNull(idempotencyKey, "idempotencyKey must not be null");
Objects.requireNonNull(state, "state must not be null");
Objects.requireNonNull(createdAt, "createdAt must not be null");
Expand All @@ -38,6 +44,67 @@ public record BudgetReservation(
modelId = optionalText(modelId, "modelId");
pricingPolicyId = optionalText(pricingPolicyId, "pricingPolicyId");
catalogVersion = optionalText(catalogVersion, "catalogVersion");
pricingSnapshot = Objects.requireNonNull(
pricingSnapshot,
"pricingSnapshot must not be null"
);
tokenEstimate = Objects.requireNonNull(
tokenEstimate,
"tokenEstimate must not be null"
);
if (pricingSnapshot.isPresent()) {
PricingSnapshot snapshot = pricingSnapshot.orElseThrow();
requireSnapshotText(modelId, snapshot.modelId(), "modelId");
requireSnapshotText(
pricingPolicyId,
snapshot.pricingPolicyId(),
"pricingPolicyId"
);
requireSnapshotText(
catalogVersion,
snapshot.catalogVersion(),
"catalogVersion"
);
if (!limit.currency().equals(snapshot.currency())) {
throw new IllegalArgumentException(
"pricing snapshot must use the budget currency"
);
}
}
}

/**
* @deprecated request ID와 idempotency key를 같은 값으로 사용하는 호환 생성자입니다.
* 신규 예약은 {@link #reserved(ReservationId, BudgetReservationRequest, Instant)}로 생성하세요.
*/
@Deprecated(since = "0.1.0", forRemoval = false)
public BudgetReservation(
ReservationId id,
BudgetKey key,
Cost limit,
Cost amount,
IdempotencyKey idempotencyKey,
String modelId,
String pricingPolicyId,
String catalogVersion,
ReservationState state,
Instant createdAt
) {
this(
id,
key,
limit,
amount,
idempotencyKey.value(),
idempotencyKey,
modelId,
pricingPolicyId,
catalogVersion,
Optional.empty(),
Optional.empty(),
state,
createdAt
);
}

public static BudgetReservation reserved(
Expand All @@ -51,10 +118,13 @@ public static BudgetReservation reserved(
request.key(),
request.limit(),
request.safeUpperBoundCost(),
request.requestId(),
request.idempotencyKey(),
request.modelId(),
request.pricingPolicyId(),
request.catalogVersion(),
request.pricingSnapshot(),
request.tokenEstimate(),
ReservationState.RESERVED,
createdAt
);
Expand All @@ -64,10 +134,18 @@ public boolean matches(BudgetReservationRequest request) {
return key.equals(request.key())
&& limit.equals(request.limit())
&& amount.equals(request.safeUpperBoundCost())
&& requestId.equals(request.requestId())
&& idempotencyKey.equals(request.idempotencyKey())
&& Objects.equals(modelId, request.modelId())
&& Objects.equals(pricingPolicyId, request.pricingPolicyId())
&& Objects.equals(catalogVersion, request.catalogVersion());
&& Objects.equals(catalogVersion, request.catalogVersion())
&& pricingSnapshot.equals(request.pricingSnapshot())
&& tokenEstimate.equals(request.tokenEstimate());
}

/** 이 예약이 지정한 provider 요청에 속하는지 확인합니다. */
public boolean belongsTo(String candidateRequestId) {
return requestId.equals(candidateRequestId);
}

private static String optionalText(String value, String name) {
Expand All @@ -76,4 +154,23 @@ private static String optionalText(String value, String name) {
}
return value;
}

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

private static void requireSnapshotText(
String value,
String snapshotValue,
String name
) {
if (!Objects.equals(value, snapshotValue)) {
throw new IllegalArgumentException(
name + " must match the pricing snapshot"
);
}
}
}
Loading
Loading